aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/configuration/checkBoxWrapper.tsx37
-rw-r--r--src/components/configuration/config.tsx248
-rw-r--r--src/components/configuration/inputWrapper.tsx41
-rw-r--r--src/components/configuration/selectWrapper.tsx44
-rw-r--r--src/components/configuration/textAreaWrapper.tsx59
-rw-r--r--src/components/error/__snapshots__/error.test.tsx.snap46
-rw-r--r--src/components/error/error.test.tsx16
-rw-r--r--src/components/error/error.tsx30
-rw-r--r--src/components/footer/__snapshots__/footer.test.tsx.snap30
-rw-r--r--src/components/footer/footer.test.tsx10
-rw-r--r--src/components/footer/footer.tsx43
-rw-r--r--src/components/header/__snapshots__/header.test.tsx.snap81
-rw-r--r--src/components/header/header.test.tsx14
-rw-r--r--src/components/header/header.tsx54
-rw-r--r--src/components/hooks/use-autofocus.ts13
-rw-r--r--src/components/mainRenderer.tsx59
-rw-r--r--src/components/mainWrapper.tsx51
-rw-r--r--src/components/preview/__snapshots__/badge.test.tsx.snap21
-rw-r--r--src/components/preview/__snapshots__/card.test.tsx.snap165
-rw-r--r--src/components/preview/badge.test.tsx22
-rw-r--r--src/components/preview/badge.tsx57
-rw-r--r--src/components/preview/card.test.tsx163
-rw-r--r--src/components/preview/card.tsx198
-rw-r--r--src/components/preview/preview.tsx218
-rw-r--r--src/components/repo/__snapshots__/repo.test.tsx.snap87
-rw-r--r--src/components/repo/repo.test.tsx10
-rw-r--r--src/components/repo/repo.tsx70
-rw-r--r--src/components/toaster.tsx38
-rw-r--r--src/contexts/ConfigContext.ts16
-rw-r--r--src/typings/hero-patterns.d.ts1
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">&rarr;</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>
+ &nbsp;and&nbsp;
+ <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" />
+ &nbsp; 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 = `![${Router.query._name}](${screenshotImageUrl})`
+ 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'