├── logo.png ├── internship-scraper ├── .eslintrc.json ├── src │ ├── app │ │ ├── old.ico │ │ ├── icon.ico │ │ ├── hook │ │ │ ├── useCustomJobPosts.tsx │ │ │ └── useUser.tsx │ │ ├── layout.tsx │ │ ├── auth │ │ │ └── callback │ │ │ │ └── route.ts │ │ ├── globals.css │ │ ├── analytics │ │ │ └── page.tsx │ │ └── page.tsx │ ├── lib │ │ ├── utils.ts │ │ ├── supabase │ │ │ ├── browser.ts │ │ │ └── server.ts │ │ └── types │ │ │ └── supabase.ts │ └── components │ │ ├── routes.ts │ │ ├── ui │ │ ├── skeleton.tsx │ │ ├── separator.tsx │ │ ├── badge.tsx │ │ ├── tooltip.tsx │ │ ├── popover.tsx │ │ ├── alert.tsx │ │ ├── tabs.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── table.tsx │ │ ├── dialog.tsx │ │ ├── command.tsx │ │ ├── select.tsx │ │ ├── multi-select.tsx │ │ └── dropdown-menu.tsx │ │ ├── theme-provider.tsx │ │ ├── query-provider.tsx │ │ ├── theme-toggle.tsx │ │ ├── deleteModal.tsx │ │ ├── header.tsx │ │ ├── modal.tsx │ │ └── editModal.tsx ├── next.config.mjs ├── postcss.config.mjs ├── components.json ├── public │ ├── vercel.svg │ └── next.svg ├── tsconfig.json ├── README.md ├── package.json └── tailwind.config.ts ├── download.png ├── package.json ├── run3.sh ├── .gitignore ├── requirements.txt ├── .github └── workflows │ └── actions.yaml ├── cleanUp.py ├── README.md ├── airtableScraper.py ├── linkedinScraper.py └── githubScraper.py /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masterspin/internship-scraper/HEAD/logo.png -------------------------------------------------------------------------------- /internship-scraper/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masterspin/internship-scraper/HEAD/download.png -------------------------------------------------------------------------------- /internship-scraper/src/app/old.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masterspin/internship-scraper/HEAD/internship-scraper/src/app/old.ico -------------------------------------------------------------------------------- /internship-scraper/src/app/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masterspin/internship-scraper/HEAD/internship-scraper/src/app/icon.ico -------------------------------------------------------------------------------- /internship-scraper/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@supabase/auth-helpers-nextjs": "^0.10.0", 4 | "@supabase/ssr": "^0.3.0", 5 | "@supabase/supabase-js": "^2.43.1" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /internship-scraper/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /internship-scraper/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /internship-scraper/src/components/routes.ts: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import Home from '@/app/page'; 3 | import Analytics from '@/app/analytics/page'; 4 | 5 | const Routes = { 6 | Home: '/', 7 | Analytics: '/analytics', 8 | }; 9 | 10 | export default Routes; 11 | -------------------------------------------------------------------------------- /internship-scraper/src/lib/supabase/browser.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserClient } from "@supabase/ssr"; 2 | import { Database } from "../types/supabase"; 3 | 4 | export function supabaseBrowser() { 5 | return createBrowserClient( 6 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 7 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /internship-scraper/src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /internship-scraper/src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { type ThemeProviderProps } from "next-themes"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /run3.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Run linkedinScraper.py 4 | echo "Running linkedinScraper.py..." 5 | python3 linkedinScraper.py 6 | echo "Waiting for 30 seconds..." 7 | sleep 60 8 | 9 | # Run githubScraper.py 10 | echo "Running githubScraper.py..." 11 | python3 githubScraper.py 12 | echo "Waiting for 30 seconds..." 13 | sleep 60 14 | 15 | # Run cleanUp.py 16 | echo "Running cleanUp.py..." 17 | python3 cleanUp.py 18 | -------------------------------------------------------------------------------- /internship-scraper/src/app/hook/useCustomJobPosts.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useBetween } from "use-between"; 3 | 4 | const useCustomJobPosts = () => { 5 | const [customJobPosts, setCustomJobPosts] = useState([]); 6 | return { 7 | customJobPosts, 8 | setCustomJobPosts, 9 | }; 10 | }; 11 | 12 | const useSharedFormState = () => useBetween(useCustomJobPosts); 13 | export default useSharedFormState; 14 | -------------------------------------------------------------------------------- /internship-scraper/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /internship-scraper/src/lib/supabase/server.ts: -------------------------------------------------------------------------------- 1 | import { createServerClient, type CookieOptions } from "@supabase/ssr"; 2 | import { cookies } from "next/headers"; 3 | import { Database } from "../types/supabase"; 4 | 5 | export function supabaseServer() { 6 | const cookieStore = cookies(); 7 | 8 | return createServerClient( 9 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 10 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 11 | { 12 | cookies: { 13 | get(name: string) { 14 | return cookieStore.get(name)?.value; 15 | }, 16 | }, 17 | } 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /internship-scraper/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internship-scraper/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /internship-scraper/src/app/hook/useUser.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { supabaseBrowser } from "@/lib/supabase/browser"; 3 | 4 | import { useQuery } from "@tanstack/react-query"; 5 | 6 | export default function useUser() { 7 | return useQuery({ 8 | queryKey: ["user"], 9 | queryFn: async () => { 10 | const supabase = supabaseBrowser(); 11 | const { data } = await supabase.auth.getSession(); 12 | if (data.session?.user) { 13 | const { data: user } = await supabase 14 | .from("profiles") 15 | .select("*") 16 | .eq("id", data.session.user.id) 17 | .single(); 18 | return user; 19 | } 20 | return null; 21 | }, 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /internship-scraper/src/components/query-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 4 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 5 | import React, { ReactNode, useState } from "react"; 6 | 7 | export default function QueryProvider({ children }: { children: ReactNode }) { 8 | const [queryClient] = useState( 9 | () => 10 | new QueryClient({ 11 | defaultOptions: { 12 | queries: { 13 | staleTime: Infinity, 14 | }, 15 | }, 16 | }) 17 | ); 18 | return ( 19 | 20 | {children} 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | supabase.txt 2 | *.env 3 | *.env.local 4 | .DS_Store 5 | env 6 | node_modules 7 | 8 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 9 | 10 | # dependencies 11 | /node_modules 12 | /.pnp 13 | .pnp.js 14 | .yarn/install-state.gz 15 | 16 | # testing 17 | /coverage 18 | 19 | # next.js 20 | /internship-scraper/.next/ 21 | /internship-scraper/out/ 22 | 23 | # production 24 | /internship-scraper/build 25 | 26 | # misc 27 | /internship-scraper/.DS_Store 28 | *.pem 29 | 30 | # debug 31 | /internship-scraper/npm-debug.log* 32 | /internship-scraper/yarn-debug.log* 33 | /internship-scraper/yarn-error.log* 34 | 35 | # local env files 36 | .env*.local 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | next-env.d.ts 44 | 45 | *.env 46 | *.env.local 47 | 48 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp 2 | aiosignal==1.3.1 3 | annotated-types==0.6.0 4 | anyio==4.3.0 5 | attrs==23.2.0 6 | beautifulsoup4==4.12.3 7 | bs4==0.0.2 8 | certifi==2024.7.4 9 | cffi==1.16.0 10 | charset-normalizer==3.3.2 11 | deprecation==2.1.0 12 | frozenlist==1.4.1 13 | gotrue==2.4.2 14 | h11 15 | httpcore 16 | httpx==0.27.0 17 | idna==3.7 18 | multidict==6.0.5 19 | ndg-httpsclient==0.5.1 20 | packaging==24.0 21 | pandas==2.2.3 22 | postgrest==0.16.4 23 | pyasn1==0.6.0 24 | pycparser==2.22 25 | pydantic==2.7.1 26 | pydantic_core==2.18.2 27 | pyOpenSSL==24.1.0 28 | python-dateutil==2.9.0.post0 29 | python-dotenv==1.0.1 30 | pytz==2024.1 31 | realtime==1.0.4 32 | requests==2.32.2 33 | selenium==4.32.0 34 | six==1.16.0 35 | sniffio==1.3.1 36 | soupsieve==2.5 37 | storage3==0.7.4 38 | StrEnum==0.4.15 39 | supabase==2.4.5 40 | supafunc==0.4.5 41 | typing_extensions==4.11.0 42 | tzdata==2024.1 43 | urllib3==2.2.2 44 | websockets==12.0 45 | yarl 46 | -------------------------------------------------------------------------------- /internship-scraper/src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /internship-scraper/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | import QueryProvider from "@/components/query-provider"; 5 | import { Analytics } from "@vercel/analytics/react"; 6 | import { ThemeProvider } from "@/components/theme-provider"; 7 | 8 | const inter = Inter({ subsets: ["latin"] }); 9 | 10 | export const metadata: Metadata = { 11 | title: "Internship Tracker", 12 | description: "Track all your internship applications!", 13 | }; 14 | 15 | export default function RootLayout({ 16 | children, 17 | }: Readonly<{ 18 | children: React.ReactNode; 19 | }>) { 20 | return ( 21 | 22 | 23 | 24 | 30 | {children} 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /internship-scraper/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "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", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /internship-scraper/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internship-scraper/src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = TooltipPrimitive.Root 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 19 | 28 | 29 | )) 30 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 31 | 32 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 33 | -------------------------------------------------------------------------------- /internship-scraper/src/components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { Moon, Sun } from "lucide-react"; 5 | import { useTheme } from "next-themes"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu"; 14 | 15 | export function ThemeToggle() { 16 | const { setTheme } = useTheme(); 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}> 29 | Light 30 | 31 | setTheme("dark")}> 32 | Dark 33 | 34 | setTheme("system")}> 35 | System 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /internship-scraper/src/app/auth/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from "next/headers"; 2 | import { NextResponse } from "next/server"; 3 | import { type CookieOptions, createServerClient } from "@supabase/ssr"; 4 | 5 | export async function GET(request: Request) { 6 | const { searchParams, origin } = new URL(request.url); 7 | const code = searchParams.get("code"); 8 | // if "next" is in param, use it as the redirect URL 9 | const next = searchParams.get("next") ?? "/"; 10 | 11 | if (code) { 12 | const cookieStore = cookies(); 13 | const supabase = createServerClient( 14 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 15 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 16 | { 17 | cookies: { 18 | get(name: string) { 19 | return cookieStore.get(name)?.value; 20 | }, 21 | set(name: string, value: string, options: CookieOptions) { 22 | cookieStore.set({ name, value, ...options }); 23 | }, 24 | remove(name: string, options: CookieOptions) { 25 | cookieStore.delete({ name, ...options }); 26 | }, 27 | }, 28 | } 29 | ); 30 | const { error } = await supabase.auth.exchangeCodeForSession(code); 31 | if (!error) { 32 | return NextResponse.redirect(`${origin}${next}`); 33 | } 34 | } 35 | 36 | // return the user to an error page with instructions 37 | return NextResponse.redirect(`${origin}/auth/auth-code-error`); 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/actions.yaml: -------------------------------------------------------------------------------- 1 | name: run Scrapers 2 | 3 | on: 4 | schedule: # runs every 2 hours from 8am - 2am 5 | - cron: "0 8-23/2 * * *" 6 | - cron: "0 0-2/2 * * *" 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: checkout repo content 16 | uses: actions/checkout@v4 # checkout the repository content to github runner 17 | 18 | - name: setup python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.10" # install the python version needed 22 | 23 | - name: install python packages 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install -r requirements.txt 27 | 28 | - name: execute githubScraper.py 29 | env: 30 | NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} 31 | NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} 32 | NEXT_PUBLIC_SERVICE_ROLE_KEY: ${{ secrets.NEXT_PUBLIC_SERVICE_ROLE_KEY }} 33 | run: python githubScraper.py 34 | 35 | - name: execute airtableScraper.py 36 | env: 37 | NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} 38 | NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} 39 | NEXT_PUBLIC_SERVICE_ROLE_KEY: ${{ secrets.NEXT_PUBLIC_SERVICE_ROLE_KEY }} 40 | run: python airtableScraper.py -------------------------------------------------------------------------------- /internship-scraper/src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverAnchor = PopoverPrimitive.Anchor 13 | 14 | const PopoverContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 18 | 19 | 29 | 30 | )) 31 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 32 | 33 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 34 | -------------------------------------------------------------------------------- /internship-scraper/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /internship-scraper/src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /internship-scraper/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "internship-scraper", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@nextui-org/button": "^2.0.31", 13 | "@radix-ui/react-dialog": "^1.1.13", 14 | "@radix-ui/react-dropdown-menu": "^2.1.12", 15 | "@radix-ui/react-popover": "^1.1.13", 16 | "@radix-ui/react-select": "^2.2.2", 17 | "@radix-ui/react-separator": "^1.1.4", 18 | "@radix-ui/react-slot": "^1.2.2", 19 | "@radix-ui/react-tabs": "^1.1.9", 20 | "@radix-ui/react-tooltip": "^1.2.4", 21 | "@supabase/auth-helpers-react": "^0.5.0", 22 | "@supabase/ssr": "^0.6.1", 23 | "@supabase/supabase-auth-helpers": "^1.4.2", 24 | "@supabase/supabase-js": "^2.43.2", 25 | "@tanstack/react-query": "^5.35.1", 26 | "@tanstack/react-query-devtools": "^5.35.1", 27 | "@types/chart.js": "^2.9.41", 28 | "@vercel/analytics": "^1.3.1", 29 | "chart.js": "^4.4.3", 30 | "class-variance-authority": "^0.7.1", 31 | "clsx": "^2.1.1", 32 | "cmdk": "^1.1.1", 33 | "fuse.js": "^7.1.0", 34 | "lucide-react": "^0.503.0", 35 | "next": "14.2.28", 36 | "next-themes": "^0.4.6", 37 | "react": "^18", 38 | "react-dom": "^18", 39 | "react-icons": "^5.2.1", 40 | "react-modal": "^3.16.1", 41 | "recharts": "^2.15.3", 42 | "shadcn-ui": "^0.9.5", 43 | "tailwind-merge": "^3.2.0", 44 | "tailwindcss-animate": "^1.0.7", 45 | "use-between": "^1.3.5", 46 | "use-debounce": "^10.0.4" 47 | }, 48 | "devDependencies": { 49 | "@shadcn/ui": "^0.0.4", 50 | "@types/node": "^20", 51 | "@types/react": "^18", 52 | "@types/react-dom": "^18", 53 | "@types/react-modal": "^3.16.3", 54 | "eslint": "^8", 55 | "eslint-config-next": "14.2.3", 56 | "postcss": "^8", 57 | "supabase": "^1.165.0", 58 | "tailwindcss": "^3.4.1", 59 | "typescript": "^5" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /cleanUp.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import os 3 | from bs4 import BeautifulSoup 4 | from supabase import create_client, Client 5 | from dotenv import load_dotenv 6 | import time 7 | 8 | load_dotenv() 9 | 10 | url: str = os.getenv("NEXT_PUBLIC_SUPABASE_URL") 11 | key: str = os.getenv("NEXT_PUBLIC_SERVICE_ROLE_KEY") 12 | supabase: Client = create_client(url, key) 13 | 14 | 15 | current_database = supabase.table('posts').select('*').eq('source', 'LinkedIn').execute().data 16 | 17 | print(len(current_database)) 18 | 19 | job_list = [] 20 | 21 | MAX_RETRIES = 25 22 | 23 | for entry in current_database: 24 | link = entry.get('job_link') 25 | oldLink = link 26 | print(link) 27 | statuses = supabase.table('statuses').select('*').eq('job', oldLink).execute().data 28 | filtered_count = len([status for status in statuses if status.get('status') != 'Not Applied']) 29 | if(filtered_count > 0): 30 | continue 31 | link = "https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/" + link.split('/')[-1] 32 | response = requests.get(link) 33 | retries = 0 34 | while(response.status_code != 200 and retries < MAX_RETRIES): 35 | print("HELP") 36 | time.sleep(0.15) 37 | response = requests.get(link) 38 | retries+=1 39 | 40 | 41 | if(response.status_code != 200): 42 | job_list.append(oldLink) 43 | print(statuses) 44 | print(filtered_count) 45 | else: 46 | data = response.text 47 | soup = BeautifulSoup(data, "html.parser") 48 | feedback_message = soup.find('figcaption', class_='closed-job__flavor--closed') 49 | if(feedback_message and "No longer accepting applications" in feedback_message.get_text()): 50 | job_list.append(oldLink) 51 | print(feedback_message.get_text()) 52 | print(statuses) 53 | print(filtered_count) 54 | 55 | print(job_list) 56 | for link in job_list: 57 | try: 58 | response = supabase.table('posts').delete().eq('job_link', link).execute() 59 | except Exception as e: 60 | print(f"An error occurred: {e}") 61 | print(len(job_list)) -------------------------------------------------------------------------------- /internship-scraper/src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Tabs = TabsPrimitive.Root 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )) 23 | TabsList.displayName = TabsPrimitive.List.displayName 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )) 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )) 53 | TabsContent.displayName = TabsPrimitive.Content.displayName 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent } 56 | -------------------------------------------------------------------------------- /internship-scraper/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | darkMode: ["class"], 5 | content: [ 6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | theme: { 11 | extend: { 12 | backgroundImage: { 13 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 14 | 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))' 15 | }, 16 | borderRadius: { 17 | lg: 'var(--radius)', 18 | md: 'calc(var(--radius) - 2px)', 19 | sm: 'calc(var(--radius) - 4px)' 20 | }, 21 | colors: { 22 | background: 'hsl(var(--background))', 23 | foreground: 'hsl(var(--foreground))', 24 | card: { 25 | DEFAULT: 'hsl(var(--card))', 26 | foreground: 'hsl(var(--card-foreground))' 27 | }, 28 | popover: { 29 | DEFAULT: 'hsl(var(--popover))', 30 | foreground: 'hsl(var(--popover-foreground))' 31 | }, 32 | primary: { 33 | DEFAULT: 'hsl(var(--primary))', 34 | foreground: 'hsl(var(--primary-foreground))' 35 | }, 36 | secondary: { 37 | DEFAULT: 'hsl(var(--secondary))', 38 | foreground: 'hsl(var(--secondary-foreground))' 39 | }, 40 | muted: { 41 | DEFAULT: 'hsl(var(--muted))', 42 | foreground: 'hsl(var(--muted-foreground))' 43 | }, 44 | accent: { 45 | DEFAULT: 'hsl(var(--accent))', 46 | foreground: 'hsl(var(--accent-foreground))' 47 | }, 48 | destructive: { 49 | DEFAULT: 'hsl(var(--destructive))', 50 | foreground: 'hsl(var(--destructive-foreground))' 51 | }, 52 | border: 'hsl(var(--border))', 53 | input: 'hsl(var(--input))', 54 | ring: 'hsl(var(--ring))', 55 | chart: { 56 | '1': 'hsl(var(--chart-1))', 57 | '2': 'hsl(var(--chart-2))', 58 | '3': 'hsl(var(--chart-3))', 59 | '4': 'hsl(var(--chart-4))', 60 | '5': 'hsl(var(--chart-5))' 61 | } 62 | } 63 | } 64 | }, 65 | plugins: [require("tailwindcss-animate")], 66 | }; 67 | export default config; 68 | -------------------------------------------------------------------------------- /internship-scraper/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /internship-scraper/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLDivElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |
53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |
61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /internship-scraper/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | @layer utilities { 20 | .text-balance { 21 | text-wrap: balance; 22 | } 23 | } 24 | 25 | 26 | 27 | @layer base { 28 | :root { 29 | --background: 0 0% 100%; 30 | --foreground: 0 0% 3.9%; 31 | --card: 0 0% 100%; 32 | --card-foreground: 0 0% 3.9%; 33 | --popover: 0 0% 100%; 34 | --popover-foreground: 0 0% 3.9%; 35 | --primary: 0 0% 9%; 36 | --primary-foreground: 0 0% 98%; 37 | --secondary: 0 0% 96.1%; 38 | --secondary-foreground: 0 0% 9%; 39 | --muted: 0 0% 96.1%; 40 | --muted-foreground: 0 0% 45.1%; 41 | --accent: 0 0% 96.1%; 42 | --accent-foreground: 0 0% 9%; 43 | --destructive: 0 84.2% 60.2%; 44 | --destructive-foreground: 0 0% 98%; 45 | --border: 0 0% 89.8%; 46 | --input: 0 0% 89.8%; 47 | --ring: 0 0% 3.9%; 48 | --chart-1: 12 76% 61%; 49 | --chart-2: 173 58% 39%; 50 | --chart-3: 197 37% 24%; 51 | --chart-4: 43 74% 66%; 52 | --chart-5: 27 87% 67%; 53 | --radius: 0.5rem; 54 | } 55 | .dark { 56 | --background: 0 0% 3.9%; 57 | --foreground: 0 0% 98%; 58 | --card: 0 0% 3.9%; 59 | --card-foreground: 0 0% 98%; 60 | --popover: 0 0% 3.9%; 61 | --popover-foreground: 0 0% 98%; 62 | --primary: 0 0% 98%; 63 | --primary-foreground: 0 0% 9%; 64 | --secondary: 0 0% 14.9%; 65 | --secondary-foreground: 0 0% 98%; 66 | --muted: 0 0% 14.9%; 67 | --muted-foreground: 0 0% 63.9%; 68 | --accent: 0 0% 14.9%; 69 | --accent-foreground: 0 0% 98%; 70 | --destructive: 0 62.8% 30.6%; 71 | --destructive-foreground: 0 0% 98%; 72 | --border: 0 0% 14.9%; 73 | --input: 0 0% 14.9%; 74 | --ring: 0 0% 83.1%; 75 | --chart-1: 220 70% 50%; 76 | --chart-2: 160 60% 45%; 77 | --chart-3: 30 80% 55%; 78 | --chart-4: 280 65% 60%; 79 | --chart-5: 340 75% 55%; 80 | } 81 | } 82 | 83 | 84 | 85 | @layer base { 86 | * { 87 | @apply border-border; 88 | } 89 | body { 90 | @apply bg-background text-foreground; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /internship-scraper/src/components/deleteModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import Modal from "react-modal"; 3 | import { MdDeleteForever } from "react-icons/md"; 4 | import { supabaseBrowser } from "@/lib/supabase/browser"; 5 | import useUser from "@/app/hook/useUser"; 6 | import useSharedFormState from "@/app/hook/useCustomJobPosts"; 7 | 8 | const supabase = supabaseBrowser(); 9 | 10 | const DeleteForm: React.FC<{ jobPost: any }> = ({ jobPost }) => { 11 | const [isOpen, setIsOpen] = useState(false); 12 | const { isFetching, data } = useUser(); 13 | const { customJobPosts, setCustomJobPosts } = useSharedFormState(); 14 | 15 | const openModal = () => { 16 | setIsOpen(true); 17 | }; 18 | 19 | const closeModal = () => { 20 | setIsOpen(false); 21 | }; 22 | 23 | const handleDelete = async () => { 24 | try { 25 | // Perform the deletion operation 26 | await supabase 27 | .from("custom_applications") 28 | .delete() 29 | .eq("job_link", jobPost.job_link) 30 | .eq("user", jobPost.user); 31 | 32 | setCustomJobPosts( 33 | customJobPosts.filter( 34 | (post) => 35 | !(post.job_link === jobPost.job_link && post.user === jobPost.user) 36 | ) 37 | ); 38 | 39 | // Close the modal 40 | closeModal(); 41 | } catch (error: any) { 42 | console.error("Error deleting application data:", error.message); 43 | } 44 | }; 45 | 46 | return ( 47 |
48 | {data && ( 49 | 55 | )} 56 | 76 |

Confirm Deletion

77 |

Are you sure you want to delete this job post?

78 |
79 | 85 | 91 |
92 |
93 |
94 | ); 95 | }; 96 | 97 | export default DeleteForm; 98 | -------------------------------------------------------------------------------- /internship-scraper/src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Table = React.forwardRef< 6 | HTMLTableElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
10 | 15 | 16 | )) 17 | Table.displayName = "Table" 18 | 19 | const TableHeader = React.forwardRef< 20 | HTMLTableSectionElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )) 25 | TableHeader.displayName = "TableHeader" 26 | 27 | const TableBody = React.forwardRef< 28 | HTMLTableSectionElement, 29 | React.HTMLAttributes 30 | >(({ className, ...props }, ref) => ( 31 | 36 | )) 37 | TableBody.displayName = "TableBody" 38 | 39 | const TableFooter = React.forwardRef< 40 | HTMLTableSectionElement, 41 | React.HTMLAttributes 42 | >(({ className, ...props }, ref) => ( 43 | tr]:last:border-b-0", 47 | className 48 | )} 49 | {...props} 50 | /> 51 | )) 52 | TableFooter.displayName = "TableFooter" 53 | 54 | const TableRow = React.forwardRef< 55 | HTMLTableRowElement, 56 | React.HTMLAttributes 57 | >(({ className, ...props }, ref) => ( 58 | 66 | )) 67 | TableRow.displayName = "TableRow" 68 | 69 | const TableHead = React.forwardRef< 70 | HTMLTableCellElement, 71 | React.ThHTMLAttributes 72 | >(({ className, ...props }, ref) => ( 73 |
[role=checkbox]]:translate-y-[2px]", 77 | className 78 | )} 79 | {...props} 80 | /> 81 | )) 82 | TableHead.displayName = "TableHead" 83 | 84 | const TableCell = React.forwardRef< 85 | HTMLTableCellElement, 86 | React.TdHTMLAttributes 87 | >(({ className, ...props }, ref) => ( 88 | [role=checkbox]]:translate-y-[2px]", 92 | className 93 | )} 94 | {...props} 95 | /> 96 | )) 97 | TableCell.displayName = "TableCell" 98 | 99 | const TableCaption = React.forwardRef< 100 | HTMLTableCaptionElement, 101 | React.HTMLAttributes 102 | >(({ className, ...props }, ref) => ( 103 |
108 | )) 109 | TableCaption.displayName = "TableCaption" 110 | 111 | export { 112 | Table, 113 | TableHeader, 114 | TableBody, 115 | TableFooter, 116 | TableHead, 117 | TableRow, 118 | TableCell, 119 | TableCaption, 120 | } 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Internship Scraper & Manager for SWE/Quant/Hardware Internships/Co-ops (2025-2026 School Year) 2 | 3 | ## Overview 4 | 5 | This project is a comprehensive solution for scraping and managing internship and co-op listings for the 2025-2026 school year. It focuses on positions in software engineering (SWE), quantitative trading (Quant), electrical engineering, and business domains. The scraper collects listings from LinkedIn, the PittCSC & Simplify GitHub repository, and the Ouckah & CSCareers GitHub repository. The platform also incorporates Google OAuth for seamless user management. 6 | 7 | [Visit the website (ritij.tech)](https://www.ritij.tech/) 8 | 9 | ## Features 10 | 11 | - **Multi-source scraping**: Collects job listings from LinkedIn, PittCSC GitHub, and Ouckah GitHub. 12 | - **User authentication**: Utilizes Google OAuth for secure and easy user management. 13 | - **Internship management**: Enables users to save, filter, and track internship applications. 14 | - **Responsive design**: Ensures a seamless experience across devices. 15 | 16 | ## Tech Stack 17 | 18 | - **Frontend**: React, TypeScript, Next.js, TailwindCSS 19 | - **Database**: PostgreSQL, Supabase 20 | - **Scraping Tools**: Beautiful Soup, asyncio, aiohttp 21 | - **Authentication**: Google OAuth 2.0 22 | 23 | ## Installation 24 | 25 | ### Prerequisites 26 | 27 | - Node.js 28 | - Python 29 | - Supabase account 30 | - Google Cloud project for OAuth 31 | 32 | ### Steps 33 | 34 | 1. **Clone the repository:** 35 | ```bash 36 | git clone https://github.com/masterspin/internship-scraper.git 37 | cd internship-scraper 38 | ``` 39 | 40 | 2. **Install dependencies:** 41 | ```bash 42 | npm install 43 | ``` 44 | 45 | 3. **Setup environment variables:** 46 | Create a `.env` file in the root directory and add the following variables: 47 | ```plaintext 48 | NEXT_PUBLIC_SUPABASE_URL=your_supabase_url 49 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key 50 | NEXT_PUBLIC_SERVICE_ROLE_KEY=your_supabase_anon_key 51 | ``` 52 | 53 | 4. **Run the Python scrapers:** 54 | Ensure you have Python and the required libraries installed. Then run: 55 | ```bash 56 | python3 linkedinScraper.py 57 | python3 githubScraper.py 58 | ``` 59 | 60 | 5. **Start the development server:** 61 | ```bash 62 | npm run dev 63 | ``` 64 | 65 | ## Usage 66 | 67 | 1. **Log in with Google:** 68 | - Navigate to the homepage. 69 | - Click on the "Sign In" button to authenticate. 70 | 71 | 2. **Scrape internships:** 72 | - Use the provided options to initiate scraping from LinkedIn, PittCSC GitHub, and Ouckah GitHub. 73 | - Filter and manage the scraped listings. 74 | 75 | 3. **Save and track applications:** 76 | - Keep track of your applications directly on the platform. 77 | - Add your own personal job postings. 78 | 79 | ## Contributing 80 | 81 | We welcome contributions from the community! To contribute, follow these steps: 82 | 83 | 1. Fork the repository. 84 | 2. Create a new branch (`git checkout -b feature-branch`). 85 | 3. Make your changes and commit them (`git commit -m 'Add new feature'`). 86 | 4. Push to the branch (`git push origin feature-branch`). 87 | 5. Create a new Pull Request. 88 | 89 | ## License 90 | 91 | This project is licensed under the MIT License. 92 | 93 | ## Contact 94 | 95 | For any inquiries or feedback, please contact me at [ritijcode@gmail.com](mailto:ritijcode@gmail.com). 96 | 97 | --- 98 | 99 | This README provides a comprehensive guide to setting up, using, and contributing to the Internship Scraper & Manager. It also highlights the key features and technologies used in the project, making it easy for users and contributors to get started. 100 | -------------------------------------------------------------------------------- /internship-scraper/src/components/header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useState } from "react"; 3 | import Link from "next/link"; 4 | import { FcGoogle } from "react-icons/fc"; 5 | import { supabaseBrowser } from "@/lib/supabase/browser"; 6 | import { useQueryClient } from "@tanstack/react-query"; 7 | import { useRouter } from "next/navigation"; 8 | import { FaArrowRightFromBracket } from "react-icons/fa6"; 9 | import Routes from "./routes"; 10 | import { ThemeToggle } from "@/components/theme-toggle"; 11 | import useUser from "@/app/hook/useUser"; 12 | 13 | // Import shadcn components 14 | import { Button } from "@/components/ui/button"; 15 | import { 16 | DropdownMenu, 17 | DropdownMenuContent, 18 | DropdownMenuItem, 19 | DropdownMenuSeparator, 20 | DropdownMenuTrigger, 21 | } from "@/components/ui/dropdown-menu"; 22 | 23 | const Header = () => { 24 | const handleLoginWithOauth = (provider: "google") => { 25 | const supabase = supabaseBrowser(); 26 | 27 | supabase.auth.signInWithOAuth({ 28 | provider, 29 | options: { 30 | redirectTo: location.origin + "/auth/callback", 31 | }, 32 | }); 33 | }; 34 | 35 | const { isFetching, data } = useUser(); 36 | const queryClient = useQueryClient(); 37 | const router = useRouter(); 38 | 39 | if (isFetching) { 40 | return <>; 41 | } 42 | 43 | const handleLogOut = async () => { 44 | const supabase = supabaseBrowser(); 45 | queryClient.clear(); 46 | await supabase.auth.signOut(); 47 | router.refresh(); 48 | }; 49 | 50 | return ( 51 |
52 |
53 | 54 | Internships 55 | 56 |
57 | 58 | {!data?.id ? ( 59 | 67 | ) : ( 68 | 69 | 70 | 88 | 89 | 90 | 91 | Opportunities 92 | 93 | 94 | Analytics 95 | 96 | 97 | 101 | Sign out 102 | 103 | 104 | 105 | )} 106 |
107 |
108 |
109 | ); 110 | }; 111 | 112 | export default Header; 113 | -------------------------------------------------------------------------------- /internship-scraper/src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogTrigger, 116 | DialogClose, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /airtableScraper.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from urllib.parse import unquote 4 | 5 | import pandas as pd 6 | import requests 7 | from dotenv import load_dotenv 8 | from supabase import create_client, Client 9 | 10 | load_dotenv() 11 | 12 | def scrape_airtable(job_function: str): 13 | url: str = os.getenv("NEXT_PUBLIC_SUPABASE_URL") 14 | key: str = os.getenv("NEXT_PUBLIC_SERVICE_ROLE_KEY") 15 | supabase: Client = create_client(url, key) 16 | 17 | print("Starting Airtable scrape...") 18 | current_data = supabase.table('posts').select('job_link').execute().data 19 | 20 | # Initialize existing_links from Supabase data 21 | existing_links = set(record['job_link'] for record in current_data) 22 | s = requests.Session() 23 | 24 | headers = { 25 | 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', 26 | 'Connection': 'keep-alive', 27 | 'Host': 'airtable.com', 28 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36' 29 | } 30 | 31 | url = 'https://airtable.com/appmXHK6JoqQcSKf6/shruwVkAKPjHFdVJu/tbl0U5gfpdphUO2KF?viewControls=on' 32 | step = s.get(url, headers=headers) 33 | 34 | # Get data table URL 35 | start = 'urlWithParams: ' 36 | end = 'earlyPrefetchSpan:' 37 | x = step.text 38 | new_url = 'https://airtable.com' + x[x.find(start) + len(start):x.rfind(end)].strip().replace('u002F', '').replace('"', '').replace('\\', '/')[:-1] 39 | 40 | # Get Airtable auth 41 | start = 'var headers = ' 42 | end = "headers['x-time-zone'] " 43 | dirty_auth_json = x[x.find(start) + len(start):x.rfind(end)].strip()[:-1] 44 | auth_json = json.loads(dirty_auth_json) 45 | 46 | new_headers = { 47 | 'Accept': '*/*', 48 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 49 | 'X-Airtable-Accept-Msgpack': 'true', 50 | 'X-Airtable-Application-Id': auth_json['x-airtable-application-id'], 51 | 'X-Airtable-Inter-Service-Client': 'webClient', 52 | 'X-Airtable-Page-Load-Id': auth_json['x-airtable-page-load-id'], 53 | 'X-Early-Prefetch': 'true', 54 | 'X-Requested-With': 'XMLHttpRequest', 55 | 'X-Time-Zone': 'Europe/London', 56 | 'X-User-Locale': 'en' 57 | } 58 | 59 | json_data = s.get(new_url, headers=new_headers).json() 60 | 61 | # Create DataFrame from column and row data 62 | cols = {x['id']: x['name'] for x in json_data['data']['table']['columns']} 63 | rows = json_data['data']['table']['rows'] 64 | 65 | df = pd.json_normalize(rows) 66 | ugly_col = df.columns 67 | 68 | clean_col = [next((x.replace('cellValuesByColumnId.', '').replace(k, v) for k, v in cols.items() if k in x), x) for x in ugly_col] 69 | df.columns = clean_col 70 | 71 | choice_dict = {} 72 | 73 | for col in json_data['data']['table']['columns']: 74 | if col['name'] == 'Job Function': 75 | choice_dict = {k: v['name'] for k, v in col['typeOptions']['choices'].items()} 76 | 77 | df['Job Function'] = df['Job Function'].map(choice_dict) 78 | 79 | # Filter by the provided Job Function 80 | df = df[df['Job Function'] == job_function] 81 | 82 | df = df.rename(columns={ 83 | 'Apply.url': 'job_link', 84 | 'Company': 'company_name', 85 | 'Position Title': 'job_role', 86 | 'Date': 'date', 87 | 'Location': 'location' 88 | }) 89 | 90 | columns_to_keep = ['job_link', 'company_name', 'job_role', 'date', 'location'] 91 | 92 | df = df[columns_to_keep] 93 | 94 | df['source'] = 'airtable' 95 | if(job_function == "Electrical and Controls Engineering"): 96 | df['job_type'] = 'EE' 97 | elif(job_function == "Hardware Engineering"): 98 | df['job_type'] = 'Hardware' 99 | 100 | # Ensure job_link URLs are properly encoded and decoded 101 | df['job_link'] = df['job_link'].apply(lambda x: unquote(x)) 102 | 103 | data_list = df.to_dict(orient='records') 104 | 105 | # Check for new records 106 | new_records = [record for record in data_list if record['job_link'] not in existing_links] 107 | 108 | if new_records: 109 | response = supabase.table('posts').upsert(new_records).execute() 110 | print(f"Inserted {len(new_records)} new records.") 111 | else: 112 | print("No new records to insert.") 113 | 114 | print(f"Number of new records for {job_function}: {len(new_records)}") 115 | 116 | scrape_airtable('Electrical and Controls Engineering') 117 | scrape_airtable('Hardware Engineering') 118 | -------------------------------------------------------------------------------- /internship-scraper/src/components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { type DialogProps } from "@radix-ui/react-dialog" 5 | import { Command as CommandPrimitive } from "cmdk" 6 | import { Search } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | import { Dialog, DialogContent } from "@/components/ui/dialog" 10 | 11 | const Command = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 23 | )) 24 | Command.displayName = CommandPrimitive.displayName 25 | 26 | const CommandDialog = ({ children, ...props }: DialogProps) => { 27 | return ( 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | ) 36 | } 37 | 38 | const CommandInput = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 |
43 | 44 | 52 |
53 | )) 54 | 55 | CommandInput.displayName = CommandPrimitive.Input.displayName 56 | 57 | const CommandList = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 66 | )) 67 | 68 | CommandList.displayName = CommandPrimitive.List.displayName 69 | 70 | const CommandEmpty = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >((props, ref) => ( 74 | 79 | )) 80 | 81 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName 82 | 83 | const CommandGroup = React.forwardRef< 84 | React.ElementRef, 85 | React.ComponentPropsWithoutRef 86 | >(({ className, ...props }, ref) => ( 87 | 95 | )) 96 | 97 | CommandGroup.displayName = CommandPrimitive.Group.displayName 98 | 99 | const CommandSeparator = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName 110 | 111 | const CommandItem = React.forwardRef< 112 | React.ElementRef, 113 | React.ComponentPropsWithoutRef 114 | >(({ className, ...props }, ref) => ( 115 | 123 | )) 124 | 125 | CommandItem.displayName = CommandPrimitive.Item.displayName 126 | 127 | const CommandShortcut = ({ 128 | className, 129 | ...props 130 | }: React.HTMLAttributes) => { 131 | return ( 132 | 139 | ) 140 | } 141 | CommandShortcut.displayName = "CommandShortcut" 142 | 143 | export { 144 | Command, 145 | CommandDialog, 146 | CommandInput, 147 | CommandList, 148 | CommandEmpty, 149 | CommandGroup, 150 | CommandItem, 151 | CommandShortcut, 152 | CommandSeparator, 153 | } 154 | -------------------------------------------------------------------------------- /internship-scraper/src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SelectPrimitive from "@radix-ui/react-select" 5 | import { Check, ChevronDown, ChevronUp } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Select = SelectPrimitive.Root 10 | 11 | const SelectGroup = SelectPrimitive.Group 12 | 13 | const SelectValue = SelectPrimitive.Value 14 | 15 | const SelectTrigger = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, children, ...props }, ref) => ( 19 | span]:line-clamp-1", 23 | className 24 | )} 25 | {...props} 26 | > 27 | {children} 28 | 29 | 30 | 31 | 32 | )) 33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 34 | 35 | const SelectScrollUpButton = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | 48 | 49 | )) 50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName 51 | 52 | const SelectScrollDownButton = React.forwardRef< 53 | React.ElementRef, 54 | React.ComponentPropsWithoutRef 55 | >(({ className, ...props }, ref) => ( 56 | 64 | 65 | 66 | )) 67 | SelectScrollDownButton.displayName = 68 | SelectPrimitive.ScrollDownButton.displayName 69 | 70 | const SelectContent = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >(({ className, children, position = "popper", ...props }, ref) => ( 74 | 75 | 86 | 87 | 94 | {children} 95 | 96 | 97 | 98 | 99 | )) 100 | SelectContent.displayName = SelectPrimitive.Content.displayName 101 | 102 | const SelectLabel = React.forwardRef< 103 | React.ElementRef, 104 | React.ComponentPropsWithoutRef 105 | >(({ className, ...props }, ref) => ( 106 | 111 | )) 112 | SelectLabel.displayName = SelectPrimitive.Label.displayName 113 | 114 | const SelectItem = React.forwardRef< 115 | React.ElementRef, 116 | React.ComponentPropsWithoutRef 117 | >(({ className, children, ...props }, ref) => ( 118 | 126 | 127 | 128 | 129 | 130 | 131 | {children} 132 | 133 | )) 134 | SelectItem.displayName = SelectPrimitive.Item.displayName 135 | 136 | const SelectSeparator = React.forwardRef< 137 | React.ElementRef, 138 | React.ComponentPropsWithoutRef 139 | >(({ className, ...props }, ref) => ( 140 | 145 | )) 146 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 147 | 148 | export { 149 | Select, 150 | SelectGroup, 151 | SelectValue, 152 | SelectTrigger, 153 | SelectContent, 154 | SelectLabel, 155 | SelectItem, 156 | SelectSeparator, 157 | SelectScrollUpButton, 158 | SelectScrollDownButton, 159 | } 160 | -------------------------------------------------------------------------------- /internship-scraper/src/components/modal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import Modal from "react-modal"; 3 | import { FaPlus } from "react-icons/fa"; 4 | import { supabaseBrowser } from "@/lib/supabase/browser"; 5 | import useUser from "@/app/hook/useUser"; 6 | import useSharedFormState from "@/app/hook/useCustomJobPosts"; 7 | 8 | const supabase = supabaseBrowser(); 9 | 10 | const Form = () => { 11 | const [isOpen, setIsOpen] = useState(false); 12 | const { isFetching, data } = useUser(); 13 | const { customJobPosts, setCustomJobPosts } = useSharedFormState(); 14 | 15 | const openModal = () => { 16 | setIsOpen(true); 17 | }; 18 | 19 | const closeModal = () => { 20 | setIsOpen(false); 21 | }; 22 | 23 | const handleSubmit = async (e: any) => { 24 | e.preventDefault(); 25 | if (data) { 26 | const formData = new FormData(e.target); 27 | const currentDate = new Date(); 28 | const month = String(currentDate.getMonth() + 1).padStart(2, "0"); 29 | const day = String(currentDate.getDate()).padStart(2, "0"); 30 | const year = currentDate.getFullYear(); 31 | try { 32 | const { data: insertData, error } = await supabase 33 | .from("custom_applications") 34 | .upsert({ 35 | job_link: String(formData.get("jobLink")), 36 | user: data.id, 37 | job_role: String(formData.get("jobRole")), 38 | company_name: String(formData.get("companyName")), 39 | location: String(formData.get("location")), 40 | status: String(formData.get("status")), 41 | date: `${month}-${day}-${year}`, 42 | }) 43 | .select(); 44 | if (error) { 45 | throw error; 46 | } 47 | 48 | setCustomJobPosts([...customJobPosts, ...insertData]); 49 | } catch (error: any) { 50 | console.error("Error upserting application data:", error.message); 51 | } 52 | } 53 | closeModal(); 54 | }; 55 | 56 | return ( 57 |
58 | {data && ( 59 | 66 | )} 67 | 87 |

Custom Application Entry

88 |
89 | 98 | 107 | 116 | 125 | 144 | 145 |
146 | 152 | 158 |
159 |
160 |
161 |
162 | ); 163 | }; 164 | 165 | export default Form; 166 | -------------------------------------------------------------------------------- /linkedinScraper.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import os 3 | from bs4 import BeautifulSoup 4 | from datetime import datetime 5 | from supabase import create_client, Client 6 | from dotenv import load_dotenv 7 | import time 8 | 9 | load_dotenv() 10 | 11 | url: str = os.getenv("NEXT_PUBLIC_SUPABASE_URL") 12 | key: str = os.getenv("NEXT_PUBLIC_SERVICE_ROLE_KEY") 13 | supabase: Client = create_client(url, key) 14 | 15 | swe_queries = [ 16 | "software engineer intern", 17 | "backend engineer intern", 18 | ] 19 | 20 | quant_queries = [ 21 | "quantitative developer intern", 22 | "quantitative trading intern", 23 | ] 24 | 25 | # bus_queries = [ 26 | # "financial analyst intern", 27 | # "investment management intern", 28 | # "equity research intern", 29 | # "product management intern" 30 | # ] 31 | 32 | 33 | current_database = supabase.table('posts').select('*').execute().data 34 | job_id_list = [] 35 | 36 | def parseQuery(query): 37 | for i in range(0,976,25): 38 | list_url = f"https://www.linkedin.com/jobs/search/?f_E=1&f_TPR=r86400&keywords={query}&location=United%20States&start={i}" 39 | response = requests.get(list_url) 40 | 41 | while(response.status_code != 200): 42 | time.sleep(0.5) 43 | print(list_url) 44 | response = requests.get(list_url) 45 | 46 | list_data = response.text 47 | list_soup = BeautifulSoup(list_data, "html.parser") 48 | 49 | page_jobs = list_soup.find_all("li") 50 | 51 | for job in page_jobs: 52 | 53 | base_card_div = job.find("div", {"class" : "base-card"}) 54 | if(base_card_div == None): 55 | continue 56 | job_id = base_card_div.get("data-entity-urn").split(":")[3] 57 | job_link_exists = any(f"https://www.linkedin.com/jobs/view/{job_id}" == item.get('job_link') for item in current_database) 58 | if(not job_link_exists and job_id not in job_id_list): 59 | job_id_list.append(job_id) 60 | time.sleep(0.1) 61 | print("finished parsing ", query) 62 | 63 | job_post_data =[] 64 | def getJobData(jobType): 65 | for job_id in job_id_list: 66 | job_url = f"https://www.linkedin.com/jobs-guest/jobs/api/jobPosting/{job_id}" 67 | job_response = requests.get(job_url) 68 | while(job_response.status_code != 200): 69 | time.sleep(0.5) 70 | print(job_url) 71 | job_response = requests.get(job_url) 72 | job_data = job_response.text 73 | job_soup = BeautifulSoup(job_data, "html.parser") 74 | job_post = {} 75 | 76 | try: 77 | job_post["job_link"] = f"https://www.linkedin.com/jobs/view/{job_id}" 78 | except: 79 | job_post["job_link"] = "" 80 | 81 | try: 82 | job_post["job_role"] = job_soup.find("h2", {"class" : "top-card-layout__title font-sans text-lg papabear:text-xl font-bold leading-open text-color-text mb-0 topcard__title"}).text.strip() 83 | except: 84 | job_post["job_role"] = "" 85 | 86 | if jobType == "QUANT" and not any(keyword in job_post["job_role"].lower() for keyword in ["quant", "trad"]): 87 | continue 88 | 89 | if jobType == "QUANT" and any(keyword in job_post["job_role"].lower() for keyword in ["data"]): 90 | continue 91 | 92 | if (jobType == "QUANT" or jobType == "SWE") and any(word in job_post["job_role"].lower() for word in ["research","supply chain", "trainee", "behavior", "sr", "senior", "tech lead", "market", "sale", "business", "mechanical", "benefit", "inclusion", "coordinator", "clearing", "electric", "design", "client", "legal", "tax", "social", "process", "accounting", "retail", "training", "customer", "administrative", "human resources", "operations analyst", "management", "apprentice", "unpaid", "phd", "civil engineer", "hr", "conversion"]): 93 | continue 94 | 95 | if (jobType == "BUS") and any(word in job_post["job_role"].lower() for word in ["hr", "human resources", "front desk", "reception", "admin", "train", "unpaid"]): 96 | continue 97 | 98 | if "2026" not in job_post["job_role"].lower(): 99 | continue 100 | 101 | 102 | try: 103 | job_post["company_name"] = " ".join(job_soup.find("a", {"class": "topcard__org-name-link topcard__flavor--black-link"}).stripped_strings) 104 | except: 105 | job_post["company_name"] = "" 106 | 107 | if(job_post["company_name"] in ["Federal Reserve Bank of St. Louis","Federal Reserve Board","Oil and Gas Job Search Ltd","ClearanceJobs","Jobs via eFinancialCareers", "WayUp", "EV.Careers","Refonte Learning", "Refonte Learning AI","Dice","U.S. Bank","JobsInLogistics.com", "Georgia Tech Research Institute", "NYC Department of Health and Mental Hygiene", "National Indemnity Company", "Navy Federal Credit Union", "National Renewable Energy Laboratory", "Oak Ridge National Laboratory", "myGwork - LGBTQ+ Business Community", "L3Harris Technologies", "Jobs via Dice", "U.S. Hunger"]): 108 | continue 109 | 110 | try: 111 | job_post["job_type"] = jobType 112 | except: 113 | job_post["job_type"] = "" 114 | 115 | 116 | try: 117 | job_post["source"] = "LinkedIn" 118 | except: 119 | job_post["source"] = "" 120 | 121 | try: 122 | job_post["location"] = " ".join(job_soup.find('span', class_='topcard__flavor topcard__flavor--bullet').stripped_strings) 123 | except: 124 | job_post["location"] = "" 125 | 126 | try: 127 | job_post["date"] = datetime.now().strftime("%m-%d-%Y") 128 | except: 129 | job_post["date"] = "" 130 | 131 | job_post_data.append(job_post) 132 | time.sleep(0.1) 133 | 134 | 135 | 136 | 137 | for sweJob in swe_queries: 138 | parseQuery(sweJob) 139 | 140 | getJobData("SWE") 141 | 142 | print("got job data for SWE") 143 | 144 | job_id_list =[] 145 | 146 | for quantJob in quant_queries: 147 | parseQuery(quantJob) 148 | 149 | getJobData("QUANT") 150 | 151 | print("got job data for QUANT") 152 | 153 | # job_id_list =[] 154 | # 155 | # for busJob in bus_queries: 156 | # parseQuery(busJob) 157 | # 158 | # getJobData("BUS") 159 | # 160 | # print("got job data for BUS") 161 | 162 | print(job_post_data) 163 | 164 | for job_post in job_post_data: 165 | try: 166 | data, count = supabase.table('posts').insert(job_post).execute() 167 | except Exception as e: 168 | print(f"Error inserting job post: {e}") 169 | 170 | 171 | ################################################################################################################################################################################################################################ 172 | -------------------------------------------------------------------------------- /internship-scraper/src/components/editModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import Modal from "react-modal"; 3 | import { FaEdit } from "react-icons/fa"; 4 | import { supabaseBrowser } from "@/lib/supabase/browser"; 5 | import useUser from "@/app/hook/useUser"; 6 | import useSharedFormState from "@/app/hook/useCustomJobPosts"; 7 | 8 | const supabase = supabaseBrowser(); 9 | 10 | const EditForm: React.FC<{ jobPost: any }> = ({ jobPost }) => { 11 | const [isOpen, setIsOpen] = useState(false); 12 | const { isFetching, data } = useUser(); 13 | const { customJobPosts, setCustomJobPosts } = useSharedFormState(); 14 | 15 | const [formData, setFormData] = useState({ 16 | jobLink: jobPost.job_link || "", 17 | jobRole: jobPost.job_role || "", 18 | companyName: jobPost.company_name || "", 19 | location: jobPost.location || "", 20 | status: jobPost.status || "", 21 | }); 22 | 23 | const openModal = () => { 24 | setIsOpen(true); 25 | }; 26 | 27 | const closeModal = () => { 28 | setIsOpen(false); 29 | }; 30 | 31 | const handleChange = (e: any) => { 32 | const { name, value } = e.target; 33 | setFormData({ ...formData, [name]: value }); 34 | }; 35 | 36 | const handleSubmit = async (e: any) => { 37 | e.preventDefault(); 38 | console.log(jobPost); 39 | if (data) { 40 | const currentDate = new Date(); 41 | const month = String(currentDate.getMonth() + 1).padStart(2, "0"); 42 | const day = String(currentDate.getDate()).padStart(2, "0"); 43 | const year = currentDate.getFullYear(); 44 | try { 45 | const { data: updateData, error } = await supabase 46 | .from("custom_applications") 47 | .update({ 48 | job_link: formData.jobLink, 49 | job_role: formData.jobRole, 50 | company_name: formData.companyName, 51 | location: formData.location, 52 | }) 53 | .eq("job_link", jobPost.job_link) 54 | .eq("user", jobPost.user) 55 | .select() 56 | .single(); 57 | 58 | if (error) { 59 | throw error; 60 | } 61 | 62 | console.log(updateData); 63 | 64 | setCustomJobPosts( 65 | customJobPosts.map((post) => { 66 | if ( 67 | post.job_link === jobPost.job_link && 68 | post.user === jobPost.user 69 | ) { 70 | return { 71 | ...post, 72 | job_link: updateData.job_link, 73 | job_role: updateData.job_role, 74 | company_name: updateData.company_name, 75 | location: updateData.location, 76 | }; 77 | } 78 | return post; 79 | }) 80 | ); 81 | 82 | // setCustomJobPosts(updatedCustomJobPosts); 83 | } catch (error: any) { 84 | console.error("Error upserting application data:", error.message); 85 | } 86 | } 87 | closeModal(); 88 | }; 89 | 90 | return ( 91 |
92 | {data && ( 93 | 99 | )} 100 | 120 |

Edit Application

121 |
122 | 134 | 146 | 158 | 170 | 171 |
172 | 178 | 184 |
185 |
186 |
187 |
188 | ); 189 | }; 190 | 191 | export default EditForm; 192 | -------------------------------------------------------------------------------- /internship-scraper/src/components/ui/multi-select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cn } from "@/lib/utils"; 3 | import { Badge } from "@/components/ui/badge"; 4 | import { 5 | Command, 6 | CommandGroup, 7 | CommandItem, 8 | CommandList, 9 | CommandEmpty, 10 | CommandInput, 11 | } from "@/components/ui/command"; 12 | import { 13 | Popover, 14 | PopoverContent, 15 | PopoverTrigger, 16 | } from "@/components/ui/popover"; 17 | import { Button } from "@/components/ui/button"; 18 | import { Check, ChevronsUpDown, X } from "lucide-react"; 19 | 20 | export type Option = { 21 | value: string | number; 22 | label: string; 23 | icon?: React.ReactNode; 24 | disable?: boolean; 25 | }; 26 | 27 | interface MultiSelectProps { 28 | options: Option[]; 29 | selected: (string | number)[]; 30 | onChange: (selected: (string | number)[]) => void; 31 | className?: string; 32 | placeholder?: string; 33 | badges?: boolean; 34 | } 35 | 36 | export function MultiSelect({ 37 | options, 38 | selected, 39 | onChange, 40 | className, 41 | placeholder = "Select options", 42 | badges = true, 43 | }: MultiSelectProps) { 44 | const [open, setOpen] = React.useState(false); 45 | 46 | const handleUnselect = (item: string | number) => { 47 | onChange(selected.filter((i) => i !== item)); 48 | }; 49 | 50 | const handleKeyDown = (e: React.KeyboardEvent) => { 51 | const input = e.target as HTMLInputElement; 52 | if (selected.length > 0 && input.value === "" && e.key === "Backspace") { 53 | onChange(selected.slice(0, -1)); 54 | } 55 | if (e.key === "Escape") { 56 | setOpen(false); 57 | } 58 | }; 59 | 60 | const selectAll = () => { 61 | onChange( 62 | options.filter((option) => !option.disable).map((option) => option.value) 63 | ); 64 | }; 65 | 66 | const clearAll = () => { 67 | onChange([]); 68 | }; 69 | 70 | // Only show in the button when there are few selections 71 | const showInButton = selected.length <= 0; 72 | 73 | // Render selected tags inline (like in the image provided) 74 | const renderSelectedTags = () => { 75 | if (selected.length === 0) return null; 76 | 77 | return ( 78 |
79 | {selected.map((item) => { 80 | const option = options.find((o) => o.value === item); 81 | if (!option) return null; 82 | 83 | return ( 84 | 89 |
90 | {option.icon} 91 | {option.label} 92 |
93 | 104 |
105 | ); 106 | })} 107 |
108 | ); 109 | }; 110 | 111 | return ( 112 |
113 | 114 | 115 | 128 | 129 | 130 | 131 | 132 |
133 |
134 | 142 | 150 |
151 |
152 | 153 | No source found. 154 | 155 | {options.map((option) => { 156 | const isSelected = selected.includes(option.value); 157 | return ( 158 | { 162 | if (isSelected) { 163 | onChange( 164 | selected.filter((item) => item !== option.value) 165 | ); 166 | } else { 167 | onChange([...selected, option.value]); 168 | } 169 | }} 170 | className="flex items-center gap-2" 171 | > 172 |
180 | 181 |
182 |
183 | {option.icon} 184 | {option.label} 185 |
186 |
187 | ); 188 | })} 189 |
190 |
191 |
192 |
193 |
194 | 195 | {/* Display selected options as individual tags outside the dropdown */} 196 | {badges && renderSelectedTags()} 197 |
198 | ); 199 | } 200 | -------------------------------------------------------------------------------- /internship-scraper/src/lib/types/supabase.ts: -------------------------------------------------------------------------------- 1 | export type Json = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | { [key: string]: Json | undefined } 7 | | Json[]; 8 | 9 | export type Database = { 10 | public: { 11 | Tables: { 12 | custom_applications: { 13 | Row: { 14 | company_name: string | null; 15 | date: string | null; 16 | job_link: string; 17 | job_role: string | null; 18 | location: string | null; 19 | status: string | null; 20 | user: string; 21 | }; 22 | Insert: { 23 | company_name?: string | null; 24 | date?: string | null; 25 | job_link: string; 26 | job_role?: string | null; 27 | location?: string | null; 28 | status?: string | null; 29 | user?: string; 30 | }; 31 | Update: { 32 | company_name?: string | null; 33 | date?: string | null; 34 | job_link?: string; 35 | job_role?: string | null; 36 | location?: string | null; 37 | status?: string | null; 38 | user?: string; 39 | }; 40 | Relationships: [ 41 | { 42 | foreignKeyName: "custom_applications_user_fkey"; 43 | columns: ["user"]; 44 | isOneToOne: false; 45 | referencedRelation: "profiles"; 46 | referencedColumns: ["id"]; 47 | } 48 | ]; 49 | }; 50 | posts: { 51 | Row: { 52 | company_name: string | null; 53 | date: string | null; 54 | job_link: string; 55 | job_role: string | null; 56 | job_type: string | null; 57 | location: string | null; 58 | source: string; 59 | term: string | null; 60 | }; 61 | Insert: { 62 | company_name?: string | null; 63 | date?: string | null; 64 | job_link: string; 65 | job_role?: string | null; 66 | job_type?: string | null; 67 | location?: string | null; 68 | source?: string; 69 | term?: string | null; 70 | }; 71 | Update: { 72 | company_name?: string | null; 73 | date?: string | null; 74 | job_link?: string; 75 | job_role?: string | null; 76 | job_type?: string | null; 77 | location?: string | null; 78 | source?: string; 79 | term?: string | null; 80 | }; 81 | Relationships: []; 82 | }; 83 | profiles: { 84 | Row: { 85 | email: string; 86 | id: string; 87 | }; 88 | Insert: { 89 | email: string; 90 | id?: string; 91 | }; 92 | Update: { 93 | email?: string; 94 | id?: string; 95 | }; 96 | Relationships: [ 97 | { 98 | foreignKeyName: "profiles_id_fkey"; 99 | columns: ["id"]; 100 | isOneToOne: true; 101 | referencedRelation: "users"; 102 | referencedColumns: ["id"]; 103 | } 104 | ]; 105 | }; 106 | statuses: { 107 | Row: { 108 | job: string; 109 | status: string | null; 110 | user: string; 111 | }; 112 | Insert: { 113 | job: string; 114 | status?: string | null; 115 | user?: string; 116 | }; 117 | Update: { 118 | job?: string; 119 | status?: string | null; 120 | user?: string; 121 | }; 122 | Relationships: [ 123 | { 124 | foreignKeyName: "statuses_job_fkey"; 125 | columns: ["job"]; 126 | isOneToOne: false; 127 | referencedRelation: "posts"; 128 | referencedColumns: ["job_link"]; 129 | }, 130 | { 131 | foreignKeyName: "statuses_user_fkey"; 132 | columns: ["user"]; 133 | isOneToOne: false; 134 | referencedRelation: "profiles"; 135 | referencedColumns: ["id"]; 136 | } 137 | ]; 138 | }; 139 | }; 140 | Views: { 141 | [_ in never]: never; 142 | }; 143 | Functions: { 144 | [_ in never]: never; 145 | }; 146 | Enums: { 147 | [_ in never]: never; 148 | }; 149 | CompositeTypes: { 150 | [_ in never]: never; 151 | }; 152 | }; 153 | }; 154 | 155 | type PublicSchema = Database[Extract]; 156 | 157 | export type Tables< 158 | PublicTableNameOrOptions extends 159 | | keyof (PublicSchema["Tables"] & PublicSchema["Views"]) 160 | | { schema: keyof Database }, 161 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 162 | ? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] & 163 | Database[PublicTableNameOrOptions["schema"]]["Views"]) 164 | : never = never 165 | > = PublicTableNameOrOptions extends { schema: keyof Database } 166 | ? (Database[PublicTableNameOrOptions["schema"]]["Tables"] & 167 | Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends { 168 | Row: infer R; 169 | } 170 | ? R 171 | : never 172 | : PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] & 173 | PublicSchema["Views"]) 174 | ? (PublicSchema["Tables"] & 175 | PublicSchema["Views"])[PublicTableNameOrOptions] extends { 176 | Row: infer R; 177 | } 178 | ? R 179 | : never 180 | : never; 181 | 182 | export type TablesInsert< 183 | PublicTableNameOrOptions extends 184 | | keyof PublicSchema["Tables"] 185 | | { schema: keyof Database }, 186 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 187 | ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] 188 | : never = never 189 | > = PublicTableNameOrOptions extends { schema: keyof Database } 190 | ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { 191 | Insert: infer I; 192 | } 193 | ? I 194 | : never 195 | : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] 196 | ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { 197 | Insert: infer I; 198 | } 199 | ? I 200 | : never 201 | : never; 202 | 203 | export type TablesUpdate< 204 | PublicTableNameOrOptions extends 205 | | keyof PublicSchema["Tables"] 206 | | { schema: keyof Database }, 207 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 208 | ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] 209 | : never = never 210 | > = PublicTableNameOrOptions extends { schema: keyof Database } 211 | ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { 212 | Update: infer U; 213 | } 214 | ? U 215 | : never 216 | : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] 217 | ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { 218 | Update: infer U; 219 | } 220 | ? U 221 | : never 222 | : never; 223 | 224 | export type Enums< 225 | PublicEnumNameOrOptions extends 226 | | keyof PublicSchema["Enums"] 227 | | { schema: keyof Database }, 228 | EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database } 229 | ? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"] 230 | : never = never 231 | > = PublicEnumNameOrOptions extends { schema: keyof Database } 232 | ? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName] 233 | : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"] 234 | ? PublicSchema["Enums"][PublicEnumNameOrOptions] 235 | : never; 236 | -------------------------------------------------------------------------------- /internship-scraper/src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )) 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 74 | 75 | )) 76 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 77 | 78 | const DropdownMenuItem = React.forwardRef< 79 | React.ElementRef, 80 | React.ComponentPropsWithoutRef & { 81 | inset?: boolean 82 | } 83 | >(({ className, inset, ...props }, ref) => ( 84 | svg]:size-4 [&>svg]:shrink-0", 88 | inset && "pl-8", 89 | className 90 | )} 91 | {...props} 92 | /> 93 | )) 94 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 95 | 96 | const DropdownMenuCheckboxItem = React.forwardRef< 97 | React.ElementRef, 98 | React.ComponentPropsWithoutRef 99 | >(({ className, children, checked, ...props }, ref) => ( 100 | 109 | 110 | 111 | 112 | 113 | 114 | {children} 115 | 116 | )) 117 | DropdownMenuCheckboxItem.displayName = 118 | DropdownMenuPrimitive.CheckboxItem.displayName 119 | 120 | const DropdownMenuRadioItem = React.forwardRef< 121 | React.ElementRef, 122 | React.ComponentPropsWithoutRef 123 | >(({ className, children, ...props }, ref) => ( 124 | 132 | 133 | 134 | 135 | 136 | 137 | {children} 138 | 139 | )) 140 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 141 | 142 | const DropdownMenuLabel = React.forwardRef< 143 | React.ElementRef, 144 | React.ComponentPropsWithoutRef & { 145 | inset?: boolean 146 | } 147 | >(({ className, inset, ...props }, ref) => ( 148 | 157 | )) 158 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 159 | 160 | const DropdownMenuSeparator = React.forwardRef< 161 | React.ElementRef, 162 | React.ComponentPropsWithoutRef 163 | >(({ className, ...props }, ref) => ( 164 | 169 | )) 170 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 171 | 172 | const DropdownMenuShortcut = ({ 173 | className, 174 | ...props 175 | }: React.HTMLAttributes) => { 176 | return ( 177 | 181 | ) 182 | } 183 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 184 | 185 | export { 186 | DropdownMenu, 187 | DropdownMenuTrigger, 188 | DropdownMenuContent, 189 | DropdownMenuItem, 190 | DropdownMenuCheckboxItem, 191 | DropdownMenuRadioItem, 192 | DropdownMenuLabel, 193 | DropdownMenuSeparator, 194 | DropdownMenuShortcut, 195 | DropdownMenuGroup, 196 | DropdownMenuPortal, 197 | DropdownMenuSub, 198 | DropdownMenuSubContent, 199 | DropdownMenuSubTrigger, 200 | DropdownMenuRadioGroup, 201 | } 202 | -------------------------------------------------------------------------------- /internship-scraper/src/app/analytics/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useEffect, useState } from "react"; 3 | import Header from "@/components/header"; 4 | import useSharedFormState from "@/app/hook/useCustomJobPosts"; 5 | import { supabaseBrowser } from "@/lib/supabase/browser"; 6 | import useUser from "@/app/hook/useUser"; 7 | 8 | // Import shadcn components 9 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 10 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 11 | import { 12 | Tooltip, 13 | TooltipContent, 14 | TooltipProvider, 15 | TooltipTrigger, 16 | } from "@/components/ui/tooltip"; 17 | import { Skeleton } from "@/components/ui/skeleton"; 18 | import { InfoIcon } from "lucide-react"; 19 | 20 | // Import Recharts for better chart integration with React 21 | import { 22 | PieChart, 23 | Pie, 24 | Cell, 25 | ResponsiveContainer, 26 | Legend, 27 | Tooltip as RechartsTooltip, 28 | } from "recharts"; 29 | 30 | const supabase = supabaseBrowser(); 31 | 32 | export default function Analytics() { 33 | const [statuses, setStatuses] = useState([]); 34 | const [loading, setLoading] = useState(false); 35 | const [customApplications, setCustomApplications] = useState([]); 36 | 37 | const { isFetching, data } = useUser(); 38 | 39 | useEffect(() => { 40 | const fetchData = async () => { 41 | setLoading(true); 42 | if (data) { 43 | try { 44 | let { data: statusData, error: statusError } = await supabase 45 | .from("statuses") 46 | .select("*") 47 | .eq("user", data.id); 48 | 49 | if (statusError) { 50 | console.log(statusError.message); 51 | } else { 52 | setStatuses(statusData as any[]); 53 | } 54 | 55 | let { data: customAppData, error: customAppError } = await supabase 56 | .from("custom_applications") 57 | .select("*") 58 | .eq("user", data.id); 59 | 60 | if (customAppError) { 61 | console.log(customAppError.message); 62 | } else { 63 | setCustomApplications(customAppData as any[]); 64 | } 65 | } catch (error: any) { 66 | console.log(error.message); 67 | } 68 | } 69 | setLoading(false); 70 | }; 71 | 72 | fetchData(); 73 | }, [data]); 74 | 75 | // Prepare data for status chart 76 | const getStatusChartData = () => { 77 | const statusLabels = [ 78 | "Applied", 79 | "OA Received", 80 | "Interview Scheduled", 81 | "Waitlisted", 82 | "Rejected", 83 | "Offer Received", 84 | "Accepted", 85 | "Will Not Apply", 86 | ]; 87 | 88 | const statusColors = [ 89 | "#6B7280", // Applied (gray) 90 | "#C084FC", // OA Received (purple) 91 | "#60A5FA", // Interview Scheduled (blue) 92 | "#FBBF24", // Waitlisted (yellow) 93 | "#F87171", // Rejected (red) 94 | "#4ADE80", // Offer Received (green) 95 | "#10B981", // Accepted (emerald) 96 | "#92400E", // Will Not Apply (amber) 97 | ]; 98 | 99 | const data = statusLabels 100 | .map((label, index) => { 101 | const count = 102 | statuses.filter((status) => status.status === label).length + 103 | customApplications.filter((app) => app.status === label).length; 104 | 105 | return { 106 | name: label, 107 | value: count, 108 | color: statusColors[index], 109 | }; 110 | }) 111 | .filter((item) => item.value > 0); 112 | 113 | return { data, colors: statusColors }; 114 | }; 115 | 116 | // Prepare data for source chart 117 | const getSourceChartData = () => { 118 | const sourceLabels = [ 119 | "LinkedIn Applications", 120 | "GitHub Applications", 121 | "Personal Applications", 122 | ]; 123 | 124 | const sourceColors = [ 125 | "#0C84FC", // LinkedIn 126 | "#22C55E", // GitHub 127 | "#F87171", // Personal 128 | ]; 129 | 130 | const counts = [ 131 | statuses.filter( 132 | (status) => 133 | status.status !== "Not Applied" && 134 | status.status !== "Will Not Apply" && 135 | new URL(status.job).hostname === "www.linkedin.com" 136 | ).length, 137 | statuses.filter( 138 | (status) => 139 | status.status !== "Not Applied" && 140 | status.status !== "Will Not Apply" && 141 | new URL(status.job).hostname !== "www.linkedin.com" 142 | ).length, 143 | customApplications.filter((status) => status.status !== "Not Applied") 144 | .length, 145 | ]; 146 | 147 | const data = sourceLabels 148 | .map((label, index) => ({ 149 | name: label, 150 | value: counts[index], 151 | color: sourceColors[index], 152 | })) 153 | .filter((item) => item.value > 0); 154 | 155 | return { data, colors: sourceColors }; 156 | }; 157 | 158 | const renderCustomizedLabel = ({ 159 | cx, 160 | cy, 161 | midAngle, 162 | innerRadius, 163 | outerRadius, 164 | percent, 165 | name, 166 | }: { 167 | cx: number; 168 | cy: number; 169 | midAngle: number; 170 | innerRadius: number; 171 | outerRadius: number; 172 | percent: number; 173 | name: string; 174 | }) => { 175 | if (percent < 0.05) return null; 176 | 177 | const RADIAN = Math.PI / 180; 178 | const radius = innerRadius + (outerRadius - innerRadius) * 0.5; 179 | const x = cx + radius * Math.cos(-midAngle * RADIAN); 180 | const y = cy + radius * Math.sin(-midAngle * RADIAN); 181 | 182 | return ( 183 | cx ? "start" : "end"} 188 | dominantBaseline="central" 189 | fontSize={12} 190 | > 191 | {`${(percent * 100).toFixed(0)}%`} 192 | 193 | ); 194 | }; 195 | 196 | return ( 197 |
198 |
199 | 200 | {data && !loading ? ( 201 |
202 | 203 | 204 | Application Statuses 205 | Application Sources 206 | 207 | 208 | 209 | 210 | 211 | Application Status Distribution 212 | 213 | 214 | 215 | 216 | 217 | 218 |

219 | This may be an overestimate due to duplicates 220 |

221 |
222 |
223 |
224 |
225 | 226 |
227 | 228 | 229 | 239 | {getStatusChartData().data.map((entry, index) => ( 240 | 241 | ))} 242 | 243 | 248 | [ 250 | `${value} applications`, 251 | name, 252 | ]} 253 | /> 254 | 255 | 256 |
257 |
258 |
259 |
260 | 261 | 262 | 263 | 264 | Application Source Distribution 265 | 266 | 267 |
268 | 269 | 270 | 280 | {getSourceChartData().data.map((entry, index) => ( 281 | 282 | ))} 283 | 284 | 289 | [ 291 | `${value} applications`, 292 | name, 293 | ]} 294 | /> 295 | 296 | 297 |
298 |
299 |
300 |
301 |
302 |
303 | ) : data && loading ? ( 304 |
305 |
306 | 307 | 308 |
309 |
310 | 311 | 312 |
313 |
314 | ) : ( 315 |
316 | 317 | 318 | 319 | Please Sign In to View Analytics 320 | 321 | 322 | 323 |
324 | )} 325 |
326 | ); 327 | } 328 | -------------------------------------------------------------------------------- /githubScraper.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | import requests 4 | from bs4 import BeautifulSoup 5 | from supabase import create_client, Client 6 | from dotenv import load_dotenv 7 | from selenium import webdriver 8 | from selenium.webdriver.chrome.service import Service 9 | from selenium.webdriver.common.by import By 10 | from selenium.webdriver.chrome.options import Options 11 | from selenium.webdriver.support.ui import WebDriverWait 12 | from selenium.webdriver.support import expected_conditions as EC 13 | 14 | load_dotenv() 15 | 16 | url: str = os.getenv("NEXT_PUBLIC_SUPABASE_URL") 17 | key: str = os.getenv("NEXT_PUBLIC_SERVICE_ROLE_KEY") 18 | supabase: Client = create_client(url, key) 19 | 20 | print("Starting Github scrape...") 21 | current_database = [post for post in supabase.table('posts').select('*').execute().data if post['source'] not in ['PittCSC', 'PittCSC Off-Season']] 22 | job_post_data = [] 23 | 24 | def githubScraper(repoLink, repoName): 25 | githubInternships = requests.get(repoLink) 26 | soup = BeautifulSoup(githubInternships.text, features="html.parser") 27 | 28 | for internship in soup.select("article table tbody tr"): 29 | internship_details = internship.find_all("td") 30 | date = "" 31 | 32 | try: 33 | date = internship_details[4].string 34 | except: 35 | date = "" 36 | 37 | links = [] 38 | 39 | try: 40 | links = internship_details[3].find_all("a") 41 | except: 42 | links = [] 43 | 44 | if(len(links) > 0): 45 | job_link_exists = any(links[0].get("href") == item.get('job_link') for item in current_database) 46 | if(not job_link_exists): 47 | job_post = {} 48 | try: 49 | job_post["job_link"] = links[0].get("href") 50 | except: 51 | job_post["job_link"] = "" 52 | 53 | try: 54 | job_post["job_role"] = internship_details[1].string 55 | except: 56 | job_post["job_role"] = "" 57 | 58 | try: 59 | job_post["company_name"] = internship_details[0].string 60 | if(job_post["company_name"] == "↳" and len(job_post_data) > 0): 61 | job_post["company_name"] = job_post_data[-1]["company_name"] 62 | except: 63 | job_post["company_name"] = "" 64 | 65 | try: 66 | job_post["job_type"] = "" 67 | except: 68 | job_post["job_type"] = "" 69 | 70 | 71 | try: 72 | job_post["source"] = repoName 73 | except: 74 | job_post["source"] = "" 75 | 76 | 77 | details_element = internship_details[2].find("details") 78 | if details_element: 79 | summary_element = details_element.find("summary") 80 | if summary_element: 81 | text_after_summary = "\n".join([str(sibling) for sibling in summary_element.next_siblings if sibling.name is None]) 82 | try: 83 | job_post["location"] = text_after_summary 84 | except: 85 | job_post["location"] = "" 86 | else: 87 | try: 88 | text = internship_details[2].get_text(separator="\n") 89 | job_post["location"] = text 90 | except: 91 | job_post["location"] = "" 92 | 93 | try: 94 | job_post["term"] = "" 95 | except: 96 | job_post["term"] = "" 97 | 98 | try: 99 | job_post["date"] = date 100 | except: 101 | job_post["date"] = "" 102 | 103 | print(job_post) 104 | job_post_data.append(job_post) 105 | 106 | 107 | 108 | def githubOffSeasonScraper(repoLink, repoName): 109 | # Set up Selenium WebDriver 110 | chrome_options = Options() 111 | chrome_options.add_argument("--headless") # Run in headless mode 112 | chrome_options.add_argument("--no-sandbox") 113 | chrome_options.add_argument("--disable-dev-shm-usage") 114 | driver = webdriver.Chrome(options=chrome_options) 115 | 116 | try: 117 | driver.get(repoLink) 118 | WebDriverWait(driver, 10).until(EC.presence_of_element_located((By.TAG_NAME, "table"))) 119 | page_source = driver.page_source 120 | soup = BeautifulSoup(page_source, features="html.parser") 121 | finally: 122 | driver.quit() 123 | # print(soup) 124 | 125 | for internship in soup.select("article table tbody tr"): 126 | internship_details = internship.find_all("td") 127 | date = internship_details[4].string 128 | 129 | links = internship_details[3].find_all("a") 130 | if(len(links) > 0): 131 | job_link_exists = any(links[0].get("href") == item.get('job_link') for item in current_database) 132 | if(not job_link_exists): 133 | job_post = {} 134 | try: 135 | job_post["job_link"] = links[0].get("href") 136 | except: 137 | job_post["job_link"] = "" 138 | 139 | try: 140 | job_post["job_role"] = internship_details[1].string 141 | except: 142 | job_post["job_role"] = "" 143 | 144 | try: 145 | job_post["company_name"] = internship_details[0].string 146 | if(job_post["company_name"] == "↳" and len(job_post_data) > 0): 147 | job_post["company_name"] = job_post_data[-1]["company_name"] 148 | except: 149 | job_post["company_name"] = "" 150 | 151 | try: 152 | job_post["job_type"] = "" 153 | except: 154 | job_post["job_type"] = "" 155 | 156 | 157 | try: 158 | job_post["source"] = repoName 159 | except: 160 | job_post["source"] = "" 161 | 162 | 163 | details_element = internship_details[2].find("details") 164 | if details_element: 165 | summary_element = details_element.find("summary") 166 | if summary_element: 167 | text_after_summary = "\n".join([str(sibling) for sibling in summary_element.next_siblings if sibling.name is None]) 168 | try: 169 | job_post["location"] = text_after_summary 170 | except: 171 | job_post["location"] = "" 172 | else: 173 | try: 174 | text = internship_details[2].get_text(separator="\n") 175 | job_post["location"] = text 176 | except: 177 | job_post["location"] = "" 178 | 179 | try: 180 | job_post["term"] = "" 181 | except: 182 | job_post["term"] = "" 183 | 184 | try: 185 | job_post["date"] = date 186 | except: 187 | job_post["date"] = "" 188 | 189 | job_post_data.append(job_post) 190 | 191 | def cscareersScraper(repoLink, repoName): 192 | response = requests.get(repoLink) 193 | if not response.ok: 194 | print("Failed to fetch README.") 195 | return 196 | 197 | content = response.text 198 | 199 | # Find the start and end of the internship table 200 | table_start = content.find('TABLE_START') 201 | table_end = content.find('TABLE_END') 202 | if table_start == -1 or table_end == -1: 203 | print("Could not find the internship table in the README.") 204 | return 205 | 206 | table_content = content[table_start:table_end].strip() 207 | lines = table_content.split('\n') 208 | 209 | for line in lines[4:]: 210 | if not line.startswith('|'): 211 | continue 212 | columns = [col.strip() for col in line.split('|')[1:-1]] 213 | 214 | if len(columns) != 5: 215 | continue 216 | 217 | # Extract the URL from the Markdown link 218 | 219 | html_string = columns[3] 220 | if html_string == "🔒": 221 | continue 222 | soup = BeautifulSoup(html_string, 'html.parser') 223 | print(soup) 224 | job_link = soup.find('a')['href'] 225 | 226 | location = columns[2] 227 | soup = BeautifulSoup(location, 'html.parser') 228 | 229 | # Remove all tags and their content 230 | for summary in soup.find_all('summary'): 231 | summary.decompose() 232 | 233 | # Replace all
tags with commas 234 | for br in soup.find_all(['br']): 235 | br.replace_with(', ') 236 | 237 | # Get text, split by commas, strip whitespace, and remove empty entries 238 | locations = [loc.strip() for loc in soup.get_text(separator=', ').split(',') if loc.strip()] 239 | 240 | # Join back into a single comma-separated string 241 | locs = ', '.join(locations) 242 | 243 | job_post = { 244 | 'company_name': columns[0], 245 | 'job_role': columns[1], 246 | 'location': locs, 247 | 'job_link': job_link, 248 | 'date': columns[4], 249 | 'source': repoName 250 | } 251 | 252 | print(job_post) 253 | # Handle subsidiary listings 254 | if job_post['company_name'] == '↳' and job_post_data: 255 | job_post['company_name'] = job_post_data[-1]['company_name'] 256 | 257 | job_post_data.append(job_post) 258 | 259 | # Print results 260 | for post in job_post_data: 261 | print(post) 262 | 263 | githubScraper("https://github.com/SimplifyJobs/Summer2025-Internships", "PittCSC") 264 | githubOffSeasonScraper("https://github.com/SimplifyJobs/Summer2025-Internships/blob/dev/README-Off-Season.md", "PittCSC Off-Season") 265 | # cscareersScraper("https://raw.githubusercontent.com/vanshb03/Summer2025-Internships/refs/heads/dev/README.md", "CSCareers") 266 | # cscareersScraper("https://raw.githubusercontent.com/vanshb03/Summer2025-Internships/refs/heads/dev/OFFSEASON_README.md", "CSCareers Off-Season") 267 | githubScraper("https://github.com/SimplifyJobs/New-Grad-Positions", "PittCSC New Grad") 268 | # print(job_post_data) 269 | 270 | for job_post in job_post_data: 271 | try: 272 | # Check if the job post already exists in the database 273 | existing_posts = supabase.table('posts').select('*').eq('job_link', job_post['job_link']).execute().data 274 | if existing_posts: 275 | existing_post = existing_posts[0] 276 | # Update the date if the source matches and is either PittCSC or PittCSC Off-Season 277 | if existing_post['source'] in ['PittCSC', 'PittCSC Off-Season'] and existing_post['source'] == job_post['source']: 278 | print("Updating existing job post...") 279 | supabase.table('posts').update({'date': job_post['date']}).eq('job_link', existing_post['job_link']).execute() 280 | else: 281 | # Insert the new job post if it doesn't exist 282 | data, count = supabase.table('posts').insert(job_post).execute() 283 | except Exception as e: 284 | print(f"Error processing job post: {e}") 285 | -------------------------------------------------------------------------------- /internship-scraper/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useEffect, useState } from "react"; 3 | import Link from "next/link"; 4 | import Header from "@/components/header"; 5 | import Form from "@/components/modal"; 6 | import EditForm from "@/components/editModal"; 7 | import DeleteForm from "@/components/deleteModal"; 8 | import useSharedFormState from "@/app/hook/useCustomJobPosts"; 9 | 10 | import { supabaseBrowser } from "@/lib/supabase/browser"; 11 | import useUser from "@/app/hook/useUser"; 12 | import { FaFile, FaGithub, FaLinkedin } from "react-icons/fa"; 13 | import { FaBoltLightning } from "react-icons/fa6"; 14 | import { Loader2, X, InfoIcon } from "lucide-react"; 15 | 16 | // Import Shadcn components 17 | import { Button } from "@/components/ui/button"; 18 | import { Card, CardContent } from "@/components/ui/card"; 19 | import { 20 | Table, 21 | TableBody, 22 | TableCell, 23 | TableHead, 24 | TableHeader, 25 | TableRow, 26 | } from "@/components/ui/table"; 27 | import { 28 | Select, 29 | SelectContent, 30 | SelectItem, 31 | SelectTrigger, 32 | SelectValue, 33 | } from "@/components/ui/select"; 34 | import { Skeleton } from "@/components/ui/skeleton"; 35 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 36 | import { Separator } from "@/components/ui/separator"; 37 | import { MultiSelect, Option } from "@/components/ui/multi-select"; 38 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 39 | 40 | import { useDebounce } from "use-debounce"; 41 | import Fuse from "fuse.js"; 42 | 43 | const supabase = supabaseBrowser(); 44 | 45 | export default function Home() { 46 | const [jobPosts, setJobPosts] = useState([]); 47 | const { customJobPosts, setCustomJobPosts } = useSharedFormState(); 48 | const [filteredJobPosts, setFilteredJobPosts] = useState([]); 49 | const [shownPosts, setShownPosts] = useState([]); 50 | const [hasStatus, setHasStatus] = useState(false); 51 | const [selectedButton, setSelectedButton] = useState(0); 52 | const [filterOption, setFilterOption] = useState("All"); 53 | const [isLoading, setIsLoading] = useState(false); 54 | const [selectedSources, setSelectedSources] = useState<(string | number)[]>( 55 | () => { 56 | // Try to load from localStorage if we're in the browser 57 | if (typeof window !== "undefined") { 58 | const savedSources = localStorage.getItem("selectedSources"); 59 | return savedSources ? JSON.parse(savedSources) : [0]; // Default to LinkedIn SWE if nothing saved 60 | } 61 | return [0]; // Default for SSR 62 | } 63 | ); 64 | const [searchQuery, setSearchQuery] = useState(() => { 65 | // Try to load search query from localStorage if we're in the browser 66 | if (typeof window !== "undefined") { 67 | const savedSearch = localStorage.getItem("searchQuery"); 68 | return savedSearch || ""; // Return saved search or empty string 69 | } 70 | return ""; // Default for SSR 71 | }); 72 | const [debouncedSearchQuery] = useDebounce(searchQuery, 300); 73 | const [showBanner, setShowBanner] = useState(true); 74 | const [currentPage, setCurrentPage] = useState(1); 75 | const [itemsPerPage, setItemsPerPage] = useState(50); // Show 50 items per page 76 | const [totalItems, setTotalItems] = useState(0); 77 | 78 | // Bulk selection state 79 | const [selectedJobs, setSelectedJobs] = useState>(new Set()); 80 | const [showBulkActions, setShowBulkActions] = useState(false); 81 | const [bulkStatus, setBulkStatus] = useState(""); 82 | const [lastSelectedIndex, setLastSelectedIndex] = useState(-1); 83 | 84 | const { isFetching, data } = useUser(); 85 | 86 | // Bulk selection handlers 87 | const handleJobSelect = ( 88 | jobId: string, 89 | isSelected: boolean, 90 | index: number, 91 | isShiftClick: boolean = false 92 | ) => { 93 | const newSelected = new Set(selectedJobs); 94 | 95 | if (isShiftClick && lastSelectedIndex !== -1) { 96 | // Shift-click: select range from last selected to current 97 | const startIndex = Math.min(lastSelectedIndex, index); 98 | const endIndex = Math.max(lastSelectedIndex, index); 99 | 100 | // Select all jobs in the range 101 | for (let i = startIndex; i <= endIndex; i++) { 102 | if (i < shownPosts.length) { 103 | newSelected.add(shownPosts[i].job_link); 104 | } 105 | } 106 | } else { 107 | // Normal click: toggle single selection 108 | if (isSelected) { 109 | newSelected.add(jobId); 110 | } else { 111 | newSelected.delete(jobId); 112 | } 113 | // Update last selected index only for normal clicks 114 | setLastSelectedIndex(index); 115 | } 116 | 117 | setSelectedJobs(newSelected); 118 | setShowBulkActions(newSelected.size > 0); 119 | }; 120 | 121 | const handleSelectAll = (isSelected: boolean) => { 122 | if (isSelected) { 123 | const allJobIds = new Set(shownPosts.map((post) => post.job_link)); 124 | setSelectedJobs(allJobIds); 125 | setShowBulkActions(true); 126 | } else { 127 | setSelectedJobs(new Set()); 128 | setShowBulkActions(false); 129 | } 130 | // Reset last selected index when selecting/deselecting all 131 | setLastSelectedIndex(-1); 132 | }; 133 | 134 | const handleBulkStatusChange = async () => { 135 | if (!bulkStatus || selectedJobs.size === 0) return; 136 | 137 | const jobIds = Array.from(selectedJobs); 138 | 139 | // Update UI immediately for better UX 140 | const updatePosts = (posts: any[]) => 141 | posts.map((post) => 142 | jobIds.includes(post.job_link) ? { ...post, status: bulkStatus } : post 143 | ); 144 | 145 | setShownPosts(updatePosts(shownPosts)); 146 | setFilteredJobPosts(updatePosts(filteredJobPosts)); 147 | setJobPosts(updatePosts(jobPosts)); 148 | setCustomJobPosts(updatePosts(customJobPosts)); 149 | 150 | // Update database for each selected job 151 | if (data) { 152 | try { 153 | const promises = jobIds.map(async (jobId) => { 154 | if (selectedButton === 4) { 155 | // Custom applications 156 | return supabase 157 | .from("custom_applications") 158 | .upsert({ user: data.id, job_link: jobId, status: bulkStatus }); 159 | } else { 160 | // Regular job posts 161 | return supabase 162 | .from("statuses") 163 | .upsert({ user: data.id, job: jobId, status: bulkStatus }); 164 | } 165 | }); 166 | 167 | await Promise.all(promises); 168 | } catch (error: any) { 169 | console.error("Error updating bulk job statuses:", error.message); 170 | } 171 | } 172 | 173 | // Clear selection 174 | setSelectedJobs(new Set()); 175 | setShowBulkActions(false); 176 | setBulkStatus(""); 177 | setLastSelectedIndex(-1); 178 | }; 179 | 180 | const clearSelection = () => { 181 | setSelectedJobs(new Set()); 182 | setShowBulkActions(false); 183 | setBulkStatus(""); 184 | setLastSelectedIndex(-1); 185 | }; 186 | 187 | useEffect(() => { 188 | const fetchData = async () => { 189 | setHasStatus(false); 190 | 191 | try { 192 | // Fetch job posts and custom applications 193 | const [ 194 | { data: jobData, error: jobError }, 195 | { data: customApplications, error: customAppError }, 196 | ] = await Promise.all([ 197 | supabase.from("posts").select("*"), 198 | data 199 | ? supabase 200 | .from("custom_applications") 201 | .select("*") 202 | .eq("user", data.id) 203 | : Promise.resolve({ data: [], error: null }), 204 | ]); 205 | 206 | if (jobError) throw jobError; 207 | if (customAppError) throw customAppError; 208 | 209 | let newJobPosts = jobData; 210 | 211 | if (data) { 212 | // Fetch all statuses for the given userId 213 | const { data: statuses, error: statusesError } = await supabase 214 | .from("statuses") 215 | .select("job, status") 216 | .eq("user", data.id); 217 | 218 | if (statusesError) throw statusesError; 219 | 220 | // Create a map of statuses for quick lookup 221 | const statusMap = statuses.reduce((acc: any, { job, status }) => { 222 | acc[job] = status; 223 | return acc; 224 | }, {}); 225 | 226 | // Map job posts to their statuses 227 | newJobPosts = jobData.map((jobPost) => { 228 | const status = statusMap[jobPost.job_link] || "Not Applied"; 229 | return { ...jobPost, status }; 230 | }); 231 | } 232 | 233 | setJobPosts(newJobPosts); 234 | setCustomJobPosts(customApplications); 235 | } catch (error: any) { 236 | console.error("Error fetching job posts:", error.message); 237 | } finally { 238 | setHasStatus(true); 239 | } 240 | }; 241 | 242 | if (!isFetching) { 243 | fetchData(); 244 | } 245 | }, [data]); 246 | 247 | const handleStatusChange = async (value: string, jobId: string) => { 248 | const newStatus = value; 249 | 250 | // First, update filteredJobPosts to maintain consistency when paging 251 | setFilteredJobPosts( 252 | filteredJobPosts.map((post) => { 253 | if (post.job_link === jobId) { 254 | return { ...post, status: newStatus }; 255 | } 256 | return post; 257 | }) 258 | ); 259 | 260 | // Update shownPosts directly to maintain the current page view 261 | setShownPosts( 262 | shownPosts.map((post) => { 263 | if (post.job_link === jobId) { 264 | return { ...post, status: newStatus }; 265 | } 266 | return post; 267 | }) 268 | ); 269 | 270 | // Also update the base data stores 271 | setJobPosts( 272 | jobPosts.map((jobPost) => { 273 | if (jobPost.job_link === jobId) { 274 | return { ...jobPost, status: newStatus }; 275 | } 276 | return jobPost; 277 | }) 278 | ); 279 | 280 | setCustomJobPosts( 281 | customJobPosts.map((jobPost) => { 282 | if (jobPost.job_link === jobId) { 283 | return { ...jobPost, status: newStatus }; 284 | } 285 | return jobPost; 286 | }) 287 | ); 288 | 289 | // Don't re-filter or change pagination here 290 | 291 | // Update the database 292 | if (data && selectedButton !== 4) { 293 | try { 294 | const { data: updateData, error } = await supabase 295 | .from("statuses") 296 | .upsert({ user: data.id, job: jobId, status: newStatus }) 297 | .select("*"); 298 | if (error) { 299 | throw error; 300 | } 301 | } catch (error: any) { 302 | console.error("Error updating job status:", error.message); 303 | } 304 | } 305 | 306 | if (data && selectedButton === 4) { 307 | try { 308 | const { data: updateData, error } = await supabase 309 | .from("custom_applications") 310 | .upsert({ user: data.id, job_link: jobId, status: newStatus }) 311 | .select("*"); 312 | if (error) { 313 | throw error; 314 | } 315 | } catch (error: any) { 316 | console.error("Error updating job status:", error.message); 317 | } 318 | } 319 | }; 320 | 321 | const handleSourceClick = (index: number) => { 322 | setSelectedButton(index); 323 | setHasStatus(false); 324 | setIsLoading(true); 325 | let filteredData = []; 326 | if (jobPosts) { 327 | const currentYear = new Date().getFullYear(); 328 | switch (index) { 329 | case 0: 330 | filteredData = jobPosts 331 | .filter( 332 | (jobPost) => 333 | jobPost.source === "LinkedIn" && jobPost.job_type === "SWE" 334 | ) 335 | .sort((a, b) => { 336 | // First sort by date 337 | const dateA: any = new Date(a.date); 338 | const dateB: any = new Date(b.date); 339 | const dateDiff = dateB - dateA; 340 | 341 | // If dates are equal, sort by company name 342 | if (dateDiff === 0) { 343 | const nameA = a.company_name ?? ""; 344 | const nameB = b.company_name ?? ""; 345 | return nameA.localeCompare(nameB); 346 | } 347 | 348 | return dateDiff; 349 | }); 350 | break; 351 | case 1: 352 | filteredData = jobPosts 353 | .filter((jobPost) => jobPost.source === "PittCSC") 354 | .sort((a, b) => { 355 | const parseDate = (date: string) => { 356 | const match = date.match(/(\d+)([a-z]+)/); 357 | if (!match) return 0; 358 | const [_, value, unit] = match; 359 | const multiplier = unit === "d" ? 1 : unit === "mo" ? 30 : 0; 360 | return parseInt(value) * multiplier; 361 | }; 362 | 363 | const dateA = parseDate(a.date); 364 | const dateB = parseDate(b.date); 365 | return dateA - dateB; 366 | }); 367 | break; 368 | case 2: 369 | filteredData = jobPosts 370 | .filter((jobPost) => jobPost.source === "CSCareers") 371 | .sort((a, b) => { 372 | const monthOrder = (date: string) => { 373 | const jobDate = new Date(`${date} ${currentYear}`); 374 | const currentMonth = new Date().getMonth(); 375 | const jobMonth = jobDate.getMonth(); 376 | return (currentMonth - jobMonth + 12) % 12; 377 | }; 378 | 379 | const dateA = monthOrder(a.date); 380 | const dateB = monthOrder(b.date); 381 | return dateA - dateB; 382 | }); 383 | break; 384 | case 3: 385 | filteredData = jobPosts 386 | .filter((jobPost) => jobPost.source === "PittCSC Off-Season") 387 | .sort((a, b) => { 388 | const parseDate = (date: string) => { 389 | const match = date.match(/(\d+)([a-z]+)/); 390 | if (!match) return 0; 391 | const [_, value, unit] = match; 392 | const multiplier = unit === "d" ? 1 : unit === "mo" ? 30 : 0; 393 | return parseInt(value) * multiplier; 394 | }; 395 | 396 | const dateA = parseDate(a.date); 397 | const dateB = parseDate(b.date); 398 | return dateA - dateB; 399 | }); 400 | break; 401 | case 4: 402 | filteredData = customJobPosts.sort((a, b) => { 403 | const dateA: any = new Date(a.date); 404 | const dateB: any = new Date(b.date); 405 | const dateDiff = dateB - dateA; 406 | 407 | // If dates are equal, sort by company name 408 | if (dateDiff === 0) { 409 | const nameA = a.company_name ?? ""; 410 | const nameB = b.company_name ?? ""; 411 | return nameA.localeCompare(nameB); 412 | } 413 | 414 | return dateDiff; 415 | }); 416 | break; 417 | case 5: 418 | filteredData = jobPosts 419 | .filter( 420 | (jobPost) => 421 | jobPost.source === "LinkedIn" && jobPost.job_type === "QUANT" 422 | ) 423 | .sort((a, b) => { 424 | const dateA: any = new Date(a.date); 425 | const dateB: any = new Date(b.date); 426 | const dateDiff = dateB - dateA; 427 | 428 | // If dates are equal, sort by company name 429 | if (dateDiff === 0) { 430 | const nameA = a.company_name ?? ""; 431 | const nameB = b.company_name ?? ""; 432 | return nameA.localeCompare(nameB); 433 | } 434 | 435 | return dateDiff; 436 | }); 437 | break; 438 | case 6: 439 | filteredData = jobPosts 440 | .filter( 441 | (jobPost) => 442 | jobPost.source === "LinkedIn" && jobPost.job_type === "BUS" 443 | ) 444 | .sort((a, b) => { 445 | const dateA: any = new Date(a.date); 446 | const dateB: any = new Date(b.date); 447 | const dateDiff = dateB - dateA; 448 | 449 | // If dates are equal, sort by company name 450 | if (dateDiff === 0) { 451 | const nameA = a.company_name ?? ""; 452 | const nameB = b.company_name ?? ""; 453 | return nameA.localeCompare(nameB); 454 | } 455 | 456 | return dateDiff; 457 | }); 458 | break; 459 | case 7: 460 | filteredData = jobPosts 461 | .filter((jobPost) => jobPost.source === "PittCSC New Grad") 462 | .sort((a, b) => { 463 | const parseDate = (date: string) => { 464 | const match = date.match(/(\d+)([a-z]+)/); 465 | if (!match) return 0; 466 | const [_, value, unit] = match; 467 | const multiplier = unit === "d" ? 1 : unit === "mo" ? 30 : 0; 468 | return parseInt(value) * multiplier; 469 | }; 470 | 471 | const dateA = parseDate(a.date); 472 | const dateB = parseDate(b.date); 473 | return dateA - dateB; 474 | }); 475 | break; 476 | case 8: 477 | filteredData = jobPosts 478 | .filter( 479 | (jobPost) => 480 | jobPost.source === "airtable" && jobPost.job_type === "EE" 481 | ) 482 | .sort((a, b) => { 483 | const dateA: any = new Date(a.date); 484 | const dateB: any = new Date(b.date); 485 | const dateDiff = dateB - dateA; 486 | 487 | // If dates are equal, sort by company name 488 | if (dateDiff === 0) { 489 | const nameA = a.company_name ?? ""; 490 | const nameB = b.company_name ?? ""; 491 | return nameA.localeCompare(nameB); 492 | } 493 | 494 | return dateDiff; 495 | }); 496 | break; 497 | case 9: 498 | filteredData = jobPosts 499 | .filter( 500 | (jobPost) => 501 | jobPost.source === "airtable" && jobPost.job_type === "Hardware" 502 | ) 503 | .sort((a, b) => { 504 | const dateA: any = new Date(a.date); 505 | const dateB: any = new Date(b.date); 506 | const dateDiff = dateB - dateA; 507 | 508 | // If dates are equal, sort by company name 509 | if (dateDiff === 0) { 510 | const nameA = a.company_name ?? ""; 511 | const nameB = b.company_name ?? ""; 512 | return nameA.localeCompare(nameB); 513 | } 514 | 515 | return dateDiff; 516 | }); 517 | break; 518 | case 10: 519 | filteredData = jobPosts 520 | .filter((jobPost) => jobPost.source === "CSCareers Off-Season") 521 | .sort((a, b) => { 522 | const monthOrder = (date: string) => { 523 | const jobDate = new Date(`${date} ${currentYear}`); 524 | const currentMonth = new Date().getMonth(); 525 | const jobMonth = jobDate.getMonth(); 526 | return (currentMonth - jobMonth + 12) % 12; 527 | }; 528 | 529 | const dateA = monthOrder(a.date); 530 | const dateB = monthOrder(b.date); 531 | return dateA - dateB; 532 | }); 533 | break; 534 | default: 535 | filteredData = [...jobPosts]; 536 | break; 537 | } 538 | setFilterOption("All"); 539 | setFilteredJobPosts(filteredData); 540 | } 541 | setIsLoading(false); 542 | setHasStatus(true); 543 | }; 544 | 545 | const handleSourcesChange = (sources: (string | number)[]) => { 546 | // Save selected sources to localStorage 547 | if (typeof window !== "undefined") { 548 | localStorage.setItem("selectedSources", JSON.stringify(sources)); 549 | } 550 | 551 | setSelectedSources(sources); 552 | setHasStatus(false); 553 | setIsLoading(true); 554 | 555 | // If no sources selected, show empty array 556 | if (sources.length === 0) { 557 | setFilteredJobPosts([]); 558 | setIsLoading(false); 559 | setHasStatus(true); 560 | return; 561 | } 562 | 563 | // Combine data from all selected sources 564 | let combinedData: any[] = []; 565 | const currentYear = new Date().getFullYear(); 566 | 567 | sources.forEach((sourceId) => { 568 | let sourceData: any[] = []; 569 | const index = Number(sourceId); 570 | 571 | switch (index) { 572 | case 0: 573 | sourceData = jobPosts 574 | .filter( 575 | (jobPost) => 576 | jobPost.source === "LinkedIn" && jobPost.job_type === "SWE" 577 | ) 578 | .sort((a, b) => { 579 | const dateA: any = new Date(a.date); 580 | const dateB: any = new Date(b.date); 581 | const dateDiff = dateB - dateA; 582 | 583 | // If dates are equal, sort by company name 584 | if (dateDiff === 0) { 585 | const nameA = a.company_name ?? ""; 586 | const nameB = b.company_name ?? ""; 587 | return nameA.localeCompare(nameB); 588 | } 589 | 590 | return dateDiff; 591 | }); 592 | break; 593 | case 1: 594 | sourceData = jobPosts 595 | .filter((jobPost) => jobPost.source === "PittCSC") 596 | .sort((a, b) => { 597 | const parseDate = (date: string) => { 598 | const match = date.match(/(\d+)([a-z]+)/); 599 | if (!match) return 0; 600 | const [_, value, unit] = match; 601 | const multiplier = unit === "d" ? 1 : unit === "mo" ? 30 : 0; 602 | return parseInt(value) * multiplier; 603 | }; 604 | 605 | const dateA = parseDate(a.date); 606 | const dateB = parseDate(b.date); 607 | 608 | // Primary sort by date 609 | const dateDiff = dateA - dateB; 610 | 611 | // If dates are equal, sort by company name 612 | if (dateDiff === 0) { 613 | const nameA = a.company_name ?? ""; 614 | const nameB = b.company_name ?? ""; 615 | return nameA.localeCompare(nameB); 616 | } 617 | 618 | return dateDiff; 619 | }); 620 | break; 621 | case 2: 622 | sourceData = jobPosts 623 | .filter((jobPost) => jobPost.source === "CSCareers") 624 | .sort((a, b) => { 625 | const monthOrder = (date: string) => { 626 | const jobDate = new Date(`${date} ${currentYear}`); 627 | const currentMonth = new Date().getMonth(); 628 | const jobMonth = jobDate.getMonth(); 629 | return (currentMonth - jobMonth + 12) % 12; 630 | }; 631 | 632 | const dateA = monthOrder(a.date); 633 | const dateB = monthOrder(b.date); 634 | return dateA - dateB; 635 | }); 636 | break; 637 | case 3: 638 | sourceData = jobPosts 639 | .filter((jobPost) => jobPost.source === "PittCSC Off-Season") 640 | .sort((a, b) => { 641 | const parseDate = (date: string) => { 642 | const match = date.match(/(\d+)([a-z]+)/); 643 | if (!match) return 0; 644 | const [_, value, unit] = match; 645 | const multiplier = unit === "d" ? 1 : unit === "mo" ? 30 : 0; 646 | return parseInt(value) * multiplier; 647 | }; 648 | 649 | const dateA = parseDate(a.date); 650 | const dateB = parseDate(b.date); 651 | return dateA - dateB; 652 | }); 653 | break; 654 | case 4: 655 | sourceData = customJobPosts.sort((a, b) => { 656 | const dateA: any = new Date(a.date); 657 | const dateB: any = new Date(b.date); 658 | return dateB - dateA; 659 | }); 660 | break; 661 | case 5: 662 | sourceData = jobPosts 663 | .filter( 664 | (jobPost) => 665 | jobPost.source === "LinkedIn" && jobPost.job_type === "QUANT" 666 | ) 667 | .sort((a, b) => { 668 | const dateA: any = new Date(a.date); 669 | const dateB: any = new Date(b.date); 670 | return dateB - dateA; 671 | }); 672 | break; 673 | case 6: 674 | sourceData = jobPosts 675 | .filter( 676 | (jobPost) => 677 | jobPost.source === "LinkedIn" && jobPost.job_type === "BUS" 678 | ) 679 | .sort((a, b) => { 680 | const dateA: any = new Date(a.date); 681 | const dateB: any = new Date(b.date); 682 | return dateB - dateA; 683 | }); 684 | break; 685 | case 7: 686 | sourceData = jobPosts 687 | .filter((jobPost) => jobPost.source === "PittCSC New Grad") 688 | .sort((a, b) => { 689 | const parseDate = (date: string) => { 690 | const match = date.match(/(\d+)([a-z]+)/); 691 | if (!match) return 0; 692 | const [_, value, unit] = match; 693 | const multiplier = unit === "d" ? 1 : unit === "mo" ? 30 : 0; 694 | return parseInt(value) * multiplier; 695 | }; 696 | 697 | const dateA = parseDate(a.date); 698 | const dateB = parseDate(b.date); 699 | return dateA - dateB; 700 | }); 701 | break; 702 | case 8: 703 | sourceData = jobPosts 704 | .filter( 705 | (jobPost) => 706 | jobPost.source === "airtable" && jobPost.job_type === "EE" 707 | ) 708 | .sort((a, b) => { 709 | const dateA: any = new Date(a.date); 710 | const dateB: any = new Date(b.date); 711 | return dateB - dateA; 712 | }); 713 | break; 714 | case 9: 715 | sourceData = jobPosts 716 | .filter( 717 | (jobPost) => 718 | jobPost.source === "airtable" && jobPost.job_type === "Hardware" 719 | ) 720 | .sort((a, b) => { 721 | const dateA: any = new Date(a.date); 722 | const dateB: any = new Date(b.date); 723 | return dateB - dateA; 724 | }); 725 | break; 726 | case 10: 727 | sourceData = jobPosts 728 | .filter((jobPost) => jobPost.source === "CSCareers Off-Season") 729 | .sort((a, b) => { 730 | const monthOrder = (date: string) => { 731 | const jobDate = new Date(`${date} ${currentYear}`); 732 | const currentMonth = new Date().getMonth(); 733 | const jobMonth = jobDate.getMonth(); 734 | return (currentMonth - jobMonth + 12) % 12; 735 | }; 736 | 737 | const dateA = monthOrder(a.date); 738 | const dateB = monthOrder(b.date); 739 | return dateA - dateB; 740 | }); 741 | break; 742 | } 743 | 744 | // Only add if there is data 745 | if (sourceData.length > 0) { 746 | combinedData = [...combinedData, ...sourceData]; 747 | } 748 | }); 749 | 750 | // Sort the combined data consistently by date first, then alphabetically by company 751 | combinedData = sortCombinedJobPosts(combinedData); 752 | 753 | // Set most recently selected button for backward compatibility 754 | if (sources.length > 0) { 755 | setSelectedButton(Number(sources[0])); 756 | } 757 | 758 | setFilterOption("All"); 759 | setFilteredJobPosts(combinedData); 760 | setIsLoading(false); 761 | setHasStatus(true); 762 | }; 763 | 764 | // Helper function to sort combined job posts consistently 765 | const sortCombinedJobPosts = (posts: any[]): any[] => { 766 | const currentYear = new Date().getFullYear(); 767 | const now = new Date(); 768 | 769 | // Convert all dates to comparable timestamps for consistent sorting 770 | const postsWithTimestamps = posts.map((post) => { 771 | const originalPost = { ...post }; 772 | 773 | // Normalize the date to a timestamp 774 | let timestamp: number; 775 | 776 | if (post.date instanceof Date) { 777 | timestamp = post.date.getTime(); 778 | } else if (typeof post.date === "string") { 779 | const dateStr = post.date.trim(); 780 | 781 | // MM-DD-YYYY format (e.g. "05-08-2025") 782 | if (/^\d{2}-\d{2}-\d{4}$/.test(dateStr)) { 783 | const [month, day, year] = dateStr.split("-").map(Number); 784 | const date = new Date(year, month - 1, day); 785 | timestamp = date.getTime(); 786 | } 787 | // YYYY-MM-DD format 788 | else if (/^\d{4}-\d{2}-\d{2}/.test(dateStr)) { 789 | timestamp = new Date(dateStr).getTime(); 790 | } 791 | // Relative date format like "5d" or "2mo" 792 | else if (/^(\d+)([a-z]+)$/.test(dateStr)) { 793 | const match = dateStr.match(/^(\d+)([a-z]+)$/); 794 | if (match) { 795 | const [_, value, unit] = match; 796 | const daysAgo = 797 | parseInt(value) * (unit === "d" ? 1 : unit === "mo" ? 30 : 0); 798 | 799 | // Create a date that's X days in the past from today 800 | const date = new Date(); 801 | date.setDate(date.getDate() - daysAgo); 802 | timestamp = date.getTime(); 803 | } else { 804 | // Fallback timestamp (old) 805 | timestamp = 0; 806 | } 807 | } 808 | // Month name format like "January", "February", etc. 809 | else if (/^[A-Za-z]+$/.test(dateStr)) { 810 | try { 811 | // Parse month name to a date 812 | const tempDate = new Date(`${dateStr} 1, ${currentYear}`); 813 | 814 | if (!isNaN(tempDate.getTime())) { 815 | // If the month is in the future, assume it's from last year 816 | if (tempDate > now) { 817 | tempDate.setFullYear(currentYear - 1); 818 | } 819 | timestamp = tempDate.getTime(); 820 | } else { 821 | // Fallback timestamp (old) 822 | timestamp = 0; 823 | } 824 | } catch (e) { 825 | // Fallback timestamp (old) 826 | timestamp = 0; 827 | } 828 | } 829 | // Month + Day format like "January 15" 830 | else if (/^[A-Za-z]+ \d+$/.test(dateStr)) { 831 | try { 832 | // Parse "Month Day" format 833 | const tempDate = new Date(`${dateStr}, ${currentYear}`); 834 | 835 | if (!isNaN(tempDate.getTime())) { 836 | // If the date is in the future, assume it's from last year 837 | if (tempDate > now) { 838 | tempDate.setFullYear(currentYear - 1); 839 | } 840 | timestamp = tempDate.getTime(); 841 | } else { 842 | // Fallback timestamp (old) 843 | timestamp = 0; 844 | } 845 | } catch (e) { 846 | // Fallback timestamp (old) 847 | timestamp = 0; 848 | } 849 | } else { 850 | // Fallback timestamp (old) 851 | timestamp = 0; 852 | } 853 | } else { 854 | // Fallback timestamp (old) 855 | timestamp = 0; 856 | } 857 | 858 | return { 859 | ...originalPost, 860 | _timestamp: timestamp, 861 | _originalDate: post.date, // Keep original for debugging 862 | _sortKey: `${post.company_name}_${post.job_role}`, // Add a stable sort key that doesn't change with status 863 | }; 864 | }); 865 | 866 | // Sort by timestamp (newest first) then by company name alphabetically for same dates 867 | return postsWithTimestamps 868 | .sort((a, b) => { 869 | // Primary sort by timestamp (newest first) 870 | const timestampDiff = b._timestamp - a._timestamp; 871 | 872 | // If timestamps are equal, sort alphabetically by company name 873 | if (timestampDiff === 0) { 874 | const nameA = a.company_name ?? ""; 875 | const nameB = b.company_name ?? ""; 876 | return nameA.localeCompare(nameB); 877 | } 878 | 879 | return timestampDiff; 880 | }) 881 | .map((post) => { 882 | // Remove the temporary fields 883 | const { _timestamp, _originalDate, _sortKey, ...cleanPost } = post; 884 | return cleanPost; 885 | }); 886 | }; 887 | 888 | const handleFilterClick = (value: string, resetPage: boolean = true) => { 889 | setFilterOption(value); 890 | setHasStatus(false); 891 | setIsLoading(true); 892 | if (resetPage) { 893 | setCurrentPage(1); // Only reset to first page when explicitly requested 894 | } 895 | 896 | let filteredData = []; 897 | if (filteredJobPosts) { 898 | switch (value) { 899 | case "Not Applied": 900 | filteredData = filteredJobPosts.filter( 901 | (jobPost) => jobPost.status === "Not Applied" 902 | ); 903 | break; 904 | case "Applied": 905 | filteredData = filteredJobPosts.filter( 906 | (jobPost) => jobPost.status === "Applied" 907 | ); 908 | break; 909 | case "OA Received": 910 | filteredData = filteredJobPosts.filter( 911 | (jobPost) => jobPost.status === "OA Received" 912 | ); 913 | break; 914 | case "Interview Scheduled": 915 | filteredData = filteredJobPosts.filter( 916 | (jobPost) => jobPost.status === "Interview Scheduled" 917 | ); 918 | break; 919 | case "Waitlisted": 920 | filteredData = filteredJobPosts.filter( 921 | (jobPost) => jobPost.status === "Waitlisted" 922 | ); 923 | break; 924 | case "Rejected": 925 | filteredData = filteredJobPosts.filter( 926 | (jobPost) => jobPost.status === "Rejected" 927 | ); 928 | break; 929 | case "Offer Received": 930 | filteredData = filteredJobPosts.filter( 931 | (jobPost) => jobPost.status === "Offer Recevied" 932 | ); 933 | break; 934 | case "Accepted": 935 | filteredData = filteredJobPosts.filter( 936 | (jobPost) => jobPost.status === "Accepted" 937 | ); 938 | break; 939 | case "Will Not Apply": 940 | filteredData = filteredJobPosts.filter( 941 | (jobPost) => jobPost.status === "Will Not Apply" 942 | ); 943 | break; 944 | default: 945 | filteredData = filteredJobPosts; 946 | break; 947 | } 948 | 949 | // Update total count for pagination 950 | setTotalItems(filteredData.length); 951 | 952 | // Get current page of data 953 | const startIndex = (currentPage - 1) * itemsPerPage; 954 | const endIndex = startIndex + itemsPerPage; 955 | const paginatedData = filteredData.slice(startIndex, endIndex); // Show current page 956 | 957 | setShownPosts(paginatedData); 958 | } 959 | setIsLoading(false); 960 | setHasStatus(true); 961 | }; 962 | 963 | // Add a useEffect to handle pagination changes 964 | useEffect(() => { 965 | if (!filteredJobPosts.length || !hasStatus || isLoading) return; 966 | 967 | let dataToPage = filteredJobPosts; 968 | 969 | // Apply status filter if needed 970 | if (filterOption !== "All" && data) { 971 | dataToPage = filteredJobPosts.filter( 972 | (post) => post.status === filterOption 973 | ); 974 | } 975 | 976 | // Apply search filter if needed 977 | if (debouncedSearchQuery.trim()) { 978 | const fuseOptions = { 979 | keys: ["job_role", "company_name", "location"], 980 | threshold: 0.33, 981 | ignoreLocation: true, 982 | shouldSort: true, 983 | }; 984 | 985 | const fuse = new Fuse(dataToPage, fuseOptions); 986 | dataToPage = fuse 987 | .search(debouncedSearchQuery) 988 | .map((result) => result.item); 989 | } 990 | 991 | // Update total count 992 | setTotalItems(dataToPage.length); 993 | 994 | // Calculate pagination 995 | const startIndex = (currentPage - 1) * itemsPerPage; 996 | const endIndex = startIndex + itemsPerPage; 997 | const paginatedData = dataToPage.slice(startIndex, endIndex); 998 | 999 | setShownPosts(paginatedData); 1000 | }, [ 1001 | currentPage, 1002 | itemsPerPage, 1003 | debouncedSearchQuery, 1004 | filterOption, 1005 | filteredJobPosts, 1006 | hasStatus, 1007 | isLoading, 1008 | ]); 1009 | 1010 | // Handle page change 1011 | const handlePageChange = (newPage: number) => { 1012 | setCurrentPage(newPage); 1013 | // Scroll to top of results 1014 | window.scrollTo({ 1015 | top: document.getElementById("resultsTop")?.offsetTop || 0, 1016 | behavior: "smooth", 1017 | }); 1018 | }; 1019 | 1020 | useEffect(() => { 1021 | handleSourceClick(selectedButton); 1022 | }, [jobPosts, customJobPosts]); 1023 | 1024 | useEffect(() => { 1025 | handleFilterClick(filterOption, false); // Don't reset page when re-filtering after status changes 1026 | }, [filteredJobPosts]); 1027 | 1028 | useEffect(() => { 1029 | // Initialize with the default source when data is loaded 1030 | if (jobPosts.length > 0) { 1031 | handleSourcesChange(selectedSources); 1032 | } 1033 | }, [jobPosts, customJobPosts]); 1034 | 1035 | // Apply search filter whenever search query changes 1036 | useEffect(() => { 1037 | if (!hasStatus || isLoading) return; 1038 | 1039 | // If no search query, show all posts based on current filters 1040 | if (!debouncedSearchQuery.trim()) { 1041 | handleFilterClick(filterOption, false); // Don't reset page 1042 | return; 1043 | } 1044 | 1045 | // Configure Fuse for fuzzy searching 1046 | const fuseOptions = { 1047 | keys: ["job_role", "company_name", "location"], 1048 | threshold: 0.33, // Lower threshold = more strict matching 1049 | ignoreLocation: true, 1050 | shouldSort: true, 1051 | }; 1052 | 1053 | const fuse = new Fuse(filteredJobPosts, fuseOptions); 1054 | const searchResults = fuse 1055 | .search(debouncedSearchQuery) 1056 | .map((result) => result.item); 1057 | 1058 | // Apply status filter to search results if needed 1059 | let filteredResults = searchResults; 1060 | if (filterOption !== "All" && data) { 1061 | filteredResults = searchResults.filter( 1062 | (post) => post.status === filterOption 1063 | ); 1064 | } 1065 | 1066 | setShownPosts(filteredResults); 1067 | }, [ 1068 | debouncedSearchQuery, 1069 | filteredJobPosts, 1070 | filterOption, 1071 | hasStatus, 1072 | isLoading, 1073 | ]); 1074 | 1075 | // Clear selection when data changes or when switching sources 1076 | useEffect(() => { 1077 | setSelectedJobs(new Set()); 1078 | setShowBulkActions(false); 1079 | setBulkStatus(""); 1080 | setLastSelectedIndex(-1); 1081 | }, [selectedSources, filterOption, debouncedSearchQuery, currentPage]); 1082 | 1083 | if (isFetching) { 1084 | return <>; 1085 | } 1086 | 1087 | // Helper function to get status color classes 1088 | const getStatusColorClass = (status: string) => { 1089 | switch (status) { 1090 | case "Not Applied": 1091 | return "text-gray-600"; 1092 | case "Applied": 1093 | return "text-gray-700 dark:text-gray-300"; 1094 | case "OA Received": 1095 | return "text-purple-500"; 1096 | case "Interview Scheduled": 1097 | return "text-blue-500"; 1098 | case "Waitlisted": 1099 | return "text-yellow-500"; 1100 | case "Rejected": 1101 | return "text-red-500"; 1102 | case "Offer Received": 1103 | return "text-green-500"; 1104 | case "Accepted": 1105 | return "text-emerald-600"; 1106 | case "Will Not Apply": 1107 | return "text-amber-800"; 1108 | default: 1109 | return "text-gray-600"; 1110 | } 1111 | }; 1112 | 1113 | return ( 1114 |
1115 |
1116 | 1117 | {/* Information Banner */} 1118 | {showBanner && ( 1119 |
1120 |
1121 |
1122 |
1123 | 1124 |
1125 |

1126 | We are migrating to{" "} 1127 | 1133 | internships.ritij.dev 1134 | 1135 |

1136 |
1137 |
1138 | 1145 |
1146 |
1147 |
1148 | )} 1149 | 1150 | {hasStatus ? ( 1151 |
1152 |
1153 |
1154 |
1155 | {/* Source Filter - Multi-select */} 1156 |
1157 | 1158 | , 1164 | }, 1165 | { 1166 | value: 5, 1167 | label: "QUANT", 1168 | icon: , 1169 | }, 1170 | { 1171 | value: 6, 1172 | label: "Business", 1173 | icon: , 1174 | }, 1175 | { 1176 | value: 1, 1177 | label: "PittCSC", 1178 | icon: , 1179 | }, 1180 | { 1181 | value: 2, 1182 | label: "CSCareers", 1183 | icon: , 1184 | }, 1185 | { 1186 | value: 3, 1187 | label: "PittCSC Off-Season", 1188 | icon: , 1189 | }, 1190 | { 1191 | value: 10, 1192 | label: "CSCareers Off-Season", 1193 | icon: , 1194 | }, 1195 | { 1196 | value: 7, 1197 | label: "PittCSC New Grad", 1198 | icon: , 1199 | }, 1200 | { 1201 | value: 8, 1202 | label: "EE", 1203 | icon: , 1204 | }, 1205 | { 1206 | value: 9, 1207 | label: "Hardware", 1208 | icon: , 1209 | }, 1210 | ...(data 1211 | ? [ 1212 | { 1213 | value: 4, 1214 | label: "Personal Applications", 1215 | icon: , 1216 | }, 1217 | ] 1218 | : []), 1219 | ]} 1220 | selected={selectedSources} 1221 | onChange={(sources) => { 1222 | // Save to localStorage when sources change 1223 | if (typeof window !== "undefined") { 1224 | localStorage.setItem( 1225 | "selectedSources", 1226 | JSON.stringify(sources) 1227 | ); 1228 | } 1229 | handleSourcesChange(sources); 1230 | }} 1231 | placeholder="Select sources..." 1232 | /> 1233 |
1234 | 1235 | {/* Status Filter - Show only when user is logged in */} 1236 | {data && ( 1237 |
1238 | 1244 | 1270 |
1271 | )} 1272 |
1273 | 1274 | {/* Search input */} 1275 |
1276 | 1279 |
1280 | { 1286 | const newValue = e.target.value; 1287 | setSearchQuery(newValue); 1288 | // Save to localStorage when search changes 1289 | if (typeof window !== "undefined") { 1290 | localStorage.setItem("searchQuery", newValue); 1291 | } 1292 | }} 1293 | className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary" 1294 | /> 1295 | {searchQuery && ( 1296 | 1308 | )} 1309 |
1310 |
1311 |
1312 | 1313 | {/* Add Personal Application button - only when user is logged in and personal apps selected */} 1314 | {data && selectedSources.includes(4) && ( 1315 |
1316 |
1317 |
1318 | )} 1319 |
1320 | 1321 | 1322 | 1323 | {/* Bulk Actions Bar */} 1324 | {showBulkActions && data && ( 1325 |
1326 |
1327 |
1328 | 1329 | {selectedJobs.size} job 1330 | {selectedJobs.size !== 1 ? "s" : ""} selected 1331 | 1332 | 1333 | (Tip: Use Shift+click to select multiple jobs) 1334 | 1335 |
1336 | 1367 | 1374 |
1375 |
1376 | 1379 |
1380 |
1381 | )} 1382 | 1383 | {isLoading ? ( 1384 |
1385 | 1386 |
1387 | ) : ( 1388 | <> 1389 | {/* Top Pagination Controls */} 1390 | {totalItems > itemsPerPage && ( 1391 |
1395 |
1396 |

1397 | Showing{" "} 1398 | 1399 | {(currentPage - 1) * itemsPerPage + 1} 1400 | {" "} 1401 | to{" "} 1402 | 1403 | {Math.min(currentPage * itemsPerPage, totalItems)} 1404 | {" "} 1405 | of {totalItems}{" "} 1406 | results 1407 |

1408 |
1409 |
1410 |
1411 | 1432 | 1433 | {/* Page numbers */} 1434 |
1435 | {Array.from({ 1436 | length: Math.min( 1437 | 5, 1438 | Math.ceil(totalItems / itemsPerPage) 1439 | ), 1440 | }).map((_, i) => { 1441 | // Calculate page numbers to show a window around current page 1442 | let pageNum: number | undefined; 1443 | const totalPages = Math.ceil( 1444 | totalItems / itemsPerPage 1445 | ); 1446 | 1447 | if (totalPages <= 5) { 1448 | // If 5 or fewer pages, show all 1449 | pageNum = i + 1; 1450 | } else if (currentPage <= 3) { 1451 | // Near the start 1452 | pageNum = i + 1; 1453 | if (i === 4) pageNum = totalPages; 1454 | if (i === 3 && totalPages > 5) pageNum = -1; // Ellipsis 1455 | } else if (currentPage >= totalPages - 2) { 1456 | // Near the end 1457 | if (i === 0) pageNum = 1; 1458 | if (i === 1 && totalPages > 5) pageNum = -1; // Ellipsis 1459 | if (i >= 2) pageNum = totalPages - (4 - i); 1460 | } else { 1461 | // Middle - show current and neighbors 1462 | if (i === 0) pageNum = 1; 1463 | if (i === 1) pageNum = -1; // Ellipsis 1464 | if (i === 2) pageNum = currentPage; 1465 | if (i === 3) pageNum = -1; // Ellipsis 1466 | if (i === 4) pageNum = totalPages; 1467 | } 1468 | 1469 | // Render page button or ellipsis 1470 | if (pageNum === -1) { 1471 | return ( 1472 | 1476 | ... 1477 | 1478 | ); 1479 | } 1480 | 1481 | return ( 1482 | 1498 | ); 1499 | })} 1500 |
1501 | 1502 | 1526 |
1527 |
1528 |
1529 | )} 1530 | 1531 |
1532 | {/* Mobile view - card-based layout */} 1533 | {shownPosts.map((shownPost: any, index: number) => ( 1534 |
1535 |
1536 |
1537 | {data && ( 1538 | { 1542 | // We'll handle the shift detection in onClick 1543 | }} 1544 | onClick={(e) => { 1545 | const checkbox = e.target as HTMLInputElement; 1546 | handleJobSelect( 1547 | shownPost.job_link, 1548 | !selectedJobs.has(shownPost.job_link), // Toggle based on current state 1549 | index, 1550 | e.shiftKey 1551 | ); 1552 | }} 1553 | className="rounded border-gray-300" 1554 | /> 1555 | )} 1556 |
1557 | 1562 | {shownPost.job_role} 1563 | 1564 |
1565 |
1566 | {data && selectedButton === 4 && ( 1567 |
1568 | 1569 | 1570 |
1571 | )} 1572 |
1573 |
1574 |
Company:
1575 |
{shownPost.company_name}
1576 | 1577 |
Location:
1578 |
{shownPost.location}
1579 | 1580 | {/* {selectedButton === 3 && ( 1581 | <> 1582 |
Term:
1583 |
{shownPost.term}
1584 | 1585 | )} */} 1586 | 1587 |
Date:
1588 |
{shownPost.date}
1589 | 1590 | {data && ( 1591 | <> 1592 |
1593 | Status: 1594 |
1595 |
1596 | 1642 |
1643 | 1644 | )} 1645 |
1646 |
1647 | ))} 1648 |
1649 | 1650 | 1651 | 1652 | 1653 | {data && ( 1654 | 1655 |
1656 | 0 1661 | } 1662 | onChange={(e) => 1663 | handleSelectAll(e.target.checked) 1664 | } 1665 | className="rounded border-gray-300" 1666 | title="Select all visible jobs" 1667 | /> 1668 | 1672 | Shift+Click 1673 | 1674 |
1675 |
1676 | )} 1677 | {data && selectedButton === 4 && ( 1678 | Actions 1679 | )} 1680 | Role 1681 | Company 1682 | Location 1683 | {selectedButton === 3 && Term} 1684 | Date 1685 | {data && Status} 1686 |
1687 |
1688 | 1689 | {shownPosts.length === 0 ? ( 1690 | 1691 | 1695 | {searchQuery 1696 | ? "No results found. Try a different search term." 1697 | : "No jobs found."} 1698 | 1699 | 1700 | ) : ( 1701 | shownPosts.map((shownPost: any, index: number) => ( 1702 | 1703 | {data && ( 1704 | 1705 | { 1709 | // We'll handle the shift detection in onClick 1710 | }} 1711 | onClick={(e) => { 1712 | const checkbox = 1713 | e.target as HTMLInputElement; 1714 | handleJobSelect( 1715 | shownPost.job_link, 1716 | !selectedJobs.has(shownPost.job_link), // Toggle based on current state 1717 | index, 1718 | e.shiftKey 1719 | ); 1720 | }} 1721 | className="rounded border-gray-300" 1722 | /> 1723 | 1724 | )} 1725 | {data && selectedButton === 4 && ( 1726 | 1727 |
1728 | 1729 | 1730 |
1731 |
1732 | )} 1733 | 1734 | 1739 | {shownPost.job_role} 1740 | 1741 | 1742 | {shownPost.company_name} 1743 | {shownPost.location} 1744 | {selectedButton === 3 && ( 1745 | {shownPost.term} 1746 | )} 1747 | {shownPost.date} 1748 | {data && ( 1749 | 1750 | 1796 | 1797 | )} 1798 |
1799 | )) 1800 | )} 1801 |
1802 |
1803 | 1804 | {/* Pagination Controls */} 1805 | {totalItems > itemsPerPage && ( 1806 | 1944 | )} 1945 | 1946 | )} 1947 |
1948 |
1949 |
1950 | ) : ( 1951 |
1952 | 1953 |

Loading job posts...

1954 |
1955 | )} 1956 |
1957 | ); 1958 | } 1959 | --------------------------------------------------------------------------------