├── .nvmrc ├── .prettierrc ├── .husky ├── .gitignore └── pre-commit ├── public ├── explore │ └── .gitkeep ├── favicon.ico ├── qrcode.png ├── create-a-github-token.png ├── nextjs-github-browser.png ├── cypress-video-user.repositories.mp4 ├── cypress-thumbnail-user.repositories.png ├── vercel.svg └── robots.txt ├── src ├── utils │ ├── index.ts │ ├── string.ts │ ├── string.test.ts │ ├── server.ts │ ├── common.ts │ ├── __snapshots__ │ │ └── svg.test.tsx.snap │ ├── date.ts │ ├── tests │ │ └── index.tsx │ ├── github │ │ ├── index.ts │ │ ├── repository.test.ts │ │ ├── index.test.ts │ │ └── repository.ts │ ├── graphql.ts │ ├── type-guards.ts │ ├── svg.ts │ ├── svg.test.tsx │ └── date.test.ts ├── libs │ ├── mocks │ │ ├── browser.ts │ │ └── common.ts │ └── graphql.ts ├── mocks │ ├── browser.ts │ └── node.ts ├── components │ ├── TheHeader │ │ ├── style.module.css │ │ ├── TheHeader.stories.tsx │ │ └── TheHeader.tsx │ ├── BaseMarkdownDisplay │ │ ├── BaseMarkdown.module.css │ │ ├── BaseMarkdownDisplay.stories.tsx │ │ ├── uri-transformer.ts │ │ └── BaseMarkdownDisplay.tsx │ ├── App404 │ │ ├── App404.stories.tsx │ │ └── App404.tsx │ ├── TheHome │ │ ├── TheHome.module.css │ │ └── TheHome.stories.tsx │ ├── AppNotFound │ │ ├── AppNotFound.stories.tsx │ │ └── AppNotFound.tsx │ ├── BaseLayout │ │ └── BaseLayout.tsx │ ├── AppListRoutePatterns │ │ └── AppListRoutePatterns.stories.tsx │ ├── AppTagLicense │ │ ├── AppTagLicense.stories.tsx │ │ └── AppTagLicense.tsx │ ├── icons │ │ ├── HamburgerIcon.tsx │ │ ├── CloseIcon.tsx │ │ └── TwitterIcon.tsx │ ├── AppTagCount │ │ ├── AppTagCount.stories.tsx │ │ └── AppTagCount.tsx │ ├── BaseBadge │ │ ├── BaseBadge.stories.tsx │ │ └── BaseBadge.tsx │ ├── AppDarkModeSwitch │ │ ├── AppDarkModeSwitch.stories.tsx │ │ └── AppDarkModeSwitch.tsx │ ├── AppLoadingSpinner │ │ ├── AppLoadingSpinner.stories.tsx │ │ ├── AppLoadingSpinner.tsx │ │ └── AppLoadingSpinner.module.css │ ├── AppTagLanguage │ │ ├── AppTagLanguage.stories.tsx │ │ └── AppTagLanguage.tsx │ ├── BaseBoxWithHeader │ │ ├── BaseBoxWithHeader.stories.tsx │ │ └── BaseBoxWithHeader.tsx │ ├── AppOrganizationCard │ │ └── AppOrganizationCard.stories.tsx │ ├── AppRepositoryHeader │ │ ├── AppRepositoryHeader.stories.tsx │ │ └── AppRepositoryHeader.tsx │ ├── AppAvatarImage │ │ ├── AppAvatarImage.stories.tsx │ │ └── AppAvatarImage.tsx │ ├── AppOrganizationCardMini │ │ ├── AppOrganizationCardMini.stories.tsx │ │ └── AppOrganizationCardMini.tsx │ ├── AppUserProfileInfos │ │ ├── AppUserProfileInfos.stories.tsx │ │ └── AppUserProfileInfos.test.tsx │ ├── BaseTag │ │ ├── BaseTag.stories.tsx │ │ └── BaseTag.tsx │ ├── BaseSyntaxHighlighter │ │ ├── helpers.test.ts │ │ └── helpers.ts │ ├── AppProfileOverview │ │ ├── AppProfileOverview.stories.tsx │ │ └── AppProfileOverview.tsx │ ├── AppRepositoryBreadcrumb │ │ ├── AppRepositoryBreadcrumb.stories.tsx │ │ ├── AppRepositoryBreadcrumb.test.tsx │ │ └── AppRepositoryBreadcrumb.tsx │ ├── BaseBox │ │ ├── BaseBox.tsx │ │ └── BaseBox.stories.tsx │ ├── AppTopicsTagList │ │ ├── AppTopicsTagList.stories.tsx │ │ └── AppTopicsTagList.tsx │ ├── BaseSearchInput │ │ ├── BaseSearchInput.stories.tsx │ │ └── BaseSearchInput.tsx │ ├── BaseButton │ │ ├── BaseButton.stories.tsx │ │ └── BaseButton.tsx │ ├── AppSearchSummary │ │ ├── AppSearchSummary.stories.tsx │ │ └── AppSearchSummary.tsx │ ├── AppSearchBarRepositories │ │ ├── AppSearchBarRepositories.stories.tsx │ │ └── AppSearchBarRepositories.tsx │ ├── AppNavBarProfile │ │ ├── AppNavBarProfile.tsx │ │ └── AppNavBarProfile.test.tsx │ ├── TheOwnerProfile │ │ └── TheOwnerProfile.stories.tsx │ ├── AppSelectMenu │ │ ├── AppSelectMenu.stories.tsx │ │ └── AppSelectMenu.tsx │ ├── AppRepositoryReadme │ │ └── AppRepositoryReadme.tsx │ ├── AppNavBarRepository │ │ └── AppNavBarRepository.tsx │ ├── AppFileHeader │ │ └── AppFilesHeader.stories.tsx │ ├── AppTagDate │ │ ├── AppTagDate.tsx │ │ └── AppTagDate.stories.tsx │ ├── AppLanguagesGraph │ │ ├── AppLanguagesGraph.tsx │ │ └── AppLanguagesGraph.stories.tsx │ ├── AppGitRefSwitch │ │ └── AppGitRefSwitch.stories.tsx │ ├── AppLanguagesList │ │ ├── AppLanguagesList.stories.tsx │ │ └── AppLanguagesList.tsx │ ├── AppPinnedItem │ │ └── AppPinnedItem.tsx │ ├── AppOrganizationProfileInfos │ │ └── AppOrganizationProfileInfos.tsx │ ├── TheFooter │ │ └── TheFooter.tsx │ ├── AppNavBar │ │ └── AppNavBar.tsx │ ├── AppMainLayout │ │ └── AppMainLayout.tsx │ ├── AppBlobDisplay │ │ └── AppBlobDisplay.tsx │ ├── ExternalTwitterButton │ │ └── ExternalTwitterButton.tsx │ ├── BaseSelectMenu │ │ ├── BaseSelectMenu.stories.tsx │ │ └── BaseSelectMenu.tsx │ ├── AppRepositoryInfosAbout │ │ └── AppRepositoryInfosAbout.tsx │ ├── AppRepositoryOverview │ │ └── AppRepositoryOverview.tsx │ └── AppRepositoryListItem │ │ └── AppRepositoryListItem.tsx ├── pages │ ├── 404.tsx │ ├── [owner] │ │ ├── [repositoryName].tsx │ │ └── [repositoryName] │ │ │ ├── tree │ │ │ └── [...branchName].tsx │ │ │ └── commit │ │ │ └── [commitId].tsx │ ├── api │ │ └── hello.ts │ ├── explore.tsx │ ├── index.tsx │ ├── _document.tsx │ ├── _app.tsx │ └── orgs │ │ └── [owner] │ │ └── repositories.tsx ├── graphql │ ├── queries │ │ ├── searchRepositories.graphql │ │ ├── getRepositoryInfosCommit.graphql │ │ ├── getRepositoryInfosTree.graphql │ │ ├── getOrganizationWithRepositories.graphql │ │ ├── getRepositoryOwnerWithRepositories.graphql │ │ ├── getProfileReadme.graphql │ │ ├── getRepositoryOwnerWithPinnedItems.graphql │ │ ├── getRepositoryInfosBlob.graphql │ │ └── getRepositoryInfosOverview.graphql │ └── fragments │ │ ├── pinnedItemInfos.graphql │ │ ├── repositoryFiles.graphql │ │ ├── userInfos.graphql │ │ ├── organizationInfos.graphql │ │ └── searchRepos.graphql ├── generated │ └── github-schema-loader.js ├── styles │ └── Home.module.css ├── stories │ ├── Introduction.stories.mdx │ └── assets │ │ ├── direction.svg │ │ ├── flow.svg │ │ ├── code-brackets.svg │ │ ├── comments.svg │ │ ├── repo.svg │ │ ├── plugin.svg │ │ └── stackalt.svg ├── types.ts └── tests │ └── helpers │ └── index.ts ├── custom.d.ts ├── jest-setup.ts ├── .env.production ├── cypress ├── fixtures │ ├── profile.json │ └── example.json ├── tsconfig.json ├── types.ts ├── integration │ ├── user.repositories.spec.ts │ ├── organization.repositories.spec.ts │ ├── misc.spec.ts │ ├── experimental.spec.ts │ └── repository.spec.ts ├── support │ ├── index.d.ts │ └── index.js ├── webpack.config.js └── plugins │ └── index.ts ├── types.d.ts ├── postcss.config.js ├── .storybook ├── preview-head.html ├── main.js └── preview.js ├── cypress.json ├── .vscode ├── extensions.json └── settings.json ├── next-env.d.ts ├── lint-staged.config.js ├── .prettierignore ├── .env ├── utils └── index.ts ├── codegen.yml ├── next.config.js ├── .github └── workflows │ ├── ci.yml │ └── e2e.yml ├── .gitignore ├── tsconfig.json ├── lib.es5.d.ts ├── README.orig.md └── tailwind.config.js /.nvmrc: -------------------------------------------------------------------------------- 1 | 14 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /public/explore/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module "human-time"; 2 | -------------------------------------------------------------------------------- /jest-setup.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | # NEXT_PUBLIC_APP_BASE_URL=https://$VERCEL_URL 2 | -------------------------------------------------------------------------------- /src/libs/mocks/browser.ts: -------------------------------------------------------------------------------- 1 | export { getMockFileName } from "./common"; 2 | -------------------------------------------------------------------------------- /src/mocks/browser.ts: -------------------------------------------------------------------------------- 1 | export { getMockFileName } from "../libs/mocks/browser"; 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/nextjs-github-browser/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/qrcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/nextjs-github-browser/HEAD/public/qrcode.png -------------------------------------------------------------------------------- /cypress/fixtures/profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 8739, 3 | "name": "Jane", 4 | "email": "jane@example.com" 5 | } -------------------------------------------------------------------------------- /src/components/TheHeader/style.module.css: -------------------------------------------------------------------------------- 1 | .dialog [data-reach-dialog-content] { 2 | padding: initial; 3 | } 4 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "human-readable-numbers" { 2 | export function toHumanString(num: number): string; 3 | } 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/create-a-github-token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/nextjs-github-browser/HEAD/public/create-a-github-token.png -------------------------------------------------------------------------------- /public/nextjs-github-browser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/nextjs-github-browser/HEAD/public/nextjs-github-browser.png -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000", 3 | "defaultCommandTimeout": 60000, 4 | "env": { 5 | "IS_CI": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /public/cypress-video-user.repositories.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/nextjs-github-browser/HEAD/public/cypress-video-user.repositories.mp4 -------------------------------------------------------------------------------- /public/cypress-thumbnail-user.repositories.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/nextjs-github-browser/HEAD/public/cypress-thumbnail-user.repositories.png -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import App404 from "../components/App404/App404"; 2 | 3 | export default function page404(): JSX.Element { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "clinyong.vscode-css-modules", 5 | "bradlc.vscode-tailwindcss" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /src/graphql/queries/searchRepositories.graphql: -------------------------------------------------------------------------------- 1 | query SearchRepositories( 2 | $query: String! 3 | $after: String 4 | $before: String 5 | $first: Int 6 | $last: Int 7 | ) { 8 | ...SearchRepos 9 | } 10 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "types": ["cypress", "node", "@testing-library/cypress"] 5 | }, 6 | "exclude": [ 7 | "node_modules", 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /cypress/types.ts: -------------------------------------------------------------------------------- 1 | export type NextWindowType = Cypress.AUTWindow & { 2 | __NEXT_DATA__: { 3 | props: { 4 | pageProps: { 5 | __APOLLO_STATE__: Record; 6 | }; 7 | }; 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/generated/github-schema-loader.js: -------------------------------------------------------------------------------- 1 | // as specified in `codegen.yml` 2 | // eslint-disable-next-line @typescript-eslint/no-var-requires 3 | const { schema } = require("@octokit/graphql-schema"); 4 | 5 | module.exports = schema.json; 6 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "*.{js,jsx,ts,tsx}": ["eslint --cache --fix", "npm run test:precommit"], 3 | "*{ts,tsx}": () => ["npm run type-check:src"], 4 | "cypress/**/*{ts,tsx}": () => ["npm run type-check:cy"], 5 | }; 6 | -------------------------------------------------------------------------------- /src/pages/[owner]/[repositoryName].tsx: -------------------------------------------------------------------------------- 1 | import { 2 | makeGetServerSideProps, 3 | makePage, 4 | } from "../../pages-shared/page-repository-tree"; 5 | 6 | export const getServerSideProps = makeGetServerSideProps(); 7 | 8 | export default makePage(); 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .git 2 | .next 3 | .eslintignore 4 | .gitignore 5 | .prettierignore 6 | .gitignore 7 | package-lock.json 8 | yarn.lock 9 | package.json 10 | build 11 | out 12 | *.fixtures.json 13 | react-modules/* 14 | bin/yarn* 15 | src/generated/* 16 | -------------------------------------------------------------------------------- /src/graphql/queries/getRepositoryInfosCommit.graphql: -------------------------------------------------------------------------------- 1 | query GetRepositoryInfosCommit( 2 | $owner: String! 3 | $name: String! 4 | $commit: String! 5 | ) { 6 | rateLimit { 7 | limit 8 | cost 9 | remaining 10 | resetAt 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/pages/[owner]/[repositoryName]/tree/[...branchName].tsx: -------------------------------------------------------------------------------- 1 | import { 2 | makeGetServerSideProps, 3 | makePage, 4 | } from "../../../../pages-shared/page-repository-tree"; 5 | 6 | export const getServerSideProps = makeGetServerSideProps(); 7 | 8 | export default makePage(); 9 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | GRAPHQL_API_ROOT_ENDPOINT=https://api.github.com/graphql 2 | # NEXT_PUBLIC_APP_BASE_URL only used for social meta tags, will be overriden in production 3 | NEXT_PUBLIC_APP_BASE_URL=http://localhost:3000 4 | NEXT_PUBLIC_APP_TITLE=nextjs-github-browser 5 | NEXT_PUBLIC_TWITTER_HANDLE=@topheman 6 | -------------------------------------------------------------------------------- /src/graphql/fragments/pinnedItemInfos.graphql: -------------------------------------------------------------------------------- 1 | fragment PinnedItemInfos on Repository { 2 | name 3 | description 4 | primaryLanguage { 5 | name 6 | color 7 | } 8 | stargazerCount 9 | forkCount 10 | nameWithOwner 11 | parent { 12 | nameWithOwner 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "tslint.jsEnable": true, // to lint .js in vscode with https://marketplace.visualstudio.com/items?itemName=ms-vscode.vscode-typescript-tslint-plugin 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | 3 | import type { NextApiRequest, NextApiResponse } from "next"; 4 | 5 | export default (req: NextApiRequest, res: NextApiResponse): void => { 6 | res.status(200).json({ name: "John Doe" }); 7 | }; 8 | -------------------------------------------------------------------------------- /src/graphql/fragments/repositoryFiles.graphql: -------------------------------------------------------------------------------- 1 | fragment RepositoryFiles on Repository { 2 | repositoryFiles: object(expression: $refPath) { 3 | ... on Tree { 4 | entries { 5 | name 6 | type 7 | extension 8 | path 9 | oid 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /utils/index.ts: -------------------------------------------------------------------------------- 1 | export function parseBooleanEnvVar( 2 | boolValueFromENV?: string, 3 | defaultValue = false 4 | ): boolean { 5 | let result; 6 | try { 7 | result = Boolean(JSON.parse(boolValueFromENV as string)); 8 | } catch (_) { 9 | result = defaultValue; 10 | } 11 | return result; 12 | } 13 | -------------------------------------------------------------------------------- /src/components/BaseMarkdownDisplay/BaseMarkdown.module.css: -------------------------------------------------------------------------------- 1 | .root a { 2 | color: var(--color-text-brand-primary); 3 | } 4 | .root a:hover { 5 | text-decoration: underline; 6 | } 7 | .root ul li { 8 | list-style: initial; 9 | } 10 | .root code { 11 | background-color: var(--color-raw-brand-secondary); 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/string.ts: -------------------------------------------------------------------------------- 1 | export function formatUrl(url: string): string { 2 | return url.replace(/https?:\/\//, ""); 3 | } 4 | 5 | export function truncate( 6 | str: string, 7 | maxLength: number, 8 | ellipsis = "..." 9 | ): string { 10 | if (str.length <= maxLength) { 11 | return str; 12 | } 13 | return str.slice(0, maxLength) + ellipsis; 14 | } 15 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], 3 | addons: [ 4 | "storybook-addon-themes", 5 | "@storybook/addon-links", 6 | "@storybook/addon-essentials", 7 | "storybook-css-modules-preset", 8 | "@storybook/addon-postcss", 9 | "storybook-addon-next-router", 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: "src/generated/github-schema-loader.js" 3 | documents: 4 | - "src/graphql/**/*.graphql" 5 | generates: 6 | src/generated/graphql.ts: 7 | plugins: 8 | - "typescript" 9 | - "typescript-operations" 10 | - "typescript-react-apollo" 11 | # - "typescript-graphql-files-modules" 12 | # - "typescript-document-nodes" 13 | -------------------------------------------------------------------------------- /src/graphql/queries/getRepositoryInfosTree.graphql: -------------------------------------------------------------------------------- 1 | query GetRepositoryInfosTree( 2 | $owner: String! 3 | $name: String! 4 | $refPath: String! # examples: HEAD:|master:|feature/foo:|master:src 5 | ) { 6 | rateLimit { 7 | limit 8 | cost 9 | remaining 10 | resetAt 11 | } 12 | repository(name: $name, owner: $owner) { 13 | ...RepositoryFiles 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | min-height: 100vh; 3 | padding: 0 0.5rem; 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: space-around; 7 | align-items: center; 8 | } 9 | 10 | .main { 11 | padding: 5rem 0; 12 | flex: 1; 13 | display: flex; 14 | flex-direction: column; 15 | justify-content: center; 16 | align-items: center; 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/string.test.ts: -------------------------------------------------------------------------------- 1 | import { formatUrl } from "./string"; 2 | 3 | describe("utils/string", () => { 4 | describe("formatUrl", () => { 5 | it("should remove http(s)://", () => { 6 | expect(formatUrl("http://example.com")).toBe("example.com"); 7 | expect(formatUrl("https://example.com")).toBe("example.com"); 8 | }); 9 | }); 10 | }); 11 | 12 | export default {}; 13 | -------------------------------------------------------------------------------- /src/components/App404/App404.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import App404, { App404Props } from "./App404"; 5 | 6 | export default { 7 | title: "App404", 8 | component: App404, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const Base = Template.bind({}); 14 | -------------------------------------------------------------------------------- /src/graphql/fragments/userInfos.graphql: -------------------------------------------------------------------------------- 1 | fragment UserInfos on User { 2 | __typename 3 | id 4 | name 5 | login 6 | bio 7 | createdAt 8 | websiteUrl 9 | twitterUsername 10 | avatarUrl 11 | location 12 | followers { 13 | totalCount 14 | } 15 | following { 16 | totalCount 17 | } 18 | starredRepositories { 19 | totalCount 20 | } 21 | allRepos: repositories(first: 1) { 22 | totalCount 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /cypress/integration/user.repositories.spec.ts: -------------------------------------------------------------------------------- 1 | import { runRepositoriesTests } from "../support/owner.repositories"; 2 | 3 | runRepositoriesTests("/topheman?tab=repositories", "topheman", { 4 | searchQuery: "react", 5 | skipCheckBackButton: 6 | Cypress.env("IS_CI") && Cypress.env("SKIP_FAILING_TESTS_ON_CI"), // doesn't work on CI 🙁 7 | skipCheckForwardButton: 8 | Cypress.env("IS_CI") && Cypress.env("SKIP_FAILING_TESTS_ON_CI"), // doesn't work on CI 🙁 9 | }); 10 | -------------------------------------------------------------------------------- /src/components/TheHome/TheHome.module.css: -------------------------------------------------------------------------------- 1 | .right { 2 | position: relative; 3 | } 4 | .right::after { 5 | position: relative; 6 | content: attr(data-help); 7 | position: absolute; 8 | top: 20px; 9 | left: 140px; 10 | font-style: italic; 11 | } 12 | .top { 13 | position: relative; 14 | } 15 | .top::before { 16 | position: relative; 17 | content: attr(data-help); 18 | position: absolute; 19 | top: -25px; 20 | left: 40%; 21 | font-style: italic; 22 | } 23 | -------------------------------------------------------------------------------- /cypress/integration/organization.repositories.spec.ts: -------------------------------------------------------------------------------- 1 | import { runRepositoriesTests } from "../support/owner.repositories"; 2 | 3 | runRepositoriesTests("/orgs/twitter/repositories", "twitter", { 4 | searchQuery: "typeahead.js", 5 | skipCheckBackButton: 6 | Cypress.env("IS_CI") && Cypress.env("SKIP_FAILING_TESTS_ON_CI"), // doesn't work on CI 🙁 7 | skipCheckForwardButton: 8 | Cypress.env("IS_CI") && Cypress.env("SKIP_FAILING_TESTS_ON_CI"), // doesn't work on CI 🙁 9 | }); 10 | -------------------------------------------------------------------------------- /src/components/TheHeader/TheHeader.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import TheHeader from "./TheHeader"; 5 | 6 | export default { 7 | title: "TheHeader", 8 | component: TheHeader, 9 | } as Meta; 10 | 11 | const Template: Story> = (args) => ( 12 | 13 | ); 14 | 15 | export const Base = Template.bind({}); 16 | Base.parameters = { 17 | layout: "fullscreen", 18 | }; 19 | -------------------------------------------------------------------------------- /src/graphql/queries/getOrganizationWithRepositories.graphql: -------------------------------------------------------------------------------- 1 | query GetOrganizationWithRepositories( 2 | $owner: String! 3 | $query: String! 4 | $after: String 5 | $before: String 6 | $first: Int 7 | $last: Int 8 | ) { 9 | rateLimit { 10 | limit 11 | cost 12 | remaining 13 | resetAt 14 | } 15 | ...SearchRepos 16 | repositoryOwner(login: $owner) { 17 | ... on Organization { 18 | login 19 | name 20 | avatarUrl 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/pages/[owner]/[repositoryName]/commit/[commitId].tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import App404 from "../../../../components/App404/App404"; 3 | 4 | export default function PageRepositoryCommit(): JSX.Element { 5 | const router = useRouter(); 6 | const { owner, repositoryName, commitId } = router.query; 7 | return ( 8 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/server.ts: -------------------------------------------------------------------------------- 1 | import type { ServerResponse } from "http"; 2 | 3 | import { parseBooleanEnvVar } from "../../utils"; 4 | 5 | export function addHttpCacheHeader(res: ServerResponse): void { 6 | const CACHE_PAGES = parseBooleanEnvVar(process.env.CACHE_PAGES, false); 7 | const CACHE_MAX_AGE = process.env.CACHE_MAX_AGE || "120"; 8 | if (CACHE_PAGES) { 9 | res.setHeader( 10 | "Cache-Control", 11 | `private, max-age=${CACHE_MAX_AGE}, must-revalidate` 12 | ); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/components/AppNotFound/AppNotFound.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import AppNotFound, { AppNotFoundProps } from "./AppNotFound"; 5 | 6 | export default { 7 | title: "AppNotFound", 8 | component: AppNotFound, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => { 12 | return ; 13 | }; 14 | 15 | export const Base = Template.bind({}); 16 | Base.args = { 17 | type: "user", 18 | }; 19 | -------------------------------------------------------------------------------- /src/utils/common.ts: -------------------------------------------------------------------------------- 1 | export function encodeBase64(str?: string | null): string { 2 | if (!str) { 3 | return ""; 4 | } 5 | if (typeof window === "undefined") { 6 | return Buffer.from(str, "utf-8").toString("base64"); 7 | } 8 | return btoa(str); 9 | } 10 | 11 | export function decodeBase64(str?: string | null): string { 12 | if (!str) { 13 | return ""; 14 | } 15 | if (typeof window === "undefined") { 16 | return Buffer.from(str, "base64").toString(); 17 | } 18 | return atob(str); 19 | } 20 | -------------------------------------------------------------------------------- /src/graphql/fragments/organizationInfos.graphql: -------------------------------------------------------------------------------- 1 | fragment OrganizationInfos on Organization { 2 | __typename 3 | id 4 | name 5 | login 6 | createdAt 7 | websiteUrl 8 | twitterUsername 9 | avatarUrl 10 | location 11 | description 12 | email 13 | isVerified 14 | people: membersWithRole(first: 20) { 15 | totalCount 16 | edges { 17 | node { 18 | avatarUrl 19 | login 20 | } 21 | } 22 | } 23 | allRepos: repositories(first: 1) { 24 | totalCount 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | // https://nextjs.org/blog/next-10-2#routing-based-on-headers-and-query-string-parameters 2 | module.exports = { 3 | async redirects() { 4 | return [ 5 | { 6 | source: "/explore/storybook", 7 | destination: "/explore/storybook/index.html", 8 | permanent: false, 9 | }, 10 | ]; 11 | }, 12 | // faster minification directly with swc (currently rc will be default in next@12.2.0) - https://nextjs.org/blog/next-12-1#faster-minification-with-swc 13 | swcMinify: true, 14 | }; 15 | -------------------------------------------------------------------------------- /src/graphql/queries/getRepositoryOwnerWithRepositories.graphql: -------------------------------------------------------------------------------- 1 | query GetRepositoryOwnerWithRepositories( 2 | $owner: String! 3 | $query: String! 4 | $after: String 5 | $before: String 6 | $first: Int 7 | $last: Int 8 | ) { 9 | rateLimit { 10 | limit 11 | cost 12 | remaining 13 | resetAt 14 | } 15 | ...SearchRepos 16 | repositoryOwner(login: $owner) { 17 | ... on User { 18 | ...UserInfos 19 | } 20 | ... on Organization { 21 | ...OrganizationInfos 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/components/BaseLayout/BaseLayout.tsx: -------------------------------------------------------------------------------- 1 | import TheHeader from "../TheHeader/TheHeader"; 2 | import TheFooter from "../TheFooter/TheFooter"; 3 | 4 | export type BaseLayoutProps = { 5 | children: React.ReactChild | React.ReactChild[]; 6 | }; 7 | 8 | export default function BaseLayout({ 9 | children, 10 | }: BaseLayoutProps): JSX.Element | null { 11 | return ( 12 | <> 13 | 14 | {children} 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/AppListRoutePatterns/AppListRoutePatterns.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import AppListRoutePatterns, { 5 | AppListRoutePatternsProps, 6 | } from "./AppListRoutePatterns"; 7 | 8 | export default { 9 | title: "AppListRoutePatterns", 10 | component: AppListRoutePatterns, 11 | } as Meta; 12 | 13 | const Template: Story = (args) => ( 14 | 15 | ); 16 | 17 | export const Base = Template.bind({}); 18 | -------------------------------------------------------------------------------- /src/components/TheHome/TheHome.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import TheHome, { TheHomeProps } from "./TheHome"; 5 | 6 | export default { 7 | title: "TheHome", 8 | component: TheHome, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const Base = Template.bind({}); 14 | Base.args = { 15 | helpActive: false, 16 | }; 17 | 18 | export const BaseHelpActive = Template.bind({}); 19 | BaseHelpActive.args = { 20 | helpActive: true, 21 | }; 22 | -------------------------------------------------------------------------------- /src/stories/Introduction.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from "@storybook/addon-docs"; 2 | 3 | 4 | 5 | # Welcome to nextjs-github-browser's storybook 6 | 7 | When you're making UI components for a website, tools like storybook are very useful (for development and documentation). 8 | 9 | Feel free to browse and play with the components I made for this project. 10 | 11 | Tophe 12 | 13 | [Github](https://github.com/topheman/nextjs-github-browser) / [Twitter](https://twitter.com/topheman) / [Online demo](https://nextjs-github-browser.vercel.app) 14 | -------------------------------------------------------------------------------- /src/components/AppTagLicense/AppTagLicense.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import AppTagLicense, { AppTagLicenseProps } from "./AppTagLicense"; 5 | 6 | export default { 7 | title: "AppTagLicense", 8 | component: AppTagLicense, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ( 12 | 13 | ); 14 | 15 | export const Base = Template.bind({}); 16 | Base.parameters = {}; 17 | Base.args = { 18 | license: { 19 | name: "MIT License", 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/icons/HamburgerIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { getSvgProps } from "../../utils/svg"; 4 | 5 | export default function HamburgerIcon( 6 | props: React.SVGProps 7 | ): JSX.Element { 8 | return ( 9 | 18 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/AppTagCount/AppTagCount.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import AppTagCount, { AppTagCountProps } from "./AppTagCount"; 5 | 6 | export default { 7 | title: "AppTagCount", 8 | component: AppTagCount, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const Base = Template.bind({}); 14 | Base.parameters = {}; 15 | Base.args = { 16 | nameWithOwner: "topheman/nextjs-github-browser", 17 | count: 12, 18 | type: "stargazers", 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/BaseBadge/BaseBadge.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import BaseBadge, { BaseBadgeProps } from "./BaseBadge"; 5 | 6 | export default { 7 | title: "BaseBadge", 8 | component: BaseBadge, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const None = Template.bind({}); 14 | None.parameters = {}; 15 | None.args = {}; 16 | 17 | export const Some = Template.bind({}); 18 | Some.parameters = {}; 19 | Some.args = { 20 | badgeContent: 12, 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/AppDarkModeSwitch/AppDarkModeSwitch.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import AppDarkModeSwitch, { AppDarkModeSwitchProps } from "./AppDarkModeSwitch"; 5 | 6 | export default { 7 | title: "AppDarkModeSwitch", 8 | component: AppDarkModeSwitch, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => { 12 | return ( 13 |
14 | 15 |
16 | ); 17 | }; 18 | 19 | export const Base = Template.bind({}); 20 | -------------------------------------------------------------------------------- /src/components/AppLoadingSpinner/AppLoadingSpinner.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import AppLoadingSpinner, { AppLoadingSpinnerProps } from "./AppLoadingSpinner"; 5 | 6 | export default { 7 | title: "AppLoadingSpinner", 8 | component: AppLoadingSpinner, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ( 12 | 13 | ); 14 | 15 | export const Base = Template.bind({}); 16 | Base.parameters = {}; 17 | Base.args = { 18 | width: 100, 19 | color: "#900000", 20 | }; 21 | -------------------------------------------------------------------------------- /src/utils/__snapshots__/svg.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`An icon component matches snapshot 1`] = ` 4 | 13 | 16 | 20 | 21 | `; 22 | -------------------------------------------------------------------------------- /src/components/TheHeader/TheHeader.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import AppDarkModeSwitch from "../AppDarkModeSwitch/AppDarkModeSwitch"; 4 | 5 | export default function TheHeader(): JSX.Element { 6 | return ( 7 | <> 8 |
9 |
10 |

11 | nextjs-github-browser 12 |

13 | 14 |
15 |
16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/AppTagLanguage/AppTagLanguage.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import AppTagLanguage, { AppTagLanguageProps } from "./AppTagLanguage"; 5 | 6 | export default { 7 | title: "AppTagLanguage", 8 | component: AppTagLanguage, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ( 12 | 13 | ); 14 | 15 | export const Base = Template.bind({}); 16 | Base.parameters = {}; 17 | Base.args = { 18 | primaryLanguage: { 19 | name: "JavaScript", 20 | color: "#f1e05a", 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ApolloClient, 3 | NormalizedCache, 4 | NormalizedCacheObject, 5 | } from "@apollo/client"; 6 | 7 | import type { ParsedUrlQuery } from "querystring"; 8 | 9 | export type AppAppoloClient = 10 | | ApolloClient 11 | | ApolloClient; 12 | 13 | // export type ParseQuery = (query: ParsedUrlQuery) => Record; 14 | 15 | export type ParseQuery = ( 16 | query: ParsedUrlQuery 17 | ) => Record & T; 18 | 19 | export type PageProps = { 20 | __APOLLO_STATE__?: { [key: string]: unknown }; 21 | [key: string]: unknown; 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/AppNotFound/AppNotFound.tsx: -------------------------------------------------------------------------------- 1 | const MAPPING = { 2 | user: { 3 | label: "User not found", 4 | }, 5 | repository: { 6 | label: "Repository not found", 7 | }, 8 | file: { 9 | label: "File not found", 10 | }, 11 | }; 12 | 13 | export type AppNotFoundProps = { 14 | type: keyof typeof MAPPING; 15 | } & React.HTMLProps; 16 | 17 | export default function AppNotFound({ 18 | type, 19 | ...props 20 | }: AppNotFoundProps): JSX.Element | null { 21 | return ( 22 |
23 |

{MAPPING[type].label}

24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/components/BaseBoxWithHeader/BaseBoxWithHeader.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import BaseBoxWithHeader, { BaseBoxWithHeaderProps } from "./BaseBoxWithHeader"; 5 | 6 | export default { 7 | title: "BaseBoxWithHeader", 8 | component: BaseBoxWithHeader, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ( 12 | 13 | ); 14 | 15 | export const Base = Template.bind({}); 16 | Base.parameters = {}; 17 | Base.args = { 18 | children:
I'm a content
, 19 | header: I'm a header, 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/AppOrganizationCard/AppOrganizationCard.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import { makeOrganization } from "../../tests/helpers"; 5 | import AppOrganizationCard, { 6 | AppOrganizationCardProps, 7 | } from "./AppOrganizationCard"; 8 | 9 | export default { 10 | title: "AppOrganizationCard", 11 | component: AppOrganizationCard, 12 | } as Meta; 13 | 14 | const Template: Story = (args) => ( 15 | 16 | ); 17 | 18 | export const Base = Template.bind({}); 19 | Base.args = { 20 | organisation: makeOrganization(), 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/AppRepositoryHeader/AppRepositoryHeader.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import AppRepositoryHeader, { 5 | AppRepositoryHeaderProps, 6 | } from "./AppRepositoryHeader"; 7 | 8 | export default { 9 | title: "AppRepositoryHeader", 10 | component: AppRepositoryHeader, 11 | } as Meta; 12 | 13 | const Template: Story = (args) => ( 14 | 15 | ); 16 | 17 | export const Base = Template.bind({}); 18 | Base.args = { 19 | owner: "microsoft", 20 | repositoryName: "vscode", 21 | stargazerCount: 123456, 22 | forkCount: 20654, 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/icons/CloseIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { getSvgProps } from "../../utils/svg"; 4 | 5 | export default function HamburgerIcon( 6 | props: React.SVGProps 7 | ): JSX.Element { 8 | return ( 9 | 19 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/AppAvatarImage/AppAvatarImage.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import AppAvatarImage, { AppAvatarImageProps } from "./AppAvatarImage"; 5 | 6 | export default { 7 | title: "AppAvatarImage", 8 | component: AppAvatarImage, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ( 12 |
13 | 14 |
15 | ); 16 | 17 | export const Base = Template.bind({}); 18 | Base.parameters = { 19 | layout: "fullscreen", 20 | }; 21 | Base.args = { 22 | avatarUrl: "https://avatars.githubusercontent.com/u/985982?v=4", 23 | }; 24 | -------------------------------------------------------------------------------- /cypress/support/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare namespace Cypress { 4 | interface Chainable { 5 | clientRepositoryAssertDefaultPage(url: string, login: string); 6 | clientRepositoryPaginationNavigate( 7 | action: { 8 | direction?: "next" | "previous"; 9 | query?: string; 10 | type?: "all" | "source" | "fork" | "archived" | "mirror"; 11 | sort?: "last-updated" | "name" | "stargazers"; 12 | custom?: () => void; 13 | }, 14 | isCached: boolean, 15 | key: string, 16 | requestAssertion?: (variable: Record) => void 17 | ); 18 | waitBetweenNavigation(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/AppOrganizationCardMini/AppOrganizationCardMini.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import { makeOrganization } from "../../tests/helpers"; 5 | import AppOrganizationCardMini, { 6 | AppOrganizationCardMiniProps, 7 | } from "./AppOrganizationCardMini"; 8 | 9 | export default { 10 | title: "AppOrganizationCardMini", 11 | component: AppOrganizationCardMini, 12 | } as Meta; 13 | 14 | const Template: Story = (args) => ( 15 | 16 | ); 17 | 18 | export const Base = Template.bind({}); 19 | Base.args = { 20 | organisation: makeOrganization(), 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/AppUserProfileInfos/AppUserProfileInfos.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import { makeUser } from "../../tests/helpers"; 5 | import AppUserProfileInfos, { 6 | AppUserProfileInfosProps, 7 | } from "./AppUserProfileInfos"; 8 | 9 | export default { 10 | title: "AppUserProfileInfos", 11 | component: AppUserProfileInfos, 12 | } as Meta; 13 | 14 | const Template: Story = (args) => ( 15 | 16 | ); 17 | 18 | export const Base = Template.bind({}); 19 | Base.parameters = { 20 | layout: "fullscreen", 21 | }; 22 | Base.args = { 23 | user: makeUser(), 24 | }; 25 | -------------------------------------------------------------------------------- /src/components/AppUserProfileInfos/AppUserProfileInfos.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "@testing-library/react"; 3 | 4 | import { makeUser } from "../../tests/helpers"; 5 | import AppUserProfileInfos from "./AppUserProfileInfos"; 6 | 7 | describe("components/AppUserProfileInfos", () => { 8 | it("it not render if no user passed", () => { 9 | const { container } = render(); 10 | expect(container).toBeEmptyDOMElement(); 11 | }); 12 | it("it shoud render a basic user", () => { 13 | const { container } = render(); 14 | expect(container).toBeTruthy(); 15 | }); 16 | }); 17 | export {}; 18 | -------------------------------------------------------------------------------- /src/components/AppTagLicense/AppTagLicense.tsx: -------------------------------------------------------------------------------- 1 | import { LawIcon } from "@primer/octicons-react"; 2 | import clsx from "clsx"; 3 | 4 | import { License } from "../../libs/graphql"; 5 | 6 | export type AppTagLicenseProps = { 7 | license: Pick; 8 | className?: string; 9 | }; 10 | 11 | export default function AppTagLicense({ 12 | license, 13 | className, 14 | ...props 15 | }: AppTagLicenseProps): JSX.Element | null { 16 | if (!license) { 17 | return null; 18 | } 19 | return ( 20 | 21 | 22 | {license.name} 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: [push, pull_request] 3 | jobs: 4 | main: 5 | runs-on: ubuntu-20.04 6 | steps: 7 | - name: Setup Node 🥣 8 | uses: actions/setup-node@v2 9 | with: 10 | node-version: 14 11 | - run: node -v 12 | - run: npm -v 13 | 14 | - name: Checkout 🛎 15 | uses: actions/checkout@v2 16 | 17 | - name: Install NPM dependencies 📦 18 | run: npm ci 19 | env: 20 | CYPRESS_INSTALL_BINARY: 0 21 | 22 | - name: Lint 23 | run: npm run lint 24 | 25 | - name: Type check 26 | run: npm run type-check 27 | 28 | - name: Unit tests 29 | run: npm run test:unit 30 | -------------------------------------------------------------------------------- /src/components/BaseTag/BaseTag.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import BaseTag, { BaseTagProps } from "./BaseTag"; 5 | 6 | export default { 7 | title: "BaseTag", 8 | component: BaseTag, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ; 12 | 13 | export const Base = Template.bind({}); 14 | Base.args = { 15 | children: "some-tag-with-no-link", 16 | color: "brand-primary", 17 | }; 18 | 19 | export const WithHref = Template.bind({}); 20 | WithHref.args = { 21 | children: "some-tag-with-link", 22 | color: "brand-primary", 23 | href: "/some-link", 24 | title: "Some description of the link", 25 | }; 26 | -------------------------------------------------------------------------------- /src/components/BaseSyntaxHighlighter/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { getLanguageFromFilename } from "./helpers"; 2 | 3 | describe("components/BaseSyntaxHighlighter/helpers", () => { 4 | describe("getLanguageFromFilename", () => { 5 | it("should match an extension if a known language is declared in mapping", () => { 6 | expect(getLanguageFromFilename("common.js")).toBe("javascript"); 7 | }); 8 | it("should return null if extension is not declared in mapping", () => { 9 | expect(getLanguageFromFilename("common.unknown")).toBeNull(); 10 | }); 11 | it("should match Makefile to makefile (extension less files)", () => { 12 | expect(getLanguageFromFilename("Makefile")).toBe("makefile"); 13 | }); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | .tmp 4 | tsconfig.tsbuildinfo 5 | 6 | # tools 7 | .eslintcache 8 | 9 | # public build of storybook available in /explore/storybook 10 | /public/explore/storybook 11 | 12 | # dependencies 13 | /node_modules 14 | /.pnp 15 | .pnp.js 16 | 17 | # testing 18 | /coverage 19 | /cypress/videos 20 | /cypress/screenshots 21 | 22 | # next.js 23 | /.next/ 24 | /out/ 25 | 26 | # production 27 | /build 28 | 29 | # misc 30 | .DS_Store 31 | *.pem 32 | 33 | # debug 34 | npm-debug.log* 35 | yarn-debug.log* 36 | yarn-error.log* 37 | 38 | # local env files 39 | .env.local 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | 44 | # vercel 45 | .vercel 46 | -------------------------------------------------------------------------------- /src/components/BaseBoxWithHeader/BaseBoxWithHeader.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import React from "react"; 3 | 4 | export type BaseBoxWithHeaderProps = { 5 | children: React.ReactChild | React.ReactChild[]; 6 | header: JSX.Element | string; 7 | className?: string; 8 | } & React.HTMLProps; 9 | 10 | export default function BaseBoxWithHeader({ 11 | children, 12 | header, 13 | className, 14 | ...props 15 | }: BaseBoxWithHeaderProps): JSX.Element { 16 | return ( 17 |
21 |
22 | {header} 23 |
24 | {children} 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/pages/explore.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import Link from "next/link"; 3 | import styles from "../styles/Home.module.css"; 4 | 5 | export default function PageExplore(): JSX.Element { 6 | return ( 7 |
8 | 9 | nextjs-github-browser 10 | 11 | 12 | 13 | 14 |
    15 |
  • 16 | Home 17 |
  • 18 |
19 | 20 |
21 |

nextjs-github-browser

22 |

Page explore

23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "downlevelIteration": true, 21 | "incremental": true, 22 | }, 23 | "include": [ 24 | "next-env.d.ts", 25 | "lib.es5.d.ts", 26 | "types.d.ts", 27 | "**/*.ts", 28 | "**/*.tsx", 29 | "jest-setup.ts", 30 | ], 31 | "exclude": [ 32 | "node_modules", 33 | "cypress" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /src/components/AppProfileOverview/AppProfileOverview.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import { makeProfileReadMe } from "../../tests/helpers"; 5 | import AppProfileOverview, { 6 | AppProfileOverviewProps, 7 | } from "./AppProfileOverview"; 8 | 9 | export default { 10 | title: "AppProfileOverview", 11 | component: AppProfileOverview, 12 | } as Meta; 13 | 14 | const Template: Story = (args) => ( 15 | 16 | ); 17 | 18 | export const Base = Template.bind({}); 19 | Base.parameters = { 20 | layout: "fullscreen", 21 | }; 22 | Base.args = { 23 | profileReadme: makeProfileReadMe(), 24 | profileReadmeInfos: { 25 | defaultBranchName: "master", 26 | login: "topheman", 27 | mode: "user", 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/AppRepositoryBreadcrumb/AppRepositoryBreadcrumb.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import AppRepositoryBreadcrumb, { 5 | AppRepositoryBreadcrumbProps, 6 | } from "./AppRepositoryBreadcrumb"; 7 | 8 | export default { 9 | title: "AppRepositoryBreadcrumb", 10 | component: AppRepositoryBreadcrumb, 11 | } as Meta; 12 | 13 | const Template: Story = (args) => ( 14 | 15 | ); 16 | 17 | export const Base = Template.bind({}); 18 | Base.args = { 19 | nameWithOwner: "topheman/nextjs-movie-browser", 20 | currentPath: "src/components/SomeComponent/SomeComponent.tsx", 21 | currentRef: { 22 | name: "master", 23 | prefix: "refs/heads/", 24 | }, 25 | defaultBranchName: "master", 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/BaseBadge/BaseBadge.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | 3 | export type BaseBadgeProps = { 4 | badgeContent: string | number; 5 | } & React.HTMLProps; 6 | 7 | export default function BaseBadge({ 8 | badgeContent, 9 | className, 10 | ...props 11 | }: BaseBadgeProps): JSX.Element { 12 | return badgeContent ? ( 13 | 20 | {badgeContent} 21 | 22 | ) : ( 23 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/BaseMarkdownDisplay/BaseMarkdownDisplay.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import { makeProfileReadMe } from "../../tests/helpers"; 5 | import BaseMarkdownDisplay, { 6 | BaseMarkdownDisplayProps, 7 | } from "./BaseMarkdownDisplay"; 8 | 9 | export default { 10 | title: "BaseMarkdownDisplay", 11 | component: BaseMarkdownDisplay, 12 | } as Meta; 13 | 14 | const Template: Story = (args) => ( 15 | 16 | ); 17 | 18 | export const Base = Template.bind({}); 19 | Base.parameters = { 20 | // layout: "fullscreen", 21 | }; 22 | Base.args = { 23 | markdown: makeProfileReadMe(), 24 | profileReadmeInfos: { 25 | defaultBranchName: "master", 26 | login: "topheman", 27 | mode: "user", 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/BaseBox/BaseBox.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import React from "react"; 3 | 4 | // this is how you type a `as` prop in TypeScript 👇 5 | 6 | type BaseBoxOwnProps = { 7 | as?: E; 8 | }; 9 | 10 | const defaultElement = "div"; 11 | 12 | export type BaseBoxProps = BaseBoxOwnProps & 13 | Omit, keyof BaseBoxOwnProps>; 14 | 15 | export default function BaseBox< 16 | E extends React.ElementType = typeof defaultElement 17 | >({ children, className, as, ...props }: BaseBoxProps): JSX.Element { 18 | const TagName = as || defaultElement; 19 | return ( 20 | 24 | {children} 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/AppTagLanguage/AppTagLanguage.tsx: -------------------------------------------------------------------------------- 1 | import { DotFillIcon } from "@primer/octicons-react"; 2 | import clsx from "clsx"; 3 | 4 | import { Language } from "../../libs/graphql"; 5 | 6 | export type AppTagLanguageProps = { 7 | primaryLanguage: Pick; 8 | className?: string; 9 | }; 10 | 11 | export default function AppTagLanguage({ 12 | primaryLanguage, 13 | className, 14 | ...props 15 | }: AppTagLanguageProps): JSX.Element | null { 16 | if (!primaryLanguage) { 17 | return null; 18 | } 19 | return ( 20 | 21 | 26 | {primaryLanguage.name} 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/components/AppTopicsTagList/AppTopicsTagList.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import AppTopicsTagList, { AppTopicsTagListProps } from "./AppTopicsTagList"; 5 | 6 | export default { 7 | title: "AppTopicsTagList", 8 | component: AppTopicsTagList, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ( 12 | 13 | ); 14 | 15 | export const Base = Template.bind({}); 16 | Base.parameters = {}; 17 | Base.args = { 18 | topics: [ 19 | { topic: { name: "react" } }, 20 | { topic: { name: "JavaScript" } }, 21 | { topic: { name: "TypeScript" } }, 22 | { topic: { name: "jest" } }, 23 | { topic: { name: "react-router" } }, 24 | { topic: { name: "cypress" } }, 25 | { topic: { name: "e2e-tests" } }, 26 | ], 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/BaseSearchInput/BaseSearchInput.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import BaseSearchInput, { BaseSearchInputProps } from "./BaseSearchInput"; 5 | 6 | export default { 7 | title: "BaseSearchInput", 8 | component: BaseSearchInput, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => { 12 | const ref = useRef(null); 13 | useEffect(() => { 14 | if (ref.current) { 15 | ref.current.focus(); 16 | } 17 | }, []); 18 | return ; 19 | }; 20 | 21 | export const Base = Template.bind({}); 22 | Base.args = { 23 | onSearch(value) { 24 | // eslint-disable-next-line no-console 25 | console.log(value); 26 | }, 27 | placeholder: "Type a username ...", 28 | }; 29 | -------------------------------------------------------------------------------- /src/utils/date.ts: -------------------------------------------------------------------------------- 1 | import human from "human-time"; 2 | 3 | const ONE_DAY_IN_SECONDS = 86400000; 4 | 5 | const MONTHS = [ 6 | "Jan", 7 | "Feb", 8 | "Mar", 9 | "Apr", 10 | "May", 11 | "Jun", 12 | "Jul", 13 | "Aug", 14 | "Sep", 15 | "Oct", 16 | "Nov", 17 | "Dev", 18 | ]; 19 | 20 | export const formatDate = ( 21 | date: Date 22 | ): { formattedDate: string; isRelative: boolean } => { 23 | const now = new Date(); 24 | const secondsLapsed = date.getTime() - now.getTime(); 25 | const daysLapsed = secondsLapsed / ONE_DAY_IN_SECONDS; 26 | if (Math.abs(daysLapsed) < 30) { 27 | return { 28 | isRelative: true, 29 | formattedDate: human(date), 30 | }; 31 | } 32 | return { 33 | isRelative: false, 34 | formattedDate: `${date.getDate()} ${ 35 | MONTHS[date.getMonth()] 36 | } ${date.getFullYear()}`, 37 | }; 38 | }; 39 | -------------------------------------------------------------------------------- /src/utils/tests/index.tsx: -------------------------------------------------------------------------------- 1 | import fetch from "cross-fetch"; 2 | import { 3 | ApolloProvider, 4 | ApolloClient, 5 | InMemoryCache, 6 | HttpLink, 7 | } from "@apollo/client"; 8 | 9 | export const GRAPHQL_URI = "http://localhost:3000/api/github/graphql"; 10 | 11 | const makeApolloClient = () => 12 | new ApolloClient({ 13 | ssrMode: false, 14 | link: new HttpLink({ 15 | uri: GRAPHQL_URI, 16 | credentials: "same-origin", 17 | fetch, 18 | }), 19 | cache: new InMemoryCache(), 20 | }); 21 | 22 | export const makeApolloProviderWrapper: () => React.FunctionComponent<{ 23 | children: React.ReactChild | React.ReactChild[]; 24 | // eslint-disable-next-line react/display-name 25 | }> = () => ({ children }): JSX.Element => { 26 | return ( 27 | {children} 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/App404/App404.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | 3 | export type App404Props = React.HTMLProps; 4 | 5 | export default function App404({ 6 | className, 7 | ...props 8 | }: App404Props): JSX.Element { 9 | return ( 10 |
17 |

18 | This feature is not yet supported. 19 |

20 |

21 | 29 |

30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import "../src/styles/globals.css"; 2 | import "github-markdown-css"; 3 | import { RouterContext } from "next/dist/shared/lib/router-context"; 4 | 5 | export const parameters = { 6 | actions: { argTypesRegex: "^on[A-Z].*" }, 7 | controls: { 8 | matchers: { 9 | color: /(background|color)$/i, 10 | date: /Date$/, 11 | }, 12 | }, 13 | docs: { 14 | inlineStories: false, 15 | }, 16 | themes: { 17 | default: "light", 18 | list: [ 19 | { name: "light", class: ["default-mode"], color: "white" }, 20 | { name: "dark", class: ["dark-mode"], color: "black" }, 21 | ], 22 | }, 23 | backgrounds: { 24 | disable: true, 25 | }, 26 | nextRouter: { 27 | Provider: RouterContext.Provider, 28 | }, 29 | }; 30 | 31 | document.body.onload = function () { 32 | document.body.classList.add("text-primary"); 33 | }; 34 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useLocalStorage } from "../utils/hooks"; 2 | 3 | import TheHome from "../components/TheHome/TheHome"; 4 | 5 | export default function PageIndex(): JSX.Element { 6 | const LOCALSTORAGE_KEY = "HELP_ENABLED"; 7 | const [isHelpEnabled, enableHelp] = useLocalStorage(LOCALSTORAGE_KEY, true); 8 | if (process.env.NODE_ENV === "development" && typeof window !== "undefined") { 9 | // eslint-disable-next-line no-console 10 | console.log( 11 | `[DEV] Help is controled by the localStorage key "${LOCALSTORAGE_KEY}", to reset it you can call resetHelp() in the console (only available in dev mode)` 12 | ); 13 | (window as Window & 14 | typeof globalThis & { resetHelp: () => void }).resetHelp = () => 15 | enableHelp(true); 16 | } 17 | return ( 18 | enableHelp(false)} /> 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/AppTopicsTagList/AppTopicsTagList.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | 3 | import BaseTag from "../BaseTag/BaseTag"; 4 | 5 | export type AppTopicsTagListProps = { 6 | className?: string; 7 | topics?: ({ topic: { name: string } } | null | undefined)[] | null; 8 | }; 9 | 10 | export default function AppTopicsTagList({ 11 | topics, 12 | className, 13 | }: AppTopicsTagListProps): JSX.Element | null { 14 | if (topics && topics.length > 0) { 15 | return ( 16 |
    17 | {topics.filter(Boolean).map(({ topic }) => { 18 | return ( 19 |
  • 20 | 21 | {topic.name} 22 | 23 |
  • 24 | ); 25 | })} 26 |
27 | ); 28 | } 29 | return null; 30 | } 31 | -------------------------------------------------------------------------------- /cypress/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // next/babel presets needed to match transpilation (see explanation in .babelrc.ignored) 3 | mode: "development", 4 | resolve: { 5 | extensions: [".ts", ".js"], 6 | }, 7 | module: { 8 | rules: [ 9 | { 10 | test: /\.tsx?$/, 11 | exclude: [/node_modules/], 12 | use: [ 13 | { 14 | loader: "babel-loader", 15 | options: { 16 | presets: ["next/babel", "@babel/preset-env"], 17 | }, 18 | }, 19 | ], 20 | }, 21 | { 22 | test: /\.jsx?$/, 23 | exclude: [/node_modules/], 24 | use: [ 25 | { 26 | loader: "babel-loader", 27 | options: { 28 | presets: ["next/babel", "@babel/preset-env"], 29 | }, 30 | }, 31 | ], 32 | }, 33 | ], 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/BaseSyntaxHighlighter/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Mapping between extension and language 3 | * https://github.com/react-syntax-highlighter/react-syntax-highlighter/blob/master/src/async-languages/hljs.js 4 | */ 5 | 6 | export function getLanguageFromFilename(fileName: string): string | null { 7 | const extracted = fileName.split("."); 8 | const matchedExtension = extracted[extracted.length - 1].toLowerCase(); 9 | const MAPPING = { 10 | typescript: ["ts", "tsx"], 11 | javascript: ["js", "jsx"], 12 | rust: ["rs"], 13 | makefile: ["makefile"], 14 | shell: ["sh"], 15 | coffee: ["coffeescript"], 16 | yaml: ["yaml", "yml"], 17 | }; 18 | let matchedLanguage = null; 19 | Object.entries(MAPPING).forEach(([language, extensions]) => { 20 | if (extensions.includes(matchedExtension)) { 21 | matchedLanguage = language; 22 | } 23 | }); 24 | return matchedLanguage; 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/github/index.ts: -------------------------------------------------------------------------------- 1 | export function profileReadmeBaseUrl( 2 | login: string, 3 | defaultBranchName: string, 4 | mode: "user" | "organization" | "repository", 5 | uriType: "link" | "image", 6 | repositoryName?: string 7 | ): string { 8 | const BASE_URL = "https://github.com"; 9 | switch (mode) { 10 | case "organization": 11 | return `${BASE_URL}/${login}/.github/raw/${defaultBranchName}`; 12 | case "user": 13 | return `${BASE_URL}/${login}/${login}/raw/${defaultBranchName}`; 14 | case "repository": 15 | if (uriType === "link") { 16 | // todo might be a link to a blob or a tree - do a redirect ? 17 | return `/${login}/${repositoryName}/blob/${defaultBranchName}?path=`; 18 | } 19 | return `${BASE_URL}/${login}/${repositoryName}/raw/${defaultBranchName}`; 20 | default: 21 | throw new Error(`Only accept "user" or "organization" mode`); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/components/BaseBox/BaseBox.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import BaseBox, { BaseBoxProps } from "./BaseBox"; 5 | 6 | export default { 7 | title: "BaseBox", 8 | component: BaseBox, 9 | } as Meta; 10 | 11 | const Template: Story> = (args) => ; 12 | 13 | export const Base = Template.bind({}); 14 | Base.parameters = {}; 15 | Base.args = { 16 | children: "Hello", 17 | className: "text-primary", 18 | }; 19 | Base.argTypes = { 20 | as: { 21 | name: "as", 22 | control: { type: "text" }, 23 | }, 24 | }; 25 | 26 | export const OverrideClassName = Template.bind({}); 27 | OverrideClassName.parameters = {}; 28 | OverrideClassName.args = { 29 | children: "Hello", 30 | className: "text-primary p-4", 31 | }; 32 | Base.argTypes = { 33 | as: { 34 | name: "as", 35 | control: { type: "text" }, 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { 2 | Html, 3 | Head, 4 | Main, 5 | NextScript, 6 | DocumentInitialProps, 7 | DocumentContext, 8 | } from "next/document"; 9 | 10 | class MyDocument extends Document { 11 | static async getInitialProps( 12 | ctx: DocumentContext 13 | ): Promise { 14 | const initialProps = await Document.getInitialProps(ctx); 15 | return { ...initialProps }; 16 | } 17 | 18 | render(): JSX.Element { 19 | return ( 20 | 21 | 22 | 26 | 27 | 28 |
29 |
30 |
31 | 32 | 33 | 34 | ); 35 | } 36 | } 37 | 38 | export default MyDocument; 39 | -------------------------------------------------------------------------------- /src/components/AppAvatarImage/AppAvatarImage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import clsx from "clsx"; 3 | 4 | /* eslint-disable @next/next/no-img-element */ 5 | export type AppAvatarImageProps = { 6 | avatarUrl: string; 7 | rounded: "full" | "medium"; 8 | className?: string; 9 | } & typeof defaultProps & 10 | Pick, "width" | "height">; 11 | 12 | const defaultProps = { 13 | alt: "Avatar", 14 | }; 15 | 16 | export default function AppAvatarImage({ 17 | avatarUrl, 18 | alt, 19 | rounded, 20 | className, 21 | ...props 22 | }: AppAvatarImageProps): JSX.Element | null { 23 | return ( 24 | {alt} 35 | ); 36 | } 37 | 38 | AppAvatarImage.defaultProps = defaultProps; 39 | -------------------------------------------------------------------------------- /src/components/BaseButton/BaseButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import { StarIcon } from "@primer/octicons-react"; 5 | import BaseButton, { BaseButtonProps } from "./BaseButton"; 6 | 7 | export default { 8 | title: "BaseButton", 9 | component: BaseButton, 10 | } as Meta; 11 | 12 | const Template: Story = (args) => ; 13 | 14 | export const Base = Template.bind({}); 15 | Base.args = { 16 | children: "Star", 17 | hasMenu: false, 18 | size: "medium", 19 | }; 20 | 21 | export const WithIcon = Template.bind({}); 22 | WithIcon.args = { 23 | children: "Star", 24 | hasMenu: false, 25 | size: "medium", 26 | icon: , 27 | }; 28 | 29 | export const WithBadge = Template.bind({}); 30 | WithBadge.args = { 31 | children: "Stars", 32 | hasMenu: false, 33 | size: "small", 34 | badge: { 35 | label: 10500, 36 | href: "/topheman/docker-experiments/stargazers", 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/AppLoadingSpinner/AppLoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Inspired by https://loading.io/css/ 3 | */ 4 | import styles from "./AppLoadingSpinner.module.css"; 5 | 6 | export type AppLoadingSpinnerProps = { 7 | width?: `${number}px` | number; 8 | color?: string; 9 | }; 10 | 11 | AppLoadingSpinner.defaultProps = { 12 | width: "100px", 13 | color: "var(--color-text-brand-primary)", 14 | } as AppLoadingSpinnerProps; 15 | 16 | export default function AppLoadingSpinner({ 17 | width, 18 | color, 19 | ...props 20 | }: AppLoadingSpinnerProps): JSX.Element { 21 | const formattedWidth = typeof width === "number" ? `${width}px` : width; 22 | return ( 23 |
33 |
34 |
35 |
36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/components/icons/TwitterIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { getSvgProps } from "../../utils/svg"; 4 | 5 | export default function TwitterIcon( 6 | props: React.SVGProps 7 | ): JSX.Element { 8 | return ( 9 | 18 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // A cypress plugin to add a tab command (in beta version) 17 | // - https://github.com/Bkucera/cypress-plugin-tab 18 | // - https://github.com/cypress-io/cypress/issues/299#issuecomment-469792368 19 | import "cypress-plugin-tab"; 20 | 21 | import "@testing-library/cypress/add-commands"; 22 | 23 | // Import commands.js using ES2015 syntax: 24 | import "./commands"; 25 | 26 | // Alternatively you can use CommonJS syntax: 27 | // require('./commands') 28 | -------------------------------------------------------------------------------- /src/graphql/queries/getProfileReadme.graphql: -------------------------------------------------------------------------------- 1 | query GetProfileReadme($owner: String!) { 2 | profileReadmeUser: repository(owner: $owner, name: $owner) { 3 | # README.md for profile might be on a main or master branch 4 | # prefer use HEAD : https://stackoverflow.com/questions/48935381/github-graphql-api-default-branch-in-repository#comment-85055968 5 | file: object(expression: "HEAD:README.md") { 6 | ... on Blob { 7 | text 8 | } 9 | repository { 10 | owner { 11 | login 12 | } 13 | defaultBranchRef { 14 | name 15 | } 16 | } 17 | } 18 | } 19 | profileReadmeOrg: repository(owner: $owner, name: ".github") { 20 | # Organisation keep their readme in different place 21 | file: object(expression: "HEAD:profile/README.md") { 22 | ... on Blob { 23 | text 24 | } 25 | repository { 26 | owner { 27 | login 28 | } 29 | defaultBranchRef { 30 | name 31 | } 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/AppOrganizationCardMini/AppOrganizationCardMini.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import Link from "next/link"; 3 | 4 | import { Organization } from "../../libs/graphql"; 5 | 6 | export type AppOrganizationCardMiniProps = { 7 | organisation: Pick; 8 | }; 9 | 10 | export default function AppOrganizationCardMini({ 11 | organisation, 12 | }: AppOrganizationCardMiniProps): JSX.Element | null { 13 | return ( 14 |
15 |

16 | 17 | 18 | {`@${organisation.login}`} 26 | {organisation.name} 27 | 28 | 29 |

30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/components/AppSearchSummary/AppSearchSummary.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import AppSearchSummary, { AppSearchSummaryProps } from "./AppSearchSummary"; 5 | import { getSearchFieldOptions } from "../../utils/github/searchRepos"; 6 | 7 | export default { 8 | title: "AppSearchSummary", 9 | component: AppSearchSummary, 10 | } as Meta; 11 | 12 | const Template: Story = (args) => ( 13 | 14 | ); 15 | 16 | export const Base = Template.bind({}); 17 | Base.args = { 18 | count: 78, 19 | pageInfo: { 20 | startCursor: "Y3Vyc29yOjE=", 21 | endCursor: "Y3Vyc29yOjMw", 22 | }, 23 | sort: "", 24 | type: "", 25 | }; 26 | Base.argTypes = { 27 | type: { 28 | name: "type", 29 | options: getSearchFieldOptions("type").map(({ value }) => value), 30 | control: { type: "select" }, 31 | }, 32 | sort: { 33 | name: "sort", 34 | options: getSearchFieldOptions("sort").map(({ value }) => value), 35 | control: { type: "select" }, 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { AppProps } from "next/app"; 3 | import { ApolloProvider } from "@apollo/client"; 4 | import NextNprogress from "nextjs-progressbar"; 5 | import { useApollo } from "../libs/apollo-client"; 6 | 7 | import "tailwindcss/tailwind.css"; 8 | import "react-toggle/style.css"; 9 | import "../styles/globals.css"; 10 | import "github-markdown-css"; 11 | import TheHeader from "../components/TheHeader/TheHeader"; 12 | import TheFooter from "../components/TheFooter/TheFooter"; 13 | 14 | function MyApp({ Component, pageProps }: AppProps): JSX.Element { 15 | const apolloClient = useApollo(pageProps); 16 | return ( 17 | 18 | 19 | 20 | 21 | 26 | 27 | ); 28 | } 29 | 30 | export default MyApp; 31 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /src/utils/graphql.ts: -------------------------------------------------------------------------------- 1 | import type { QueryOptions } from "@apollo/client/core/watchQueryOptions"; 2 | 3 | import type { AppAppoloClient } from "../types"; 4 | 5 | type MyQueryOptions = { 6 | query: QueryOptions["query"]; 7 | variables: QueryOptions["variables"]; 8 | }; 9 | 10 | /** 11 | * You might need to do parallel call for some edge cases. 12 | * @todo remove in no use cases 13 | */ 14 | export async function fetchMultipleGraphQLQuery( 15 | apolloClient: AppAppoloClient, 16 | queriesOptions: MyQueryOptions[] 17 | ): Promise> { 18 | const promises = queriesOptions.map((queryOptions) => { 19 | return apolloClient.query(queryOptions); 20 | }); 21 | const allResults = await Promise.allSettled(promises); 22 | const successResults = allResults.reduce((acc, result) => { 23 | if (result.status === "fulfilled" && result.value) { 24 | // eslint-disable-next-line no-param-reassign 25 | acc = { 26 | ...acc, 27 | ...result.value.data, 28 | }; 29 | } 30 | return acc; 31 | }, {}); 32 | return successResults; 33 | } 34 | -------------------------------------------------------------------------------- /cypress/integration/misc.spec.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | describe("misc", () => { 4 | it("[Home] search input should land you to a user page", () => { 5 | cy.visit("/"); 6 | cy.findByPlaceholderText("Type a username ...").type("topheman{enter}"); 7 | cy.url().should("eq", `${Cypress.config().baseUrl}/topheman`); 8 | }); 9 | it('[Repository][Mobile] "View code" should be visible / file list should be hidden', () => { 10 | cy.viewport("iphone-8"); 11 | cy.visit("/topheman/topheman"); 12 | cy.findByTestId("app-files-file-list").should("not.be.visible"); 13 | cy.findByTestId("app-files-view-code-button") 14 | .should("be.visible") 15 | .click() 16 | .should("not.be.visible"); 17 | cy.findByTestId("app-files-file-list").should("be.visible"); 18 | }); 19 | it('[Repository][Desktop] "View code" should NOT be visible / file list should NOT be hidden', () => { 20 | cy.visit("/topheman/topheman"); 21 | cy.findByTestId("app-files-file-list").should("be.visible"); 22 | cy.findByTestId("app-files-view-code-button").should("not.be.visible"); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/utils/github/repository.test.ts: -------------------------------------------------------------------------------- 1 | import { resolveCurrentRef } from "./repository"; 2 | 3 | describe("utils/github/repository", () => { 4 | describe("resolveCurrentRef", () => { 5 | it("with currentRef = null and defaultBranchName = master", () => { 6 | expect( 7 | resolveCurrentRef({ 8 | currentRef: null, 9 | defaultBranchName: "master", 10 | }) 11 | ).toStrictEqual({ name: "master", prefix: "refs/heads/" }); 12 | }); 13 | it("with currentRef set a branch", () => { 14 | expect( 15 | resolveCurrentRef({ 16 | currentRef: { name: "main", prefix: "refs/heads/" }, 17 | defaultBranchName: "master", 18 | }) 19 | ).toStrictEqual({ name: "main", prefix: "refs/heads/" }); 20 | }); 21 | it("with currentRef set a tag", () => { 22 | expect( 23 | resolveCurrentRef({ 24 | currentRef: { name: "v1.0.0", prefix: "refs/tags/" }, 25 | defaultBranchName: "master", 26 | }) 27 | ).toStrictEqual({ name: "v1.0.0", prefix: "refs/tags/" }); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/utils/type-guards.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | import { User, Organization, Repository } from "../libs/graphql"; 3 | 4 | /** 5 | * - Using type predicates: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates 6 | * - Using assertion functions: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions 7 | */ 8 | 9 | export function isUser(userLikeObject: unknown): userLikeObject is User { 10 | if ((userLikeObject as User)?.__typename === "User") { 11 | return true; 12 | } 13 | return false; 14 | } 15 | 16 | export function isOrganization( 17 | organizationLikeObject: unknown 18 | ): organizationLikeObject is Organization { 19 | if ((organizationLikeObject as Organization)?.__typename === "Organization") { 20 | return true; 21 | } 22 | return false; 23 | } 24 | 25 | export function isRepository( 26 | repositoryLikeObject: unknown 27 | ): repositoryLikeObject is Repository { 28 | if ((repositoryLikeObject as Repository)?.__typename === "Repository") { 29 | return true; 30 | } 31 | return false; 32 | } 33 | -------------------------------------------------------------------------------- /cypress/integration/experimental.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | /* eslint-disable func-names */ 3 | /** 4 | * These tests will only run if you pass the flag `CYPRESS_EXPERIMENTAL=true` 5 | */ 6 | 7 | export {}; 8 | 9 | (Cypress.env("EXPERIMENTAL") ? describe : describe.skip)( 10 | `experimental(${ 11 | Cypress.env("EXPERIMENTAL") 12 | ? "run" 13 | : "skipped - pass CYPRESS_EXPERIMENTAL=true flag to run" 14 | })`, 15 | () => { 16 | describe("loadMock", () => { 17 | before(function () { 18 | cy.task("loadMock", [ 19 | "GetRepositoryOwnerWithRepositories", 20 | { 21 | owner: "topheman", 22 | first: 30, 23 | query: "user:topheman sort:updated-desc fork:true", 24 | }, 25 | ]).then((result) => { 26 | this.loadedMock = result; 27 | }); 28 | }); 29 | it(`cy.task("loadMock") should correctly loadMock`, function () { 30 | expect(this.loadedMock).not.to.be.undefined; 31 | expect(this.loadedMock.data).not.to.be.undefined; 32 | }); 33 | }); 34 | } 35 | ); 36 | -------------------------------------------------------------------------------- /src/graphql/fragments/searchRepos.graphql: -------------------------------------------------------------------------------- 1 | fragment SearchRepos on Query { 2 | searchRepos: search( 3 | query: $query 4 | type: REPOSITORY 5 | first: $first 6 | last: $last 7 | after: $after 8 | before: $before 9 | ) { 10 | repositoryCount 11 | pageInfo { 12 | hasNextPage 13 | hasPreviousPage 14 | startCursor 15 | endCursor 16 | } 17 | edges { 18 | node { 19 | ... on Repository { 20 | primaryLanguage { 21 | color 22 | name 23 | } 24 | repositoryTopics(first: 10) { 25 | edges { 26 | node { 27 | topic { 28 | name 29 | } 30 | } 31 | } 32 | } 33 | stargazerCount 34 | forkCount 35 | licenseInfo { 36 | name 37 | } 38 | issues { 39 | totalCount 40 | } 41 | name 42 | nameWithOwner 43 | description 44 | updatedAt 45 | parent { 46 | nameWithOwner 47 | } 48 | } 49 | } 50 | cursor 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/components/AppSearchBarRepositories/AppSearchBarRepositories.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useReducer } from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import { SearchParamsType } from "../../utils/github/searchRepos"; 5 | import AppSearchBarRepositories, { 6 | AppSearchBarRepositoriesProps, 7 | } from "./AppSearchBarRepositories"; 8 | 9 | export default { 10 | title: "AppSearchBarRepositories", 11 | component: AppSearchBarRepositories, 12 | } as Meta; 13 | 14 | const Template: Story = () => { 15 | const [state, setState] = useReducer< 16 | (a: SearchParamsType, b: SearchParamsType) => SearchParamsType 17 | >( 18 | (previousState, nextState) => ({ 19 | ...previousState, 20 | ...nextState, 21 | }), 22 | { 23 | type: "", 24 | sort: "", 25 | q: "", 26 | } 27 | ); 28 | return ( 29 |
30 | 34 |
35 | ); 36 | }; 37 | 38 | export const Base = Template.bind({}); 39 | Base.parameters = {}; 40 | Base.args = {}; 41 | -------------------------------------------------------------------------------- /src/components/AppNavBarProfile/AppNavBarProfile.tsx: -------------------------------------------------------------------------------- 1 | import { BookIcon, RepoIcon } from "@primer/octicons-react"; 2 | import React from "react"; 3 | 4 | import AppNavBar, { LinksDataType } from "../AppNavBar/AppNavBar"; 5 | 6 | export type AppProfileNavTabProps = { 7 | owner: string; 8 | currentTab: "default" | "repositories"; 9 | mode: "user" | "organization"; 10 | reposTotalCount?: number; 11 | }; 12 | 13 | export default function AppProfileNavTab({ 14 | owner, 15 | currentTab, 16 | reposTotalCount, 17 | mode, 18 | ...ownProps 19 | }: AppProfileNavTabProps): JSX.Element | null { 20 | const links: LinksDataType[] = [ 21 | { 22 | label: "Overview", 23 | icon: BookIcon, 24 | tab: "default", 25 | href: { 26 | pathname: `/${owner}`, 27 | }, 28 | }, 29 | { 30 | label: "Repositories", 31 | tab: "repositories", 32 | icon: RepoIcon, 33 | badge: reposTotalCount, 34 | href: { 35 | pathname: 36 | mode === "organization" ? `/orgs/${owner}/repositories` : `/${owner}`, 37 | query: mode === "user" ? { tab: "repositories" } : {}, 38 | }, 39 | }, 40 | ]; 41 | return ; 42 | } 43 | -------------------------------------------------------------------------------- /src/components/TheOwnerProfile/TheOwnerProfile.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | import { ApolloProvider } from "@apollo/client"; 4 | 5 | import { useApollo } from "../../libs/apollo-client"; 6 | 7 | import TheOwnerProfile, { TheOwnerProfileProps } from "./TheOwnerProfile"; 8 | 9 | export default { 10 | title: "TheOwnerProfile", 11 | component: TheOwnerProfile, 12 | } as Meta; 13 | 14 | const Template: Story = (args) => { 15 | const pageProps = {}; 16 | const apolloClient = useApollo(pageProps); 17 | return ( 18 | <> 19 | 20 | 21 | 22 | 23 | ); 24 | }; 25 | 26 | export const User = Template.bind({}); 27 | User.parameters = { 28 | layout: "fullscreen", 29 | searchUrlParams: {}, 30 | }; 31 | User.args = { 32 | owner: "topheman", 33 | tab: "default", 34 | searchUrlParams: {}, 35 | }; 36 | 37 | export const Organization = Template.bind({}); 38 | Organization.parameters = { 39 | layout: "fullscreen", 40 | searchUrlParams: {}, 41 | }; 42 | Organization.args = { 43 | owner: "facebook", 44 | tab: "default", 45 | searchUrlParams: {}, 46 | }; 47 | -------------------------------------------------------------------------------- /src/stories/assets/direction.svg: -------------------------------------------------------------------------------- 1 | illustration/direction -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | # inpired by https://github.com/cypress-io/cypress-and-jest-typescript-example 2 | name: e2e 3 | on: [push, pull_request] 4 | jobs: 5 | e2e: 6 | env: 7 | GRAPHQL_API_TOKEN: ${{secrets.GRAPHQL_API_TOKEN}} 8 | runs-on: ubuntu-20.04 9 | steps: 10 | - name: Setup Node 🥣 11 | uses: actions/setup-node@v2 12 | with: 13 | node-version: 14 14 | - run: node -v 15 | - run: npm -v 16 | 17 | - name: Checkout 🛎 18 | uses: actions/checkout@v2 19 | 20 | - name: Install and run Cypress tests 🌲 21 | uses: cypress-io/github-action@v2 22 | with: 23 | build: npm run build 24 | start: npm start 25 | wait-on: "http://localhost:3000" 26 | record: true 27 | env: 28 | CYPRESS_PROJECT_ID: ${{ secrets.CYPRESS_PROJECT_ID }} 29 | CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} 30 | # Recommended: pass the GitHub token lets this action correctly (automatically generated by workflow) 31 | # determine the unique run id necessary to re-run the checks 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | CYPRESS_IS_CI: true 34 | # some tests only fail on CI and run correctly in local ... 35 | CYPRESS_SKIP_FAILING_TESTS_ON_CI: true 36 | -------------------------------------------------------------------------------- /src/components/AppSelectMenu/AppSelectMenu.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import AppSelectMenu, { AppSelectMenuProps } from "./AppSelectMenu"; 5 | 6 | export default { 7 | title: "AppSelectMenu", 8 | component: AppSelectMenu, 9 | } as Meta; 10 | 11 | const Template: Story> = (...args) => { 12 | const [type, setType] = React.useState(""); 13 | const updateType = (newType: string) => { 14 | setType(newType); 15 | }; 16 | return ( 17 |
18 | 30 |
31 | ); 32 | }; 33 | 34 | export const Base = Template.bind({}); 35 | // Base.parameters = {}; 36 | Base.args = { 37 | alignMenu: "right", 38 | menuLabel: "Select type", 39 | buttonLabel: "Type", 40 | }; 41 | Base.argTypes = { 42 | alignMenu: { 43 | options: ["right", "left"], 44 | control: { type: "radio" }, 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # Prevent duplicate content (the goal of this project is an open source technical challenge, 2 | # not to take the place of github.com website) 3 | # Not disallowing social media crawlers, so that they could make thumbnails (part of the technical challenge - SSR) 4 | # 5 | # User agents from https://www.keycdn.com/blog/web-crawlers 6 | # 7 | # Inspired by https://github.com/topheman/nextjs-movie-browser/blob/master/static/robots.txt 8 | 9 | User-agent: googlebot 10 | Allow: /$ 11 | Allow: /about 12 | Allow: /explore 13 | Disallow: / 14 | 15 | User-agent: bingbot 16 | Allow: /$ 17 | Allow: /about 18 | Allow: /explore 19 | Disallow: / 20 | 21 | User-agent: slurp 22 | Allow: /$ 23 | Allow: /about 24 | Allow: /explore 25 | Disallow: / 26 | 27 | User-agent: duckduckbot 28 | Allow: /$ 29 | Allow: /about 30 | Allow: /explore 31 | Disallow: / 32 | 33 | User-agent: baiduspider 34 | Allow: /$ 35 | Allow: /about 36 | Allow: /explore 37 | Disallow: / 38 | 39 | User-agent: yandexbot 40 | Allow: /$ 41 | Allow: /about 42 | Allow: /explore 43 | Disallow: / 44 | 45 | User-agent: exabot 46 | Allow: /$ 47 | Allow: /about 48 | Allow: /explore 49 | Disallow: / 50 | 51 | User-agent: ia_archiver 52 | Allow: /$ 53 | Allow: /about 54 | Allow: /explore 55 | Disallow: / 56 | 57 | User-agent: sogou 58 | Allow: /$ 59 | Allow: /about 60 | Allow: /explore 61 | Disallow: / 62 | -------------------------------------------------------------------------------- /lib.es5.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /** 3 | * Fixes https://github.com/microsoft/TypeScript/issues/16655 for `Array.prototype.filter()` 4 | * For example, using the fix the type of `bar` is `string[]` in the below snippet as it should be. 5 | * 6 | * const foo: (string | null | undefined)[] = []; 7 | * const bar = foo.filter(Boolean); 8 | * 9 | * For related definitions, see https://github.com/microsoft/TypeScript/blob/master/src/lib/es5.d.ts 10 | * 11 | * Original licenses apply, see 12 | * - https://github.com/microsoft/TypeScript/blob/master/LICENSE.txt 13 | * - https://stackoverflow.com/help/licensing 14 | */ 15 | 16 | /** See https://stackoverflow.com/a/51390763/1470607 */ 17 | type Falsy = false | 0 | "" | null | undefined; 18 | 19 | interface Array { 20 | /** 21 | * Returns the elements of an array that meet the condition specified in a callback function. 22 | * @param predicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array. 23 | * @param thisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value. 24 | */ 25 | filter( 26 | predicate: BooleanConstructor, 27 | thisArg?: any 28 | ): Exclude[]; 29 | } 30 | -------------------------------------------------------------------------------- /src/components/AppRepositoryReadme/AppRepositoryReadme.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import BaseBoxWithHeader from "../BaseBoxWithHeader/BaseBoxWithHeader"; 3 | import BaseMarkdownDisplay from "../BaseMarkdownDisplay/BaseMarkdownDisplay"; 4 | 5 | export type AppRepositoryReadmeProps = { 6 | markdown?: string | null; 7 | nameWithOwner: string; 8 | currentRefName?: string; 9 | letterCase: "lower" | "upper"; 10 | className?: string; 11 | }; 12 | 13 | export default function AppRepositoryReadme({ 14 | markdown, 15 | nameWithOwner, 16 | currentRefName, // todo rename to branchName 17 | letterCase, 18 | className, 19 | ...props 20 | }: AppRepositoryReadmeProps): JSX.Element | null { 21 | if (!markdown) return null; 22 | return ( 23 | 27 |

Readme

28 | {letterCase === "upper" ? "README.md" : "readme.md"} 29 | 30 | } 31 | className={className} 32 | {...props} 33 | > 34 | 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/BaseSearchInput/BaseSearchInput.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useState } from "react"; 2 | import clsx from "clsx"; 3 | 4 | export type BaseSearchInputProps = React.HTMLProps & { 5 | onSearch: (value: string, { resetValue }: { resetValue: () => void }) => void; 6 | placeholder: string; 7 | size?: "normal"; 8 | }; 9 | 10 | const BaseSearchInput = forwardRef( 11 | ({ onSearch, placeholder, className }, ref) => { 12 | const [value, setValue] = useState(""); 13 | return ( 14 |
{ 18 | e.preventDefault(); 19 | onSearch(value, { resetValue: () => setValue("") }); 20 | }} 21 | > 22 | setValue(e.target.value)} 25 | autoComplete="off" 26 | name="search" 27 | ref={ref} 28 | type="text" 29 | placeholder={placeholder} 30 | className={clsx( 31 | "px-2 w-full h-8 leading-7 rounded border border-light", 32 | className 33 | )} 34 | /> 35 |
36 | ); 37 | } 38 | ); 39 | BaseSearchInput.displayName = "BaseSearchInput"; 40 | 41 | export default BaseSearchInput; 42 | -------------------------------------------------------------------------------- /src/stories/assets/flow.svg: -------------------------------------------------------------------------------- 1 | illustration/flow -------------------------------------------------------------------------------- /src/components/AppNavBarRepository/AppNavBarRepository.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CodeIcon, 3 | IssueOpenedIcon, 4 | GitPullRequestIcon, 5 | } from "@primer/octicons-react"; 6 | import React from "react"; 7 | 8 | import AppNavBar, { LinksDataType } from "../AppNavBar/AppNavBar"; 9 | 10 | export type AppProfileNavTabProps = { 11 | owner: string; 12 | repositoryName: string; 13 | currentTab: "code" | "issues" | "pull-requests"; 14 | }; 15 | 16 | export default function AppProfileNavTab({ 17 | owner, 18 | repositoryName, 19 | currentTab, 20 | ...ownProps 21 | }: AppProfileNavTabProps): JSX.Element | null { 22 | const links: LinksDataType[] = [ 23 | { 24 | label: "Code", 25 | icon: CodeIcon, 26 | tab: "code", 27 | href: { 28 | pathname: `/${owner}/${repositoryName}`, 29 | }, 30 | }, 31 | { 32 | label: "Issues", 33 | tab: "issues", 34 | icon: IssueOpenedIcon, 35 | href: { 36 | pathname: `/${owner}/${repositoryName}/issues`, 37 | }, 38 | disabled: true, 39 | }, 40 | { 41 | label: "Pull requests", 42 | tab: "pull-requests", 43 | icon: GitPullRequestIcon, 44 | href: { 45 | pathname: `/${owner}/${repositoryName}/pulls`, 46 | }, 47 | disabled: true, 48 | }, 49 | ]; 50 | return ; 51 | } 52 | -------------------------------------------------------------------------------- /src/components/AppTagCount/AppTagCount.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | StarIcon, 3 | RepoForkedIcon, 4 | IssueOpenedIcon, 5 | GitPullRequestIcon, 6 | } from "@primer/octicons-react"; 7 | import Link from "next/link"; 8 | import clsx from "clsx"; 9 | 10 | type AllowedType = "stargazers" | "forks" | "issues" | "pulls"; 11 | 12 | export type AppTagCountProps = { 13 | nameWithOwner: string; 14 | type: AllowedType; 15 | count?: number | string; 16 | className?: string; 17 | }; 18 | 19 | const iconMapping: Record = { 20 | stargazers: [StarIcon, "/stargazers"], 21 | forks: [RepoForkedIcon, "/network/member"], 22 | issues: [IssueOpenedIcon, "/issues"], 23 | pulls: [GitPullRequestIcon, "/pulls"], 24 | }; 25 | 26 | export default function AppTagCount({ 27 | nameWithOwner, 28 | type, 29 | count, 30 | className, 31 | ...props 32 | }: AppTagCountProps): JSX.Element | null { 33 | if (count === undefined || count === null) { 34 | return null; 35 | } 36 | const [Icon, relativePath] = iconMapping[type]; 37 | return ( 38 | 39 | 46 | {count} 47 | 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/github/index.test.ts: -------------------------------------------------------------------------------- 1 | import { profileReadmeBaseUrl } from "./index"; 2 | 3 | // eslint-disable-next-line global-require 4 | jest.mock("next/router", () => require("next-router-mock")); 5 | 6 | describe("utils/github", () => { 7 | describe("profileReadmeBaseUrl", () => { 8 | it("check for organization", () => { 9 | expect( 10 | profileReadmeBaseUrl("microsoft", "main", "organization", "image") 11 | ).toBe("https://github.com/microsoft/.github/raw/main"); 12 | }); 13 | it("check for user", () => { 14 | expect(profileReadmeBaseUrl("topheman", "master", "user", "image")).toBe( 15 | "https://github.com/topheman/topheman/raw/master" 16 | ); 17 | }); 18 | it("check for repository (image)", () => { 19 | expect( 20 | profileReadmeBaseUrl( 21 | "topheman", 22 | "master", 23 | "repository", 24 | "image", 25 | "nextjs-movie-browser" 26 | ) 27 | ).toBe("https://github.com/topheman/nextjs-movie-browser/raw/master"); 28 | }); 29 | it("check for repository (link)", () => { 30 | expect( 31 | profileReadmeBaseUrl( 32 | "topheman", 33 | "master", 34 | "repository", 35 | "link", 36 | "nextjs-movie-browser" 37 | ) 38 | ).toBe("/topheman/nextjs-movie-browser/blob/master?path="); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/components/AppFileHeader/AppFilesHeader.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import AppFilesHeader, { AppFilesHeaderProps } from "./AppFilesHeader"; 5 | 6 | export default { 7 | title: "AppFilesHeader", 8 | component: AppFilesHeader, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => { 12 | return ( 13 |
14 | 15 |
16 | ); 17 | }; 18 | 19 | export const Base = Template.bind({}); 20 | Base.args = { 21 | repositoryNameWithOwner: "topheman/nextjs-movie-browser", 22 | author: { 23 | login: "topheman", 24 | avatarUrl: 25 | "https://avatars.githubusercontent.com/u/985982?u=9319a164fa8c3c905b48e00bb554c4243d9c734b&v=4&s=48", 26 | }, 27 | lastCommit: { 28 | committedDate: new Date(), 29 | messageHeadline: "Just commited", 30 | oid: "azertyuiovbnlkjhgv", 31 | }, 32 | commitsTotalCount: 123, 33 | currentRef: { 34 | name: "v1.2.3", 35 | prefix: "refs/tags/", 36 | }, 37 | className: "p-3", 38 | }; 39 | 40 | export const WithoutCommitInfos = Template.bind({}); 41 | WithoutCommitInfos.args = { 42 | repositoryNameWithOwner: "topheman/nextjs-movie-browser", 43 | commitsTotalCount: 123, 44 | currentRef: { 45 | name: "v1.2.3", 46 | prefix: "refs/tags/", 47 | }, 48 | className: "p-3", 49 | }; 50 | -------------------------------------------------------------------------------- /src/components/AppTagDate/AppTagDate.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import clsx from "clsx"; 3 | import { formatDate } from "../../utils/date"; 4 | 5 | AppTagDate.defaultProps = { 6 | mode: "updated", 7 | reactive: true, 8 | }; 9 | 10 | const MAPPING_MODE = Object.freeze({ 11 | updated: "Updated ", 12 | default: "", 13 | }); 14 | 15 | export type AppTagDateProps = { 16 | mode: "updated" | "default"; 17 | date: Date; 18 | className?: string; 19 | } & typeof AppTagDate.defaultProps; 20 | 21 | export default function AppTagDate({ 22 | date, 23 | mode, 24 | className, 25 | reactive, 26 | ...props 27 | }: AppTagDateProps): JSX.Element | null { 28 | const [innerDate, setInnerDate] = useState(date); 29 | useEffect(() => { 30 | setInnerDate(date); 31 | const timer = setTimeout(() => { 32 | if (reactive) { 33 | setInnerDate(new Date(innerDate.getTime() + 1000)); 34 | } 35 | }, 1000); 36 | return () => { 37 | clearTimeout(timer); 38 | }; 39 | }, [date, innerDate, reactive]); 40 | if (!innerDate) { 41 | return null; 42 | } 43 | const { formattedDate, isRelative } = formatDate(innerDate); 44 | return ( 45 | 46 | {MAPPING_MODE[mode]} 47 | {!isRelative ? "on " : ""} 48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/stories/assets/code-brackets.svg: -------------------------------------------------------------------------------- 1 | illustration/code-brackets -------------------------------------------------------------------------------- /src/components/AppLanguagesGraph/AppLanguagesGraph.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | 3 | export type AppLanguagesGraphProps = { 4 | languages: ({ 5 | size: number; 6 | node: { name: string; color?: string | null }; 7 | } | null)[]; 8 | className?: string; 9 | }; 10 | 11 | export default function AppLanguagesGraph({ 12 | languages, 13 | className, 14 | ...props 15 | }: AppLanguagesGraphProps): JSX.Element | null { 16 | const fullSize = languages.reduce( 17 | (sum, language) => sum + (language?.size || 0), 18 | 0 19 | ); 20 | return ( 21 | 25 | {languages 26 | .map((language) => { 27 | if (!language) { 28 | return null; 29 | } 30 | const percentage = ((100 * language.size) / fullSize).toFixed(1); 31 | // dont display languages at 0.0% 32 | if (!Number(percentage)) { 33 | return null; 34 | } 35 | return ( 36 | 45 | ); 46 | }) 47 | .filter(Boolean)} 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/components/AppLoadingSpinner/AppLoadingSpinner.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | --custom-width: 800px; 3 | --calculated-height: calc(var(--custom-width) * 0.1625); 4 | --custom-color: var(--color-text-brand-primary); 5 | display: inline-block; 6 | position: relative; 7 | width: var(--custom-width); 8 | height: var(--calculated-height); 9 | } 10 | .root div { 11 | position: absolute; 12 | width: var(--calculated-height); 13 | height: var(--calculated-height); 14 | border-radius: 50%; 15 | background: var(--custom-color); 16 | animation-timing-function: cubic-bezier(0, 1, 1, 0); 17 | } 18 | .root div:nth-child(1) { 19 | left: calc(var(--custom-width) * 0.1); 20 | animation: anim1 0.6s infinite; 21 | } 22 | .root div:nth-child(2) { 23 | left: calc(var(--custom-width) * 0.1); 24 | animation: anim2 0.6s infinite; 25 | } 26 | .root div:nth-child(3) { 27 | left: calc(var(--custom-width) * 0.4); 28 | animation: anim2 0.6s infinite; 29 | } 30 | .root div:nth-child(4) { 31 | left: calc(var(--custom-width) * 0.7); 32 | animation: anim3 0.6s infinite; 33 | } 34 | @keyframes anim1 { 35 | 0% { 36 | transform: scale(0); 37 | } 38 | 100% { 39 | transform: scale(1); 40 | } 41 | } 42 | @keyframes anim3 { 43 | 0% { 44 | transform: scale(1); 45 | } 46 | 100% { 47 | transform: scale(0); 48 | } 49 | } 50 | @keyframes anim2 { 51 | 0% { 52 | transform: translate(0, 0); 53 | } 54 | 100% { 55 | transform: translate(calc(var(--custom-width) * 0.3), 0); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/stories/assets/comments.svg: -------------------------------------------------------------------------------- 1 | illustration/comments -------------------------------------------------------------------------------- /src/utils/svg.ts: -------------------------------------------------------------------------------- 1 | // inspired by https://github.com/primer/octicons/blob/main/lib/octicons_react/src/get-svg-props.js 2 | 3 | import { CSSProperties } from "react"; 4 | 5 | type GetSvgPropsType = { 6 | viewBox: string; 7 | "aria-label"?: string; 8 | role: string; 9 | className?: string; 10 | fill?: string; 11 | width?: string | number; 12 | height?: string | number; 13 | verticalAlign?: string; 14 | }; 15 | 16 | type GetSvgResultType = { 17 | "aria-hidden": boolean | "true" | "false"; 18 | "aria-label"?: string; 19 | role: string; 20 | className?: string; 21 | viewBox: string; 22 | width: string | number; 23 | height?: string | number; 24 | fill: string; 25 | style: CSSProperties; 26 | }; 27 | 28 | export function getSvgProps( 29 | { 30 | viewBox, 31 | "aria-label": ariaLabel, 32 | role = "img", 33 | className, 34 | fill = "currentColor", 35 | width = "1em", 36 | height, 37 | verticalAlign, 38 | }: GetSvgPropsType, 39 | runtimeProps?: React.SVGProps 40 | ): GetSvgResultType { 41 | const { style, ...rest } = runtimeProps || {}; 42 | return { 43 | "aria-hidden": ariaLabel ? "false" : "true", 44 | "aria-label": ariaLabel, 45 | role, 46 | className, 47 | viewBox, 48 | width, 49 | height, 50 | fill, 51 | style: { 52 | display: "inline-block", 53 | userSelect: "none", 54 | verticalAlign, 55 | overflow: "visible", 56 | ...style, 57 | }, 58 | ...rest, 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /src/components/AppGitRefSwitch/AppGitRefSwitch.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import AppGitRefSwitch, { AppGitRefSwitchProps } from "./AppGitRefSwitch"; 5 | 6 | export default { 7 | title: "AppGitRefSwitch", 8 | component: AppGitRefSwitch, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => { 12 | return ( 13 |
14 | 15 |
16 | ); 17 | }; 18 | 19 | export const Base = Template.bind({}); 20 | Base.args = { 21 | nameWithOwner: "topheman/npm-registry-browser", 22 | currentRef: { 23 | name: "master", 24 | prefix: "refs/heads/", 25 | }, 26 | defaultBranchName: "master", 27 | branches: ["master", "develop"], 28 | tags: ["v0.0.1", "v0.0.2", "v1.0.0"], 29 | branchesTotalCount: 2, 30 | tagsTotalCount: 3, 31 | }; 32 | 33 | export const Advanced = Template.bind({}); 34 | Advanced.args = { 35 | nameWithOwner: "topheman/npm-registry-browser", 36 | currentRef: { 37 | name: "feature/specific", 38 | prefix: "refs/heads/", 39 | }, 40 | defaultBranchName: "master", 41 | branches: [ 42 | "develop", 43 | ...Array(8) 44 | .fill(1) 45 | .map((_, i) => `feature/next-${i + 1}`), 46 | ], 47 | tags: [ 48 | "v0.0.1", 49 | "v1.0.0", 50 | ...Array(8) 51 | .fill(1) 52 | .map((_, i) => `v${i + 2}.0.0`), 53 | ], 54 | branchesTotalCount: 300, 55 | tagsTotalCount: 200, 56 | }; 57 | -------------------------------------------------------------------------------- /src/components/AppLanguagesList/AppLanguagesList.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import AppLanguagesList, { AppLanguagesListProps } from "./AppLanguagesList"; 5 | 6 | export default { 7 | title: "AppLanguagesList", 8 | component: AppLanguagesList, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ( 12 | 13 | ); 14 | 15 | export const Base = Template.bind({}); 16 | Base.args = { 17 | // from topheman/docker-experiments 18 | languages: [ 19 | { 20 | size: 7929, 21 | node: { 22 | name: "JavaScript", 23 | color: "#f1e05a", 24 | }, 25 | }, 26 | { 27 | size: 4963, 28 | node: { 29 | name: "Makefile", 30 | color: "#427819", 31 | }, 32 | }, 33 | { 34 | size: 4098, 35 | node: { 36 | name: "Go", 37 | color: "#00ADD8", 38 | }, 39 | }, 40 | { 41 | size: 1942, 42 | node: { 43 | name: "Dockerfile", 44 | color: "#384d54", 45 | }, 46 | }, 47 | { 48 | size: 1686, 49 | node: { 50 | name: "Shell", 51 | color: "#89e051", 52 | }, 53 | }, 54 | { 55 | size: 1590, 56 | node: { 57 | name: "HTML", 58 | color: "#e34c26", 59 | }, 60 | }, 61 | { 62 | size: 850, 63 | node: { 64 | name: "CSS", 65 | color: "#563d7c", 66 | }, 67 | }, 68 | ], 69 | }; 70 | -------------------------------------------------------------------------------- /src/graphql/queries/getRepositoryOwnerWithPinnedItems.graphql: -------------------------------------------------------------------------------- 1 | query GetRepositoryOwnerWithPinnedItems($owner: String!) { 2 | rateLimit { 3 | limit 4 | cost 5 | remaining 6 | resetAt 7 | } 8 | repositoryOwner(login: $owner) { 9 | ... on User { 10 | ...UserInfos 11 | pinnedRepositories: pinnedItems(first: 6, types: REPOSITORY) { 12 | nodes { 13 | ... on Repository { 14 | ...PinnedItemInfos 15 | } 16 | } 17 | } 18 | popularRepositories: repositories( 19 | first: 6 20 | orderBy: { field: STARGAZERS, direction: DESC } 21 | ) { 22 | edges { 23 | node { 24 | # Some users may not have pinned items (or less than 6 pinned items) 25 | ...PinnedItemInfos 26 | } 27 | } 28 | totalCount 29 | } 30 | } 31 | ... on Organization { 32 | ...OrganizationInfos 33 | pinnedRepositories: pinnedItems(first: 6, types: REPOSITORY) { 34 | nodes { 35 | ... on Repository { 36 | ...PinnedItemInfos 37 | } 38 | } 39 | } 40 | popularRepositories: repositories( 41 | first: 6 42 | orderBy: { field: STARGAZERS, direction: DESC } 43 | ) { 44 | edges { 45 | node { 46 | # Some users may not have pinned items (or less than 6 pinned items) 47 | ...PinnedItemInfos 48 | } 49 | } 50 | totalCount 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/components/AppLanguagesGraph/AppLanguagesGraph.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import AppLanguagesGraph, { AppLanguagesGraphProps } from "./AppLanguagesGraph"; 5 | 6 | export default { 7 | title: "AppLanguagesGraph", 8 | component: AppLanguagesGraph, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => ( 12 | 13 | ); 14 | 15 | export const Base = Template.bind({}); 16 | Base.args = { 17 | // from topheman/docker-experiments 18 | languages: [ 19 | { 20 | size: 7929, 21 | node: { 22 | name: "JavaScript", 23 | color: "#f1e05a", 24 | }, 25 | }, 26 | { 27 | size: 4963, 28 | node: { 29 | name: "Makefile", 30 | color: "#427819", 31 | }, 32 | }, 33 | { 34 | size: 4098, 35 | node: { 36 | name: "Go", 37 | color: "#00ADD8", 38 | }, 39 | }, 40 | { 41 | size: 1942, 42 | node: { 43 | name: "Dockerfile", 44 | color: "#384d54", 45 | }, 46 | }, 47 | { 48 | size: 1686, 49 | node: { 50 | name: "Shell", 51 | color: "#89e051", 52 | }, 53 | }, 54 | { 55 | size: 1590, 56 | node: { 57 | name: "HTML", 58 | color: "#e34c26", 59 | }, 60 | }, 61 | { 62 | size: 850, 63 | node: { 64 | name: "CSS", 65 | color: "#563d7c", 66 | }, 67 | }, 68 | ], 69 | }; 70 | -------------------------------------------------------------------------------- /src/components/BaseMarkdownDisplay/uri-transformer.ts: -------------------------------------------------------------------------------- 1 | /** From https://github.com/remarkjs/react-markdown/blob/main/lib/uri-transformer.js */ 2 | const protocols = ["http", "https", "mailto", "tel"]; 3 | 4 | /** 5 | * @param {string} uri 6 | * @returns {string} 7 | */ 8 | export function makeUriTransformer(baseUrl: string): (uri: string) => string { 9 | return function uriTransformer(uri: string) { 10 | const url = (uri || "").trim(); 11 | const first = url.charAt(0); 12 | 13 | if (first === "/") { 14 | return `${baseUrl}${url}`; 15 | } 16 | 17 | if (first === ".") { 18 | return `${baseUrl}${url.replace(".", "")}`; 19 | } 20 | 21 | if (first === "#") { 22 | return url; 23 | } 24 | 25 | const colon = url.indexOf(":"); 26 | if (colon === -1) { 27 | return `${baseUrl}${url}`; 28 | } 29 | 30 | let index = -1; 31 | 32 | // eslint-disable-next-line no-plusplus 33 | while (++index < protocols.length) { 34 | const protocol = protocols[index]; 35 | 36 | if ( 37 | colon === protocol.length && 38 | url.slice(0, protocol.length).toLowerCase() === protocol 39 | ) { 40 | return url; 41 | } 42 | } 43 | 44 | index = url.indexOf("?"); 45 | if (index !== -1 && colon > index) { 46 | return url; 47 | } 48 | 49 | index = url.indexOf("#"); 50 | if (index !== -1 && colon > index) { 51 | return url; 52 | } 53 | 54 | // eslint-disable-next-line no-script-url 55 | return "javascript:void(0)"; 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /src/components/BaseTag/BaseTag.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import React from "react"; 3 | import Link from "next/link"; 4 | 5 | export type BaseTagProps = { 6 | href?: string; 7 | className?: string; 8 | children?: React.ReactChild | React.ReactChild[]; 9 | color: "brand-primary" | "primary" | "secondary"; 10 | } & React.HTMLAttributes; 11 | 12 | const LinkOrSpan = ({ 13 | href, 14 | children, 15 | ...props 16 | }: Omit) => { 17 | if (href) { 18 | return ( 19 | 20 | {children} 21 | 22 | ); 23 | } 24 | return {children}; 25 | }; 26 | 27 | export default function BaseTag({ 28 | href, 29 | className, 30 | color, 31 | ...props 32 | }: BaseTagProps): JSX.Element { 33 | return ( 34 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/utils/svg.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable tailwindcss/no-custom-classname */ 2 | import "@testing-library/jest-dom"; 3 | import { render } from "@testing-library/react"; 4 | import React from "react"; 5 | import CloseIcon from "../components/icons/CloseIcon"; 6 | 7 | describe("An icon component", () => { 8 | it("matches snapshot", () => { 9 | const { container } = render(); 10 | expect(container.querySelector("svg")).toMatchSnapshot(); 11 | }); 12 | 13 | it('sets aria-hidden="false" if ariaLabel prop is present', () => { 14 | const { container } = render(); 15 | expect(container.querySelector("svg")).toHaveAttribute( 16 | "aria-hidden", 17 | "false" 18 | ); 19 | expect(container.querySelector("svg")).toHaveAttribute( 20 | "aria-label", 21 | "icon" 22 | ); 23 | }); 24 | 25 | it("respects the className prop", () => { 26 | const { container } = render(); 27 | expect(container.querySelector("svg")).toHaveAttribute("class", "foo"); 28 | }); 29 | 30 | it("respects the fill prop", () => { 31 | const { container } = render(); 32 | expect(container.querySelector("svg")).toHaveAttribute("fill", "#f00"); 33 | }); 34 | 35 | it("respects the style prop", () => { 36 | const { container } = render( 37 | 38 | ); 39 | expect(container.querySelector("svg")).toHaveStyle({ 40 | verticalAlign: "middle", 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/stories/assets/repo.svg: -------------------------------------------------------------------------------- 1 | illustration/repo -------------------------------------------------------------------------------- /src/libs/graphql.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-imports */ 2 | 3 | /** 4 | * src/generated/graphql.tsx is generated with `npm run graphql-codegen` 5 | * we don't import directly from the generated file in order to be able to make any overrides 6 | */ 7 | import { 8 | Maybe, 9 | Commit, 10 | CommitHistoryConnection, 11 | User, 12 | } from "../generated/graphql"; 13 | 14 | export * from "../generated/graphql"; 15 | 16 | export type GitRefType = { 17 | name: string; 18 | prefix: "refs/heads/" | "refs/tags/"; 19 | }; 20 | 21 | // GetRepositoryInfosOverviewQuery["repository"]["gitInfos"] 22 | export type GitInfosType = { 23 | history: { __typename?: "CommitHistoryConnection" } & Pick< 24 | CommitHistoryConnection, 25 | "totalCount" 26 | > & { 27 | edges?: Maybe< 28 | Array< 29 | Maybe< 30 | { __typename?: "CommitEdge" } & { 31 | node?: Maybe< 32 | { __typename?: "Commit" } & Pick< 33 | Commit, 34 | "oid" | "messageHeadline" | "committedDate" 35 | > & { 36 | author?: Maybe< 37 | { __typename?: "GitActor" } & { 38 | user?: Maybe< 39 | { __typename?: "User" } & Pick< 40 | User, 41 | "login" | "avatarUrl" 42 | > 43 | >; 44 | } 45 | >; 46 | } 47 | >; 48 | } 49 | > 50 | > 51 | >; 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /cypress/plugins/index.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | import { loadEnvConfig } from "@next/env"; 19 | 20 | import { loadMock } from "../../src/mocks/node"; 21 | 22 | const { GRAPHQL_API_ROOT_ENDPOINT } = loadEnvConfig("./", true).combinedEnv; 23 | // eslint-disable-next-line @typescript-eslint/no-var-requires 24 | const webpackPreprocessor = require("@cypress/webpack-preprocessor"); 25 | 26 | module.exports = (on) => { 27 | const options = { 28 | // eslint-disable-next-line global-require 29 | webpackOptions: require("../webpack.config"), 30 | }; 31 | on("file:preprocessor", webpackPreprocessor(options)); 32 | on("task", { 33 | loadMock: ([operationName, variables, loadMockOptions]) => { 34 | const resolvedOptions = { 35 | endpoint: GRAPHQL_API_ROOT_ENDPOINT, 36 | ...loadMockOptions, 37 | }; 38 | // eslint-disable-next-line no-console 39 | console.log("loadMock", [operationName, variables, resolvedOptions]); 40 | return loadMock(operationName, variables, resolvedOptions); 41 | }, 42 | }); 43 | }; 44 | -------------------------------------------------------------------------------- /src/components/AppPinnedItem/AppPinnedItem.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { RepoIcon } from "@primer/octicons-react"; 3 | 4 | import AppTagCount from "../AppTagCount/AppTagCount"; 5 | import { PinnedItemInfosFragment } from "../../libs/graphql"; 6 | import { isRepository } from "../../utils/type-guards"; 7 | 8 | export type AppPinnedItemProps = { 9 | repository: PinnedItemInfosFragment; 10 | }; 11 | 12 | export default function AppPinnedItem({ 13 | repository, 14 | ...props 15 | }: AppPinnedItemProps): JSX.Element { 16 | return ( 17 |
18 |
19 | 20 | 21 | 22 | {repository.name} 23 | 24 | 25 |
26 |

27 | {repository.description || 28 | (repository.parent?.nameWithOwner && 29 | `Forked from ${repository.parent?.nameWithOwner}`)} 30 |

31 |

32 | 37 | {isRepository(repository) ? ( 38 | 44 | ) : null} 45 |

46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/components/AppLanguagesList/AppLanguagesList.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { DotFillIcon } from "@primer/octicons-react"; 3 | 4 | export type AppLanguagesListProps = { 5 | languages: ({ 6 | size: number; 7 | node: { name: string; color?: string | null }; 8 | } | null)[]; 9 | className?: string; 10 | }; 11 | 12 | export default function AppLanguagesList({ 13 | languages, 14 | className, 15 | ...props 16 | }: AppLanguagesListProps): JSX.Element | null { 17 | const fullSize = languages.reduce( 18 | (sum, language) => sum + (language?.size || 0), 19 | 0 20 | ); 21 | return ( 22 |
    23 | {languages 24 | .map((language) => { 25 | if (!language) { 26 | return null; 27 | } 28 | const percentage = ((100 * language.size) / fullSize).toFixed(1); 29 | // dont display languages at 0.0% 30 | if (!Number(percentage)) { 31 | return null; 32 | } 33 | return ( 34 |
  • 41 | {" "} 42 | 43 | {language.node.name}{" "} 44 | {percentage}% 45 | 46 |
  • 47 | ); 48 | }) 49 | .filter(Boolean)} 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/utils/date.test.ts: -------------------------------------------------------------------------------- 1 | import MockDate from "mockdate"; 2 | 3 | import { formatDate } from "./date"; 4 | 5 | describe("utils/date", () => { 6 | describe("formatDate", () => { 7 | beforeEach(() => { 8 | MockDate.set("2021-09-30 12:00:00"); 9 | }); 10 | afterEach(() => { 11 | MockDate.reset(); 12 | }); 13 | it("should not mutate param", () => { 14 | const originalDate = new Date("2021-09-01T12:34:56.000Z"); 15 | formatDate(originalDate); 16 | expect(originalDate.toISOString()).toBe("2021-09-01T12:34:56.000Z"); 17 | }); 18 | it("should return relative date for less than 30 days", () => { 19 | const { formattedDate, isRelative } = formatDate(new Date("2021-09-01")); 20 | expect(formattedDate).toBe("4 weeks ago"); 21 | expect(isRelative).toBe(true); 22 | }); 23 | it("should return relative date for less than 30 days - taking in account future dates", () => { 24 | const { formattedDate, isRelative } = formatDate(new Date("2021-10-10")); 25 | expect(formattedDate).toBe("1 week from now"); 26 | expect(isRelative).toBe(true); 27 | }); 28 | it("should return absolute date for more than 30 days", () => { 29 | const { formattedDate, isRelative } = formatDate(new Date("2021-08-01")); 30 | expect(formattedDate).toBe("1 Aug 2021"); 31 | expect(isRelative).toBe(false); 32 | }); 33 | it("should return absolute date for more than 30 days - taking in account future dates", () => { 34 | const { formattedDate, isRelative } = formatDate(new Date("2021-11-10")); 35 | expect(formattedDate).toBe("10 Nov 2021"); 36 | expect(isRelative).toBe(false); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /README.orig.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 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | 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. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /src/libs/mocks/common.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require,@typescript-eslint/no-var-requires,import/no-dynamic-require */ 2 | function generateMockIdFromGraphqlVariables( 3 | variables: Record 4 | ): string { 5 | const serializedVariablesSortedByKeyWithoutUndefined = Object.entries( 6 | variables 7 | ) 8 | .filter(([, value]) => typeof value !== "undefined") 9 | .sort(([a], [b]) => (a < b ? -1 : 1)) 10 | .map(([key, value]) => `${key}|${value}`) 11 | .join(""); 12 | const hashed = require("hash.js") 13 | .sha256() 14 | .update(serializedVariablesSortedByKeyWithoutUndefined) 15 | .digest("hex"); 16 | return hashed; 17 | } 18 | 19 | export function getMockFileName( 20 | operationName: string, 21 | variables: Record, 22 | { isRequest = false }: { isRequest: boolean } 23 | ): string { 24 | return `${operationName}_${generateMockIdFromGraphqlVariables(variables)}${ 25 | isRequest ? "_request" : "_response" 26 | }.json`; 27 | } 28 | 29 | export function serializeMocksMap(map: Map): string { 30 | return JSON.stringify(Array.from(map.entries())); 31 | } 32 | 33 | export function parseMocksMap(str: string): Map { 34 | return new Map(JSON.parse(str)); 35 | } 36 | 37 | export function fromMocksMap( 38 | map: Map 39 | ): { 40 | get: ( 41 | operationName: string, 42 | variables: Record 43 | ) => Record; 44 | } { 45 | return { 46 | get: (operationName: string, variables: Record) => { 47 | return map.get( 48 | getMockFileName(operationName, variables, { isRequest: false }) 49 | ) as Record; 50 | }, 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /src/components/AppDarkModeSwitch/AppDarkModeSwitch.tsx: -------------------------------------------------------------------------------- 1 | import Toggle from "react-toggle"; 2 | import { useEffect, useState } from "react"; 3 | import { SunIcon, MoonIcon } from "@primer/octicons-react"; 4 | 5 | import { useLocalStorage } from "../../utils/hooks"; 6 | 7 | export type AppDarkModeSwitchProps = { 8 | className?: string; 9 | }; 10 | 11 | export default function AppDarkModeSwitch({ 12 | className, 13 | ...props 14 | }: AppDarkModeSwitchProps): JSX.Element | null { 15 | const [darkMode, setDarkMode] = useLocalStorage("DARKMODE", false); 16 | 17 | const [isClient, setIsClient] = useState(false); 18 | useEffect(() => { 19 | setIsClient(true); 20 | }, []); 21 | 22 | useEffect(() => { 23 | if (darkMode) { 24 | document.body.classList.remove("default-mode"); 25 | document.body.classList.add("dark-mode"); 26 | } else { 27 | document.body.classList.remove("dark-mode"); 28 | document.body.classList.add("default-mode"); 29 | } 30 | }, [darkMode]); 31 | 32 | if (isClient) { 33 | return ( 34 | 53 | ); 54 | } 55 | return null; 56 | } 57 | -------------------------------------------------------------------------------- /src/components/AppOrganizationProfileInfos/AppOrganizationProfileInfos.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { OrganizationInfosFragment } from "../../libs/graphql"; 4 | import AppAvatarImage from "../AppAvatarImage/AppAvatarImage"; 5 | 6 | export type AppUserProfileInfosProps = { 7 | organization?: OrganizationInfosFragment; 8 | }; 9 | 10 | export default function AppOrganizationProfileInfos({ 11 | organization, 12 | }: AppUserProfileInfosProps): JSX.Element | null { 13 | if (!organization) { 14 | return null; 15 | } 16 | return ( 17 |
18 |
19 |
20 | 21 | 22 |

People

23 |
24 | 25 |
26 | {organization.people.edges?.map((member) => { 27 | return ( 28 | 32 | 33 | 39 | 40 | 41 | ); 42 | })} 43 |
44 |
45 | 46 | View all 47 | 48 |
49 |
50 |
51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/components/BaseButton/BaseButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Link from "next/link"; 3 | import clsx from "clsx"; 4 | import { TriangleDownIcon } from "@primer/octicons-react"; 5 | import HRNumbers from "human-readable-numbers"; 6 | 7 | export type BaseButtonProps = Omit< 8 | React.HTMLProps, 9 | "size" 10 | > & { 11 | size: "small" | "medium"; 12 | hasMenu?: boolean; 13 | badge?: { 14 | label: string | number; 15 | href: string; 16 | }; 17 | icon?: JSX.Element; 18 | }; 19 | 20 | export default function BaseButton({ 21 | hasMenu, 22 | size, 23 | badge, 24 | className, 25 | icon, 26 | children, 27 | ...props 28 | }: BaseButtonProps): JSX.Element | null { 29 | return ( 30 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/components/AppProfileOverview/AppProfileOverview.tsx: -------------------------------------------------------------------------------- 1 | import { PinnedItemInfosFragment } from "../../libs/graphql"; 2 | import BaseBox from "../BaseBox/BaseBox"; 3 | import BaseMarkdownDisplay, { 4 | BaseMarkdownDisplayProps, 5 | } from "../BaseMarkdownDisplay/BaseMarkdownDisplay"; 6 | import AppPinnedItem from "../AppPinnedItem/AppPinnedItem"; 7 | 8 | export type AppProfileOverviewProps = { 9 | profileReadme: string | null | undefined; 10 | profileReadmeInfos: BaseMarkdownDisplayProps["profileReadmeInfos"]; 11 | pinnedRepositories?: PinnedItemInfosFragment[]; 12 | popularRepositories?: PinnedItemInfosFragment[]; 13 | }; 14 | 15 | export default function AppProfileOverview({ 16 | profileReadme, 17 | profileReadmeInfos, 18 | pinnedRepositories, 19 | popularRepositories, 20 | }: AppProfileOverviewProps): JSX.Element | null { 21 | return ( 22 |
23 | {profileReadme ? ( 24 | 25 | 30 | 31 | ) : null} 32 |
33 |

34 | {pinnedRepositories && pinnedRepositories.length > 0 35 | ? "Pinned" 36 | : "Popular repositories"} 37 |

38 |
    39 | {( 40 | (pinnedRepositories && pinnedRepositories.length > 0 41 | ? pinnedRepositories 42 | : popularRepositories) || [] 43 | ).map((item) => ( 44 |
  1. 45 | 46 | 47 | 48 |
  2. 49 | ))} 50 |
51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/components/AppSearchBarRepositories/AppSearchBarRepositories.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | 3 | import AppSelectMenu from "../AppSelectMenu/AppSelectMenu"; 4 | import { 5 | getSearchFieldOptions, 6 | SearchParamsType, 7 | } from "../../utils/github/searchRepos"; 8 | import { SetReducerStateType } from "../../utils/useSearchRepos"; 9 | 10 | export type AppSearchBarRepositoriesProps = { 11 | onUpdate: SetReducerStateType>>; 12 | params: SearchParamsType; 13 | className?: string; 14 | }; 15 | 16 | export default function AppSearchBarRepositories({ 17 | onUpdate, 18 | params: { type = "", sort = "", q = "" }, 19 | className, 20 | ...props 21 | }: AppSearchBarRepositoriesProps): JSX.Element { 22 | return ( 23 |
27 | { 33 | onUpdate({ q: (e.target as HTMLInputElement).value }); 34 | }} 35 | /> 36 |
37 | { 40 | onUpdate({ type: value }); 41 | }} 42 | options={getSearchFieldOptions("type")} 43 | buttonLabel="Type" 44 | menuLabel="Select type" 45 | className="" 46 | alignMenu="right" 47 | /> 48 | { 51 | onUpdate({ sort: value }); 52 | }} 53 | options={getSearchFieldOptions("sort")} 54 | buttonLabel="Sort" 55 | menuLabel="Select order" 56 | className="ml-1" 57 | alignMenu="right" 58 | /> 59 |
60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/components/AppSelectMenu/AppSelectMenu.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon } from "@primer/octicons-react"; 2 | import clsx from "clsx"; 3 | 4 | import BaseSelectMenu from "../BaseSelectMenu/BaseSelectMenu"; 5 | 6 | type OptionType = { value: T; label: string }; 7 | 8 | export type AppSelectMenuProps = { 9 | value: T; 10 | options: OptionType[]; 11 | buttonLabel: string; 12 | menuLabel?: string; 13 | onChange: (newValue: T) => void; 14 | className?: string; 15 | alignMenu: "left" | "right"; 16 | }; 17 | 18 | export default function AppSelectMenu({ 19 | value: currentValue, 20 | options, 21 | buttonLabel, 22 | menuLabel, 23 | onChange, 24 | className, 25 | alignMenu, 26 | ...props 27 | }: AppSelectMenuProps): JSX.Element { 28 | const internalOnChange = (newValue: T) => { 29 | onChange(newValue); 30 | }; 31 | const makeHandleKeypress = ( 32 | newValue: T 33 | ): React.KeyboardEventHandler => (event) => { 34 | if (event.key === "Enter" || event.key === " ") { 35 | internalOnChange(newValue); 36 | } 37 | }; 38 | return ( 39 | 46 |
    47 | {options.map(({ label, value }) => ( 48 |
  • internalOnChange(value)} 52 | className="flex py-1 pl-4 w-full border-b border-light cursor-pointer hover:bg-brand-secondary" 53 | tabIndex={0} 54 | onKeyDown={makeHandleKeypress(value)} 55 | > 56 | 59 | {label} 60 |
  • 61 | ))} 62 |
63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/mocks/node.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getMockFilePath as _getMockFilePath, 3 | saveMock as _saveMock, 4 | loadMock as _loadMock, 5 | loadAllMocks as _loadAllMocks, 6 | GetMockFilePathOptionsType, 7 | ManageMockOptionsType, 8 | } from "../libs/mocks/node"; 9 | 10 | type OptionsType = Omit< 11 | ManageMockOptionsType, 12 | "rootMockDirectory" | "endpoint" 13 | >; 14 | 15 | // will do it by providing factories of functions in src/libs/mocks in the future 16 | 17 | function getOptions(options: Record = {}) { 18 | return { 19 | rootMockDirectory: () => 20 | // need to `require` native node module so that they will work inside cypress (if you want to use the cypress plugin) 21 | // eslint-disable-next-line @typescript-eslint/no-var-requires,global-require 22 | require("path").join( 23 | process.cwd(), 24 | process.env.MOCKS_TARGET || ".tmp/.mocks" 25 | ), 26 | endpoint: process.env.GRAPHQL_API_ROOT_ENDPOINT as string, 27 | ...options, 28 | }; 29 | } 30 | 31 | export function getMockFilePath( 32 | operationName: string, 33 | variables: Record, 34 | options: Partial 35 | ): string { 36 | return _getMockFilePath(operationName, variables, getOptions(options)); 37 | } 38 | 39 | export function saveMock( 40 | operationName: string, 41 | variables: Record, 42 | requestBody: string, 43 | responseBody: string, 44 | options?: OptionsType 45 | ): Promise { 46 | return _saveMock( 47 | operationName, 48 | variables, 49 | requestBody, 50 | responseBody, 51 | getOptions(options) 52 | ); 53 | } 54 | 55 | export function loadMock( 56 | operationName: string, 57 | variables: Record, 58 | options?: OptionsType 59 | ): Promise { 60 | return _loadMock(operationName, variables, getOptions(options)); 61 | } 62 | 63 | export async function loadAllMocks( 64 | options?: OptionsType 65 | ): Promise | null> { 66 | return _loadAllMocks(getOptions(options)); 67 | } 68 | -------------------------------------------------------------------------------- /src/components/TheFooter/TheFooter.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useRouter } from "next/router"; 3 | import clsx from "clsx"; 4 | 5 | import ExternalTwitterButton from "../ExternalTwitterButton/ExternalTwitterButton"; 6 | 7 | export type TheFooterProps = { 8 | fromFullYear: number; 9 | toFullYear?: number; 10 | className?: string; 11 | }; 12 | 13 | export default function TheFooter({ 14 | fromFullYear, 15 | toFullYear, 16 | className, 17 | ...remainingProps 18 | }: TheFooterProps): JSX.Element | null { 19 | const [currentUrl, setCurrentUrl] = useState(null); 20 | const router = useRouter(); 21 | useEffect(() => { 22 | setCurrentUrl(window.location.href); 23 | }, [router.asPath]); 24 | return ( 25 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/stories/assets/plugin.svg: -------------------------------------------------------------------------------- 1 | illustration/plugin -------------------------------------------------------------------------------- /src/components/AppRepositoryHeader/AppRepositoryHeader.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { StarIcon, RepoForkedIcon, RepoIcon } from "@primer/octicons-react"; 3 | 4 | import BaseButton from "../BaseButton/BaseButton"; 5 | 6 | export type AppRepositoryHeaderProps = { 7 | owner: string; 8 | repositoryName: string; 9 | stargazerCount?: number; 10 | forkCount?: number; 11 | }; 12 | 13 | export default function AppRepositoryHeader({ 14 | owner, 15 | repositoryName, 16 | stargazerCount, 17 | forkCount, 18 | }: AppRepositoryHeaderProps): JSX.Element | null { 19 | return ( 20 |
21 |
22 |

23 | 24 | 25 | {owner} 26 | {" "} 27 | /{" "} 28 | 29 | 30 | {repositoryName} 31 | 32 | 33 |

34 | 35 | Public 36 | 37 |
38 |
    39 |
  • 40 | } 42 | size="small" 43 | badge={{ 44 | href: `/${owner}/${repositoryName}/stargazers`, 45 | label: stargazerCount || 0, 46 | }} 47 | > 48 | Star 49 | 50 |
  • 51 |
  • 52 | } 54 | size="small" 55 | badge={{ 56 | href: `/${owner}/${repositoryName}/network/members`, 57 | label: forkCount || 0, 58 | }} 59 | > 60 | Fork 61 | 62 |
  • 63 |
64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/components/AppNavBar/AppNavBar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import React from "react"; 3 | import clsx from "clsx"; 4 | 5 | import BaseBadge from "../BaseBadge/BaseBadge"; 6 | 7 | export type LinksDataType = { 8 | label: string; 9 | icon: React.FunctionComponent<{ className?: string }>; 10 | badge?: string | number; 11 | tab?: string; 12 | href: string | { pathname: string; query?: Record }; 13 | disabled?: boolean; 14 | }; 15 | 16 | export type AppNavBarProps = { 17 | links: LinksDataType[]; 18 | currentTab: string; 19 | }; 20 | 21 | type TabComponent = React.FunctionComponent<{ 22 | children: React.ReactChild | React.ReactChild[]; 23 | className?: string; 24 | style?: React.CSSProperties; 25 | }>; 26 | 27 | export default function AppNavBar({ 28 | links, 29 | currentTab, 30 | ...ownProps 31 | }: AppNavBarProps): JSX.Element | null { 32 | return ( 33 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/components/AppMainLayout/AppMainLayout.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | 3 | export type AppMainLayoutProps = { 4 | reverse?: boolean; 5 | children: () => { 6 | nav: JSX.Element | null; 7 | main: JSX.Element | null; 8 | sidebar?: JSX.Element | null; 9 | topNav?: JSX.Element | null; 10 | }; 11 | }; 12 | 13 | export default function AppMainLayout({ 14 | reverse = false, 15 | children, 16 | }: AppMainLayoutProps): JSX.Element | null { 17 | const { nav, main, sidebar, topNav } = children(); 18 | const navbar = ( 19 |
{nav}
20 | ); 21 | return ( 22 | <> 23 |
26 | {topNav ? ( 27 |
28 |
{topNav}
29 |
30 | ) : null} 31 |
37 | {sidebar ?
: null} 38 |
39 | {nav} 40 |
41 |
42 |
43 |
44 |
52 | {sidebar ? ( 53 |
59 | {sidebar} 60 |
61 | ) : null} 62 | {!reverse ? navbar : null} 63 |
64 | {main} 65 |
66 | {reverse ? navbar : null} 67 |
68 |
69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/components/AppNavBarProfile/AppNavBarProfile.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, within } from "@testing-library/react"; 3 | 4 | import AppNavBarProfile from "./AppNavBarProfile"; 5 | 6 | const data = { 7 | organization: [ 8 | { label: "Overview", link: "/microsoft" }, 9 | { label: "Repositories", link: "/orgs/microsoft/repositories" }, 10 | ], 11 | user: [ 12 | { label: "Overview", link: "/topheman" }, 13 | { label: "Repositories", link: "/topheman?tab=repositories" }, 14 | ], 15 | }; 16 | 17 | describe("components/AppNavBarProfile", () => { 18 | it("[organization] should show correct links", () => { 19 | const { container } = render( 20 | 25 | ); 26 | data.organization.forEach(({ label, link }) => { 27 | const { getByText } = within( 28 | container.querySelector(`[href="${link}"]`) as HTMLElement 29 | ); 30 | expect(getByText(label)).toBeVisible(); 31 | }); 32 | }); 33 | it("[user] should show correct links", () => { 34 | const { container } = render( 35 | 36 | ); 37 | data.user.forEach(({ label, link }) => { 38 | const { getByText } = within( 39 | container.querySelector(`[href="${link}"]`) as HTMLElement 40 | ); 41 | expect(getByText(label)).toBeVisible(); 42 | }); 43 | }); 44 | it("should handle current tab default", () => { 45 | const { container } = render( 46 | 47 | ); 48 | const { getByText } = within( 49 | container.querySelector(`.border-brand-primary`) as HTMLElement 50 | ); 51 | expect(getByText("Overview")).toBeVisible(); 52 | }); 53 | it("should handle current tab repositories", () => { 54 | const { container } = render( 55 | 60 | ); 61 | const { getByText } = within( 62 | container.querySelector(`.border-brand-primary`) as HTMLElement 63 | ); 64 | expect(getByText("Repositories")).toBeVisible(); 65 | }); 66 | }); 67 | export {}; 68 | -------------------------------------------------------------------------------- /src/components/AppBlobDisplay/AppBlobDisplay.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import processByteSize from "byte-size"; 3 | 4 | import BaseBoxWithHeader from "../BaseBoxWithHeader/BaseBoxWithHeader"; 5 | import BaseSyntaxHighlighter, { 6 | BaseSyntaxHighlighterProps, 7 | } from "../BaseSyntaxHighlighter/BaseSyntaxHighlighter"; 8 | 9 | export type AppBlobDisplayProps = { 10 | byteSize: number; 11 | rawHref: { 12 | pathname: string; 13 | query: Record; 14 | }; 15 | className?: string; 16 | } & BaseSyntaxHighlighterProps; 17 | 18 | const MAX_BYTE_SIZE = 800_000; 19 | 20 | export default function AppBlobDisplay({ 21 | code, 22 | fileName, 23 | byteSize, 24 | language, 25 | rawHref, 26 | className, 27 | }: AppBlobDisplayProps): JSX.Element | null { 28 | const codeWithoutLastLineBreak = code.replace(/\n$/, ""); 29 | const numberOfLines = codeWithoutLastLineBreak.split("\n").length; 30 | const sourceLinesOfCode = code 31 | .split("\n") 32 | .map((line) => line.trim()) 33 | .filter(Boolean).length; 34 | const size = processByteSize(byteSize, { 35 | units: "iec", 36 | precision: 2, 37 | locale: "en", 38 | }); 39 | if (byteSize > MAX_BYTE_SIZE) { 40 | return ( 41 |
42 | File too big to be displayed ({size.value} {size.unit}) 43 |
44 | ); 45 | } 46 | return ( 47 | 50 |
51 | {numberOfLines} lines ({sourceLinesOfCode}{" "} 52 | 56 | sloc 57 | 58 | ){" | "} {size.value} {size.unit} 59 |
60 | 61 | 62 | Raw 63 | 64 | 65 |
66 | } 67 | className={className} 68 | > 69 | 76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/components/AppSearchSummary/AppSearchSummary.tsx: -------------------------------------------------------------------------------- 1 | import { XIcon } from "@primer/octicons-react"; 2 | import clsx from "clsx"; 3 | 4 | import { PageInfo } from "../../libs/graphql"; 5 | import { 6 | getSearchFieldSummaryInfos, 7 | decodeCursor, 8 | } from "../../utils/github/searchRepos"; 9 | 10 | export type AppSearchSummaryProps = { 11 | count: number; 12 | pageInfo: Pick; 13 | sort: string | undefined; 14 | type: string | undefined; 15 | clearFilter?: () => void; 16 | className?: string; 17 | }; 18 | 19 | const TYPE_LABEL_MAPPING = getSearchFieldSummaryInfos("type"); 20 | const SORT_LABEL_MAPPING = getSearchFieldSummaryInfos("sort"); 21 | 22 | export default function AppSearchSummary({ 23 | count, 24 | pageInfo, 25 | sort = "", 26 | type = "", 27 | clearFilter, 28 | className, 29 | ...props 30 | }: AppSearchSummaryProps): JSX.Element | null { 31 | const sortByLabel = SORT_LABEL_MAPPING[sort]; 32 | const typeLabel = TYPE_LABEL_MAPPING[type]; 33 | const from = decodeCursor(pageInfo.startCursor); 34 | const to = decodeCursor(pageInfo.endCursor); 35 | return ( 36 |
37 |
38 | {count} result 39 | {count > 1 ? "s" : ""}{" "} 40 | {count ? ( 41 | <> 42 | ({from}- 43 | {to}) 44 | 45 | ) : null} 46 | {typeLabel ? ( 47 | <> 48 | {" "} 49 | for{" "} 50 | 51 | {typeLabel} 52 | {" "} 53 | repositories 54 | 55 | ) : null}{" "} 56 | sorted by{" "} 57 | {sortByLabel} 58 |
59 |
60 | 68 |
69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/utils/github/repository.ts: -------------------------------------------------------------------------------- 1 | import type { ParsedUrlQuery } from "querystring"; 2 | /** 3 | * Parse query from urls like : 4 | * - `[owner]/[repository]/tree/[branchName]` 5 | * - `[owner]/[repository]/commit/[commitId]` 6 | * - `[owner]/[repository]/blob/[branchName]?path=[path]` 7 | */ 8 | export const parseQuery = ( 9 | query: ParsedUrlQuery 10 | ): { 11 | owner: string; 12 | repositoryName: string; 13 | branchName?: string; 14 | commitId?: string; 15 | path?: string; 16 | } => { 17 | const { owner, repositoryName, branchName, commitId, path } = query; 18 | return { 19 | owner: typeof owner === "string" ? owner : "", 20 | repositoryName: typeof repositoryName === "string" ? repositoryName : "", 21 | branchName: 22 | // eslint-disable-next-line no-nested-ternary 23 | typeof branchName === "string" 24 | ? branchName 25 | : Array.isArray(branchName) 26 | ? branchName.join("/") 27 | : undefined, 28 | commitId: typeof commitId === "string" ? commitId : undefined, 29 | path: typeof path === "string" ? path : undefined, 30 | }; 31 | }; 32 | 33 | export function getRepositoryVariables({ 34 | owner, 35 | repositoryName, 36 | branchName, 37 | commitId, 38 | path, 39 | }: { 40 | owner: string; 41 | repositoryName: string; 42 | branchName?: string; 43 | commitId?: string; 44 | path?: string; 45 | }): { 46 | owner: string; 47 | name: string; 48 | ref: string; 49 | refPath: string; 50 | upperCaseReadmeRefPath: string; 51 | lowerCaseReadmeRefPath: string; 52 | commit?: string; 53 | path?: string; 54 | } { 55 | return { 56 | owner, 57 | name: repositoryName, 58 | ref: branchName ?? "HEAD", 59 | refPath: `${branchName ?? "HEAD"}:${path || ""}`, 60 | upperCaseReadmeRefPath: `${branchName ?? "HEAD"}:README.md`, 61 | lowerCaseReadmeRefPath: `${branchName ?? "HEAD"}:readme.md`, 62 | commit: commitId, 63 | path, 64 | }; 65 | } 66 | 67 | type ResolveCurrentRefType = { 68 | currentRef: { 69 | name: string; 70 | prefix: "refs/heads/" | "refs/tags/"; 71 | } | null; 72 | defaultBranchName: string; 73 | }; 74 | 75 | export function resolveCurrentRef({ 76 | currentRef, 77 | defaultBranchName, 78 | }: ResolveCurrentRefType): { 79 | name: string; 80 | prefix: "refs/heads/" | "refs/tags/"; 81 | } { 82 | return ( 83 | currentRef || { 84 | name: defaultBranchName, 85 | prefix: "refs/heads/", 86 | } 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /src/stories/assets/stackalt.svg: -------------------------------------------------------------------------------- 1 | illustration/stackalt -------------------------------------------------------------------------------- /src/components/ExternalTwitterButton/ExternalTwitterButton.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * inspired by https://github.com/topheman/d3-react-experiments/blob/master/src/components/TwitterButton/TwitterButton.js 3 | * and https://github.com/topheman/react-fiber-experiments/blob/master/src/components/TwitterButton.js 4 | * and finally https://github.com/topheman/nextjs-movie-browser/blob/master/src/components/TwitterButton.tsx 5 | */ 6 | 7 | import clsx from "clsx"; 8 | import React from "react"; 9 | 10 | import TwitterIcon from "../icons/TwitterIcon"; 11 | 12 | export interface ExternalTwitterButtonProps { 13 | text?: string; 14 | url?: string; 15 | hashtags?: string; 16 | via?: string; 17 | related?: string; 18 | buttonTitle?: string; 19 | className?: string; 20 | } 21 | 22 | /** 23 | * Not using the iframe because it breaks back button for some reason 24 | */ 25 | const ExternalTwitterButton: React.FunctionComponent = ( 26 | props 27 | ) => { 28 | const { 29 | text, 30 | url, 31 | hashtags, 32 | via, 33 | related, 34 | buttonTitle, 35 | className, 36 | ...remainingProps 37 | } = props; 38 | const params = [ 39 | (typeof text !== "undefined" && `text=${encodeURIComponent(text)}`) || 40 | undefined, 41 | (typeof url !== "undefined" && `url=${encodeURIComponent(url)}`) || 42 | undefined, 43 | (typeof hashtags !== "undefined" && 44 | `hashtags=${encodeURIComponent(hashtags)}`) || 45 | undefined, 46 | (typeof via !== "undefined" && `via=${encodeURIComponent(via)}`) || 47 | undefined, 48 | (typeof related !== "undefined" && 49 | `related=${encodeURIComponent(related)}`) || 50 | undefined, 51 | ] 52 | .filter((item) => item !== undefined) 53 | .join("&"); 54 | return ( 55 | 66 | 67 | tweet 68 | 69 | ); 70 | }; 71 | 72 | ExternalTwitterButton.defaultProps = { 73 | buttonTitle: "Tweet about this", 74 | text: undefined, 75 | url: undefined, 76 | hashtags: undefined, 77 | via: undefined, 78 | related: undefined, 79 | }; 80 | 81 | export default ExternalTwitterButton; 82 | -------------------------------------------------------------------------------- /src/components/BaseSelectMenu/BaseSelectMenu.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import BaseSelectMenu, { BaseSelectMenuProps } from "./BaseSelectMenu"; 5 | 6 | export default { 7 | title: "BaseSelectMenu", 8 | component: BaseSelectMenu, 9 | } as Meta; 10 | 11 | const Template: Story = (args) => { 12 | return ( 13 |
14 | 15 |
16 | ); 17 | }; 18 | 19 | const SHORT_LIST = ["Red", "Blue", "Orange"]; 20 | const LONG_LIST = [ 21 | "black", 22 | "red", 23 | "green", 24 | "yellow", 25 | "blue", 26 | "magenta", 27 | "cyan", 28 | "white", 29 | "gray", 30 | "grey", 31 | "d-black", 32 | "d-red", 33 | "d-green", 34 | "d-yellow", 35 | "d-blue", 36 | "d-magenta", 37 | "d-cyan", 38 | "d-white", 39 | "d-gray", 40 | "d-grey", 41 | ]; 42 | 43 | export const Base = Template.bind({}); 44 | Base.args = { 45 | buttonLabel: "Type", 46 | menuLabel: "Select Type", 47 | alignMenu: "right", 48 | children: ( 49 |
50 |
    51 | {SHORT_LIST.map((color) => ( 52 |
  • {color}
  • 53 | ))} 54 |
55 |
56 | ), 57 | }; 58 | 59 | export const WithoutMenuLabel = Template.bind({}); 60 | WithoutMenuLabel.args = { 61 | buttonLabel: "Type", 62 | alignMenu: "right", 63 | children: ( 64 |
65 |
    66 | {SHORT_LIST.map((color) => ( 67 |
  • {color}
  • 68 | ))} 69 |
70 |
71 | ), 72 | }; 73 | 74 | export const MultipleData = Template.bind({}); 75 | MultipleData.args = { 76 | buttonLabel: "Type", 77 | menuLabel: "Select Type", 78 | alignMenu: "right", 79 | children: ( 80 |
81 |
    82 | {LONG_LIST.map((color) => ( 83 |
  • {color}
  • 84 | ))} 85 |
86 |
87 | ), 88 | }; 89 | 90 | export const MultipleDataWithoutMenuLabel = Template.bind({}); 91 | MultipleDataWithoutMenuLabel.args = { 92 | buttonLabel: "Type", 93 | alignMenu: "right", 94 | children: ( 95 |
96 |
    97 | {LONG_LIST.map((color) => ( 98 |
  • {color}
  • 99 | ))} 100 |
101 |
102 | ), 103 | }; 104 | -------------------------------------------------------------------------------- /src/components/AppRepositoryBreadcrumb/AppRepositoryBreadcrumb.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "@testing-library/react"; 3 | 4 | import AppRepositoryBreadCrumb, { 5 | AppRepositoryBreadcrumbProps, 6 | } from "./AppRepositoryBreadcrumb"; 7 | 8 | const baseRender = (props: Partial = {}) => 9 | render( 10 | 20 | ); 21 | 22 | describe("components/AppRepositoryBreadCrumb", () => { 23 | it("should show the name of repository with a link to it (default branch case)", () => { 24 | const { getByRole } = baseRender(); 25 | expect(getByRole("link", { name: "nextjs-movie-browser" })).toBeVisible(); 26 | expect(getByRole("link", { name: "nextjs-movie-browser" })).toHaveAttribute( 27 | "href", 28 | "/topheman/nextjs-movie-browser" 29 | ); 30 | }); 31 | it("should show the name of repository with a link to the branch (if not default branch)", () => { 32 | const { getByRole } = baseRender({ 33 | defaultBranchName: "main", 34 | }); 35 | expect(getByRole("link", { name: "nextjs-movie-browser" })).toBeVisible(); 36 | expect(getByRole("link", { name: "nextjs-movie-browser" })).toHaveAttribute( 37 | "href", 38 | "/topheman/nextjs-movie-browser/tree/master" 39 | ); 40 | }); 41 | it("should expose link for each path fragment except last", () => { 42 | const { getByRole } = baseRender(); 43 | [ 44 | ["src", "src"], 45 | ["components", "src/components"], 46 | ["SomeComponent", "src/components/SomeComponent"], 47 | ].forEach(([pathFragment, path]) => { 48 | expect(getByRole("link", { name: pathFragment })).toBeVisible(); 49 | expect(getByRole("link", { name: pathFragment })).toHaveAttribute( 50 | "href", 51 | `/topheman/nextjs-movie-browser/tree/master?path=${encodeURIComponent( 52 | path 53 | )}` 54 | ); 55 | }); 56 | }); 57 | it("should not expose link on last fragment", () => { 58 | const { queryByRole, getByText } = baseRender(); 59 | expect( 60 | queryByRole("link", { name: "SomeComponent.tsx" }) 61 | ).not.toBeInTheDocument(); 62 | expect(getByText("SomeComponent.tsx")).toBeVisible(); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/components/AppRepositoryBreadcrumb/AppRepositoryBreadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from "react"; 2 | import Link from "next/link"; 3 | 4 | import { GitRefType } from "../../libs/graphql"; 5 | 6 | export type AppRepositoryBreadcrumbProps = { 7 | nameWithOwner: string; 8 | defaultBranchName: string; 9 | currentPath: string; 10 | currentRef: GitRefType; 11 | className?: string; 12 | } & React.HTMLProps; 13 | 14 | export default function AppRepositoryBreadCrumb({ 15 | nameWithOwner, 16 | defaultBranchName, 17 | currentPath, 18 | currentRef, 19 | className, 20 | ...props 21 | }: AppRepositoryBreadcrumbProps): JSX.Element | null { 22 | const repositoryName = nameWithOwner.split("/").splice(-1)[0]; 23 | const separator = /; 24 | return ( 25 |

26 | 34 | 35 | {repositoryName} 36 | 37 | 38 | {separator} 39 | {currentPath 40 | .split("/") 41 | .reduce<{ path: string; pathFragment: string; isLast: boolean }[]>( 42 | (acc, pathFragment, index, array) => { 43 | acc.push({ 44 | path: [`${acc[index - 1]?.path || ""}`, pathFragment] 45 | .filter(Boolean) 46 | .join("/"), 47 | pathFragment, 48 | isLast: index === array.length - 1, 49 | }); 50 | return acc; 51 | }, 52 | [] 53 | ) 54 | .map(({ path, pathFragment, isLast }) => { 55 | return ( 56 | 57 | {isLast ? ( 58 | {pathFragment} 59 | ) : ( 60 | 68 | 69 | {pathFragment} 70 | 71 | 72 | )} 73 | {separator} 74 | 75 | ); 76 | })} 77 |

78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /src/components/AppRepositoryInfosAbout/AppRepositoryInfosAbout.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { LinkIcon, BookIcon, LawIcon } from "@primer/octicons-react"; 3 | import clsx from "clsx"; 4 | 5 | import AppTopicsTagList from "../AppTopicsTagList/AppTopicsTagList"; 6 | import { GetRepositoryInfosOverviewQuery } from "../../libs/graphql"; 7 | import { formatUrl } from "../../utils/string"; 8 | 9 | export type AppRepositoryInfosAboutProps = { 10 | repository?: GetRepositoryInfosOverviewQuery["repository"]; 11 | className?: string; 12 | }; 13 | 14 | export default function AppRepositoryInfosAbout({ 15 | repository, 16 | className, 17 | }: AppRepositoryInfosAboutProps): JSX.Element | null { 18 | if (!repository) { 19 | return null; 20 | } 21 | return ( 22 |
23 |

About

24 |

{repository.description}

25 | {repository.homepageUrl ? ( 26 | 38 | ) : null} 39 |

Topics

40 | 44 |

Resources

45 | {repository.readmeLowercase || repository.readmeUppercase ? ( 46 |
47 | 48 | 49 | 50 | Readme 51 | 52 | 53 |
54 | ) : null} 55 |

License

56 | {repository.licenseInfo ? ( 57 | 72 | ) : null} 73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/graphql/queries/getRepositoryInfosBlob.graphql: -------------------------------------------------------------------------------- 1 | query GetRepositoryInfosBlob( 2 | $owner: String! 3 | $name: String! 4 | $ref: String! # examples: HEAD|master|feature/foo 5 | $refPath: String! # examples: HEAD:common.js|master:index.js|feature/foo:src/foo.php|master:src 6 | $path: String! # examples: common.js|src/foo.js|style/toto.css 7 | ) { 8 | rateLimit { 9 | limit 10 | cost 11 | remaining 12 | resetAt 13 | } 14 | repository(name: $name, owner: $owner) { 15 | id 16 | nameWithOwner 17 | description 18 | homepageUrl 19 | stargazerCount 20 | forkCount 21 | defaultBranchRef { 22 | name 23 | prefix 24 | } 25 | openGraphImageUrl 26 | # identified what kind of ref was passed 27 | currentRef: ref(qualifiedName: $ref) { 28 | name 29 | prefix 30 | } 31 | branches: refs(refPrefix: "refs/heads/", first: 10, direction: DESC) { 32 | totalCount 33 | edges { 34 | node { 35 | name 36 | } 37 | } 38 | } 39 | tags: refs(refPrefix: "refs/tags/", first: 10, direction: DESC) { 40 | totalCount 41 | edges { 42 | node { 43 | name 44 | } 45 | } 46 | } 47 | file: object(expression: $refPath) { 48 | ... on Blob { 49 | byteSize 50 | text 51 | } 52 | } 53 | gitInfos: ref(qualifiedName: $ref) { 54 | # if $ref is a tag 55 | tag: target { 56 | ... on Tag { 57 | name 58 | target { 59 | ... on Commit { 60 | history(first: 1, path: $path) { 61 | edges { 62 | node { 63 | oid 64 | messageHeadline 65 | committedDate 66 | author { 67 | user { 68 | login 69 | avatarUrl 70 | } 71 | } 72 | } 73 | } 74 | } 75 | } 76 | } 77 | } 78 | } 79 | # if $ref is a branch 80 | branch: target { 81 | ... on Commit { 82 | history(first: 1, path: $path) { 83 | edges { 84 | node { 85 | oid 86 | messageHeadline 87 | committedDate 88 | author { 89 | user { 90 | login 91 | avatarUrl 92 | } 93 | } 94 | } 95 | } 96 | } 97 | } 98 | } 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/components/AppTagDate/AppTagDate.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { Story, Meta } from "@storybook/react"; 3 | 4 | import AppTagDate, { AppTagDateProps } from "./AppTagDate"; 5 | 6 | export default { 7 | title: "AppTagDate", 8 | component: AppTagDate, 9 | } as Meta; 10 | 11 | const Template: Story = ({ 12 | dateLabel, 13 | ...ownProps 14 | }) => { 15 | const [date, setDate] = useState(new Date()); 16 | useEffect(() => { 17 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 18 | const dateFactory = OPTIONS.find(({ label }) => label === dateLabel) || { 19 | value: () => new Date(), 20 | }; 21 | setDate(dateFactory.value()); 22 | }, [dateLabel]); 23 | return ; 24 | }; 25 | 26 | const OPTIONS: { label: string; value: () => Date }[] = [ 27 | { label: "now", value: () => new Date() }, 28 | { 29 | label: "30s ago", 30 | value: () => { 31 | const now = new Date(); 32 | return new Date(now.getTime() - 30000); 33 | }, 34 | }, 35 | { 36 | label: "1min ago", 37 | value: () => { 38 | const now = new Date(); 39 | return new Date(now.getTime() - 60000); 40 | }, 41 | }, 42 | { 43 | label: "5min ago", 44 | value: () => { 45 | const now = new Date(); 46 | return new Date(now.getTime() - 60000 * 5); 47 | }, 48 | }, 49 | { 50 | label: "1day ago", 51 | value: () => { 52 | const now = new Date(); 53 | return new Date(now.getTime() - 60000 * 60 * 24); 54 | }, 55 | }, 56 | { 57 | label: "1week ago", 58 | value: () => { 59 | const now = new Date(); 60 | return new Date(now.getTime() - 60000 * 60 * 24 * 7); 61 | }, 62 | }, 63 | { 64 | label: "1month ago", 65 | value: () => { 66 | const now = new Date(); 67 | return new Date(now.getTime() - 60000 * 60 * 24 * 31); 68 | }, 69 | }, 70 | { 71 | label: "1year ago", 72 | value: () => { 73 | const now = new Date(); 74 | return new Date(now.getTime() - 60000 * 60 * 24 * 365); 75 | }, 76 | }, 77 | ]; 78 | 79 | export const Base = Template.bind({}); 80 | Base.parameters = { 81 | controls: { exclude: ["date"] }, 82 | }; 83 | Base.args = { 84 | reactive: true, 85 | mode: "updated", 86 | className: "", 87 | }; 88 | Base.argTypes = { 89 | mode: { 90 | options: ["updated", "default"], 91 | }, 92 | dateLabel: { 93 | name: "date ", // with a trailing space because `date` is excluded 94 | options: OPTIONS.map(({ label }) => label), 95 | control: { type: "select" }, 96 | }, 97 | reactive: { 98 | options: [true, false], 99 | control: { type: "boolean" }, 100 | }, 101 | }; 102 | -------------------------------------------------------------------------------- /src/components/AppRepositoryOverview/AppRepositoryOverview.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | 3 | import AppRepositoryMainHeader from "../AppRepositoryMainHeader/AppRepositoryMainHeader"; 4 | import AppFilesList from "../AppFilesList/AppFilesList"; 5 | import AppRepositoryReadme from "../AppRepositoryReadme/AppRepositoryReadme"; 6 | import { resolveCurrentRef } from "../../utils/github/repository"; 7 | import { 8 | GetRepositoryInfosOverviewQuery, 9 | GitInfosType, 10 | TreeEntry, 11 | Blob, 12 | } from "../../libs/graphql"; 13 | import AppListRoutePatterns from "../AppListRoutePatterns/AppListRoutePatterns"; 14 | 15 | export type AppRepositoryOverviewProps = { 16 | repository?: GetRepositoryInfosOverviewQuery["repository"]; 17 | currentPath?: string; 18 | className?: string; 19 | }; 20 | 21 | export default function AppRepositoryOverview({ 22 | repository, 23 | currentPath, 24 | className, 25 | }: AppRepositoryOverviewProps): JSX.Element | null { 26 | if (!repository) { 27 | return null; 28 | } 29 | const resolvedCurrentRef = resolveCurrentRef({ 30 | currentRef: repository.currentRef as { 31 | name: string; 32 | prefix: "refs/heads/" | "refs/tags/"; 33 | }, 34 | defaultBranchName: repository.defaultBranchRef?.name as string, 35 | }); 36 | if (!repository.gitInfos) { 37 | return ( 38 |
39 |

Your request couldn't be matched.

40 |

A little explanation:

41 | ; 42 |
43 | ); 44 | } 45 | return ( 46 |
51 | 56 | {repository.gitInfos ? ( 57 | 68 | ) : null} 69 | {!currentPath ? ( 70 | 80 | ) : null} 81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /cypress/integration/repository.spec.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | describe("repository", () => { 4 | describe("AppGitRefSwitch", () => { 5 | it("Default branch should be checked", () => { 6 | cy.visit("/topheman/docker-experiments"); 7 | cy.get("button[label=master]").click(); 8 | cy.get( 9 | "a[href='/topheman/docker-experiments/tree/master'][aria-checked=true]" 10 | ).should("exist"); 11 | cy.get("a[href='/topheman/docker-experiments/tree/develop']").should( 12 | "exist" 13 | ); 14 | }); 15 | it("Changing path should adjust", () => { 16 | cy.visit("/topheman/docker-experiments"); 17 | cy.get("[role=grid]").findByRole("link", { name: "api" }).click(); 18 | cy.url().should( 19 | "eq", 20 | `${ 21 | Cypress.config().baseUrl 22 | }/topheman/docker-experiments/tree/master?path=api` 23 | ); 24 | cy.get("button[label=master]").click(); 25 | cy.get( 26 | "a[href='/topheman/docker-experiments/tree/master?path=api'][aria-checked=true]" 27 | ).should("exist"); 28 | cy.get( 29 | "a[href='/topheman/docker-experiments/tree/develop?path=api']" 30 | ).should("exist"); 31 | }); 32 | it("Changing branch should adjust", () => { 33 | cy.visit("/topheman/docker-experiments"); 34 | cy.get("button[label=master]").click(); 35 | cy.get("a[href='/topheman/docker-experiments/tree/develop']").click(); 36 | cy.url().should( 37 | "eq", 38 | `${Cypress.config().baseUrl}/topheman/docker-experiments/tree/develop` 39 | ); 40 | cy.get("button[label=develop]").should("exist"); 41 | cy.get("a[href='/topheman/docker-experiments/tree/master']").should( 42 | "exist" 43 | ); 44 | cy.get( 45 | "a[href='/topheman/docker-experiments/tree/develop'][aria-checked=true]" 46 | ).should("exist"); 47 | }); 48 | it("Changing path to blob should also adjust", () => { 49 | cy.visit("/topheman/docker-experiments"); 50 | cy.get("[role=grid]").findByRole("link", { name: "api" }).click(); 51 | cy.url().should( 52 | "eq", 53 | `${ 54 | Cypress.config().baseUrl 55 | }/topheman/docker-experiments/tree/master?path=api` 56 | ); 57 | cy.get("[role=grid]").findByRole("link", { name: "README.md" }).click(); 58 | cy.url().should( 59 | "eq", 60 | `${ 61 | Cypress.config().baseUrl 62 | }/topheman/docker-experiments/blob/master?path=api%2FREADME.md` 63 | ); 64 | cy.get("button[label=master]").click(); 65 | cy.get( 66 | "a[href='/topheman/docker-experiments/blob/master?path=api%2FREADME.md'][aria-checked=true]" 67 | ).should("exist"); 68 | cy.get( 69 | "a[href='/topheman/docker-experiments/blob/develop?path=api%2FREADME.md']" 70 | ).should("exist"); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/components/BaseSelectMenu/BaseSelectMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import { XIcon } from "@primer/octicons-react"; 3 | import clsx from "clsx"; 4 | 5 | import BaseButton from "../BaseButton/BaseButton"; 6 | 7 | export type BaseSelectMenuProps = { 8 | buttonLabel: string; 9 | menuLabel?: string; 10 | children: React.ReactChild | React.ReactChild[]; 11 | alignMenu: "left" | "right"; 12 | icon?: JSX.Element; 13 | className?: string; 14 | }; 15 | 16 | export default function BaseSelectMenu({ 17 | buttonLabel, 18 | menuLabel, 19 | className, 20 | children, 21 | alignMenu, 22 | icon, 23 | ...props 24 | }: BaseSelectMenuProps): JSX.Element | null { 25 | const buttonRef = useRef(null); 26 | const [open, setOpen] = useState(false); 27 | const toggle = () => { 28 | setOpen(!open); 29 | }; 30 | const close = (e: MouseEvent) => { 31 | if (e.target === buttonRef.current) { 32 | return; 33 | } 34 | setOpen(false); 35 | }; 36 | useEffect(() => { 37 | if (open) { 38 | document.addEventListener("click", close); 39 | } 40 | return () => { 41 | document.removeEventListener("click", close); 42 | }; 43 | }, [open]); 44 | return ( 45 | <> 46 |
47 | 55 | {buttonLabel} 56 | 57 | <> 58 |
65 | {menuLabel ? ( 66 |
67 | {menuLabel} 68 | 71 |
72 | ) : null} 73 |
74 | {children} 75 |
76 |
77 |
83 | 84 |
85 | 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * When customising color in tailwind, use the following in order to 3 | * benefit from the opacity utility. 4 | * Declare your colors like: 5 | * ``` 6 | :root { 7 | --color-primary: 37, 99, 235; 8 | --color-secondary: 253, 224, 71; 9 | } 10 | * 11 | * Source : https://github.com/adamwathan/tailwind-css-variable-text-opacity-demo 12 | * 13 | * However, colors that are declared that way won't pass the linter, 14 | * - so you have to add them to the whitelist in .eslintrc.js 15 | * - only use them on background (since it's mainly here you need opacity) 16 | * - override the colors on textColor/borderColor with the raw color so 17 | * it will be recognized by linter 18 | */ 19 | const makeColorWithOpacity = (cssVarname) => ({ 20 | opacityVariable, 21 | opacityValue, 22 | }) => { 23 | if (opacityValue !== undefined) { 24 | return `rgba(var(${cssVarname}), ${opacityValue})`; 25 | } 26 | if (opacityVariable !== undefined) { 27 | return `rgba(var(${cssVarname}), var(${opacityVariable}, 1))`; 28 | } 29 | return `rgb(var(${cssVarname}))`; 30 | }; 31 | 32 | const brandColors = { 33 | white: makeColorWithOpacity("--color-rgb-white"), 34 | "white-always": makeColorWithOpacity("--color-rgb-white-always"), 35 | "brand-primary": makeColorWithOpacity("--color-rgb-brand-primary"), 36 | "brand-secondary": makeColorWithOpacity("--color-rgb-brand-secondary"), 37 | }; 38 | 39 | module.exports = { 40 | mode: "jit", 41 | purge: [ 42 | "./src/pages/**/*.{js,ts,jsx,tsx}", 43 | "./src/components/**/*.{js,ts,jsx,tsx}", 44 | ], 45 | darkMode: false, // or 'media' or 'class' 46 | theme: { 47 | colors: { 48 | ...brandColors, 49 | }, 50 | // only targetting Roboto:300,400,700 in google fonts 51 | fontWeight: { 52 | light: 300, 53 | normal: 400, 54 | bold: 700, 55 | }, 56 | textColor: { 57 | ...brandColors, 58 | white: "var(--color-text-white)", 59 | "white-always": "var(--color-text-white-always)", 60 | "brand-primary": "var(--color-text-brand-primary)", 61 | primary: "var(--color-text-primary)", 62 | secondary: "var(--color-text-secondary)", 63 | }, 64 | backgroundColor: { 65 | ...brandColors, 66 | canvas: makeColorWithOpacity("--color-bg-canvas"), 67 | "canvas-inverted": makeColorWithOpacity("--color-bg-canvas-inverted"), 68 | primary: makeColorWithOpacity("--color-bg-primary"), 69 | }, 70 | borderColor: { 71 | ...brandColors, 72 | "brand-primary": "var(--color-border-brand-primary)", 73 | primary: "var(--color-border-primary)", 74 | "primary-active": "var(--color-border-primary-active)", 75 | "primary-hover": "var(--color-border-primary-hover)", 76 | "primary-focus": "var(--color-border-primary-focus)", 77 | secondary: "var(--color-border-secondary)", 78 | light: "var(--color-border-light)", 79 | }, 80 | extend: {}, 81 | }, 82 | variants: { 83 | extend: {}, 84 | }, 85 | plugins: [], 86 | }; 87 | -------------------------------------------------------------------------------- /src/tests/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import { User, Organization } from "../../libs/graphql"; 2 | 3 | export const makeUser = (props: Partial = {}): User => { 4 | return { 5 | name: "Christophe Rosset", 6 | login: "topheman", 7 | bio: "❤️JavaScript", 8 | websiteUrl: "http://labs.topheman.com/", 9 | twitterUsername: "topheman", 10 | avatarUrl: "https://avatars.githubusercontent.com/u/985982?v=4", 11 | location: "Paris", 12 | followers: { 13 | totalCount: 177, 14 | }, 15 | following: { 16 | totalCount: 3, 17 | }, 18 | starredRepositories: { 19 | totalCount: 363, 20 | }, 21 | repositories: { 22 | totalCount: 78, 23 | }, 24 | ...props, 25 | } as User; 26 | }; 27 | 28 | export const makeOrganization = ( 29 | props: Partial = {} 30 | ): Organization => { 31 | return { 32 | name: "Microsoft", 33 | login: "microsoft", 34 | createdAt: "2013-12-10T19:06:48Z", 35 | websiteUrl: "https://opensource.microsoft.com", 36 | twitterUsername: "OpenAtMicrosoft", 37 | avatarUrl: "https://avatars.githubusercontent.com/u/6154722?v=4", 38 | location: "Redmond, WA", 39 | email: "opensource@microsoft.com", 40 | description: "Open source projects and samples from Microsoft", 41 | isVerified: true, 42 | ...props, 43 | } as Organization; 44 | }; 45 | 46 | export const makeProfileReadMe = (): string => 47 | 'My name is Christophe Rosset, I live in 🇫🇷 Paris, France.\n\nI’ve been working in Web Development for a long time now, I still really enjoy it and keep learning new things. What I like the most is sharing my knowledge with others, not only in my job but also through my personal projects.\n\n

\n @topheman on twitter\n @topheman on LinkedIn\n @topheman on stackoverflow\n

\n

\n My projects\n My talks\n

\n\n
\nClick for GitHub Stats\n

GitHub Stats\n

\n
\n'; 48 | -------------------------------------------------------------------------------- /src/pages/orgs/[owner]/repositories.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import type { GetServerSideProps, GetServerSidePropsResult } from "next"; 3 | 4 | import type { ParseQuery } from "../../../types"; 5 | import { initializeApollo, addApolloState } from "../../../libs/apollo-client"; 6 | import { getSearchRepoGraphqlVariables } from "../../../utils/github/searchRepos"; 7 | import { 8 | GetOrganizationWithRepositoriesQuery, 9 | GetOrganizationWithRepositoriesDocument, 10 | useGetOrganizationWithRepositoriesQuery, 11 | Organization, 12 | } from "../../../libs/graphql"; 13 | import AppMainLayout from "../../../components/AppMainLayout/AppMainLayout"; 14 | import AppProfileNavTab from "../../../components/AppNavBarProfile/AppNavBarProfile"; 15 | import AppProfileRepositories from "../../../components/AppProfileRepositories/AppProfileRepositories"; 16 | import AppOrganizationCardMini from "../../../components/AppOrganizationCardMini/AppOrganizationCardMini"; 17 | import { addHttpCacheHeader } from "../../../utils/server"; 18 | 19 | // necessary typeguard as query.owner is of type string | string[] 20 | const parseQuery: ParseQuery = (query) => { 21 | const { owner, ...searchUrlParams } = query; 22 | return { 23 | owner: typeof owner === "string" ? owner : "", 24 | ...searchUrlParams, 25 | }; 26 | }; 27 | 28 | export const getServerSideProps: GetServerSideProps = async ( 29 | context 30 | ): Promise>> => { 31 | addHttpCacheHeader(context.res); 32 | const { owner, ...searchUrlParams } = parseQuery(context.query); 33 | // create a new ApolloClient instance on each request server-side 34 | const apolloClient = initializeApollo(); 35 | await apolloClient.query({ 36 | query: GetOrganizationWithRepositoriesDocument, 37 | variables: { 38 | owner, 39 | ...getSearchRepoGraphqlVariables(owner, searchUrlParams), 40 | }, 41 | }); 42 | return addApolloState(apolloClient, { props: {} }); 43 | }; 44 | 45 | export default function PageOrganizationRepositories(): JSX.Element { 46 | const router = useRouter(); 47 | const { owner, ...searchUrlParams } = parseQuery(router.query); 48 | const result = useGetOrganizationWithRepositoriesQuery({ 49 | variables: { 50 | owner, 51 | ...getSearchRepoGraphqlVariables(owner, searchUrlParams), 52 | }, 53 | }); 54 | return ( 55 | 56 | {() => ({ 57 | topNav: result.data?.repositoryOwner ? ( 58 | 61 | ) : null, 62 | nav: ( 63 | 69 | ), 70 | main: , 71 | })} 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/components/BaseMarkdownDisplay/BaseMarkdownDisplay.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-danger */ 2 | import ReactMarkdown from "react-markdown"; 3 | import gfm from "remark-gfm"; // github flavour markdown 4 | import raw from "rehype-raw"; // allow html in markdown - https://github.com/remarkjs/react-markdown#appendix-a-html-in-markdown 5 | import sanitize from "rehype-sanitize"; // https://github.com/remarkjs/react-markdown#security 6 | import clsx from "clsx"; 7 | 8 | import { profileReadmeBaseUrl } from "../../utils/github"; 9 | import { makeUriTransformer } from "./uri-transformer"; 10 | import styles from "./BaseMarkdown.module.css"; 11 | 12 | export type BaseMarkdownDisplayProps = { 13 | markdown: string | null | undefined; 14 | profileReadmeInfos: { 15 | login: string; 16 | defaultBranchName: string | undefined; 17 | mode: "user" | "organization" | "repository"; 18 | }; 19 | className?: string; 20 | }; 21 | 22 | export default function BaseMarkdownDisplay({ 23 | markdown, 24 | profileReadmeInfos, 25 | className, 26 | ...props 27 | }: BaseMarkdownDisplayProps): JSX.Element | null { 28 | if (markdown) { 29 | let imageUriTransformer; 30 | let linkUriTransformer; 31 | if ( 32 | profileReadmeInfos.mode === "repository" && 33 | profileReadmeInfos.defaultBranchName 34 | ) { 35 | const [login, repositoryName] = profileReadmeInfos.login.split("/"); 36 | imageUriTransformer = makeUriTransformer( 37 | profileReadmeBaseUrl( 38 | login, 39 | profileReadmeInfos.defaultBranchName, 40 | profileReadmeInfos.mode, 41 | "image", 42 | repositoryName 43 | ) 44 | ); 45 | linkUriTransformer = makeUriTransformer( 46 | profileReadmeBaseUrl( 47 | login, 48 | profileReadmeInfos.defaultBranchName, 49 | profileReadmeInfos.mode, 50 | "link", 51 | repositoryName 52 | ) 53 | ); 54 | } else if (profileReadmeInfos.defaultBranchName) { 55 | imageUriTransformer = makeUriTransformer( 56 | profileReadmeBaseUrl( 57 | profileReadmeInfos.login, 58 | profileReadmeInfos.defaultBranchName, 59 | profileReadmeInfos.mode, 60 | "image" 61 | ) 62 | ); 63 | linkUriTransformer = makeUriTransformer( 64 | profileReadmeBaseUrl( 65 | profileReadmeInfos.login, 66 | profileReadmeInfos.defaultBranchName, 67 | profileReadmeInfos.mode, 68 | "link" 69 | ) 70 | ); 71 | } 72 | return ( 73 | // eslint-disable-next-line tailwindcss/no-custom-classname 74 |
75 | 82 | {markdown} 83 | 84 |
85 | ); 86 | } 87 | return null; 88 | } 89 | -------------------------------------------------------------------------------- /src/components/AppRepositoryListItem/AppRepositoryListItem.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { Repository } from "../../libs/graphql"; 4 | import AppTagLanguage from "../AppTagLanguage/AppTagLanguage"; 5 | import AppTagCount from "../AppTagCount/AppTagCount"; 6 | import AppTagLicense from "../AppTagLicense/AppTagLicense"; 7 | import AppTagDate from "../AppTagDate/AppTagDate"; 8 | import AppTopicsTagList from "../AppTopicsTagList/AppTopicsTagList"; 9 | 10 | export type AppRepositoryListItemProps = { 11 | repository: Repository; 12 | }; 13 | 14 | export default function AppRepositoryListItem({ 15 | repository, 16 | ...props 17 | }: AppRepositoryListItemProps): JSX.Element { 18 | return ( 19 |
23 |
24 |

25 | 26 | 30 | {repository.name} 31 | 32 | 33 |

34 |

{repository.description}

35 | node)} 40 | /> 41 |
    42 | {repository.primaryLanguage ? ( 43 |
  • 44 | 45 |
  • 46 | ) : null} 47 | {repository.stargazerCount > 0 ? ( 48 |
  • 49 | 55 |
  • 56 | ) : null} 57 | {repository.forkCount > 0 ? ( 58 |
  • 59 | 65 |
  • 66 | ) : null} 67 | {repository.licenseInfo ? ( 68 |
  • 69 | 70 |
  • 71 | ) : null} 72 | {repository.updatedAt ? ( 73 |
  • 74 | 79 |
  • 80 | ) : null} 81 |
82 |
83 |
84 |
{/* some graph */}
85 |
86 |
87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /src/graphql/queries/getRepositoryInfosOverview.graphql: -------------------------------------------------------------------------------- 1 | query GetRepositoryInfosOverview( 2 | $owner: String! 3 | $name: String! 4 | $ref: String! # examples: HEAD|master|feature/foo 5 | $refPath: String! # examples: HEAD:|master:|feature/foo:|master:src 6 | $upperCaseReadmeRefPath: String! # examples: HEAD:README.md|feature/foo:README.md 7 | $lowerCaseReadmeRefPath: String! # examples: HEAD:README.md|feature/foo:README.md 8 | ) { 9 | rateLimit { 10 | limit 11 | cost 12 | remaining 13 | resetAt 14 | } 15 | repository(name: $name, owner: $owner) { 16 | id 17 | nameWithOwner 18 | description 19 | homepageUrl 20 | stargazerCount 21 | forkCount 22 | defaultBranchRef { 23 | name 24 | prefix 25 | } 26 | openGraphImageUrl 27 | # identified what kind of ref was passed 28 | currentRef: ref(qualifiedName: $ref) { 29 | name 30 | prefix 31 | } 32 | branches: refs(refPrefix: "refs/heads/", first: 10, direction: DESC) { 33 | totalCount 34 | edges { 35 | node { 36 | name 37 | } 38 | } 39 | } 40 | tags: refs(refPrefix: "refs/tags/", first: 10, direction: DESC) { 41 | totalCount 42 | edges { 43 | node { 44 | name 45 | } 46 | } 47 | } 48 | gitInfos: object(expression: $ref) { 49 | ... on Commit { 50 | history(first: 1) { 51 | totalCount 52 | edges { 53 | node { 54 | oid 55 | messageHeadline 56 | committedDate 57 | author { 58 | user { 59 | login 60 | avatarUrl 61 | } 62 | } 63 | } 64 | } 65 | } 66 | } 67 | } 68 | repositoryTopics(first: 15) { 69 | nodes { 70 | topic { 71 | name 72 | } 73 | } 74 | } 75 | # check should be case insensitive 76 | readmeUppercase: object(expression: $upperCaseReadmeRefPath) { 77 | ... on Blob { 78 | text 79 | } 80 | } 81 | readmeLowercase: object(expression: $lowerCaseReadmeRefPath) { 82 | ... on Blob { 83 | text 84 | } 85 | } 86 | licenseInfo { 87 | name 88 | } 89 | # latest release + totalCount 90 | releases(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { 91 | totalCount 92 | edges { 93 | node { 94 | name 95 | createdAt 96 | tag { 97 | name 98 | } 99 | } 100 | } 101 | } 102 | packages(first: 10) { 103 | totalCount 104 | edges { 105 | node { 106 | name 107 | packageType 108 | } 109 | } 110 | } 111 | # no contributors for the moment - https://maravindblog.wordpress.com/2021/08/12/how-to-get-contributors-using-github-graphql/ 112 | languages(first: 10, orderBy: { field: SIZE, direction: DESC }) { 113 | edges { 114 | size 115 | node { 116 | name 117 | color 118 | } 119 | } 120 | } 121 | ...RepositoryFiles 122 | } 123 | } 124 | --------------------------------------------------------------------------------