diff options
| author | Bobby <[email protected]> | 2022-11-30 23:16:07 -0500 |
|---|---|---|
| committer | Bobby <[email protected]> | 2022-11-30 23:16:07 -0500 |
| commit | daaa789068cebb5fdfcea6197ade6e663be46e0f (patch) | |
| tree | 1cd315851b779ac28fe622da332c3c16fe1c433c /src | |
| download | tcssocialify-daaa789068cebb5fdfcea6197ade6e663be46e0f.tar.xz tcssocialify-daaa789068cebb5fdfcea6197ade6e663be46e0f.zip | |
socialify update
Diffstat (limited to 'src')
30 files changed, 1942 insertions, 0 deletions
diff --git a/src/components/configuration/checkBoxWrapper.tsx b/src/components/configuration/checkBoxWrapper.tsx new file mode 100644 index 0000000..e9dd1eb --- /dev/null +++ b/src/components/configuration/checkBoxWrapper.tsx @@ -0,0 +1,37 @@ +import ConfigType from '../../../common/types/configType' + +type CheckBoxProps = { + title: string + keyName: keyof ConfigType + checked?: boolean + disabled?: boolean + + handleChange: (value: any, key: keyof ConfigType) => void +} + +const CheckBoxWrapper = ({ + title, + keyName, + checked, + disabled, + handleChange +}: CheckBoxProps) => { + return ( + <div className="form-control"> + <label className="label cursor-pointer justify-start gap-2"> + <input + className="checkbox checkbox-sm" + type="checkbox" + checked={!!checked} + disabled={disabled} + onChange={(e) => { + handleChange({ state: e.target.checked }, keyName) + }} + /> + <span className="label-text">{title}</span> + </label> + </div> + ) +} + +export default CheckBoxWrapper diff --git a/src/components/configuration/config.tsx b/src/components/configuration/config.tsx new file mode 100644 index 0000000..9331f87 --- /dev/null +++ b/src/components/configuration/config.tsx @@ -0,0 +1,248 @@ +import React, { useContext, useEffect } from 'react' +import { useRouter } from 'next/router' + +import { RepoQueryResponse } from '../../../common/github/repoQuery' +import ConfigContext from '../../contexts/ConfigContext' + +import ConfigType, { + Theme, + Pattern, + Font, + RequiredConfigsKeys +} from '../../../common/types/configType' + +import { getOptionalConfig } from '../../../common/configHelper' + +import SelectWrapper from './selectWrapper' +import CheckBoxWrapper from './checkBoxWrapper' +import InputWrapper from './inputWrapper' +import TextAreaWrapper from './textAreaWrapper' + +type ConfigProp = { + repository: RepoQueryResponse['repository'] +} + +const Config = ({ repository }: ConfigProp) => { + const router = useRouter() + + const { config, setConfig } = useContext(ConfigContext) + + const handleChanges = (changes: { value: any; key: keyof ConfigType }[]) => { + let newConfig: ConfigType = { ...config } + const urlParams = router.query + // Remove extraneous params from route + delete urlParams._owner + delete urlParams._name + changes.forEach(({ value, key }) => { + const currentValue = newConfig[key] ? newConfig[key] : {} + if (value.required === true) { + newConfig = { ...newConfig, [key]: value.val } + } else { + newConfig = { ...newConfig, [key]: { ...currentValue, ...value } } + } + + if (value && value.state === true && value.editable) { + urlParams[key] = '1' + urlParams[`${key}Editable`] = value.value + } else if (value && value.state === true) { + urlParams[key] = '1' + } else if (value && value.required === true) { + urlParams[key] = value.val + } else { + urlParams[key] = '0' + } + + if (!urlParams[key] || urlParams[key] === '0') { + delete urlParams[key] + if (`${key}Editable` in urlParams) { + delete urlParams[`${key}Editable`] + } + } + }) + + router.push( + `${window.location.pathname}?${Object.entries(urlParams) + .sort() + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`) + .join('&')}`, + undefined, + { shallow: true } + ) + } + + const handleChange = (value: any, key: keyof ConfigType) => { + handleChanges([{ value, key }]) + } + + useEffect(() => { + const handleRouteChange = (asPath: string) => { + if (repository) { + const newConfig = getOptionalConfig(repository) + if (newConfig) { + const params = new URLSearchParams(asPath.split('?')[1]) + + Array.from(params.keys()).forEach((stringKey) => { + const key = stringKey as keyof ConfigType + if (key in newConfig) { + const query = params.get(key) + const currentConfig = newConfig[key as keyof typeof newConfig] + const newChange = { + state: query === '1' + } + if (currentConfig?.editable) { + const editableValue = params.get(`${key}Editable`) + if (editableValue != null) { + Object.assign(newChange, { + value: editableValue + }) + } + } + + Object.assign( + newConfig[key as keyof typeof newConfig] ?? {}, + newChange + ) + } else if (key in RequiredConfigsKeys) { + const query = params.get(key) + if (query != null) { + const newChange = { + [key]: query + } + + Object.assign(newConfig, newChange) + } + } + }) + setConfig({ ...config, ...newConfig }) + } + } + } + + router.events.on('routeChangeComplete', handleRouteChange) + handleRouteChange(router.asPath) + + return () => { + router.events.off('routeChangeComplete', handleRouteChange) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + if (!repository) { + return null + } + + return ( + <div className="card w-96 max-w-[90vw] bg-base-200 text-primary-content shadow-xl"> + <div className="card-body"> + <SelectWrapper + title="Theme" + keyName="theme" + map={Object.keys(Theme).map((key) => ({ + key, + label: (Theme as any)[key] + }))} + value={config.theme} + handleChange={handleChange} + /> + <SelectWrapper + title="Font" + keyName="font" + map={Object.keys(Font).map((key) => ({ + key, + label: (Font as any)[key] + }))} + value={config.font} + handleChange={handleChange} + /> + <SelectWrapper + title="Background Pattern" + keyName="pattern" + map={Object.keys(Pattern).map((key) => ({ + key, + label: (Pattern as any)[key] + }))} + value={config.pattern} + handleChange={handleChange} + /> + <InputWrapper + title="Logo" + alt="Image url or data uri" + keyName="logo" + placeholder="Optional" + value={config.logo} + handleChange={handleChange} + /> + + <div className="columns-2"> + <CheckBoxWrapper + title="Name" + keyName="name" + checked={config.name?.state} + handleChange={handleChange} + disabled + /> + <CheckBoxWrapper + title="Owner" + keyName="owner" + checked={config.owner?.state} + handleChange={handleChange} + /> + <CheckBoxWrapper + title="Language" + keyName="language" + checked={config.language?.state} + handleChange={handleChange} + /> + <CheckBoxWrapper + title="2nd Language" + keyName="language2" + checked={config.language2?.state} + handleChange={handleChange} + /> + <CheckBoxWrapper + title="Stars" + keyName="stargazers" + checked={config.stargazers?.state} + handleChange={handleChange} + /> + <CheckBoxWrapper + title="Forks" + keyName="forks" + checked={config.forks?.state} + handleChange={handleChange} + /> + <CheckBoxWrapper + title="Issues" + keyName="issues" + checked={config.issues?.state} + handleChange={handleChange} + /> + <CheckBoxWrapper + title="Pull Requests" + keyName="pulls" + checked={config.pulls?.state} + handleChange={handleChange} + /> + <CheckBoxWrapper + title="Description" + keyName="description" + checked={config.description?.state} + handleChange={handleChange} + /> + </div> + + {config.description?.state && ( + <TextAreaWrapper + title="Description" + keyName="description" + value={config.description?.value} + handleChange={handleChange} + disabled={!config.description?.state} + /> + )} + </div> + </div> + ) +} + +export default Config diff --git a/src/components/configuration/inputWrapper.tsx b/src/components/configuration/inputWrapper.tsx new file mode 100644 index 0000000..0d3fd95 --- /dev/null +++ b/src/components/configuration/inputWrapper.tsx @@ -0,0 +1,41 @@ +import ConfigType from '../../../common/types/configType' + +type InputProps = { + title: string + alt?: string + keyName: keyof ConfigType + value: string + placeholder: string + disabled?: boolean + handleChange: (value: any, key: keyof ConfigType) => void +} + +const InputWrapper = ({ + title, + alt, + keyName, + value, + placeholder, + disabled, + handleChange +}: InputProps) => { + return ( + <div className="form-control w-full" style={{ display: 'none' }}> + <label className="label"> + <span className="label-text">{title}</span> + {alt && <span className="label-text-alt">{alt}</span>} + </label> + <input + className="input input-bordered w-full input-sm" + type="text" + value={value || ''} + disabled={!!disabled} + placeholder={placeholder} + onChange={(e) => { + handleChange({ val: e.target.value, required: true }, keyName) + }} + /> + </div> + ) +} +export default InputWrapper diff --git a/src/components/configuration/selectWrapper.tsx b/src/components/configuration/selectWrapper.tsx new file mode 100644 index 0000000..81c3709 --- /dev/null +++ b/src/components/configuration/selectWrapper.tsx @@ -0,0 +1,44 @@ +import ConfigType from '../../../common/types/configType' + +type SelectWrapperProps = { + title: string + alt?: string + keyName: keyof ConfigType + map: { key: string; label: any }[] + value: string + handleChange: (value: any, key: keyof ConfigType) => void +} + +const SelectWrapper = ({ + title, + alt, + keyName, + map, + value, + handleChange +}: SelectWrapperProps) => { + return ( + <div className="form-control w-full"> + <label className="label"> + <span className="label-text">{title}</span> + {alt && <span className="label-text-alt">{alt}</span>} + </label> + <select + className="select select-bordered select-sm" + onChange={(e) => { + handleChange({ val: e.target.value, required: true }, keyName) + }} + value={value}> + {map.map(({ key, label }) => { + return ( + <option key={key} value={label}> + {label} + </option> + ) + })} + </select> + </div> + ) +} + +export default SelectWrapper diff --git a/src/components/configuration/textAreaWrapper.tsx b/src/components/configuration/textAreaWrapper.tsx new file mode 100644 index 0000000..6a30f42 --- /dev/null +++ b/src/components/configuration/textAreaWrapper.tsx @@ -0,0 +1,59 @@ +import React, { useEffect, useState } from 'react' + +import { useDebouncedCallback } from 'use-debounce' + +import ConfigType from '../../../common/types/configType' + +type TextAreaProps = { + title?: string + alt?: string + value: string + placeholder?: string + keyName: keyof ConfigType + handleChange: (value: any, key: keyof ConfigType) => void + disabled?: boolean +} + +const TextAreaWrapper = ({ + title, + alt, + keyName, + value, + placeholder, + handleChange, + disabled +}: TextAreaProps) => { + const [internalValue, setInternalValue] = useState(value) + + const debounced = useDebouncedCallback((value) => { + handleChange({ value, editable: true, state: true }, keyName) + }, 500) + + const processChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { + setInternalValue(e.target.value) + debounced(e.target.value) + } + + useEffect(() => { + setInternalValue(value) + }, [value]) + + return ( + <div className="form-control" style={{ display: 'none' }}> + {title && ( + <label className="label"> + <span className="label-text">{title}</span> + {alt && <span className="label-text-alt">{alt}</span>} + </label> + )} + <textarea + className="textarea textarea-bordered h-20" + value={internalValue} + onChange={processChange} + disabled={disabled} + placeholder={placeholder} + /> + </div> + ) +} +export default TextAreaWrapper diff --git a/src/components/error/__snapshots__/error.test.tsx.snap b/src/components/error/__snapshots__/error.test.tsx.snap new file mode 100644 index 0000000..9e1289e --- /dev/null +++ b/src/components/error/__snapshots__/error.test.tsx.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Error renders 1`] = ` +<main + class="mx-auto flex w-full max-w-7xl flex-grow flex-col justify-center px-4 sm:px-6 lg:px-8" +> + <div + class="py-16" + > + <div + class="text-center" + > + <p + class="text-base font-semibold text-error" + > + 404 + </p> + <h1 + class="mt-2 text-5xl leading-tight font-extrabold text-transparent bg-clip-text bg-gradient-to-br from-secondary to-error" + > + Page not found. + </h1> + <p + class="mt-2 text-base" + > + Sorry, we couldn't find the page you're looking for. + </p> + <div + class="mt-6" + > + <a + class="btn btn-primary gap-2" + href="/" + > + Go back home + <span + aria-hidden="true" + > + β + </span> + </a> + </div> + </div> + </div> +</main> +`; diff --git a/src/components/error/error.test.tsx b/src/components/error/error.test.tsx new file mode 100644 index 0000000..9cd67f9 --- /dev/null +++ b/src/components/error/error.test.tsx @@ -0,0 +1,16 @@ +import { render } from '@testing-library/react' + +import Error from './error' + +test('Error renders', () => { + const { container } = render( + <Error + code="404" + title="Page not found." + description="Sorry, we couldn't find the page you're looking for." + /> + ) + const error = container.firstElementChild! + + expect(error).toMatchSnapshot() +}) diff --git a/src/components/error/error.tsx b/src/components/error/error.tsx new file mode 100644 index 0000000..8a002f7 --- /dev/null +++ b/src/components/error/error.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import Link from 'next/link' + +type ErrorProp = { + code: string + title: string + description: string +} + +const Error: React.FC<ErrorProp> = ({ code, title, description }) => ( + <main className="mx-auto flex w-full max-w-7xl flex-grow flex-col justify-center px-4 sm:px-6 lg:px-8"> + <div className="py-16"> + <div className="text-center"> + <p className="text-base font-semibold text-error">{code}</p> + <h1 className="mt-2 text-5xl leading-tight font-extrabold text-transparent bg-clip-text bg-gradient-to-br from-secondary to-error"> + {title} + </h1> + <p className="mt-2 text-base">{description}</p> + <div className="mt-6"> + <Link href="/" className="btn btn-primary gap-2"> + Go back home + <span aria-hidden="true">→</span> + </Link> + </div> + </div> + </div> + </main> +) + +export default Error diff --git a/src/components/footer/__snapshots__/footer.test.tsx.snap b/src/components/footer/__snapshots__/footer.test.tsx.snap new file mode 100644 index 0000000..4513536 --- /dev/null +++ b/src/components/footer/__snapshots__/footer.test.tsx.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Footer renders 1`] = ` +<footer + class="footer footer-center p-2 text-base-content" +> + <div> + <p> + <span> + Made with π by + </span> + <a + class="link link-accent no-underline" + href="https://cryogenicplanet.tech" + target="_blank" + > + CryogenicPlanet + </a> + Β andΒ + <a + class="link link-accent no-underline" + href="https://github.com/wei/" + target="_blank" + > + Wei + </a> + </p> + </div> +</footer> +`; diff --git a/src/components/footer/footer.test.tsx b/src/components/footer/footer.test.tsx new file mode 100644 index 0000000..09892e5 --- /dev/null +++ b/src/components/footer/footer.test.tsx @@ -0,0 +1,10 @@ +import { render } from '@testing-library/react' + +import Footer from './footer' + +test('Footer renders', () => { + const { container } = render(<Footer />) + const footer = container.firstElementChild! + + expect(footer).toMatchSnapshot() +}) diff --git a/src/components/footer/footer.tsx b/src/components/footer/footer.tsx new file mode 100644 index 0000000..ed0fe41 --- /dev/null +++ b/src/components/footer/footer.tsx @@ -0,0 +1,43 @@ +import Link from 'next/link' + +const Footer = () => { + return ( + <footer className="footer footer-center p-2 text-base-content"> + <div> + <p> + <span>Made with π by </span> + <Link + className="link link-accent no-underline" + href="https://cryogenicplanet.tech" + target="_blank"> + CryogenicPlanet + </Link> + and + <Link + className="link link-accent no-underline" + href="https://github.com/wei/" + target="_blank"> + Wei + </Link> + <span>. Modified Fork by </span> + <Link + className="link link-accent no-underline" + href="https://thatcomputerscientist.com/" + target="_blank"> + That Computer Scientist + </Link> + <span>. Original Work available at </span> + <Link + className="link link-accent no-underline" + href="https://socialify.git.ci/" + target="_blank"> + Socialify + </Link> + <span>.</span> + </p> + </div> + </footer> + ) +} + +export default Footer diff --git a/src/components/header/__snapshots__/header.test.tsx.snap b/src/components/header/__snapshots__/header.test.tsx.snap new file mode 100644 index 0000000..a490d2c --- /dev/null +++ b/src/components/header/__snapshots__/header.test.tsx.snap @@ -0,0 +1,81 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Header renders 1`] = ` +<header + class="jsx-4e1e8caf692508b2" +> + <div + class="jsx-4e1e8caf692508b2 navbar" + > + <div + class="jsx-4e1e8caf692508b2 flex-1" + > + <a + class="btn btn-ghost text-primary-content normal-case text-xl" + href="/" + > + <svg + class="w-8 h-8" + fill="currentColor" + height="1em" + role="img" + stroke="currentColor" + stroke-width="0" + viewBox="0 0 24 24" + width="1em" + xmlns="http://www.w3.org/2000/svg" + > + <title /> + <path + d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" + /> + </svg> + Β GitHub Socialify + </a> + </div> + <div + class="jsx-4e1e8caf692508b2 flex-0" + > + <a + class="invisible sm:visible mr-6" + href="https://www.producthunt.com/posts/socialify?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-socialify" + rel="noopener noreferrer" + target="_blank" + > + <img + alt="Socialify - π Socialify your project. π Share with the world! | Product Hunt" + class="jsx-4e1e8caf692508b2" + height="54" + src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=271993&theme=dark" + width="250" + /> + </a> + <a + aria-label="View source on GitHub" + href="https://github.com/wei/socialify" + > + <svg + class="jsx-4e1e8caf692508b2 github-svg w-16 h-16 scale-125 fill-gray-50 text-base-300" + viewBox="0 0 250 250" + > + <path + class="jsx-4e1e8caf692508b2" + d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z" + /> + <path + class="jsx-4e1e8caf692508b2 octo-arm" + d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" + fill="currentColor" + style="transform-origin: 130px 106px;" + /> + <path + class="jsx-4e1e8caf692508b2 octo-body" + d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" + fill="currentColor" + /> + </svg> + </a> + </div> + </div> +</header> +`; diff --git a/src/components/header/header.test.tsx b/src/components/header/header.test.tsx new file mode 100644 index 0000000..474e84d --- /dev/null +++ b/src/components/header/header.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react' + +import Header from './header' + +jest.mock('next/router', () => ({ + useRouter: jest.fn() +})) + +test('Header renders', () => { + const { container } = render(<Header />) + const header = container.firstElementChild! + + expect(header).toMatchSnapshot() +}) diff --git a/src/components/header/header.tsx b/src/components/header/header.tsx new file mode 100644 index 0000000..8177252 --- /dev/null +++ b/src/components/header/header.tsx @@ -0,0 +1,54 @@ +import Link from 'next/link' +import { SiGithub } from 'react-icons/si' + +const Header = () => { + return ( + <header> + <div className="navbar"> + <div className="flex-1"> + <Link + className="btn btn-ghost text-primary-content normal-case text-xl" + href="/"> + <SiGithub className="w-8 h-8" /> + Socialify + </Link> + </div> + </div> + + <style jsx>{` + .github-svg:hover .octo-arm { + animation: octocat-wave 560ms ease-in-out; + } + + @keyframes octocat-wave { + 0%, + 100% { + transform: rotate(0); + } + + 20%, + 60% { + transform: rotate(-25deg); + } + + 40%, + 80% { + transform: rotate(10deg); + } + } + + @media (max-width: 500px) { + .github-svg:hover .octo-arm { + animation: none; + } + + .github-svg .octo-arm { + animation: octocat-wave 560ms ease-in-out; + } + } + `}</style> + </header> + ) +} + +export default Header diff --git a/src/components/hooks/use-autofocus.ts b/src/components/hooks/use-autofocus.ts new file mode 100644 index 0000000..a54ed94 --- /dev/null +++ b/src/components/hooks/use-autofocus.ts @@ -0,0 +1,13 @@ +import { useCallback } from 'react' + +const useAutoFocus = () => { + const inputRef = useCallback((inputElement) => { + if (inputElement) { + inputElement.focus() + } + }, []) + + return inputRef +} + +export default useAutoFocus diff --git a/src/components/mainRenderer.tsx b/src/components/mainRenderer.tsx new file mode 100644 index 0000000..5d5d75b --- /dev/null +++ b/src/components/mainRenderer.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import { useRouter } from 'next/router' +import { MdErrorOutline } from 'react-icons/md' + +import MainWrapper from './mainWrapper' +import { + getRepoDetails, + RepoQueryResponse +} from '../../common/github/repoQuery' + +type Props = { + error: Error | null + props: RepoQueryResponse | undefined +} + +const MainRenderer = () => { + const router = useRouter() + const path = router.asPath.split('?')[0] + + const [, owner, name] = path.split('/') + + const [{ error, props }, setProps] = React.useState<Props>({ + error: null, + props: undefined + }) + + React.useEffect(() => { + if (owner && owner.charAt(0) !== '[') { + getRepoDetails(owner, name) + .then((props) => setProps({ error: null, props })) + .catch((error) => setProps({ error, props: undefined })) + } + }, [owner, name]) + + return ( + <main className="hero"> + {error ? ( + <div className="hero-content"> + <div className="alert alert-error shadow-lg"> + <div> + <MdErrorOutline className="w-6 h-6" /> + <span>{error.message}</span> + </div> + </div> + </div> + ) : !props ? ( + <div className="hero-content"> + <progress className="progress progress-primary w-56"></progress> + </div> + ) : ( + <div className="hero-content p-0 w-full max-w-full"> + <MainWrapper response={props} /> + </div> + )} + </main> + ) +} + +export default MainRenderer diff --git a/src/components/mainWrapper.tsx b/src/components/mainWrapper.tsx new file mode 100644 index 0000000..41a2f6d --- /dev/null +++ b/src/components/mainWrapper.tsx @@ -0,0 +1,51 @@ +import React, { useEffect, useState } from 'react' +import { useRouter } from 'next/router' + +import ConfigType from '../../common/types/configType' +import { RepoQueryResponse } from '../../common/github/repoQuery' +import ConfigContext from '../contexts/ConfigContext' +import { DEFAULT_CONFIG } from '../../common/configHelper' + +import Config from './configuration/config' +import Preview from './preview/preview' +import toast from './toaster' + +type MainWrapperProps = { + response: RepoQueryResponse +} + +const MainWrapper = ({ response }: MainWrapperProps) => { + const router = useRouter() + const [config, setConfig] = useState<ConfigType>(DEFAULT_CONFIG) + + const setConfigHelper = (config: ConfigType) => { + setConfig(config) + } + + useEffect(() => { + if (!response || !response.repository) { + router.push('/') + toast.error('Please enter a valid GitHub repository.') + } + }, [response, router]) + + if (response && response.repository) { + const { repository } = response + + return ( + <ConfigContext.Provider value={{ config, setConfig: setConfigHelper }}> + <div className="flex flex-col lg:flex-row w-full justify-center items-center lg:justify-evenly"> + <div className="hero w-fit"> + <Preview /> + </div> + <div className="hero w-fit"> + <Config repository={repository} /> + </div> + </div> + </ConfigContext.Provider> + ) + } else { + return null + } +} +export default MainWrapper diff --git a/src/components/preview/__snapshots__/badge.test.tsx.snap b/src/components/preview/__snapshots__/badge.test.tsx.snap new file mode 100644 index 0000000..9000a91 --- /dev/null +++ b/src/components/preview/__snapshots__/badge.test.tsx.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Badge renders 1`] = ` +<div + class="badge-wrapper" + style="height: 25px; background-color: rgb(85, 85, 85); display: flex; margin: 0px 7px;" +> + <p + class="badge-label" + style="color: rgb(255, 255, 255); font-family: Jost; font-size: 11px; height: 100%; letter-spacing: 1px; margin: 0px; text-transform: uppercase; padding: 0px 12px 0px 10px; display: flex; align-items: center;" + > + name1 + </p> + <p + class="badge-value" + style="background-color: black; color: rgb(255, 255, 255); font-family: Jost; font-size: 11px; height: 100%; letter-spacing: 1px; margin: 0px -4px 0px -4px; padding: 0px 8px; display: flex; align-items: center;" + > + value1 + </p> +</div> +`; diff --git a/src/components/preview/__snapshots__/card.test.tsx.snap b/src/components/preview/__snapshots__/card.test.tsx.snap new file mode 100644 index 0000000..e5ec993 --- /dev/null +++ b/src/components/preview/__snapshots__/card.test.tsx.snap @@ -0,0 +1,165 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Card #1 renders 1`] = ` +<div + class="card-wrapper theme-light" + style="width: 640px; height: 320px; padding: 10px 30px; font-family: Inter; font-weight: 400; background-color: rgb(255, 255, 255); background-image: url(data:image/svg+xml,%3Csvg%20width%3D%2242%22%20height%3D%2244%22%20viewBox%3D%220%200%2042%2044%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M0%200h42v44H0V0zm1%201h40v20H1V1zM0%2023h20v20H0V23zm22%200h20v20H22V23z%22%20fill%3D%22%23eaeaea%22%20fill-opacity%3D%220.6%22%20fill-rule%3D%22evenodd%22%2F%3E%3C%2Fsvg%3E); background-size: 42px 44px; background-repeat: repeat; color: rgb(0, 0, 0); text-align: center; overflow: hidden; display: flex; flex-direction: column; justify-content: center; align-items: center; transform: scale(2); transform-origin: top left;" +> + <div + class="card-logo-wrapper" + style="display: flex; justify-content: center; align-items: center; margin-top: 10px;" + > + <img + alt="Logo" + height="100" + src="data:image/svg+xml,%3Csvg%20fill%3D%22%23181717%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EGitHub%3C%2Ftitle%3E%3Cpath%20d%3D%22M12%20.297c-6.63%200-12%205.373-12%2012%200%205.303%203.438%209.8%208.205%2011.385.6.113.82-.258.82-.577%200-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422%2018.07%203.633%2017.7%203.633%2017.7c-1.087-.744.084-.729.084-.729%201.205.084%201.838%201.236%201.838%201.236%201.07%201.835%202.809%201.305%203.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93%200-1.31.465-2.38%201.235-3.22-.135-.303-.54-1.523.105-3.176%200%200%201.005-.322%203.3%201.23.96-.267%201.98-.399%203-.405%201.02.006%202.04.138%203%20.405%202.28-1.552%203.285-1.23%203.285-1.23.645%201.653.24%202.873.12%203.176.765.84%201.23%201.91%201.23%203.22%200%204.61-2.805%205.625-5.475%205.92.42.36.81%201.096.81%202.22%200%201.606-.015%202.896-.015%203.286%200%20.315.21.69.825.57C20.565%2022.092%2024%2017.592%2024%2012.297c0-6.627-5.373-12-12-12%22%2F%3E%3C%2Fsvg%3E" + style="object-fit: contain;" + width="100" + /> + </div> + <p + class="card-name-wrapper" + style="display: flex; align-items: center; margin-top: 15px; margin-bottom: 0px; font-weight: 500; font-size: 40px; line-height: 1.4;" + > + <span + class="card-name-owner" + style="display: flex; white-space: nowrap; font-weight: 200;" + /> + <span + class="card-name-name" + style="display: flex; white-space: nowrap;" + > + project_name + </span> + </p> +</div> +`; + +exports[`Card #2 renders 1`] = ` +<div + class="card-wrapper theme-dark" + style="width: 640px; height: 320px; padding: 10px 30px; font-family: KoHo; font-weight: 400; background-color: rgb(0, 0, 0); background-image: url(data:image/svg+xml,%3Csvg%20width%3D%2242%22%20height%3D%2244%22%20viewBox%3D%220%200%2042%2044%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M0%200h42v44H0V0zm1%201h40v20H1V1zM0%2023h20v20H0V23zm22%200h20v20H22V23z%22%20fill%3D%22%23eaeaea%22%20fill-opacity%3D%220.2%22%20fill-rule%3D%22evenodd%22%2F%3E%3C%2Fsvg%3E); background-size: 42px 44px; background-repeat: repeat; color: rgb(255, 255, 255); text-align: center; overflow: hidden; display: flex; flex-direction: column; justify-content: center; align-items: center; transform: scale(2); transform-origin: top left;" +> + <div + class="card-logo-wrapper" + style="display: flex; justify-content: center; align-items: center; margin-top: 10px;" + > + <img + alt="Logo" + height="100" + src="data:image/gif;base64,R0lGODlhAQABAAAAACw=" + style="object-fit: contain;" + width="100" + /> + <p + class="card-logo-divider" + style="color: rgb(187, 187, 187); font-size: 30px; margin: 0px 20px; font-family: Jost;" + > + + + </p> + <img + alt="JavaScript" + height="85" + src="data:image/svg+xml,%3Csvg%20fill%3D%22%23fff%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EJavaScript%3C%2Ftitle%3E%3Cpath%20d%3D%22M0%200h24v24H0V0zm22.034%2018.276c-.175-1.095-.888-2.015-3.003-2.873-.736-.345-1.554-.585-1.797-1.14-.091-.33-.105-.51-.046-.705.15-.646.915-.84%201.515-.66.39.12.75.42.976.9%201.034-.676%201.034-.676%201.755-1.125-.27-.42-.404-.601-.586-.78-.63-.705-1.469-1.065-2.834-1.034l-.705.089c-.676.165-1.32.525-1.71%201.005-1.14%201.291-.811%203.541.569%204.471%201.365%201.02%203.361%201.244%203.616%202.205.24%201.17-.87%201.545-1.966%201.41-.811-.18-1.26-.586-1.755-1.336l-1.83%201.051c.21.48.45.689.81%201.109%201.74%201.756%206.09%201.666%206.871-1.004.029-.09.24-.705.074-1.65l.046.067zm-8.983-7.245h-2.248c0%201.938-.009%203.864-.009%205.805%200%201.232.063%202.363-.138%202.711-.33.689-1.18.601-1.566.48-.396-.196-.597-.466-.83-.855-.063-.105-.11-.196-.127-.196l-1.825%201.125c.305.63.75%201.172%201.324%201.517.855.51%202.004.675%203.207.405.783-.226%201.458-.691%201.811-1.411.51-.93.402-2.07.397-3.346.012-2.054%200-4.109%200-6.179l.004-.056z%22%2F%3E%3C%2Fsvg%3E" + style="object-fit: contain;" + width="85" + /> + </div> + <p + class="card-name-wrapper" + style="display: flex; align-items: center; margin-top: 15px; margin-bottom: 0px; font-weight: 500; font-size: 40px; line-height: 1.4;" + > + <span + class="card-name-owner" + style="display: flex; white-space: nowrap; font-weight: 200;" + > + owner/ + </span> + <span + class="card-name-name" + style="display: flex; white-space: nowrap;" + > + project_name + </span> + </p> + <p + class="card-description-wrapper" + style="margin-top: 10px; margin-bottom: 0px; font-size: 17px; line-height: 1.4; max-height: 3em; overflow: hidden; word-break: break-all;" + > + TEST DESCRIPTION + </p> + <div + class="card-badges-wrapper" + style="margin-top: 25px; margin-bottom: 0px; display: flex; flex-direction: row;" + > + <div + class="badge-wrapper" + style="height: 25px; background-color: rgb(85, 85, 85); display: flex; margin: 0px 7px;" + > + <p + class="badge-label" + style="color: rgb(255, 255, 255); font-family: Jost; font-size: 11px; height: 100%; letter-spacing: 1px; margin: 0px; text-transform: uppercase; padding: 0px 12px 0px 10px; display: flex; align-items: center;" + > + stars + </p> + <p + class="badge-value" + style="background-color: rgb(223, 179, 23); color: rgb(255, 255, 255); font-family: Jost; font-size: 11px; height: 100%; letter-spacing: 1px; margin: 0px -4px 0px -4px; padding: 0px 8px; display: flex; align-items: center;" + > + 1 + </p> + </div> + <div + class="badge-wrapper" + style="height: 25px; background-color: rgb(85, 85, 85); display: flex; margin: 0px 7px;" + > + <p + class="badge-label" + style="color: rgb(255, 255, 255); font-family: Jost; font-size: 11px; height: 100%; letter-spacing: 1px; margin: 0px; text-transform: uppercase; padding: 0px 12px 0px 10px; display: flex; align-items: center;" + > + forks + </p> + <p + class="badge-value" + style="background-color: rgb(151, 202, 0); color: rgb(255, 255, 255); font-family: Jost; font-size: 11px; height: 100%; letter-spacing: 1px; margin: 0px -4px 0px -4px; padding: 0px 8px; display: flex; align-items: center;" + > + 2 + </p> + </div> + <div + class="badge-wrapper" + style="height: 25px; background-color: rgb(85, 85, 85); display: flex; margin: 0px 7px;" + > + <p + class="badge-label" + style="color: rgb(255, 255, 255); font-family: Jost; font-size: 11px; height: 100%; letter-spacing: 1px; margin: 0px; text-transform: uppercase; padding: 0px 12px 0px 10px; display: flex; align-items: center;" + > + issues + </p> + <p + class="badge-value" + style="background-color: rgb(0, 126, 198); color: rgb(255, 255, 255); font-family: Jost; font-size: 11px; height: 100%; letter-spacing: 1px; margin: 0px -4px 0px -4px; padding: 0px 8px; display: flex; align-items: center;" + > + 3 + </p> + </div> + <div + class="badge-wrapper" + style="height: 25px; background-color: rgb(85, 85, 85); display: flex; margin: 0px 7px;" + > + <p + class="badge-label" + style="color: rgb(255, 255, 255); font-family: Jost; font-size: 11px; height: 100%; letter-spacing: 1px; margin: 0px; text-transform: uppercase; padding: 0px 12px 0px 10px; display: flex; align-items: center;" + > + pulls + </p> + <p + class="badge-value" + style="background-color: rgb(254, 125, 55); color: rgb(255, 255, 255); font-family: Jost; font-size: 11px; height: 100%; letter-spacing: 1px; margin: 0px -4px 0px -4px; padding: 0px 8px; display: flex; align-items: center;" + > + 4 + </p> + </div> + </div> +</div> +`; diff --git a/src/components/preview/badge.test.tsx b/src/components/preview/badge.test.tsx new file mode 100644 index 0000000..fea753d --- /dev/null +++ b/src/components/preview/badge.test.tsx @@ -0,0 +1,22 @@ +import { render } from '@testing-library/react' + +import Badge from './badge' + +test('Badge renders', () => { + const { container } = render( + <Badge color="black" name="name1" value="value1" /> + ) + const badge = container.firstElementChild! + + expect(badge).toMatchSnapshot() + expect(badge.classList.contains('badge-wrapper')).toBe(true) + expect( + badge.querySelector<HTMLElement>('.badge-label')?.textContent + ).toStrictEqual('name1') + expect( + badge.querySelector<HTMLElement>('.badge-value')?.textContent + ).toStrictEqual('value1') + expect( + badge.querySelector<HTMLElement>('.badge-value')?.style.backgroundColor + ).toStrictEqual('black') +}) diff --git a/src/components/preview/badge.tsx b/src/components/preview/badge.tsx new file mode 100644 index 0000000..4ccb876 --- /dev/null +++ b/src/components/preview/badge.tsx @@ -0,0 +1,57 @@ +import React from 'react' + +type BadgeConfig = { + name: string + value: string + color: string +} + +const Badge: React.FC<BadgeConfig> = (config) => { + return ( + <div + className="badge-wrapper" + style={{ + height: 25, + backgroundColor: '#555', + display: 'flex', + margin: '0 7px' + }}> + <p + className="badge-label" + style={{ + color: '#fff', + fontFamily: 'Jost', + fontSize: 11, + height: '100%', + letterSpacing: 1, + margin: 0, + textTransform: 'uppercase', + padding: '0 12px 0 10px', + display: 'flex', + alignItems: 'center' + }}> + {config.name} + </p> + <p + className="badge-value" + style={{ + backgroundColor: config.color, + color: '#fff', + fontFamily: 'Jost', + fontSize: 11, + height: '100%', + letterSpacing: 1, + margin: 0, + padding: '0 8px', + display: 'flex', + alignItems: 'center', + marginLeft: -4, + marginRight: -4 + }}> + {config.value} + </p> + </div> + ) +} + +export default Badge diff --git a/src/components/preview/card.test.tsx b/src/components/preview/card.test.tsx new file mode 100644 index 0000000..8b4b945 --- /dev/null +++ b/src/components/preview/card.test.tsx @@ -0,0 +1,163 @@ +/* eslint-disable jest/no-conditional-expect */ +import { render } from '@testing-library/react' + +import Card from './card' + +import Configuration, { + Font, + Pattern, + Theme +} from '../../../common/types/configType' + +test('Card #1 renders', () => { + const config: Configuration = { + font: Font.inter, + logo: '', + name: { + value: 'project_name', + state: true + }, + pattern: Pattern.brickWall, + theme: Theme.light + } + + const { container } = render(<Card {...config} />) + + const cardWrapper = container.firstElementChild! as HTMLDivElement + expect(cardWrapper).toMatchSnapshot() + + expect(cardWrapper).toBeTruthy() + expect(cardWrapper.classList.contains('card-wrapper')).toBe(true) + expect(cardWrapper.style.fontFamily).toStrictEqual(config.font) + expect( + cardWrapper.classList.contains(`theme-${config.theme.toLowerCase()}`) + ).toBe(true) + expect(cardWrapper.querySelectorAll('.card-logo-wrapper img').length).toBe(1) + expect( + cardWrapper.querySelectorAll<HTMLImageElement>( + '.card-logo-wrapper img' + )?.[0]?.alt + ).toBe('Logo') + expect(cardWrapper.querySelectorAll('.card-logo-divider').length).toBe(0) + expect( + cardWrapper.querySelector('.card-name-name')?.textContent + ).toStrictEqual(config.name?.value) + expect(cardWrapper.querySelector('.card-description-wrapper')).toBeFalsy() + expect(cardWrapper.querySelectorAll('.card-badges-wrapper').length).toBe(0) +}) + +test('Card #2 renders', () => { + const config: Configuration = { + font: Font.koHo, + logo: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=', + name: { + value: 'project_name', + state: true + }, + pattern: Pattern.brickWall, + theme: Theme.dark, + description: { + value: 'TEST DESCRIPTION', + state: true + }, + owner: { + value: 'owner', + state: true + }, + language: { + value: 'JavaScript', + state: true + }, + language2: { + value: 'TypeScript', + state: true + }, + stargazers: { + value: 1, + state: true + }, + forks: { + value: 2, + state: true + }, + issues: { + value: 3, + state: true + }, + pulls: { + value: 4, + state: true + } + } + + const { container } = render(<Card {...config} />) + + const cardWrapper = container.firstElementChild! as HTMLDivElement + expect(cardWrapper).toMatchSnapshot() + + expect(cardWrapper).toBeTruthy() + expect(cardWrapper.classList.contains('card-wrapper')).toBe(true) + expect(cardWrapper.style.fontFamily).toStrictEqual(config.font) + expect( + cardWrapper.classList.contains(`theme-${config.theme.toLowerCase()}`) + ).toBe(true) + expect( + cardWrapper.querySelector('.card-name-name')?.textContent + ).toStrictEqual(config.name?.value) + expect(cardWrapper.querySelectorAll('.card-logo-wrapper img').length).toBe(2) + expect( + cardWrapper.querySelectorAll<HTMLImageElement>( + '.card-logo-wrapper img' + )?.[0].src + ).toBe(config.logo) + expect( + cardWrapper.querySelectorAll<HTMLImageElement>( + '.card-logo-wrapper img' + )?.[0]?.alt + ).toBe('Logo') + expect(cardWrapper.querySelectorAll('.card-logo-divider').length).toBe(1) + expect( + cardWrapper.querySelectorAll<HTMLImageElement>( + '.card-logo-wrapper img' + )?.[1]?.alt + ).toBe('JavaScript') + expect( + cardWrapper.querySelector('.card-description-wrapper')?.textContent + ).toStrictEqual(config.description?.value) + expect(cardWrapper.querySelectorAll('.card-badges-wrapper').length).toBe(1) + expect(cardWrapper.querySelectorAll('.card-badges-wrapper > *').length).toBe( + 4 + ) + expect( + cardWrapper.querySelectorAll('.card-badges-wrapper > *')[0] + ?.firstElementChild?.textContent + ).toStrictEqual('stars') + expect( + cardWrapper.querySelectorAll('.card-badges-wrapper > *')[0] + ?.lastElementChild?.textContent + ).toStrictEqual(`${config.stargazers?.value}`) + expect( + cardWrapper.querySelectorAll('.card-badges-wrapper > *')[1] + ?.firstElementChild?.textContent + ).toStrictEqual('forks') + expect( + cardWrapper.querySelectorAll('.card-badges-wrapper > *')[1] + ?.lastElementChild?.textContent + ).toStrictEqual(`${config.forks?.value}`) + expect( + cardWrapper.querySelectorAll('.card-badges-wrapper > *')[2] + ?.firstElementChild?.textContent + ).toStrictEqual('issues') + expect( + cardWrapper.querySelectorAll('.card-badges-wrapper > *')[2] + ?.lastElementChild?.textContent + ).toStrictEqual(`${config.issues?.value}`) + expect( + cardWrapper.querySelectorAll('.card-badges-wrapper > *')[3] + ?.firstElementChild?.textContent + ).toStrictEqual('pulls') + expect( + cardWrapper.querySelectorAll('.card-badges-wrapper > *')[3] + ?.lastElementChild?.textContent + ).toStrictEqual(`${config.pulls?.value}`) +}) diff --git a/src/components/preview/card.tsx b/src/components/preview/card.tsx new file mode 100644 index 0000000..a5ddf5c --- /dev/null +++ b/src/components/preview/card.tsx @@ -0,0 +1,198 @@ +import Badge from './badge' + +import Configuration from '../../../common/types/configType' + +import { getHeroPattern, getSimpleIconsImageURI } from '../../../common/helpers' + +const Card = (config: Configuration) => { + const backgroundPatternStyles = getHeroPattern(config.pattern, config.theme) + + const languageIconImageURI = + config.language?.state && + getSimpleIconsImageURI(config.language.value, config.theme) + + const language2IconImageURI = + config.language2?.state && + getSimpleIconsImageURI(config.language2.value, config.theme) + + const displayName = [ + config.owner?.state && config.owner?.value, + config.name?.state && config.name?.value + ] + .filter((value) => typeof value === 'string') + .join('/') + const nameLength = displayName.length + const nameFontSize = + nameLength > 55 + ? '17px' + : nameLength > 45 + ? '20px' + : nameLength > 35 + ? '24px' + : nameLength > 25 + ? '30px' + : '40px' + + return ( + <div + className={`card-wrapper theme-${config.theme.toLowerCase()}`} + style={{ + width: 640, + height: 320, + padding: '10px 30px', + fontFamily: config.font, + fontWeight: 400, + ...backgroundPatternStyles, + color: config.theme.match(/dark/i) ? '#fff' : '#000', + textAlign: 'center', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + transform: 'scale(2)', + transformOrigin: 'top left' + }}> + {/* Logo */} + <div + className="card-logo-wrapper" + style={{ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + marginTop: 10 + }}> + {languageIconImageURI && ( + <img + src={languageIconImageURI} + alt={config?.language?.value} + width={85} + height={85} + style={{ + objectFit: 'contain' + }} + /> + )} + {language2IconImageURI && ( + <p + className="card-logo-divider" + style={{ + color: '#bbb', + fontSize: 30, + margin: '0 20px', + fontFamily: 'Jost' + }}> + + + </p> + )} + {language2IconImageURI && ( + <img + src={language2IconImageURI} + alt={config?.language2?.value} + width={85} + height={85} + style={{ + objectFit: 'contain' + }} + /> + )} + </div> + + {/* Name */} + <p + className="card-name-wrapper" + style={{ + display: 'flex', + alignItems: 'center', + marginTop: 15, + marginBottom: 0, + fontWeight: 500, + fontSize: nameFontSize, + lineHeight: 1.4 + }}> + <span + className="card-name-owner" + style={{ + display: 'flex', + whiteSpace: 'nowrap', + fontWeight: 200 + }}> + {config.owner?.state + ? `${config.owner.value}${config.name?.state ? '/' : ''}` + : ''} + </span> + <span + className="card-name-name" + style={{ + display: 'flex', + whiteSpace: 'nowrap' + }}> + {config.name?.state ? `${config.name.value}` : ''} + </span> + </p> + + {/* Description */} + {config.description?.state && ( + <p + className="card-description-wrapper" + style={{ + marginTop: 10, + marginBottom: 0, + fontSize: 17, + lineHeight: 1.4, + maxHeight: '3em', + overflow: 'hidden', + wordBreak: 'break-all' + }}> + {config.description.value} + </p> + )} + + {/* Badges */} + {(config.stargazers?.state || + config.forks?.state || + config.issues?.state || + config.pulls?.state) && ( + <div + className="card-badges-wrapper" + style={{ + marginTop: 25, + marginBottom: 0, + display: 'flex', + flexDirection: 'row' + }}> + {config.stargazers?.state && ( + <Badge + name="stars" + value={`${config.stargazers.value}`} + color="#dfb317" + /> + )} + {config.forks?.state && ( + <Badge + name="forks" + value={`${config.forks.value}`} + color="#97ca00" + /> + )} + {config.issues?.state && ( + <Badge + name="issues" + value={`${config.issues.value}`} + color="#007ec6" + /> + )} + {config.pulls?.state && ( + <Badge + name="pulls" + value={`${config.pulls.value}`} + color="#fe7d37" + /> + )} + </div> + )} + </div> + ) +} + +export default Card diff --git a/src/components/preview/preview.tsx b/src/components/preview/preview.tsx new file mode 100644 index 0000000..6517696 --- /dev/null +++ b/src/components/preview/preview.tsx @@ -0,0 +1,218 @@ +import React, { useContext } from 'react' +import classnames from 'clsx' +import Head from 'next/head' +import Router from 'next/router' +import { toClipboard } from 'copee' +import { MdContentCopy, MdDownload } from 'react-icons/md' + +import toaster from '../toaster' + +import ConfigContext from '../../contexts/ConfigContext' +import { checkWebpSupport } from '../../../common/helpers' +import Card from './card' + +const getRelativeImageUrl = (format = 'image') => { + const [path, query] = Router.asPath.split('?') + return `${path}/${format}${query ? `?${query}` : ''}` +} + +const getImageUrl = (format = 'image') => { + return `${window.location.protocol}//${ + window.location.host + }${getRelativeImageUrl(format)}` +} + +const copyImageUrl = () => { + const screenshotImageUrl = getImageUrl() + const success = toClipboard(screenshotImageUrl) + if (success) { + toaster.success('Copied image url to clipboard') + } else { + window.open(screenshotImageUrl, '_blank') + } +} + +const copyMarkdown = () => { + const screenshotImageUrl = getImageUrl() + const ogTag = `` + const success = toClipboard(ogTag) + if (success) { + toaster.success('Copied markdown to clipboard') + } +} + +const copyImageTag = () => { + const screenshotImageUrl = getImageUrl() + const ogTag = `<img src="${screenshotImageUrl}" alt="${Router.query._name}" width="640" height="320" />` + const success = toClipboard(ogTag) + if (success) { + toaster.success('Copied image tag to clipboard') + } +} + +const copyOpenGraphTags = () => { + const ogTag = ` +<meta property="og:image" content="${getImageUrl('png')}" /> +<meta property="og:image:width" content="1280" /> +<meta property="og:image:height" content="640" /> + `.trim() + const success = toClipboard(ogTag) + if (success) { + toaster.success('Copied open graph tags to clipboard') + } +} + +const handleDownload = (fileType: string) => async () => { + toaster.info('Downloading...') + + try { + const img = new Image() + img.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = 1280 + canvas.height = 640 + const context = canvas.getContext('2d') + if (context && img) { + context.drawImage(img, 0, 0, canvas.width, canvas.height) + const dataUrl = canvas.toDataURL(`image/${fileType}`) + const link = document.createElement('a') + link.download = `${Router.query._name}.${fileType}` + link.href = dataUrl + link.click() + } + } + img.src = getRelativeImageUrl() + } catch (error) { + toaster.error('Download failed: Please use a modern browser.') + console.error(error) + } +} + +const openRelativeURLInNewTab = (fileType: string) => async () => { + window.open(getRelativeImageUrl(fileType), '_blank') +} + +const Preview: React.FC = () => { + const { config } = useContext(ConfigContext) + + return ( + <section className="mb-3"> + <div + className={classnames( + 'relative cursor-pointer rounded-lg shadow-2xl overflow-hidden', + 'w-[320px] h-[160px]', + 'min-[384px]:w-[384px] min-[384px]:h-[192px]', + 'min-[400px]:w-[400px] min-[400px]:h-[200px]', + 'min-[480px]:w-[480px] min-[480px]:h-[240px]', + 'min-[640px]:w-[640px] min-[640px]:h-[320px]' + )} + onClick={copyImageUrl}> + <div + className={classnames( + 'origin-top-left', + 'scale-[0.25]', + 'min-[384px]:scale-[0.3]', + 'min-[400px]:scale-[0.3125]', + 'min-[480px]:scale-[0.375]', + 'min-[640px]:scale-[0.5]' + )}> + <Head> + <link + href={`https://fonts.googleapis.com/css2?family=Jost:wght@400&display=swap`} + rel="stylesheet" + key="preview-card-fonts-1" + /> + <link + href={`https://fonts.googleapis.com/css2?family=${config.font}:wght@200;400;500&display=swap`} + rel="stylesheet" + key="preview-card-fonts-2" + /> + </Head> + <Card {...config} /> + </div> + <img + className="absolute top-0 left-0 w-full h-full opacity-0" + alt="Card" + src={getRelativeImageUrl()} + /> + </div> + <div className="card mt-3 mx-auto w-fit bg-base-100 shadow-xl"> + <div className="card-body px-3 py-2"> + <div + className={classnames('flex justify-center content-center gap-2')}> + <div className="dropdown"> + <label tabIndex={0} className="btn btn-primary btn-sm gap-2"> + <MdDownload className="w-5 h-5" /> + Download + </label> + <ul + style={{ width: 'auto' }} + tabIndex={0} + className="dropdown-content menu menu-compact p-2 shadow bg-base-100 rounded-box w-52"> + {(checkWebpSupport() + ? ['png', 'jpeg', 'webp'] + : ['png', 'jpeg'] + ).map((fileType) => ( + <li key={fileType}> + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + <a + className="font-bold gap-2" + onClick={handleDownload(fileType)}> + <MdDownload className="w-5 h-5" /> + {`${config.name?.value ?? ''}.${fileType}`} + </a> + </li> + ))} + </ul> + </div> + <div className="dropdown"> + <label tabIndex={0} className="btn btn-primary btn-sm gap-2"> + API URL + </label> + <ul + style={{ width: 'auto' }} + tabIndex={0} + className="dropdown-content menu menu-compact p-2 shadow bg-base-100 rounded-box w-52"> + {['svg', 'png'].map((fileType) => ( + <li key={fileType}> + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + <a + className="font-bold gap-2" + onClick={openRelativeURLInNewTab(fileType)} + target="_blank" + rel="noopener noreferrer"> + {`${config.name?.value ?? ''}.${fileType}`} + </a> + </li> + ))} + </ul> + </div> + <div className="btn-group"> + <button className="btn btn-sm gap-2" onClick={copyImageUrl}> + <MdContentCopy className="w-4 h-4" /> + Url + </button> + <button + className="btn btn-sm hidden sm:inline-flex" + onClick={copyMarkdown}> + Markdown + </button> + <button + className="btn btn-sm hidden sm:inline-flex" + onClick={copyImageTag}> + {'<img />'} + </button> + <button + className="btn btn-sm gap-2 hidden sm:inline-flex" + onClick={copyOpenGraphTags}> + Open Graph + </button> + </div> + </div> + </div> + </div> + </section> + ) +} + +export default Preview diff --git a/src/components/repo/__snapshots__/repo.test.tsx.snap b/src/components/repo/__snapshots__/repo.test.tsx.snap new file mode 100644 index 0000000..b13cef5 --- /dev/null +++ b/src/components/repo/__snapshots__/repo.test.tsx.snap @@ -0,0 +1,87 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Repo renders 1`] = ` +<main + class="hero" +> + <div + class="hero-content" + > + <div + class="flex flex-col gap-6 max-w-xl" + > + <h1 + class="text-5xl font-extrabold text-transparent bg-clip-text bg-gradient-to-br from-secondary to-secondary-focus" + > + Start with a + <span + class="inline-block" + > + GitHub repo + </span> + </h1> + <div + class="card w-full shadow-2xl bg-base-200" + > + <div + class="card-body p-0" + > + <form> + <div + class="form-control" + > + <div + class="input-group" + > + <span + class="pr-0 bg-base-200" + > + <svg + class="w-6 h-6" + fill="none" + height="1em" + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + viewBox="0 0 24 24" + width="1em" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" + /> + </svg> + </span> + <input + class="input flex-1 pl-3 font-bold bg-base-200 focus:outline-none" + type="text" + value="" + /> + <button + class="btn btn-square btn-primary" + > + <svg + class="h-6 w-6" + fill="currentColor" + height="1em" + stroke="currentColor" + stroke-width="0" + viewBox="0 0 512 512" + width="1em" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M256 8c137 0 248 111 248 248S393 504 256 504 8 393 8 256 119 8 256 8zm-28.9 143.6l75.5 72.4H120c-13.3 0-24 10.7-24 24v16c0 13.3 10.7 24 24 24h182.6l-75.5 72.4c-9.7 9.3-9.9 24.8-.4 34.3l11 10.9c9.4 9.4 24.6 9.4 33.9 0L404.3 273c9.4-9.4 9.4-24.6 0-33.9L271.6 106.3c-9.4-9.4-24.6-9.4-33.9 0l-11 10.9c-9.5 9.6-9.3 25.1.4 34.4z" + /> + </svg> + </button> + </div> + </div> + </form> + </div> + </div> + </div> + </div> +</main> +`; diff --git a/src/components/repo/repo.test.tsx b/src/components/repo/repo.test.tsx new file mode 100644 index 0000000..5b8ce46 --- /dev/null +++ b/src/components/repo/repo.test.tsx @@ -0,0 +1,10 @@ +import { render } from '@testing-library/react' + +import Repo from './repo' + +test('Repo renders', () => { + const { container } = render(<Repo />) + const repo = container.firstElementChild! + + expect(repo).toMatchSnapshot() +}) diff --git a/src/components/repo/repo.tsx b/src/components/repo/repo.tsx new file mode 100644 index 0000000..f59ad76 --- /dev/null +++ b/src/components/repo/repo.tsx @@ -0,0 +1,70 @@ +import React, { FormEvent, useState } from 'react' +import Router from 'next/router' + +import { FiGithub } from 'react-icons/fi' +import { FaArrowCircleRight } from 'react-icons/fa' + +import useAutoFocus from '../hooks/use-autofocus' +import toast from '../toaster' + +const Repo: React.FC = () => { + const repoInputRef = useAutoFocus() + const [repoInput, setRepoInput] = useState('') + + const submitRepo = (repoUrl: string) => { + const [, , owner, name] = + repoUrl.match(/^(https?:\/\/github\.com\/)?([^/]+)\/([^/]+).*/) ?? [] + if (owner && name) { + Router.push( + `/${owner}/${name}?language=1&language2=1&name=1&&theme=Dark&font=Inter` + ) + } else { + toast.warning('Please enter a valid GitHub repository.') + } + } + + const onSubmit = (e: FormEvent) => { + e.preventDefault() + + submitRepo(repoInput) + } + + return ( + <main className="hero"> + <div className="hero-content"> + <div className="flex flex-col gap-6 max-w-xl"> + <h1 className="text-5xl font-extrabold text-transparent bg-clip-text bg-gradient-to-br from-secondary to-secondary-focus"> + Start with a <span className="inline-block">GitHub repo</span> + </h1> + <div className="card w-full shadow-2xl bg-base-200"> + <div className="card-body p-0"> + <form onSubmit={onSubmit}> + <div className="form-control"> + <div className="input-group"> + <span className="pr-0 bg-base-200"> + <FiGithub className="w-6 h-6" /> + </span> + <input + className="input flex-1 pl-3 font-bold bg-base-200 focus:outline-none" + ref={repoInputRef} + type="text" + value={repoInput} + onChange={(e) => { + setRepoInput(e.target.value) + }} + /> + <button className="btn btn-square btn-primary"> + <FaArrowCircleRight className="h-6 w-6" /> + </button> + </div> + </div> + </form> + </div> + </div> + </div> + </div> + </main> + ) +} + +export default Repo diff --git a/src/components/toaster.tsx b/src/components/toaster.tsx new file mode 100644 index 0000000..44abd8e --- /dev/null +++ b/src/components/toaster.tsx @@ -0,0 +1,38 @@ +import { toast as hotToast } from 'react-hot-toast' +import { + MdInfoOutline, + MdCheckCircleOutline, + MdOutlineWarningAmber, + MdErrorOutline +} from 'react-icons/md' + +const ToastTypeMap = { + info: { icon: MdInfoOutline, className: 'alert-info' }, + success: { icon: MdCheckCircleOutline, className: 'alert-success' }, + warning: { icon: MdOutlineWarningAmber, className: 'alert-warning' }, + error: { icon: MdErrorOutline, className: 'alert-error' } +} + +const _helper = (type: keyof typeof ToastTypeMap) => { + const { icon: Icon, className } = ToastTypeMap[type] + + return (message: string) => + hotToast.custom((t) => { + return ( + <div className={`alert ${className} w-fit shadow-lg`}> + <div> + <Icon className="w-6 h-6" /> {message} + </div> + </div> + ) + }) +} + +const toast = { + info: _helper('info'), + success: _helper('success'), + warning: _helper('warning'), + error: _helper('error') +} + +export default toast diff --git a/src/contexts/ConfigContext.ts b/src/contexts/ConfigContext.ts new file mode 100644 index 0000000..952c012 --- /dev/null +++ b/src/contexts/ConfigContext.ts @@ -0,0 +1,16 @@ +import React from 'react' + +import { DEFAULT_CONFIG } from '../../common/configHelper' +import Configuration from '../../common/types/configType' + +type ConfigContextType = { + config: Configuration + setConfig: (config: Configuration) => void +} + +const ConfigContext: React.Context<ConfigContextType> = React.createContext({ + config: DEFAULT_CONFIG, + setConfig: (config: Configuration) => {} +}) + +export default ConfigContext diff --git a/src/typings/hero-patterns.d.ts b/src/typings/hero-patterns.d.ts new file mode 100644 index 0000000..b928f4d --- /dev/null +++ b/src/typings/hero-patterns.d.ts @@ -0,0 +1 @@ +declare module 'hero-patterns' |
