diff --git a/manifest.json b/manifest.json index c4ee64a5..3d904328 100644 --- a/manifest.json +++ b/manifest.json @@ -25,5 +25,5 @@ "128": "src/assets/os-icons/os-icon-128.png" }, "host_permissions": [""], - "permissions": ["storage","webRequest"] + "permissions": ["storage","webRequest", "activeTab"] } diff --git a/src/App.tsx b/src/App.tsx index 9a79174d..07031fe4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,44 +1,50 @@ -import { useState, useEffect } from "react"; -import { OPEN_SAUCED_AUTH_TOKEN_KEY } from "./constants"; +import { useState, useEffect, createContext } from "react"; + import Start from "./pages/start"; import Home from "./pages/home"; import Loading from "./pages/loading"; -import { checkTokenValidity } from "./utils/fetchOpenSaucedApiData"; +import { Profile } from "./pages/profile"; +import { useAuth } from "./hooks/useAuth"; + +export const RouteContext = createContext<{page: {name: string, props?: any}, setCurrentPage: (page: RouteKeys, props?: any) => void}>({page: {name: "loading"}, setCurrentPage: () => {}}); + +const routes = { + start: , + home: , + loading: , + profile: +} + +type RouteKeys = keyof typeof routes; function App() { - const [osAccessToken, setOsAccessToken] = useState(""); - // renderedPage can be either "start", "home" or "loading" - const [renderedPage, setRenderedPage] = useState("loading"); + const {isTokenValid} = useAuth() + const [renderedPage, setRenderedPage] = useState<{name: RouteKeys, props?: any}>({name: "loading", props: {}}); + + const setCurrentPage = (name: RouteKeys, props: any = {}) => { + setRenderedPage({name: name, props}) + } + useEffect(() => { - chrome.storage.sync.get([OPEN_SAUCED_AUTH_TOKEN_KEY], (result) => { - const authToken: string | undefined = result[OPEN_SAUCED_AUTH_TOKEN_KEY]; - if (authToken) { - checkTokenValidity(authToken).then((valid) => { - if (!valid) { - setOsAccessToken(""); - setRenderedPage("signin"); - } else { - setOsAccessToken(authToken); - setRenderedPage("home"); - } - }); - } else { - setRenderedPage("start"); - } - }); - }, []); + if(isTokenValid === null) { + setCurrentPage("loading") + } + else if(isTokenValid) { + setCurrentPage("home") + } + else { + setCurrentPage("start") + } + }, [isTokenValid]); return ( -
- {renderedPage === "start" ? ( - - ) : renderedPage === "home" ? ( - - ) : ( - - )} -
+ +
+ {routes[renderedPage.name]} +
+
+ ); } diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 00000000..7035ba44 --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from "react" +import { OPEN_SAUCED_AUTH_TOKEN_KEY, OPEN_SAUCED_SESSION_ENDPOINT } from "../constants" +import { cachedFetch } from "../utils/cache" + +const removeTokenFromStorage = () => { + return new Promise((resolve, reject) => { + + chrome.storage.sync.remove(OPEN_SAUCED_AUTH_TOKEN_KEY, () => { + resolve(true) + }) + }) +} + +export const useAuth = () => { + const [authToken, setAuthToken] = useState(null) + const [user, setUser] = useState(null) + const [isTokenValid, setIsTokenValid] = useState(null) + + useEffect(() => { + chrome.storage.sync.get([OPEN_SAUCED_AUTH_TOKEN_KEY], (result) => { + if (result[OPEN_SAUCED_AUTH_TOKEN_KEY]) { + setAuthToken(result[OPEN_SAUCED_AUTH_TOKEN_KEY]) + //get account data + cachedFetch(OPEN_SAUCED_SESSION_ENDPOINT, { + expireInSeconds: 2 * 60 * 60, // 2 hours + headers: { + Authorization: `Bearer ${result[OPEN_SAUCED_AUTH_TOKEN_KEY]}`, + Accept: 'application/json', + }, + }).then((resp) => { + if (!resp.ok) { + console.log('error getting user info') + removeTokenFromStorage().then(() => { + setAuthToken(null) + setUser(null) + setIsTokenValid(false) + }) + } + return resp.json() + }) + .then((json) => { + setUser(json) + setIsTokenValid(true) + }) + } else { + setIsTokenValid(false) + } + }); + }, []) + + return { authToken, user, isTokenValid } +} diff --git a/src/hooks/useOpensaucedUserCheck.ts b/src/hooks/useOpensaucedUserCheck.ts new file mode 100644 index 00000000..700d7fcd --- /dev/null +++ b/src/hooks/useOpensaucedUserCheck.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from "react" +import { isOpenSaucedUser } from "../utils/fetchOpenSaucedApiData" +import { getGithubUsername } from "../utils/urlMatchers" + +export const useOpensaucedUserCheck = () => { + const [currentTabIsOpensaucedUser, setCurrentTabIsOpensaucedUser] = useState(false) + const [checkedUser, setCheckedUser] = useState(null) + useEffect(() => { + //get active tab + chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => { + if (tabs.length > 0) { + const tab = tabs[0] + const username = getGithubUsername(tab.url!) + if(username != null) { + setCheckedUser(username) + setCurrentTabIsOpensaucedUser(await isOpenSaucedUser(username)) + } else { + setCheckedUser(null) + setCurrentTabIsOpensaucedUser(false) + } + } + }) + }, []) + + + return { currentTabIsOpensaucedUser, checkedUser } +} \ No newline at end of file diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 5d8fdcaf..d9a4daa6 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -1,14 +1,64 @@ -import React from "react"; +import React, { useContext } from "react"; +import { HiArrowTopRightOnSquare, HiUserCircle } from 'react-icons/hi2' +import { RouteContext } from "../App"; +import OpenSaucedLogo from "../assets/opensauced-logo.svg"; +import { useAuth } from "../hooks/useAuth"; +import { useOpensaucedUserCheck } from "../hooks/useOpensaucedUserCheck"; -interface HomeProps { - osAccessToken: string; - setRenderedPage: (page: string) => void; -} +function Home() { + const {setCurrentPage} = useContext(RouteContext) + const {user} = useAuth() + const {currentTabIsOpensaucedUser, checkedUser} = useOpensaucedUserCheck() -function Home({ osAccessToken, setRenderedPage }: HomeProps) { return ( -
-

Home

+
+
+ OpenSauced logo + {user && + } +
+
+

Tools:

+
+ + + Go to Highlights feed + + + + Go to Dashboard + + { + currentTabIsOpensaucedUser && + + } +
+
); } diff --git a/src/pages/profile.tsx b/src/pages/profile.tsx new file mode 100644 index 00000000..fa94ec90 --- /dev/null +++ b/src/pages/profile.tsx @@ -0,0 +1,140 @@ +import { useContext, useEffect, useState } from 'react'; +import { FaBrain, FaChevronLeft, FaRobot } from 'react-icons/fa'; +import { RiLinkedinFill, RiLinkM, RiTwitterFill } from 'react-icons/ri'; +import { AiOutlineReload } from 'react-icons/ai'; +import { SiC, SiCplusplus, SiCsharp, SiGoland, SiJavascript, SiPhp, SiPython, SiReact, SiRuby, SiRust, SiTypescript } from 'react-icons/si'; +import { DiJava } from 'react-icons/di' +import OpenSaucedLogo from "../assets/opensauced-logo.svg"; +import { getUserData, getUserPRData } from '../utils/fetchOpenSaucedApiData'; +import { RouteContext } from '../App'; + +const interestIcon = { + 'python': , + 'java': , + 'javascript': , + 'typescript': , + 'csharp': , + 'cpp': , + 'c': , + 'php': , + 'ruby': , + 'react': , + 'ml': , + 'ai': , + 'golang': , + 'rust': +} + +type InterestIconKeys = keyof typeof interestIcon; + +export const Profile = () => { + const { page, setCurrentPage } = useContext(RouteContext) + const [user, setUser] = useState(null) + const [userPR, setUserPR] = useState(null) + +useEffect(() => { + const fetchUserData = async () => { + const [userData, userPRData] = await Promise.all([getUserData(page.props.userName), getUserPRData(page.props.userName)]); + setUser(userData); + setUserPR(userPRData); + } + fetchUserData(); +}, []) + + + return ( +
+
+
+ + OpenSauced logo +
+ +
+
+
+ profile image +

@{page.props.userName}

+ {(user?.linkedin_url || user?.twitter_username) && +
+ {user?.linkedin_url && + + + + } + {user?.twitter_username && + + + + } +
+ } + {user?.bio && {user.bio}} + {user?.blog && + + + {user.blog} + + } +
+
+
+

Open Issues

+

{user?.open_issues}

+
+
+

PRs Made

+

{userPR?.meta.itemCount}

+
+
+

Avg PRs Velocity

+

-

+
+
+

Contributed Repos

+

-

+
+
+ { +
+

Current Interest

+
+ {user?.interests.split(',').map((interest) => ( + + {interestIcon[interest as InterestIconKeys] ?? null} + {interest} + + ) + )} +
+
+ } +
+
+ ) +} \ No newline at end of file diff --git a/src/pages/start.tsx b/src/pages/start.tsx index 7506e033..af3d4bdc 100644 --- a/src/pages/start.tsx +++ b/src/pages/start.tsx @@ -1,11 +1,8 @@ import OpenSaucedLogo from "../assets/opensauced-logo.svg"; import { SUPABASE_LOGIN_URL } from "../constants"; -interface StartProps { - setRenderedPage: (page: string) => void; -} +function Start() { -function Start({ setRenderedPage }: StartProps) { return (
Open Sauced Logo diff --git a/src/utils/cache.ts b/src/utils/cache.ts new file mode 100644 index 00000000..fb2589c7 --- /dev/null +++ b/src/utils/cache.ts @@ -0,0 +1,46 @@ +export const cachedFetch = async ( + url: string, + options: + | number + | (RequestInit & { expireInSeconds: number; forceRefresh?: boolean }) + | undefined +) => { + let expiry = 5 * 60; // 5 min default + if (typeof options === "number") { + expiry = options; + options = undefined; + } else if (typeof options === "object") { + expiry = options.expireInSeconds ?? expiry; + } + + let cacheKey = url; + let cached = (await chrome.storage.local.get(cacheKey))[cacheKey]; + let whenCached = (await chrome.storage.local.get(cacheKey + ":ts"))[cacheKey + ":ts"]; + + if (cached && whenCached !== null && !options?.forceRefresh) { + let age = (Date.now() - parseInt(whenCached)) / 1000; + if (age < expiry) { + let response = new Response(new Blob([cached])); + return Promise.resolve(response); + } else { + chrome.storage.local.remove(cacheKey); + chrome.storage.local.remove(cacheKey + ":ts"); + } + } + + return fetch(url, options).then((response) => { + if (response.status === 200) { + let ct = response.headers.get("Content-Type"); + if (ct && ct.match(/(application\/json|text\/.*)/i)) { + response + .clone() + .text() + .then((content) => { + chrome.storage.local.set({ [cacheKey]: content }); + chrome.storage.local.set({ [cacheKey + ":ts"]: Date.now() }); + }); + } + } + return response; + }); +}; \ No newline at end of file diff --git a/src/utils/fetchOpenSaucedApiData.ts b/src/utils/fetchOpenSaucedApiData.ts index f9404ecf..c8bffdfd 100644 --- a/src/utils/fetchOpenSaucedApiData.ts +++ b/src/utils/fetchOpenSaucedApiData.ts @@ -1,3 +1,4 @@ +import { cachedFetch } from "./cache"; import { OPEN_SAUCED_USERS_ENDPOINT, OPEN_SAUCED_SESSION_ENDPOINT } from "../constants"; export const isOpenSaucedUser = async (username: string) => { @@ -24,3 +25,39 @@ export const checkTokenValidity = async (token: string) => { }); return response.status === 200; }; + +export const getUserData = async (userName: string, forceRefresh: boolean = false) => { + return cachedFetch(`${OPEN_SAUCED_USERS_ENDPOINT}/${userName}`, { + expireInSeconds: 2 * 60 * 60, // 2 hours + forceRefresh, + headers: { + Accept: 'application/json', + }, + }).then((resp) => { + if (!resp.ok) { + console.log('error getting user info') + } + return resp.json() + }) + .then((json) => { + return json + }) +} + +export const getUserPRData = async (userName: string, forceRefresh: boolean = false) => { + return cachedFetch(`${OPEN_SAUCED_USERS_ENDPOINT}/${userName}/prs`, { + expireInSeconds: 2 * 60 * 60, // 2 hours + forceRefresh, + headers: { + Accept: 'application/json', + }, + }).then((resp) => { + if (!resp.ok) { + console.log('error getting user PR info') + } + return resp.json() + }) + .then((json) => { + return json + }) +} diff --git a/tailwind.config.js b/tailwind.config.js index 6912a3ed..a51ba6cf 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,3 +1,5 @@ +const plugin = require("tailwindcss/plugin"); + /** @type {import('tailwindcss').Config} */ module.exports = { content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], @@ -14,6 +16,44 @@ module.exports = { } }, }, - plugins: [], + plugins: [ + // plugin for centering dividers + // usage:
+ plugin(function({ matchUtilities, theme }) { + matchUtilities( + { + 'divider-x-center': (value) => { + return { + "& > :not([hidden]):first-child": { + paddingLeft: 0, + }, + "& > :not([hidden])": { + paddingLeft: value, + paddingRight: value, + }, + "& > :not([hidden]):last-child": { + paddingRight: 0, + }, + }; + }, + 'divider-y-center': (value) => { + return { + "& > :not([hidden]):first-child": { + paddingTop: 0, + }, + "& > :not([hidden])": { + paddingTop: value, + paddingBottom: value, + }, + "& > :not([hidden]):last-child": { + paddingBottom: 0, + }, + }; + } + }, + { values: theme('padding') } + ) + }) + ], important: true, };