├── supabase ├── seed.sql ├── .gitignore ├── functions │ └── .vscode │ │ ├── extensions.json │ │ └── settings.json ├── migrations │ └── 20230916110521_users_table.sql └── config.toml ├── .node-version ├── app ├── utils │ ├── constants.ts │ ├── request-info.ts │ ├── noonce-provider.tsx │ ├── theme.server.ts │ ├── misc.tsx │ ├── theme.tsx │ └── client-hints.tsx ├── lib │ ├── utils.ts │ └── github.ts ├── entry.client.tsx ├── routes │ ├── stream.tsx │ ├── actions.toggle-theme.tsx │ ├── api.$username.tsx │ ├── auth.callback.tsx │ ├── actions.toggle-featured.tsx │ ├── api.$username.og.tsx │ ├── _index.tsx │ └── $username.tsx ├── components │ ├── ui │ │ ├── separator.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── multiselect.tsx │ │ └── select.tsx │ └── custom │ │ ├── PullRequestIcon.tsx │ │ ├── PRFilter.tsx │ │ ├── Header.tsx │ │ ├── DarkModeToggle.tsx │ │ └── GithubCard.tsx ├── tailwind.css ├── types │ └── shared.ts └── root.tsx ├── bun.lockb ├── public ├── robots.txt ├── .DS_Store ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── assets │ ├── og-banner.png │ ├── inter-regular.ttf │ ├── inter-semibold.ttf │ └── hero-screenshot.webp ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── _routes.json ├── _headers ├── browserconfig.xml └── site.webmanifest ├── remix.env.d.ts ├── remix.config.js ├── .eslintrc.cjs ├── .gitignore ├── my-pr.code-workspace ├── components.json ├── tsconfig.json ├── README.md ├── package.json └── tailwind.config.ts /supabase/seed.sql: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 16.13.0 -------------------------------------------------------------------------------- /supabase/.gitignore: -------------------------------------------------------------------------------- 1 | # Supabase 2 | .branches 3 | .temp 4 | .env -------------------------------------------------------------------------------- /app/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const THEME_FETCHER = "THEME_FETCHER"; 2 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kishanhitk/MyPRs/HEAD/bun.lockb -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /actions/ 3 | Disallow: /auth/ -------------------------------------------------------------------------------- /public/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kishanhitk/MyPRs/HEAD/public/.DS_Store -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kishanhitk/MyPRs/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kishanhitk/MyPRs/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kishanhitk/MyPRs/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kishanhitk/MyPRs/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/assets/og-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kishanhitk/MyPRs/HEAD/public/assets/og-banner.png -------------------------------------------------------------------------------- /supabase/functions/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["denoland.vscode-deno"] 3 | } 4 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kishanhitk/MyPRs/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kishanhitk/MyPRs/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/assets/inter-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kishanhitk/MyPRs/HEAD/public/assets/inter-regular.ttf -------------------------------------------------------------------------------- /public/assets/inter-semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kishanhitk/MyPRs/HEAD/public/assets/inter-semibold.ttf -------------------------------------------------------------------------------- /public/_routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "include": ["/*"], 4 | "exclude": ["/favicon.ico", "/build/*"] 5 | } 6 | -------------------------------------------------------------------------------- /public/assets/hero-screenshot.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kishanhitk/MyPRs/HEAD/public/assets/hero-screenshot.webp -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@remix-run/dev').AppConfig} */ 2 | export default { 3 | ignoredRouteFiles: ["**/.*"], 4 | tailwind: true, 5 | }; 6 | -------------------------------------------------------------------------------- /public/_headers: -------------------------------------------------------------------------------- 1 | /favicon.ico 2 | Cache-Control: public, max-age=3600, s-maxage=3600 3 | /build/* 4 | Cache-Control: public, max-age=31536000, immutable 5 | -------------------------------------------------------------------------------- /supabase/functions/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "editor.defaultFormatter": "denoland.vscode-deno" 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], 4 | }; 5 | -------------------------------------------------------------------------------- /app/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /functions/\[\[path\]\].js 5 | /functions/\[\[path\]\].js.map 6 | /functions/metafile.* 7 | /public/build 8 | .dev.vars 9 | .env 10 | 11 | node_modules 12 | 13 | /.cache 14 | /build 15 | .vercel 16 | 17 | 18 | .DS_Store 19 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /my-pr.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "name": "project-root", 5 | "path": "./" 6 | }, 7 | { 8 | "name": "supabase-functions", 9 | "path": "supabase/functions" 10 | } 11 | ], 12 | "settings": { 13 | "files.exclude": { 14 | "supabase/functions/": true 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/tailwind.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "~/components", 14 | "utils": "~/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /app/utils/request-info.ts: -------------------------------------------------------------------------------- 1 | import { useRouteLoaderData } from "@remix-run/react"; 2 | import { type loader as rootLoader } from "~/root"; 3 | import { invariant } from "./misc"; 4 | 5 | /** 6 | * @returns the request info from the root loader 7 | */ 8 | export function useRequestInfo() { 9 | const data = useRouteLoaderData("root"); 10 | 11 | invariant(data?.requestInfo, "No requestInfo found in root loader"); 12 | 13 | return data.requestInfo; 14 | } 15 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from "@remix-run/react"; 2 | import * as React from "react"; 3 | import { hydrateRoot } from "react-dom/client"; 4 | 5 | // fixup stuff before hydration 6 | function hydrate() { 7 | React.startTransition(() => { 8 | hydrateRoot(document, ); 9 | }); 10 | } 11 | 12 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 13 | if (window.requestIdleCallback) { 14 | window.requestIdleCallback(hydrate); 15 | } else { 16 | window.setTimeout(hydrate, 1); 17 | } 18 | -------------------------------------------------------------------------------- /app/utils/noonce-provider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | // This exists to allow us to render React with a nonce on the server and 4 | // without one on the client. This is necessary because we can't send the nonce 5 | // to the client in JS because it's a security risk and the browser removes the 6 | // nonce attribute from scripts and things anyway so if we hydrated with a nonce 7 | // we'd get a hydration warning. 8 | 9 | export const NonceContext = React.createContext(undefined); 10 | export const NonceProvider = NonceContext.Provider; 11 | export const useNonce = () => React.useContext(NonceContext); 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "target": "ES2019", 11 | "strict": true, 12 | "allowJs": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "baseUrl": ".", 15 | "paths": { 16 | "~/*": ["./app/*"] 17 | }, 18 | 19 | // Remix takes care of building everything in `remix build`. 20 | "noEmit": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/utils/theme.server.ts: -------------------------------------------------------------------------------- 1 | import * as cookie from "cookie"; 2 | 3 | const cookieName = "en_theme"; 4 | export type Theme = "light" | "dark"; 5 | 6 | export function getTheme(request: Request): Theme | null { 7 | const cookieHeader = request.headers.get("cookie"); 8 | const parsed = cookieHeader 9 | ? cookie.parse(cookieHeader)[cookieName] 10 | : "light"; 11 | if (parsed === "light" || parsed === "dark") return parsed; 12 | return null; 13 | } 14 | 15 | export function setTheme(theme: Theme | "system") { 16 | if (theme === "system") { 17 | return cookie.serialize(cookieName, "", { path: "/", maxAge: -1 }); 18 | } else { 19 | return cookie.serialize(cookieName, theme, { path: "/" }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/routes/stream.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { Await, useLoaderData } from "@remix-run/react"; 3 | import { defer } from "@vercel/remix"; 4 | 5 | export async function loader() { 6 | const version = process.versions.node; 7 | 8 | return defer({ 9 | version: sleep(version, 1000), 10 | }); 11 | } 12 | 13 | function sleep(val: string, ms: number) { 14 | return new Promise((resolve) => setTimeout(() => resolve(val), ms)); 15 | } 16 | 17 | export default function App() { 18 | const { version } = useLoaderData(); 19 | 20 | return ( 21 |
22 | Hello from React! Node.js version:{" "} 23 | 24 | 25 | {(version) => {version}} 26 | 27 | 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /app/utils/misc.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Provide a condition and if that condition is falsey, this throws an error 3 | * with the given message. 4 | * 5 | * inspired by invariant from 'tiny-invariant' except will still include the 6 | * message in production. 7 | * 8 | * @example 9 | * invariant(typeof value === 'string', `value must be a string`) 10 | * 11 | * @param condition The condition to check 12 | * @param message The message to throw (or a callback to generate the message) 13 | * @param responseInit Additional response init options if a response is thrown 14 | * 15 | * @throws {Error} if condition is falsey 16 | */ 17 | export function invariant( 18 | condition: any, 19 | message: string | (() => string) 20 | ): asserts condition { 21 | if (!condition) { 22 | throw new Error(typeof message === "function" ? message() : message); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 3 | 4 | import { cn } from "~/lib/utils" 5 | 6 | const Separator = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >( 10 | ( 11 | { className, orientation = "horizontal", decorative = true, ...props }, 12 | ref 13 | ) => ( 14 | 25 | ) 26 | ) 27 | Separator.displayName = SeparatorPrimitive.Root.displayName 28 | 29 | export { Separator } 30 | -------------------------------------------------------------------------------- /app/routes/actions.toggle-theme.tsx: -------------------------------------------------------------------------------- 1 | import { parse } from "@conform-to/zod"; 2 | import { type DataFunctionArgs, json } from "@remix-run/server-runtime"; 3 | import { ThemeFormSchema } from "~/utils/theme"; 4 | import { setTheme } from "~/utils/theme.server"; 5 | 6 | export async function action({ request }: DataFunctionArgs) { 7 | const formData = await request.formData(); 8 | const submission = parse(formData, { 9 | schema: ThemeFormSchema, 10 | }); 11 | if (submission.intent !== "submit") { 12 | return json({ status: "idle", submission } as const); 13 | } 14 | if (!submission.value) { 15 | return json({ status: "error", submission } as const, { status: 400 }); 16 | } 17 | const { theme } = submission.value; 18 | 19 | const responseInit = { 20 | headers: { "set-cookie": setTheme(theme) }, 21 | }; 22 | return json({ success: true, submission }, responseInit); 23 | } 24 | -------------------------------------------------------------------------------- /app/routes/api.$username.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from "@vercel/remix"; 2 | import { json } from "@vercel/remix"; 3 | import { getGitHubUserData, getPRsFromGithubAPI } from "~/lib/github"; 4 | 5 | export const loader = async ({ params }: LoaderFunctionArgs) => { 6 | const username = params.username!; 7 | 8 | const [ghResponse, userResponse] = await Promise.all([ 9 | getPRsFromGithubAPI({ 10 | author: username, 11 | limit: 100, 12 | }), 13 | getGitHubUserData(username), 14 | ]); 15 | 16 | const { data: ghData, error } = ghResponse; 17 | const { data: userData, error: userError } = userResponse; 18 | 19 | return json( 20 | { ghData, error, userData, userError }, 21 | { 22 | headers: { 23 | "Cache-Control": 24 | "public, max-age=10, s-max-age=3600, stale-while-revalidate=604800", 25 | }, 26 | } 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # MyPRs 4 | ### One link to highlight your Open-Source Contributions. 5 | The 'link-in-bio' for your Open-Source PRs. Curate a selection of your proudest GitHub PRs, showcase your expertise, and set yourself apart in the crowd. 6 | 7 | ![MyPRs](https://www.myprs.xyz/assets/og-banner.png) 8 | ## Development 9 | 10 | This is already wired up in your package.json as the `dev` script: 11 | 12 | ```sh 13 | # start the remix dev server 14 | npm run dev 15 | ``` 16 | 17 | Open up [http://127.0.0.1:3000](http://127.0.0.1:3000) and you should be ready to go! 18 | 19 | ## Built with 20 | Using the awesome tools: 21 | - @remix_run (build fast apps following web standards) 22 | - @tailwindcss 23 | - @vercel(the perfect place to deploy modern web apps. I tried all alternatives and finally chose Vercel) 24 | - @shadcn (best way to build accessible web apps, by default) 25 | - @supabase 26 | 27 | 28 | ### ⚠️ Disclamer : This is a work in progress. I am still working on it. Feel free to contribute. -------------------------------------------------------------------------------- /app/components/custom/PullRequestIcon.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | className?: string; 3 | } 4 | 5 | const PullRequestIcon = ({ className }: Props) => { 6 | return ( 7 | 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default PullRequestIcon; 19 | -------------------------------------------------------------------------------- /supabase/migrations/20230916110521_users_table.sql: -------------------------------------------------------------------------------- 1 | create table public.users ( 2 | id uuid primary key default uuid_generate_v4 (), 3 | created_at timestamp with time zone not null default now(), 4 | updated_at timestamp with time zone not null default now(), 5 | full_name text, 6 | avatar_url text, 7 | github_username text, 8 | excluded_github_repos text[], 9 | featured_github_prs text[] 10 | ) tablespace pg_default; 11 | 12 | create function public.handle_new_user() returns trigger language plpgsql security definer 13 | set 14 | search_path = public as $$ begin 15 | insert into 16 | public.users (id, full_name, avatar_url, github_username) 17 | values 18 | ( 19 | new.id, 20 | new.raw_user_meta_data->>'full_name', 21 | new.raw_user_meta_data->>'avatar_url', 22 | new.raw_user_meta_data->>'user_name' 23 | ); 24 | 25 | return new; 26 | 27 | end; 28 | 29 | $$; 30 | 31 | create trigger on_auth_user_created 32 | after 33 | insert 34 | on auth.users for each row execute procedure public.handle_new_user(); -------------------------------------------------------------------------------- /app/routes/auth.callback.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from "@vercel/remix"; 2 | import { redirect } from "@vercel/remix"; 3 | import { createServerClient } from "@supabase/auth-helpers-remix"; 4 | 5 | export const loader = async ({ request, context }: LoaderFunctionArgs) => { 6 | const response = new Response(); 7 | const url = new URL(request.url); 8 | const code = url.searchParams.get("code"); 9 | const redirectTo = url.searchParams.get("redirectTo"); 10 | let redirectUrl = redirectTo && redirectTo !== "false" ? redirectTo : "/"; 11 | 12 | if (code) { 13 | const supabaseClient = createServerClient( 14 | process.env.SUPABASE_URL!, 15 | process.env.SUPABASE_ANON_KEY!, 16 | { request, response } 17 | ); 18 | const { data } = await supabaseClient.auth.exchangeCodeForSession(code); 19 | const githubUsername = data.user?.user_metadata.user_name; 20 | 21 | if (githubUsername && redirectTo !== "false") { 22 | redirectUrl = `/${githubUsername}`; 23 | } 24 | } 25 | 26 | return redirect(redirectUrl, { 27 | headers: response.headers, 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /app/components/custom/PRFilter.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import MultiSelect from "../ui/multiselect"; 3 | import { Form } from "@remix-run/react"; 4 | import { Button } from "../ui/button"; 5 | 6 | export interface IPRFilterProps { 7 | repoNames: string[]; 8 | excludedRepoNames: string[]; 9 | } 10 | const PRFilter = ({ repoNames, excludedRepoNames }: IPRFilterProps) => { 11 | const alreadySelected = repoNames.filter( 12 | (repoName) => !excludedRepoNames.includes(repoName) 13 | ); 14 | const [selected, setSelected] = React.useState(alreadySelected); 15 | const allRepoNames = repoNames.concat(excludedRepoNames); 16 | 17 | return ( 18 |
19 |
20 | 25 |
26 |
27 | !selected.includes(repoName) 34 | )} 35 | /> 36 | 39 |
40 |
41 | ); 42 | }; 43 | 44 | export default PRFilter; 45 | -------------------------------------------------------------------------------- /app/routes/actions.toggle-featured.tsx: -------------------------------------------------------------------------------- 1 | import { createServerClient } from "@supabase/auth-helpers-remix"; 2 | import type { Env } from "~/types/shared"; 3 | import { z } from "zod"; 4 | import type { ActionFunctionArgs } from "@vercel/remix"; 5 | import { json } from "@vercel/remix"; 6 | 7 | export async function action({ request, context }: ActionFunctionArgs) { 8 | const response = new Response(); 9 | const env = process.env as Env; 10 | const supabaseClient = createServerClient( 11 | env.SUPABASE_URL!, 12 | env.SUPABASE_ANON_KEY!, 13 | { request, response } 14 | ); 15 | const { 16 | data: { user }, 17 | } = await supabaseClient.auth.getUser(); 18 | if (!user) { 19 | return json({ error: "You must be logged in to do that" }, { status: 401 }); 20 | } 21 | const body = await request.formData(); 22 | const pr_id = z.string().parse(body.get("prId")); 23 | const featuredGithubPRs = z.string().parse(body.get("featured_github_prs")); 24 | const isFeatured = body.get("isFeatured"); 25 | 26 | const updatedFeaturedGithubPRs = 27 | isFeatured === "true" 28 | ? featuredGithubPRs.split(",").filter((id) => id !== pr_id) 29 | : [...(featuredGithubPRs ? featuredGithubPRs.split(",") : []), pr_id]; 30 | 31 | const { data, error } = await supabaseClient 32 | .from("users") 33 | .update({ 34 | featured_github_prs: updatedFeaturedGithubPRs, 35 | }) 36 | .eq("id", user.id); 37 | if (error) console.error(error); 38 | 39 | return json({ data, error }); 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "sideEffects": false, 4 | "type": "module", 5 | "scripts": { 6 | "build": "remix build", 7 | "dev": "remix dev --manual", 8 | "start": "remix-serve ./build/index.js", 9 | "typecheck": "tsc" 10 | }, 11 | "dependencies": { 12 | "@conform-to/zod": "^0.9.0", 13 | "@fontsource/inter": "^5.0.8", 14 | "@headlessui/react": "^1.7.17", 15 | "@radix-ui/react-dropdown-menu": "^2.0.5", 16 | "@radix-ui/react-icons": "^1.3.0", 17 | "@radix-ui/react-select": "^1.2.2", 18 | "@radix-ui/react-separator": "^1.0.3", 19 | "@radix-ui/react-slot": "^1.0.2", 20 | "@remix-run/node": "^2.2.0", 21 | "@remix-run/react": "^2.0.0", 22 | "@remix-run/serve": "^2.2.0", 23 | "@supabase/auth-helpers-remix": "^0.2.1", 24 | "@supabase/supabase-js": "^2.33.2", 25 | "@vercel/analytics": "^1.0.2", 26 | "@vercel/og": "^0.5.17", 27 | "@vercel/remix": "^2.0.0", 28 | "class-variance-authority": "^0.7.0", 29 | "clsx": "^2.0.0", 30 | "framer-motion": "^10.16.4", 31 | "isbot": "^3.6.8", 32 | "lucide-react": "^0.277.0", 33 | "posthog-js": "^1.81.2", 34 | "react": "^18.2.0", 35 | "react-dom": "^18.2.0", 36 | "tailwind-merge": "^1.14.0", 37 | "tailwindcss-animate": "^1.0.7", 38 | "zod": "^3.22.2" 39 | }, 40 | "devDependencies": { 41 | "@remix-run/dev": "^2.0.0", 42 | "@remix-run/eslint-config": "^2.0.0", 43 | "@types/react": "^18.0.35", 44 | "@types/react-dom": "^18.0.11", 45 | "eslint": "^8.38.0", 46 | "tailwindcss": "^3.3.3", 47 | "typescript": "^5.0.4" 48 | }, 49 | "engines": { 50 | "node": ">=18.0.0" 51 | } 52 | } -------------------------------------------------------------------------------- /app/components/custom/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Link, useRouteLoaderData } from "@remix-run/react"; 2 | import { Button } from "../ui/button"; 3 | import type { SupabaseClient } from "@supabase/supabase-js"; 4 | import posthog from "posthog-js"; 5 | import PullRequestIcon from "./PullRequestIcon"; 6 | import DarkModeToggle from "./DarkModeToggle"; 7 | 8 | interface HeaderProps { 9 | supabase: SupabaseClient; 10 | } 11 | export const Header = ({ supabase }: HeaderProps) => { 12 | const { user } = useRouteLoaderData("root"); 13 | 14 | const handleGitHubLogin = async () => { 15 | const baseUrl = new URL(window.location.origin); 16 | const pathName = window.location.pathname; 17 | await supabase.auth.signInWithOAuth({ 18 | provider: "github", 19 | options: { 20 | redirectTo: baseUrl + `auth/callback?redirectTo=${pathName}&`, 21 | }, 22 | }); 23 | }; 24 | 25 | const handleLogout = async () => { 26 | await supabase.auth.signOut(); 27 | posthog.reset(); 28 | }; 29 | 30 | return ( 31 | <> 32 |
33 | 38 | MyPRs 39 | 40 | 41 | 42 |
43 | 44 | {user ? ( 45 |
46 | 49 |
50 | ) : ( 51 | 54 | )} 55 |
56 |
57 |
58 | 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /app/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 rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /app/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | 78 | .animate-in { 79 | animation: animateIn 0.3s ease 0.15s both; 80 | } 81 | 82 | @keyframes animateIn { 83 | from { 84 | opacity: 0; 85 | transform: translateY(10px); 86 | } 87 | to { 88 | opacity: 1; 89 | transform: translateY(0); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /app/utils/theme.tsx: -------------------------------------------------------------------------------- 1 | import { useFetcher, useFetchers } from "@remix-run/react"; 2 | import { parse } from "@conform-to/zod"; 3 | 4 | import * as React from "react"; 5 | import { z } from "zod"; 6 | import { useHints } from "./client-hints"; 7 | import { useRequestInfo } from "./request-info"; 8 | import { THEME_FETCHER } from "./constants"; 9 | 10 | enum Theme { 11 | DARK = "dark", 12 | LIGHT = "light", 13 | } 14 | const themes: Array = Object.values(Theme); 15 | 16 | export const ThemeFormSchema = z.object({ 17 | theme: z.enum(["system", "light", "dark"]), 18 | }); 19 | /** 20 | * @returns the user's theme preference, or the client hint theme if the user 21 | * has not set a preference. 22 | */ 23 | function useTheme() { 24 | const hints = useHints(); 25 | const requestInfo = useRequestInfo(); 26 | const optimisticMode = useOptimisticThemeMode(); 27 | if (optimisticMode) { 28 | return optimisticMode === "system" ? hints.theme : optimisticMode; 29 | } 30 | return requestInfo.userPrefs.theme ?? hints.theme; 31 | } 32 | 33 | /** 34 | * If the user's changing their theme mode preference, this will return the 35 | * value it's being changed to. 36 | */ 37 | function useOptimisticThemeMode() { 38 | const themeFetcher = useFetcher({ key: THEME_FETCHER }); 39 | 40 | if (themeFetcher.formData) { 41 | const submission = parse(themeFetcher.formData, { 42 | schema: ThemeFormSchema, 43 | }); 44 | return submission.value?.theme; 45 | } 46 | } 47 | 48 | function Themed({ 49 | dark, 50 | light, 51 | initialOnly = false, 52 | }: { 53 | dark: React.ReactNode | string; 54 | light: React.ReactNode | string; 55 | initialOnly?: boolean; 56 | }) { 57 | const [theme] = useTheme(); 58 | const [initialTheme] = React.useState(theme); 59 | const themeToReference = initialOnly ? initialTheme : theme; 60 | // eslint-disable-next-line react/jsx-no-useless-fragment 61 | return <>{themeToReference === "light" ? light : dark}; 62 | } 63 | 64 | function isTheme(value: unknown): value is Theme { 65 | return typeof value === "string" && themes.includes(value as Theme); 66 | } 67 | 68 | export { useTheme, useOptimisticThemeMode, themes, Theme, isTheme, Themed }; 69 | -------------------------------------------------------------------------------- /app/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 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /app/components/ui/multiselect.tsx: -------------------------------------------------------------------------------- 1 | import { Listbox, Transition } from "@headlessui/react"; 2 | import { CheckIcon } from "@radix-ui/react-icons"; 3 | import React from "react"; 4 | export interface IMultiSelectProps { 5 | options: string[]; 6 | selected: string[]; 7 | setSelected: React.Dispatch>; 8 | } 9 | export default function MultiSelect({ 10 | options, 11 | selected, 12 | setSelected, 13 | }: IMultiSelectProps) { 14 | return ( 15 | 16 |
17 | 18 | 19 | {selected.map((option) => option).join(", ")} 20 | 21 | 22 | 28 | 29 | {options.map((option, optionIdx) => ( 30 | 35 | {({ selected }) => ( 36 | <> 37 | {option} 38 | {selected ? ( 39 | 40 | 41 | 42 | ) : null} 43 | 44 | )} 45 | 46 | ))} 47 | 48 | 49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: ["./app/**/*.{js,jsx,ts,tsx}"], 5 | 6 | theme: { 7 | container: { 8 | center: true, 9 | padding: "2rem", 10 | screens: { 11 | "2xl": "1400px", 12 | }, 13 | }, 14 | fontFamily: { 15 | sans: ["inter", "sans-serif"], 16 | display: ["inter", "sans-serif"], 17 | body: ["inter", "sans-serif"], 18 | }, 19 | 20 | extend: { 21 | colors: { 22 | border: "hsl(var(--border))", 23 | input: "hsl(var(--input))", 24 | ring: "hsl(var(--ring))", 25 | background: "hsl(var(--background))", 26 | foreground: "hsl(var(--foreground))", 27 | primary: { 28 | DEFAULT: "hsl(var(--primary))", 29 | foreground: "hsl(var(--primary-foreground))", 30 | }, 31 | secondary: { 32 | DEFAULT: "hsl(var(--secondary))", 33 | foreground: "hsl(var(--secondary-foreground))", 34 | }, 35 | destructive: { 36 | DEFAULT: "hsl(var(--destructive))", 37 | foreground: "hsl(var(--destructive-foreground))", 38 | }, 39 | muted: { 40 | DEFAULT: "hsl(var(--muted))", 41 | foreground: "hsl(var(--muted-foreground))", 42 | }, 43 | accent: { 44 | DEFAULT: "hsl(var(--accent))", 45 | foreground: "hsl(var(--accent-foreground))", 46 | }, 47 | popover: { 48 | DEFAULT: "hsl(var(--popover))", 49 | foreground: "hsl(var(--popover-foreground))", 50 | }, 51 | card: { 52 | DEFAULT: "hsl(var(--card))", 53 | foreground: "hsl(var(--card-foreground))", 54 | }, 55 | github_merged: "rgb(130, 86, 208)", 56 | }, 57 | borderRadius: { 58 | lg: "var(--radius)", 59 | md: "calc(var(--radius) - 2px)", 60 | sm: "calc(var(--radius) - 4px)", 61 | }, 62 | keyframes: { 63 | "accordion-down": { 64 | from: { height: 0 }, 65 | to: { height: "var(--radix-accordion-content-height)" }, 66 | }, 67 | "accordion-up": { 68 | from: { height: "var(--radix-accordion-content-height)" }, 69 | to: { height: 0 }, 70 | }, 71 | }, 72 | animation: { 73 | "accordion-down": "accordion-down 0.2s ease-out", 74 | "accordion-up": "accordion-up 0.2s ease-out", 75 | }, 76 | }, 77 | }, 78 | plugins: [require("tailwindcss-animate")], 79 | }; 80 | -------------------------------------------------------------------------------- /app/components/custom/DarkModeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useFetcher } from "@remix-run/react"; 2 | import clsx from "clsx"; 3 | import { MoonIcon, SunIcon, LaptopIcon } from "lucide-react"; 4 | import { THEME_FETCHER } from "~/utils/constants"; 5 | import { useRequestInfo } from "~/utils/request-info"; 6 | import { useOptimisticThemeMode } from "~/utils/theme"; 7 | 8 | const iconTransformOrigin = { transformOrigin: "50% 100px" }; 9 | export default function DarkModeToggle({ 10 | variant = "icon", 11 | }: { 12 | variant?: "icon" | "labelled"; 13 | }) { 14 | const requestInfo = useRequestInfo(); 15 | const fetcher = useFetcher({ key: THEME_FETCHER }); 16 | 17 | const optimisticMode = useOptimisticThemeMode(); 18 | const mode = optimisticMode ?? requestInfo.userPrefs.theme ?? "system"; 19 | const nextMode = 20 | mode === "system" ? "light" : mode === "light" ? "dark" : "system"; 21 | 22 | const iconSpanClassName = 23 | "absolute inset-0 transform transition-transform duration-700 motion-reduce:duration-[0s]"; 24 | return ( 25 | 26 | 27 | 28 | 75 | 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /app/types/shared.ts: -------------------------------------------------------------------------------- 1 | export interface Env { 2 | SUPABASE_URL?: string; 3 | SUPABASE_ANON_KEY?: string; 4 | } 5 | export interface GitHubUser { 6 | login: string; 7 | id: number; 8 | node_id: string; 9 | avatar_url: string; 10 | gravatar_id: string; 11 | url: string; 12 | html_url: string; 13 | followers_url: string; 14 | following_url: string; 15 | gists_url: string; 16 | starred_url: string; 17 | subscriptions_url: string; 18 | organizations_url: string; 19 | repos_url: string; 20 | events_url: string; 21 | received_events_url: string; 22 | type: string; 23 | site_admin: boolean; 24 | } 25 | 26 | export interface Reactions { 27 | url: string; 28 | total_count: number; 29 | "+1": number; 30 | "-1": number; 31 | laugh: number; 32 | hooray: number; 33 | confused: number; 34 | heart: number; 35 | rocket: number; 36 | eyes: number; 37 | } 38 | 39 | export interface PullRequest { 40 | url: string; 41 | html_url: string; 42 | diff_url: string; 43 | patch_url: string; 44 | merged_at: string; 45 | } 46 | 47 | export interface GitHubIssue { 48 | url: string; 49 | repository_url: string; 50 | labels_url: string; 51 | comments_url: string; 52 | events_url: string; 53 | html_url: string; 54 | id: number; 55 | node_id: string; 56 | number: number; 57 | title: string; 58 | user: GitHubUser; 59 | labels: any[]; 60 | state: string; 61 | locked: boolean; 62 | assignee: null; 63 | assignees: any[]; 64 | milestone: null; 65 | comments: number; 66 | created_at: string; 67 | updated_at: string; 68 | closed_at: string; 69 | author_association: string; 70 | active_lock_reason: null; 71 | draft: boolean; 72 | pull_request: PullRequest; 73 | body: null; 74 | reactions: Reactions; 75 | timeline_url: string; 76 | performed_via_github_app: null; 77 | state_reason: null; 78 | score: number; 79 | } 80 | 81 | export interface GitHubIssuesResponse { 82 | total_count: number; 83 | incomplete_results: boolean; 84 | items: GitHubIssue[]; 85 | } 86 | 87 | export interface GithubUser { 88 | login: string; 89 | id: number; 90 | node_id: string; 91 | avatar_url: string; 92 | gravatar_id: string; 93 | url: string; 94 | html_url: string; 95 | followers_url: string; 96 | following_url: string; 97 | gists_url: string; 98 | starred_url: string; 99 | subscriptions_url: string; 100 | organizations_url: string; 101 | repos_url: string; 102 | events_url: string; 103 | received_events_url: string; 104 | type: string; 105 | site_admin: boolean; 106 | name: string | null; 107 | company: string | null; 108 | blog: string; 109 | location: string | null; 110 | email: string | null; 111 | hireable: boolean | null; 112 | bio: string | null; 113 | twitter_username: string | null; 114 | public_repos: number; 115 | public_gists: number; 116 | followers: number; 117 | following: number; 118 | created_at: string; 119 | updated_at: string; 120 | } 121 | -------------------------------------------------------------------------------- /app/lib/github.ts: -------------------------------------------------------------------------------- 1 | import type { GitHubIssuesResponse, GitHubUser } from "~/types/shared"; 2 | 3 | export interface PRFilter { 4 | startDate?: Date; 5 | endDate?: Date; 6 | includedRepos?: string[]; 7 | excludedRepos?: string[]; 8 | includedOrgs?: string[]; 9 | excludedOrgs?: string[]; 10 | author: string; 11 | limit?: number; 12 | } 13 | 14 | export const getPRsFromGithubAPI = async (filter: PRFilter) => { 15 | let queryParts: string[] = []; 16 | 17 | // Set default values for startDate and endDate 18 | const currentDate = new Date(); 19 | const threeYearsAgo = new Date(); 20 | threeYearsAgo.setFullYear(currentDate.getFullYear() - 3); 21 | 22 | const startDate = filter.startDate || threeYearsAgo; 23 | const endDate = filter.endDate || currentDate; 24 | const limit = filter.limit || 30; 25 | 26 | if (filter.includedRepos && filter.includedRepos.length > 0) { 27 | const includedReposParam = filter.includedRepos 28 | .map((repo) => `repo:${repo}`) 29 | .join("+"); 30 | queryParts.push(includedReposParam); 31 | } 32 | 33 | if (filter.excludedRepos && filter.excludedRepos.length > 0) { 34 | const excludedReposParam = filter.excludedRepos 35 | .map((repo) => `-repo:${repo}`) 36 | .join("+"); 37 | queryParts.push(excludedReposParam); 38 | } 39 | 40 | if (filter.includedOrgs && filter.includedOrgs.length > 0) { 41 | const includedOrgsParam = filter.includedOrgs 42 | .map((org) => `org:${org}`) 43 | .join("+"); 44 | queryParts.push(includedOrgsParam); 45 | } 46 | 47 | if (filter.excludedOrgs && filter.excludedOrgs.length > 0) { 48 | const excludedOrgsParam = filter.excludedOrgs 49 | .map((org) => `-org:${org}`) 50 | .join("+"); 51 | queryParts.push(excludedOrgsParam); 52 | } 53 | 54 | const authorParam = `author:${filter.author}`; 55 | queryParts.push(authorParam); 56 | 57 | const dateParam = `created:${startDate.toISOString()}..${endDate.toISOString()}`; 58 | queryParts.push(dateParam); 59 | 60 | queryParts.push("type:pr"); 61 | queryParts.push("is:public"); 62 | queryParts.push("is:merged"); 63 | 64 | const url = `https://api.github.com/search/issues?q=${queryParts.join( 65 | "+" 66 | )}&per_page=${limit}`; 67 | const init = { 68 | headers: { 69 | "content-type": "application/json;charset=UTF-8", 70 | "User-Agent": "request", 71 | }, 72 | }; 73 | 74 | try { 75 | const response = await fetch(url, init); 76 | const data = await response.json(); 77 | if (data.message) throw new Error(data.message); 78 | return { data, error: null } as { data: GitHubIssuesResponse; error: null }; 79 | } catch (error) { 80 | console.error(error); 81 | return { data: null, error }; 82 | } 83 | }; 84 | 85 | export const getGitHubUserData = async (username: string) => { 86 | const url = `https://api.github.com/users/${username}`; 87 | 88 | const init = { 89 | headers: { 90 | "content-type": "application/json;charset=UTF-8", 91 | "User-Agent": "request", 92 | }, 93 | }; 94 | 95 | try { 96 | const response = await fetch(url, init); 97 | const data = await response.json(); 98 | if (data.message) throw new Error(data.message); 99 | return { data, error: null } as { data: GitHubUser; error: null }; 100 | } catch (error) { 101 | console.error(error); 102 | return { data: null, error }; 103 | } 104 | }; 105 | -------------------------------------------------------------------------------- /app/components/custom/GithubCard.tsx: -------------------------------------------------------------------------------- 1 | import { StarIcon, SmileIcon } from "lucide-react"; 2 | import { Button } from "../ui/button"; 3 | import type { GitHubIssue } from "~/types/shared"; 4 | import { useFetcher, useLoaderData } from "@remix-run/react"; 5 | import type { loader } from "~/routes/$username"; 6 | import { ChatBubbleIcon, OpenInNewWindowIcon } from "@radix-ui/react-icons"; 7 | import { motion } from "framer-motion"; 8 | import PullRequestIcon from "./PullRequestIcon"; 9 | 10 | interface IGithubCardProps { 11 | item: GitHubIssue; 12 | isFeatured?: boolean; 13 | isOwner?: boolean; 14 | } 15 | 16 | export function DemoGithub({ 17 | item, 18 | isFeatured = false, 19 | isOwner = false, 20 | }: IGithubCardProps) { 21 | const { featured_github_prs } = useLoaderData(); 22 | const fetcher = useFetcher(); 23 | 24 | const toggleFeatured = async (prId: number) => { 25 | fetcher.submit( 26 | { prId, featured_github_prs, isFeatured }, 27 | { method: "post", action: "/actions/toggle-featured" } 28 | ); 29 | }; 30 | 31 | return ( 32 | 38 |
39 |
40 |

41 | {item.repository_url.slice(29)} 42 |

43 | 44 | {isOwner ? ( 45 | 64 | ) : null} 65 | 71 | 72 | 73 |
74 | 85 |
86 | 87 |
88 |
89 | 90 | {item.reactions.total_count} 91 |
92 |
93 | 94 | {item.comments} 95 |
96 |
97 | Merged on:{" "} 98 | {new Date(item.pull_request.merged_at).toDateString().slice(4)} 99 |
100 |
101 |
102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /app/routes/api.$username.og.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from "@vercel/og"; 2 | import type { LoaderFunctionArgs } from "@vercel/remix"; 3 | 4 | export const loader = async ({ request, params }: LoaderFunctionArgs) => { 5 | const username = params.username!; 6 | const url = new URL(request.url); 7 | const domain = url.origin; 8 | const avatar = `https://github.com/${username}.png?size=200`; 9 | const featuredPRsCount = url.searchParams.get("featuredPRsCount"); 10 | 11 | const interSemiBold = await fetch(`${domain}/assets/inter-semibold.ttf`).then( 12 | (res) => res.arrayBuffer() 13 | ); 14 | const interRegular = await fetch(`${domain}/assets/inter-regular.ttf`).then( 15 | (res) => res.arrayBuffer() 16 | ); 17 | 18 | return new ImageResponse( 19 | ( 20 |
34 |
41 |
50 |

59 | One link to highlight your Open-Source Contributions. 60 |

61 |

69 | The 'link-in-bio' for your Open-Source PRs. Curate a selection of 70 | your proudest GitHub PRs, showcase your expertise, and set 71 | yourself apart in the crowd. 72 |

73 |
74 |
84 | User 95 |

103 | {username} 104 |

105 | {`${username}'s 112 | {featuredPRsCount && featuredPRsCount !== "0" ? ( 113 |

121 | {featuredPRsCount} Featured PRs 122 |

123 | ) : null} 124 |
125 |
126 |
127 | ), 128 | { 129 | width: 1200, 130 | height: 630, 131 | fonts: [ 132 | { 133 | name: "Inter", 134 | data: interSemiBold, 135 | weight: 600, 136 | }, 137 | { 138 | name: "Inter", 139 | data: interRegular, 140 | weight: 400, 141 | }, 142 | ], 143 | } 144 | ); 145 | }; 146 | -------------------------------------------------------------------------------- /app/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SelectPrimitive from "@radix-ui/react-select" 3 | import { Check, ChevronDown } from "lucide-react" 4 | 5 | import { cn } from "~/lib/utils" 6 | 7 | const Select = SelectPrimitive.Root 8 | 9 | const SelectGroup = SelectPrimitive.Group 10 | 11 | const SelectValue = SelectPrimitive.Value 12 | 13 | const SelectTrigger = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, children, ...props }, ref) => ( 17 | 25 | {children} 26 | 27 | 28 | 29 | 30 | )) 31 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 32 | 33 | const SelectContent = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, children, position = "popper", ...props }, ref) => ( 37 | 38 | 49 | 56 | {children} 57 | 58 | 59 | 60 | )) 61 | SelectContent.displayName = SelectPrimitive.Content.displayName 62 | 63 | const SelectLabel = React.forwardRef< 64 | React.ElementRef, 65 | React.ComponentPropsWithoutRef 66 | >(({ className, ...props }, ref) => ( 67 | 72 | )) 73 | SelectLabel.displayName = SelectPrimitive.Label.displayName 74 | 75 | const SelectItem = React.forwardRef< 76 | React.ElementRef, 77 | React.ComponentPropsWithoutRef 78 | >(({ className, children, ...props }, ref) => ( 79 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | {children} 94 | 95 | )) 96 | SelectItem.displayName = SelectPrimitive.Item.displayName 97 | 98 | const SelectSeparator = React.forwardRef< 99 | React.ElementRef, 100 | React.ComponentPropsWithoutRef 101 | >(({ className, ...props }, ref) => ( 102 | 107 | )) 108 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 109 | 110 | export { 111 | Select, 112 | SelectGroup, 113 | SelectValue, 114 | SelectTrigger, 115 | SelectContent, 116 | SelectLabel, 117 | SelectItem, 118 | SelectSeparator, 119 | } 120 | -------------------------------------------------------------------------------- /app/utils/client-hints.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains utilities for using client hints for user preference which 3 | * are needed by the server, but are only known by the browser. 4 | */ 5 | import { useRevalidator } from "@remix-run/react"; 6 | import * as React from "react"; 7 | import { useRequestInfo } from "./request-info"; 8 | 9 | const clientHints = { 10 | theme: { 11 | cookieName: "CH-prefers-color-scheme", 12 | getValueCode: `window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'`, 13 | fallback: "light", 14 | transform(value: string) { 15 | return value === "dark" ? "dark" : "light"; 16 | }, 17 | }, 18 | timeZone: { 19 | cookieName: "CH-time-zone", 20 | getValueCode: `Intl.DateTimeFormat().resolvedOptions().timeZone`, 21 | fallback: "UTC", 22 | }, 23 | // add other hints here 24 | }; 25 | 26 | type ClientHintNames = keyof typeof clientHints; 27 | 28 | function getCookieValue(cookieString: string, name: ClientHintNames) { 29 | const hint = clientHints[name]; 30 | 31 | const value = cookieString 32 | .split(";") 33 | .map((c) => c.trim()) 34 | .find((c) => c.startsWith(`${hint.cookieName}=`)) 35 | ?.split("=")[1]; 36 | 37 | return value ? decodeURIComponent(value) : null; 38 | } 39 | 40 | /** 41 | * 42 | * @param request {Request} - optional request object (only used on server) 43 | * @returns an object with the client hints and their values 44 | */ 45 | export function getHints(request?: Request) { 46 | let cookieString = ""; 47 | if (typeof document !== "undefined") { 48 | cookieString = document.cookie; 49 | } else if (typeof request !== "undefined" && request.headers.has("Cookie")) { 50 | cookieString = request.headers.get("Cookie")!; 51 | } 52 | 53 | return Object.entries(clientHints).reduce( 54 | (acc, [name, hint]) => { 55 | const hintName = name as ClientHintNames; 56 | if ("transform" in hint) { 57 | acc[hintName] = hint.transform( 58 | getCookieValue(cookieString, hintName) ?? hint.fallback 59 | ); 60 | } else { 61 | // @ts-expect-error - this is fine (PRs welcome though) 62 | acc[hintName] = getCookieValue(cookieString, hintName) ?? hint.fallback; 63 | } 64 | return acc; 65 | }, 66 | // eslint-disable-next-line @typescript-eslint/prefer-reduce-type-parameter 67 | {} as { 68 | [name in ClientHintNames]: (typeof clientHints)[name] extends { 69 | transform: (value: any) => infer ReturnValue; 70 | } 71 | ? ReturnValue 72 | : (typeof clientHints)[name]["fallback"]; 73 | } 74 | ); 75 | } 76 | 77 | /** 78 | * @returns an object with the client hints and their values 79 | */ 80 | export function useHints() { 81 | const requestInfo = useRequestInfo(); 82 | return requestInfo.hints; 83 | } 84 | 85 | /** 86 | * @returns inline script element that checks for client hints and sets cookies 87 | * if they are not set then reloads the page if any cookie was set to an 88 | * inaccurate value. 89 | */ 90 | export function ClientHintCheck({ nonce }: { nonce: string }) { 91 | const { revalidate } = useRevalidator(); 92 | React.useEffect(() => { 93 | const themeQuery = window.matchMedia("(prefers-color-scheme: dark)"); 94 | function handleThemeChange() { 95 | document.cookie = `${clientHints.theme.cookieName}=${ 96 | themeQuery.matches ? "dark" : "light" 97 | }; Max-Age=31536000; Path=/`; 98 | revalidate(); 99 | } 100 | themeQuery.addEventListener("change", handleThemeChange); 101 | return () => { 102 | themeQuery.removeEventListener("change", handleThemeChange); 103 | }; 104 | }, [revalidate]); 105 | 106 | return ( 107 |