From e3ecea51355ddfbceb8c0cd6cc1a5a84cf863d74 Mon Sep 17 00:00:00 2001 From: Vince Date: Fri, 22 Jan 2021 13:43:42 +1100 Subject: [PATCH] Templated structure --- .babelrc | 19 + .eslintrc.js | 55 + .firebaserc | 7 + .gitignore | 33 + .nowignore | 1 + .nvmrc | 1 + .prettierignore | 4 + .prettierrc.js | 9 + @types/index.d.ts | 7 + README.md | 30 + assets/style.scss | 148 + client.js | 13 + components/Breadcrumbs.tsx | 27 + components/Button.tsx | 131 + components/ButtonGroup.tsx | 44 + components/Contained.tsx | 33 + components/Dropdown.tsx | 72 + components/DropdownItem.tsx | 41 + components/Footer.tsx | 29 + components/Input.tsx | 238 + components/InputGroup.tsx | 52 + components/Modal.tsx | 80 + components/OutlineBlock.tsx | 42 + components/SectionTitle.tsx | 14 + components/Title.tsx | 86 + components/article/Article.tsx | 48 + .../sections/ArticleSectionAbstract.tsx | 21 + .../sections/ArticleSectionContent.tsx | 84 + .../sections/ArticleSectionFeatureImage.tsx | 27 + .../article/sections/ArticleSectionTitle.tsx | 35 + .../sections/ArticleSubtitleSection.tsx | 26 + .../widgets/ArticleFeatureVideoWidget.tsx | 43 + .../article/widgets/ArticleShareWidget.tsx | 105 + .../article/widgets/ArticleWidgetAuthor.tsx | 22 + components/cards/ArticleCard.tsx | 97 + components/cards/ArticleCardFavourite.tsx | 81 + components/cards/ArticleCardRow.tsx | 74 + components/cards/CardGrid.tsx | 37 + components/design/DesignColorPalette.tsx | 70 + components/header/Header.tsx | 121 + components/header/HeaderSearch.tsx | 62 + components/layout/index.tsx | 25 + components/modals/LoginModal.tsx | 42 + components/search/SearchDropdown.tsx | 59 + components/search/SearchInput.tsx | 184 + components/search/SearchItem.tsx | 50 + components/search/SearchOverlay.tsx | 19 + components/search/SearchOverlayBackdrop.tsx | 36 + components/search/SearchOverlayInner.tsx | 131 + components/search/SearchOverlayMobile.tsx | 57 + constants/firebase.ts | 30 + constants/index.ts | 6 + constants/metadata.tsx | 6 + constants/search.ts | 6 + constants/ui.ts | 21 + contexts/screen.tsx | 30 + hooks/screen.ts | 43 + hooks/search.ts | 66 + lib/mapbox.ts | 14 + next-env.d.ts | 0 next.config.js | 29 + package.json | 93 + pages/404.tsx | 150 + pages/_app.tsx | 60 + pages/about.tsx | 14 + pages/blog/[slug].tsx | 50 + pages/blog/index.tsx | 52 + pages/design.tsx | 28 + pages/index.tsx | 60 + pages/search.tsx | 204 + postcss.config.js | 8 + public/_document.tsx | 36 + public/fonts/Roboto/Roboto-Black.ttf | Bin 0 -> 171072 bytes public/fonts/Roboto/Roboto-BlackItalic.ttf | Bin 0 -> 177120 bytes public/fonts/Roboto/Roboto-Bold.ttf | Bin 0 -> 170348 bytes public/fonts/Roboto/Roboto-BoldItalic.ttf | Bin 0 -> 174520 bytes public/fonts/Roboto/Roboto-Italic.ttf | Bin 0 -> 173516 bytes public/fonts/Roboto/Roboto-Light.ttf | Bin 0 -> 170012 bytes public/fonts/Roboto/Roboto-LightItalic.ttf | Bin 0 -> 176184 bytes public/fonts/Roboto/Roboto-Medium.ttf | Bin 0 -> 171656 bytes public/fonts/Roboto/Roboto-MediumItalic.ttf | Bin 0 -> 176428 bytes public/fonts/Roboto/Roboto-Regular.ttf | Bin 0 -> 171272 bytes public/fonts/Roboto/Roboto-Thin.ttf | Bin 0 -> 171500 bytes public/fonts/Roboto/Roboto-ThinItalic.ttf | Bin 0 -> 175872 bytes public/fonts/RobotoSlab/RobotoSlab-Bold.ttf | Bin 0 -> 173400 bytes .../fonts/RobotoSlab/RobotoSlab-ExtraBold.ttf | Bin 0 -> 173484 bytes .../RobotoSlab/RobotoSlab-ExtraLight.ttf | Bin 0 -> 173052 bytes public/fonts/RobotoSlab/RobotoSlab-Light.ttf | Bin 0 -> 173088 bytes public/fonts/RobotoSlab/RobotoSlab-Medium.ttf | Bin 0 -> 173376 bytes .../fonts/RobotoSlab/RobotoSlab-Regular.ttf | Bin 0 -> 172696 bytes .../fonts/RobotoSlab/RobotoSlab-SemiBold.ttf | Bin 0 -> 173548 bytes public/fonts/RobotoSlab/RobotoSlab-Thin.ttf | Bin 0 -> 171876 bytes server.js | 23 + state/navigation.ts | 40 + state/reducers/article.ts | 34 + state/reducers/index.ts | 28 + state/reducers/navigation.ts | 32 + state/reducers/search.ts | 29 + state/search.ts | 37 + tailwind.config.js | 58 + tsconfig.json | 19 + types/article.ts | 52 + types/firebase.ts | 17 + types/index.ts | 2 + utils/article.ts | 80 + utils/metadata.ts | 5 + utils/posts.tsx | 55 + utils/routing.ts | 11 + utils/share.ts | 63 + utils/text.ts | 11 + yarn.lock | 12200 ++++++++++++++++ 111 files changed, 16574 insertions(+) create mode 100644 .babelrc create mode 100644 .eslintrc.js create mode 100644 .firebaserc create mode 100644 .gitignore create mode 100644 .nowignore create mode 100644 .nvmrc create mode 100644 .prettierignore create mode 100644 .prettierrc.js create mode 100644 @types/index.d.ts create mode 100644 README.md create mode 100644 assets/style.scss create mode 100644 client.js create mode 100644 components/Breadcrumbs.tsx create mode 100644 components/Button.tsx create mode 100644 components/ButtonGroup.tsx create mode 100644 components/Contained.tsx create mode 100644 components/Dropdown.tsx create mode 100644 components/DropdownItem.tsx create mode 100644 components/Footer.tsx create mode 100644 components/Input.tsx create mode 100644 components/InputGroup.tsx create mode 100644 components/Modal.tsx create mode 100644 components/OutlineBlock.tsx create mode 100644 components/SectionTitle.tsx create mode 100644 components/Title.tsx create mode 100644 components/article/Article.tsx create mode 100644 components/article/sections/ArticleSectionAbstract.tsx create mode 100644 components/article/sections/ArticleSectionContent.tsx create mode 100644 components/article/sections/ArticleSectionFeatureImage.tsx create mode 100644 components/article/sections/ArticleSectionTitle.tsx create mode 100644 components/article/sections/ArticleSubtitleSection.tsx create mode 100644 components/article/widgets/ArticleFeatureVideoWidget.tsx create mode 100644 components/article/widgets/ArticleShareWidget.tsx create mode 100644 components/article/widgets/ArticleWidgetAuthor.tsx create mode 100644 components/cards/ArticleCard.tsx create mode 100644 components/cards/ArticleCardFavourite.tsx create mode 100644 components/cards/ArticleCardRow.tsx create mode 100644 components/cards/CardGrid.tsx create mode 100644 components/design/DesignColorPalette.tsx create mode 100644 components/header/Header.tsx create mode 100644 components/header/HeaderSearch.tsx create mode 100644 components/layout/index.tsx create mode 100644 components/modals/LoginModal.tsx create mode 100644 components/search/SearchDropdown.tsx create mode 100644 components/search/SearchInput.tsx create mode 100644 components/search/SearchItem.tsx create mode 100644 components/search/SearchOverlay.tsx create mode 100644 components/search/SearchOverlayBackdrop.tsx create mode 100644 components/search/SearchOverlayInner.tsx create mode 100644 components/search/SearchOverlayMobile.tsx create mode 100644 constants/firebase.ts create mode 100644 constants/index.ts create mode 100644 constants/metadata.tsx create mode 100644 constants/search.ts create mode 100644 constants/ui.ts create mode 100644 contexts/screen.tsx create mode 100644 hooks/screen.ts create mode 100644 hooks/search.ts create mode 100644 lib/mapbox.ts create mode 100644 next-env.d.ts create mode 100644 next.config.js create mode 100644 package.json create mode 100644 pages/404.tsx create mode 100644 pages/_app.tsx create mode 100644 pages/about.tsx create mode 100644 pages/blog/[slug].tsx create mode 100644 pages/blog/index.tsx create mode 100644 pages/design.tsx create mode 100644 pages/index.tsx create mode 100644 pages/search.tsx create mode 100644 postcss.config.js create mode 100644 public/_document.tsx create mode 100644 public/fonts/Roboto/Roboto-Black.ttf create mode 100644 public/fonts/Roboto/Roboto-BlackItalic.ttf create mode 100644 public/fonts/Roboto/Roboto-Bold.ttf create mode 100644 public/fonts/Roboto/Roboto-BoldItalic.ttf create mode 100644 public/fonts/Roboto/Roboto-Italic.ttf create mode 100644 public/fonts/Roboto/Roboto-Light.ttf create mode 100644 public/fonts/Roboto/Roboto-LightItalic.ttf create mode 100644 public/fonts/Roboto/Roboto-Medium.ttf create mode 100644 public/fonts/Roboto/Roboto-MediumItalic.ttf create mode 100644 public/fonts/Roboto/Roboto-Regular.ttf create mode 100644 public/fonts/Roboto/Roboto-Thin.ttf create mode 100644 public/fonts/Roboto/Roboto-ThinItalic.ttf create mode 100644 public/fonts/RobotoSlab/RobotoSlab-Bold.ttf create mode 100644 public/fonts/RobotoSlab/RobotoSlab-ExtraBold.ttf create mode 100644 public/fonts/RobotoSlab/RobotoSlab-ExtraLight.ttf create mode 100644 public/fonts/RobotoSlab/RobotoSlab-Light.ttf create mode 100644 public/fonts/RobotoSlab/RobotoSlab-Medium.ttf create mode 100644 public/fonts/RobotoSlab/RobotoSlab-Regular.ttf create mode 100644 public/fonts/RobotoSlab/RobotoSlab-SemiBold.ttf create mode 100644 public/fonts/RobotoSlab/RobotoSlab-Thin.ttf create mode 100644 server.js create mode 100644 state/navigation.ts create mode 100644 state/reducers/article.ts create mode 100644 state/reducers/index.ts create mode 100644 state/reducers/navigation.ts create mode 100644 state/reducers/search.ts create mode 100644 state/search.ts create mode 100644 tailwind.config.js create mode 100644 tsconfig.json create mode 100644 types/article.ts create mode 100644 types/firebase.ts create mode 100644 types/index.ts create mode 100644 utils/article.ts create mode 100644 utils/metadata.ts create mode 100644 utils/posts.tsx create mode 100644 utils/routing.ts create mode 100644 utils/share.ts create mode 100644 utils/text.ts create mode 100644 yarn.lock diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..0d0c087 --- /dev/null +++ b/.babelrc @@ -0,0 +1,19 @@ +{ + "env": { + "development": { + "plugins": [ + ["tailwind", { "ssr": true, "displayName": true, "preprocess": false }] + ], + "presets": ["next/babel"] + }, + "production": { + "plugins": [ + ["tailwind", { "ssr": true, "displayName": true, "preprocess": false }] + ], + "presets": ["next/babel"] + } + }, + "plugins": [ + ["tailwind", { "ssr": true, "displayName": true, "preprocess": false }] + ] +} diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..79583ea --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,55 @@ +module.exports = { + // root: true, + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2020, // Allows for ES8+ + ecmaFeatures: { jsx: true }, + }, + env: { + browser: true, + node: true, + }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react/recommended', + 'plugin:jsx-a11y/recommended', + // Prettier plugin and recommended rules + 'prettier/@typescript-eslint', + 'plugin:prettier/recommended', + ], + rules: { + // Include .prettierrc.js rules + 'prettier/prettier': ['error', {}, { usePrettierrc: true }], + 'react/prop-types': 'off', + 'react/react-in-jsx-scope': 'off', + 'react/no-unescaped-entities': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/ban-ts-ignore': 'off', + 'jsx-a11y/anchor-is-valid': 'off', + 'jsx-a11y/anchor-has-content': 'off', + 'jsx-a11y/iframe-has-title': 'off', + 'jsx-a11y/click-events-have-key-events': 'off', + 'jsx-a11y/alt-text': 'off', + 'jsx-a11y/img-redundant-alt': 'off', + 'jsx-a11y/no-static-element-interactions': 'off', + 'jsx-a11y/label-has-associated-control': [ + 'error', + { + labelComponents: [], + labelAttributes: [], + controlComponents: [], + assert: 'either', + depth: 25, + }, + ], + '@typescript-eslint/no-explicit-any': 'off', + }, + settings: { + react: { + version: 'detect', + }, + }, +}; diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..6cdd4d9 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,7 @@ +{ + "projects": { + "default": "oxen-io", + "staging": "oxen-io", + "production": "oxen-io" + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d0371c --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ +!public/ + +# production +/build + +# misc +.DS_Store +.env* + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment Variables +.env +.env.build + +.now +.vercel diff --git a/.nowignore b/.nowignore new file mode 100644 index 0000000..baf365d --- /dev/null +++ b/.nowignore @@ -0,0 +1 @@ +functions \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..55d1782 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v14.15.0 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..b58ff16 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +# Ignore .next +.next +.git +node_modules \ No newline at end of file diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..184da38 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,9 @@ +module.exports = { + semi: true, + trailingComma: 'all', + singleQuote: true, + arrowParens: 'avoid', + printWidth: 80, + tabWidth: 2, + useTabs: false, +}; diff --git a/@types/index.d.ts b/@types/index.d.ts new file mode 100644 index 0000000..03eb40b --- /dev/null +++ b/@types/index.d.ts @@ -0,0 +1,7 @@ +declare module '*.svg' { + import * as React from 'react'; + + const ReactComponent: React.FunctionComponent>; + export { ReactComponent }; + export default string; +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee61611 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/zeit/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/zeit/next.js/) - your feedback and contributions are welcome! + +## Deploy on ZEIT Now + +The easiest way to deploy your Next.js app is to use the [ZEIT Now Platform](https://zeit.co/import?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/assets/style.scss b/assets/style.scss new file mode 100644 index 0000000..8cd3616 --- /dev/null +++ b/assets/style.scss @@ -0,0 +1,148 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* ********************* */ +/* ***** CONSTANTS ***** */ +/* ********************* */ +$theme-primary: #fe4c00; +$theme-primary-2: #eb5929; +$theme-secondary: #ffd618; +$theme-secondary-1: #ffd400; + +/* **************************** */ +/* ******** RESPONSIVE ******** */ +/* **************************** */ + +$breakpoint-mobile: 500px; +$breakpoint-tablet: 768px; +$breakpoint-desktop: 992px; +$breakpoint-huge: 1280px; + +/* ********************* */ +/* ******* FONTS ******* */ +/* ********************* */ + +@font-face { + font-family: Roboto; + src: url(/fonts/Roboto/Roboto-Thin.ttf) format('truetype'); + font-weight: 200; +} + +@font-face { + font-family: Roboto; + src: url(/fonts/Roboto/Roboto-ThinItalic.ttf) format('truetype'); + font-weight: 200; + font-style: italic; +} + +@font-face { + font-family: Roboto; + src: url(/fonts/Roboto/Roboto-Regular.ttf) format('truetype'); +} + +@font-face { + font-family: Roboto; + src: url(/fonts/Roboto/Roboto-MediumItalic.ttf) format('truetype'); + font-style: italic; + font-weight: 500; +} + +@font-face { + font-family: Roboto; + src: url(/fonts/Roboto/Roboto-Medium.ttf) format('truetype'); + font-weight: 500; +} + +@font-face { + font-family: Roboto; + src: url(/fonts/Roboto/Roboto-LightItalic.ttf) format('truetype'); + font-style: italic; +} + +@font-face { + font-family: Roboto; + src: url(/fonts/Roboto/Roboto-Light.ttf) format('truetype'); +} + +@font-face { + font-family: Roboto; + src: url(/fonts/Roboto/Roboto-Italic.ttf) format('truetype'); + font-style: italic; +} + +@font-face { + font-family: Roboto; + src: url(/fonts/Roboto/Roboto-BoldItalic.ttf) format('truetype'); + font-weight: 700; + font-style: italic; +} + +@font-face { + font-family: Roboto; + src: url(/fonts/Roboto/Roboto-Bold.ttf) format('truetype'); + font-weight: 700; +} + +@font-face { + font-family: Roboto; + src: url(/fonts/Roboto/Roboto-BlackItalic.ttf) format('truetype'); + font-weight: 900; + font-style: italic; +} + +@font-face { + font-family: Roboto; + src: url(/fonts/Roboto/Roboto-Black.ttf) format('truetype'); + font-weight: 900; +} + +@font-face { + font-family: RobotoSlab; + src: url(/fonts/RobotoSlab/RobotoSlab-ExtraLight.ttf) format('truetype'); + font-weight: 100; +} + +@font-face { + font-family: RobotoSlab; + src: url(/fonts/RobotoSlab/RobotoSlab-Thin.ttf) format('truetype'); + font-weight: 200; +} + +@font-face { + font-family: RobotoSlab; + src: url(/fonts/RobotoSlab/RobotoSlab-Light.ttf) format('truetype'); + font-weight: 300; +} + +@font-face { + font-family: RobotoSlab; + src: url(/fonts/RobotoSlab/RobotoSlab-Regular.ttf) format('truetype'); + font-weight: 400; +} + +@font-face { + font-family: RobotoSlab; + src: url(/fonts/RobotoSlab/RobotoSlab-SemiBold.ttf) format('truetype'); + font-weight: 500; +} + +@font-face { + font-family: RobotoSlab; + src: url(/fonts/RobotoSlab/RobotoSlab-Bold.ttf) format('truetype'); + font-weight: 600; +} + +@font-face { + font-family: RobotoSlab; + src: url(/fonts/RobotoSlab/RobotoSlab-ExtraBold.ttf) format('truetype'); + font-weight: 700; +} + +body { + min-width: 350px; +} + +.__next { + overflow-x: hidden; +} diff --git a/client.js b/client.js new file mode 100644 index 0000000..0c5507e --- /dev/null +++ b/client.js @@ -0,0 +1,13 @@ +// client.js +import sanityClient from '@sanity/client'; + +export const SANITY_CONSTATNTS = { + PROJECT_ID: 'q2qmxra4', + DATASET: 'production', +}; + +export default sanityClient({ + projectId: SANITY_CONSTATNTS.PROJECT_ID, // you can find this in sanity.json + dataset: SANITY_CONSTATNTS.DATASET, // or the name you chose in step 1 + useCdn: true, // `false` if you want to ensure fresh data +}); diff --git a/components/Breadcrumbs.tsx b/components/Breadcrumbs.tsx new file mode 100644 index 0000000..76f63fe --- /dev/null +++ b/components/Breadcrumbs.tsx @@ -0,0 +1,27 @@ +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import React from 'react'; +// import HomeSVG from '../assets/svgs/home-primary.svg'; + +export function Breadcrumbs() { + const router = useRouter(); + const path = router.asPath.split('/').filter(i => Boolean(i)); + + console.log('Breadcrumbs ➡️ path:', path); + + return ( +
+ {/* */} + + {path.map((item, index) => ( + + + {'>'} + {item} + + + ))} + +
+ ); +} diff --git a/components/Button.tsx b/components/Button.tsx new file mode 100644 index 0000000..841585e --- /dev/null +++ b/components/Button.tsx @@ -0,0 +1,131 @@ +import classNames from 'classnames'; +import React, { useContext } from 'react'; +import { ScreenContext } from '../contexts/screen'; + +export interface Props { + color?: 'primary' | 'secondary' | 'danger'; + type?: 'text' | 'ghost' | 'solid' | 'outline'; + size?: 'tiny' | 'small' | 'medium' | 'large'; + + disabled?: boolean; + selected?: boolean; + onClick?(): any; + children?: string; + className?: string; + + // Icons + prefix?: JSX.Element; + suffix?: JSX.Element; + + wide?: boolean; +} + +export function Button(props: Props) { + const { + color = 'primary', + size = 'medium', + type = 'solid', + disabled = false, + selected = false, + onClick, + children, + className, + prefix, + suffix, + wide = false, + } = props; + + const { isDesktop } = useContext(ScreenContext); + + const clickHandler = (e: React.MouseEvent) => { + if (onClick) { + e?.stopPropagation && e?.stopPropagation(); + onClick(); + } + }; + + const onClickFn = disabled ? () => null : clickHandler; + + const ghostClassNames = [ + 'bg-white', + `hover:bg-${color}`, + 'bg-transparent', + selected && `bg-white`, + selected ? 'text-white' : `text-${color}`, + ]; + + const solidClassNames = [ + 'text-white', + `bg-${color}`, + 'hover:opacity-75', + selected && 'bg-opacity-75', + ]; + + const outlineClassNames = ['rounded-xl py-2']; + + const textTypeClassNames = [`text-${color}`, 'hover:opacity-75']; + + const off = disabled + ? ['cursor-not-allowed', 'opacity-50'] + : ['cursor-pointer']; + + // Conditional isDesktop makes buttons more touch friendly + // with more padding + const sizeStyles = [ + size === 'large' && 'text-lg py-2', + size === 'medium' && 'text-base py-1', + size === 'small' && ['text-sm', isDesktop ? 'py-0' : 'py-1'], + size === 'tiny' && 'text-xs py-0', + ]; + + // Make bg crop to text with tailwind on gradient + // https://tailwindcss.com/docs/background-clip#class-reference + + // prettier-ignore + const typeStyles = + type === 'ghost' ? ghostClassNames : + type === 'solid' ? solidClassNames : + type === 'text' ? textTypeClassNames : + type === 'outline' ? outlineClassNames : + ''; + + return ( +
+ {prefix && ( +
+ {prefix} +
+ )} + {children} + {suffix && ( +
+ {suffix} +
+ )} +
+ ); +} diff --git a/components/ButtonGroup.tsx b/components/ButtonGroup.tsx new file mode 100644 index 0000000..9bf1dcf --- /dev/null +++ b/components/ButtonGroup.tsx @@ -0,0 +1,44 @@ +import classNames from 'classnames'; +import React from 'react'; + +interface Props { + className?: string; + children: JSX.Element[]; + equalWidth?: boolean; +} + +export function ButtonGroup(props: Props) { + // Attach props to children following this guide + // https://stackoverflow.com/a/35102287 + const { className, children, equalWidth } = props; + const numChildren = children.length; + const hasChildren = Boolean(numChildren); + + const firstChild = children[0]; + const lastChild = children[numChildren - 1]; + + return ( +
+ {hasChildren && + React.cloneElement(firstChild, { + className: classNames( + 'rounded-r-none border-r-0', + equalWidth && 'flex-1', + ), + })} + {numChildren > 2 && + children.slice(1, numChildren - 1).map(child => + React.cloneElement(child, { + className: classNames( + 'rounded-r-none rounded-l-none border-r-0', + equalWidth && 'flex-1', + ), + }), + )} + {hasChildren && + React.cloneElement(lastChild, { + className: classNames('rounded-l-none', equalWidth && 'flex-1'), + })} +
+ ); +} diff --git a/components/Contained.tsx b/components/Contained.tsx new file mode 100644 index 0000000..a8c5252 --- /dev/null +++ b/components/Contained.tsx @@ -0,0 +1,33 @@ +import classNames from 'classnames'; +import React, { ReactNode } from 'react'; +import { UI } from '../constants'; + +interface Props { + backgroundColor?: 'primary' | 'secondary' | 'secondary-1'; + children: ReactNode; +} + +export function Contained(props: Props) { + const { backgroundColor, children } = props; + + const containerStyle = { + paddingLeft: `${UI.PAGE_CONTAINED_PADDING_VW}vw`, + paddingRight: `${UI.PAGE_CONTAINED_PADDING_VW}vw`, + width: '100%', + maxWidth: `${UI.MAX_CONTENT_WIDTH}px`, + margin: '0 auto', + }; + + return ( +
+
+ {children} +
+
+ ); +} diff --git a/components/Dropdown.tsx b/components/Dropdown.tsx new file mode 100644 index 0000000..f0a642c --- /dev/null +++ b/components/Dropdown.tsx @@ -0,0 +1,72 @@ +import classNames from 'classnames'; +import React, { useRef } from 'react'; +import { useClickAway } from 'react-use'; + +interface Props { + isOpen: boolean; + pull?: 'left' | 'right' | 'center'; + style?: 'default' | 'outline'; + onClickAway: () => void; + center?: boolean; + + offsetX?: number; + offsetY?: number; + + // Use DropdownItem + children?: JSX.Element | JSX.Element[]; +} + +export function Dropdown(props: Props) { + // Ensure children are all DropdownItems + const { + isOpen, + pull = 'right', + style = 'default', + center = false, + offsetX, + offsetY, + onClickAway, + children, + } = props; + + const ref = useRef(null); + useClickAway(ref, onClickAway); + + return ( +
+
+
+ {children} +
+
+
+ ); +} diff --git a/components/DropdownItem.tsx b/components/DropdownItem.tsx new file mode 100644 index 0000000..9fd55cb --- /dev/null +++ b/components/DropdownItem.tsx @@ -0,0 +1,41 @@ +import classNames from 'classnames'; + +// Simplest use is to define your options and map over them to product JSX and your desired onSelect; eg: +// options?.map(option => setDropdownItem(option.key)})} +export interface DropdownItemProps { + id: string; + selected?: boolean; + onSelect?(): void; + style?: 'default' | 'outline'; + children: JSX.Element | JSX.Element[] | string; +} + +export const DropdownItem = (props: DropdownItemProps) => { + const { id, children, onSelect, selected = false, style = 'default' } = props; + + const handleOnSelect = () => { + if (onSelect) { + onSelect(); + } + }; + + return ( +
handleOnSelect()} + className={classNames( + 'flex items-center', + 'block', + 'font-roboto text-sm text-primary', + 'hover:text-opacity-100 text-opacity-75', + 'select-none', + 'cursor-pointer', + style === 'default' && + 'border-b border-secondary font-semibold py-2 mx-3', + style === 'outline' && ['pl-4', 'pr-6', 'py-1'], + selected && 'bold text-opacity-100', + )} + > + {children} +
+ ); +}; diff --git a/components/Footer.tsx b/components/Footer.tsx new file mode 100644 index 0000000..1a60f95 --- /dev/null +++ b/components/Footer.tsx @@ -0,0 +1,29 @@ +import Link from 'next/link'; +import React from 'react'; +import { Contained } from './Contained'; + +export function Footer() { + return ( +
+ +
+
+ +
+
+ + Home + + + + About Us + + + Privacy + +
+
+
+
+ ); +} diff --git a/components/Input.tsx b/components/Input.tsx new file mode 100644 index 0000000..ec468b5 --- /dev/null +++ b/components/Input.tsx @@ -0,0 +1,238 @@ +import classNames from 'classnames'; +import { + ChangeEvent, + CSSProperties, + FocusEvent, + RefObject, + useRef, + useState, +} from 'react'; + +export interface InputProps { + id?: string; + ref?: RefObject; + + // Applied to parent only + className?: string; + inputClassName?: string; + + // If value is not given in props, the component will manage it through state (default) + value?: string | number; + type?: 'text' | 'number' | 'search' | 'email' | 'password'; + name?: string; + size?: 'large' | 'medium' | 'small'; + border?: 'primary' | 'secondary' | 'none'; + + inputMode?: + | 'none' + | 'text' + | 'decimal' + | 'numeric' + | 'tel' + | 'search' + | 'url'; + + // Content + prefix?: JSX.Element; + suffix?: JSX.Element; + placeholder?: string; + + // Styling + style?: CSSProperties; + fitHeight?: boolean; + readonly?: boolean; + center?: boolean; + duration?: boolean; + + // Callbacks + onKeyDown?(): any; + onMouseUp?(): any; + onBlur?(event: FocusEvent): void; + onFocus?(event: FocusEvent): void; + onChange?(event: ChangeEvent): any; + onValueChange?(value: string): any; + + // HTMLInputElement Props + + autofocus?: boolean; + // required?: boolean; + // validity?: ValidityState; + // validationMessage?: string; + // willValidate?: boolean; + // autocomplete?: string; + + // Validation + max?: number | string; + maxLength?: number | string; + min?: number | string; + minLength?: number | string; + disabled?: boolean; + step?: number | string | undefined; +} + +export function Input(props: InputProps) { + const { + className, + inputClassName, + type = 'text', + center = false, + readonly = false, + size = 'medium', + border = 'secondary', + style, + prefix, + duration = true, + suffix, + autofocus, + disabled, + min, + max, + step, + placeholder = '', + inputMode = 'text', + fitHeight = false, + onKeyDown, + onMouseUp, + } = props; + + // Focus + const inputRef = props.ref ?? useRef(null); + const setInputFocus = () => { + if (typeof inputRef !== 'string') { + inputRef?.current?.focus(); + } + }; + + // Value + const [value, setValue] = useState('' as string | number); + const [hasFocus, setHasFocus] = useState(false); + + // Styles + const fontSize = + size !== 'medium' && size === 'large' ? 'text-lg' : 'text-sm'; + + // Functions + const handleOnChange = (event: ChangeEvent) => { + const element = event?.target as HTMLInputElement; + if (element.value === undefined) { + return; + } + + // Emails don't support selectionStart + if (type !== 'email') { + const caret = element.selectionStart; + window.requestAnimationFrame(() => { + element.selectionStart = caret; + element.selectionEnd = caret; + }); + } + + if (props.onValueChange) { + props.onValueChange(element.value); + } + + setValue(element.value); + }; + + const handleOnBlur = (event: FocusEvent) => { + setHasFocus(false); + + if (props.onBlur) { + props.onBlur(event); + } + }; + + const handleOnFocus = (event: FocusEvent) => { + if (!readonly) { + setHasFocus(true); + } + + if (props.onFocus) { + props.onFocus(event); + } + }; + + // // Effects + // useEffect(() => { + // if (autofocus) { + // setInputFocus(); + // } + // }, []); + + return ( +
+ {prefix && ( + + {prefix} + + )} + + + + {type === 'number' &&
} + + {suffix && ( + + {suffix} + + )} +
+ ); +} diff --git a/components/InputGroup.tsx b/components/InputGroup.tsx new file mode 100644 index 0000000..b6d89f2 --- /dev/null +++ b/components/InputGroup.tsx @@ -0,0 +1,52 @@ +import classNames from 'classnames'; +import React from 'react'; + +interface Props { + children: JSX.Element[]; + className?: string; + hasOpenDropdown?: boolean; +} + +export function InputGroup(props: Props) { + // Attach props to children following this guide + // https://stackoverflow.com/a/35102287 + const { children, className, hasOpenDropdown = false } = props; + const numChildren = children.length; + const hasChildren = Boolean(numChildren); + + const firstChild = children[0]; + const lastChild = children[numChildren - 1]; + + return ( +
+ {hasChildren && + React.cloneElement(firstChild, { + className: classNames( + 'rounded-r-none', + 'border-r-0', + 'duration-300', + hasOpenDropdown && 'rounded-b-none', + ), + })} + {numChildren > 2 && + children.slice(1, numChildren - 1).map(child => + React.cloneElement(child, { + className: classNames( + 'rounded-r-none', + 'border-l-none', + 'duration-300', + hasOpenDropdown && 'rounded-b-none', + ), + }), + )} + {hasChildren && + React.cloneElement(lastChild, { + className: classNames( + 'rounded-l-none', + 'duration-300', + hasOpenDropdown && 'rounded-b-none', + ), + })} +
+ ); +} diff --git a/components/Modal.tsx b/components/Modal.tsx new file mode 100644 index 0000000..5d14990 --- /dev/null +++ b/components/Modal.tsx @@ -0,0 +1,80 @@ +import classNames from 'classnames'; +import React, { ReactNode, useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useClickAway } from 'react-use'; +// import ExitSVG from '../assets/svgs/exit-primary.svg'; +import { UI } from '../constants'; +import { collapseSearchOverlay } from '../state/navigation'; +import { IState } from '../state/reducers'; + +interface Props { + modalId: string; + isOpen: boolean; + children: ReactNode; + isMobileFullscreen?: boolean; + className?: string; + close?: () => void; +} + +export function Modal(props: Props) { + const { modalId, isOpen, close, className, children } = props; + const { searchOverlayExpanded, openedModal } = useSelector( + (state: IState) => state.navigation, + ); + + const dispatch = useDispatch(); + const [shouldRender, setShouldRender] = useState(false); + + const ref = useRef(null); + useClickAway(ref, close); + + useEffect(() => { + // If modal is open, close search overlay + if (isOpen && searchOverlayExpanded) { + dispatch(collapseSearchOverlay()); + } + + // Refuse to open if another modal is currently open + if (modalId !== openedModal) { + console.log( + `Cannot open modal ${modalId}, ${openedModal} is already open.`, + ); + + setShouldRender(true); + } + }, []); + + if (!isOpen || !shouldRender) { + return null; + } + + return ( +
+
+
+ {/* */} +
+ {children} +
+
+ ); +} diff --git a/components/OutlineBlock.tsx b/components/OutlineBlock.tsx new file mode 100644 index 0000000..372d8df --- /dev/null +++ b/components/OutlineBlock.tsx @@ -0,0 +1,42 @@ +import classNames from 'classnames'; + +interface Props { + size?: 'tiny' | 'small' | 'medium' | 'large'; + theme?: 'default' | 'alt'; + bold?: boolean; + children: string; + className?: string; + onClick?(): void; +} + +export function OutlineBlock(props: Props) { + const { + children, + size = 'medium', + theme = 'default', + bold, + className, + onClick, + } = props; + + return ( +
+ + {children} + +
+ ); +} diff --git a/components/SectionTitle.tsx b/components/SectionTitle.tsx new file mode 100644 index 0000000..959266b --- /dev/null +++ b/components/SectionTitle.tsx @@ -0,0 +1,14 @@ +interface Props { + children: string; +} + +export function SectionTitle(props: Props) { + const { children } = props; + + return ( +
+ {children} +
+
+ ); +} diff --git a/components/Title.tsx b/components/Title.tsx new file mode 100644 index 0000000..e054f15 --- /dev/null +++ b/components/Title.tsx @@ -0,0 +1,86 @@ +import classNames from 'classnames'; +interface Props { + level: 1 | 2 | 3 | 4; + small?: boolean; + bold?: boolean; + soft?: boolean; + disabled?: boolean; + margin?: boolean; + className?: string; + children: React.ReactNode; +} + +export function Title(props: Props) { + const { + level, + children, + className = '', + bold = false, + small = false, + soft = false, + disabled = false, + margin = true, + } = props; + + const opacity = disabled ? 'opacity-25' : soft ? 'opacity-75' : undefined; + + const commonClassNames = classNames( + disabled && 'select-none', + margin && 'mb-2', + opacity, + className, + ); + + return ( + <> + {level === 1 && ( +

+ {children} +

+ )} + {level === 2 && ( +

+ {children} +

+ )} + {level === 3 && ( +

+ {children} +

+ )} + {level === 4 && ( +

+ {children} +

+ )} + + ); +} diff --git a/components/article/Article.tsx b/components/article/Article.tsx new file mode 100644 index 0000000..da96044 --- /dev/null +++ b/components/article/Article.tsx @@ -0,0 +1,48 @@ +import React, { useContext } from 'react'; +import { ScreenContext } from '../../contexts/screen'; +import { IArticle } from '../../types/article'; +import { ArticleSectionAbstract } from './sections/ArticleSectionAbstract'; +import { ArticleSectionContent } from './sections/ArticleSectionContent'; +import { ArticleSectionTitle } from './sections/ArticleSectionTitle'; +import { ArticleSubtitleSection } from './sections/ArticleSubtitleSection'; + +export function Article(props: IArticle) { + const { isMobile } = useContext(ScreenContext); + + return ( +
+ {isMobile ? : } +
+ ); +} + +function ArticleMobile(props: IArticle) { + const { id, title, slug, subtitle, author, date, city, location } = props; + + return ( +
+ + + + + +
+ ); +} + +function ArticleDesktop(props: IArticle) { + const { id, title, subtitle, author, date, slug, city, location } = props; + + return ( +
+ + + + + +
+ ); +} diff --git a/components/article/sections/ArticleSectionAbstract.tsx b/components/article/sections/ArticleSectionAbstract.tsx new file mode 100644 index 0000000..5b606ed --- /dev/null +++ b/components/article/sections/ArticleSectionAbstract.tsx @@ -0,0 +1,21 @@ +import React, { ReactNode } from 'react'; +import { ILocation } from '../../../types/article'; +import { Contained } from '../../Contained'; + +interface Props { + city: string; + location: ILocation; + children?: ReactNode; +} + +export function ArticleSectionAbstract(props: Props) { + const { city, location, children } = props; + + return ( + +
+ {children} +
+
+ ); +} diff --git a/components/article/sections/ArticleSectionContent.tsx b/components/article/sections/ArticleSectionContent.tsx new file mode 100644 index 0000000..3f35c7e --- /dev/null +++ b/components/article/sections/ArticleSectionContent.tsx @@ -0,0 +1,84 @@ +import BlockContent from '@sanity/block-content-to-react'; +import React, { useContext } from 'react'; +import { SANITY_CONSTATNTS } from '../../../client'; +import { ScreenContext } from '../../../contexts/screen'; +import { IArticle } from '../../../types/article'; +import { Contained } from '../../Contained'; +import { ArticleSectionFeatureImage } from './ArticleSectionFeatureImage'; + +const paragraphs = [ + `Lorem ipsum, dolor sit amet consectetur adipisicing elit. Hic, labore consequatur. Architecto, aperiam impedit. Corrupti inventore eius, exercitationem odit est fugit, iste aspernatur incidunt quod iure aliquid fugiat. Earum, voluptate. Lorem ipsum, dolor sit amet consectetur adipisicing elit. Hic, labore consequatur. Architecto, aperiam impedit. Corrupti inventore eius, exercitationem odit est fugit, iste aspernatur incidunt quod iure aliquid fugiat. Earum, voluptate. Lorem ipsum, dolor sit amet consectetur adipisicing elit. Hic, labore consequatur. Architecto, aperiam impedit. Corrupti inventore eius, exercitationem odit est fugit, iste aspernatur incidunt quod iure aliquid fugiat. Earum, voluptate. Lorem ipsum, dolor sit amet consectetur adipisicing elit. Hic, labore consequatur. Architecto, aperiam impedit.', 'Corrupti inventore eius, exercitationem +odit est fugit, iste aspernatur incidunt quod iure aliquid fugiat. +Earum, voluptate. Lorem ipsum, dolor sit amet consectetur adipisicing +elit. Hic, labore consequatur. Architecto, aperiam impedit. Corrupti +inventore eius, exercitationem odit est fugit, iste aspernatur +incidunt quod iure aliquid fugiat. Earum, voluptate. Lorem ipsum, +dolor sit amet consectetur adipisicing elit. Hic, labore consequatur. +Architecto, aperiam impedit. Corrupti inventore eius, exercitationem +odit est fugit, iste aspernatur incidunt quod iure aliquid fugiat. +Earum, voluptate. Lorem ipsum, dolor sit amet consectetur adipisicing +elit. Hic, labore consequatur. Architecto, aperiam impedit. Corrupti +inventore eius, exercitationem odit est fugit, iste aspernatur +incidunt quod iure aliquid fugiat. Earum, voluptate.`, + `Lorem ipsum, dolor sit amet consectetur adipisicing elit. Hic, labore +consequatur. Architecto, aperiam impedit. Corrupti inventore eius, +exercitationem odit est fugit, iste aspernatur incidunt quod iure +aliquid fugiat. Earum, voluptate. Lorem ipsum, dolor sit amet +consectetur adipisicing elit. Hic, labore consequatur. Architecto, +aperiam impedit. Corrupti inventore eius, exercitationem odit est +fugit, iste aspernatur incidunt quod iure aliquid fugiat. Earum, +voluptate. Lorem ipsum, dolor sit amet consectetur adipisicing elit. +Hic, labore consequatur. Architecto, aperiam impedit. Corrupti +inventore eius, exercitationem odit est fugit, iste aspernatur +incidunt quod iure aliquid fugiat.`, + `Earum, voluptate. Lorem ipsum, +dolor sit amet consectetur adipisicing elit. Hic, labore consequatur. +Architecto, aperiam impedit. Corrupti inventore eius, exercitationem +odit est fugit, iste aspernatur incidunt quod iure aliquid fugiat. +Earum, voluptate. Lorem ipsum, dolor sit amet consectetur adipisicing +elit. Hic, labore consequatur. Architecto, aperiam impedit. Corrupti +inventore eius, exercitationem odit est fugit, iste aspernatur +incidunt quod iure aliquid fugiat. Earum, voluptate. Lorem ipsum, +dolor sit amet consectetur adipisicing elit. Hic, labore consequatur. +Architecto, aperiam impedit. Corrupti inventore eius, exercitationem +odit est fugit, iste aspernatur incidunt quod iure aliquid fugiat.`, +]; + +export function ArticleSectionContent(post: IArticle) { + const { isDesktop } = useContext(ScreenContext); + + return ( + + {isDesktop ? : } + + ); +} + +const MobileContent = (post: IArticle) => ( +
+
{paragraphs[0]}
+ + + + +
+); + +const DesktopContent = (post: IArticle) => ( +
+
+
+ +
+
+ +
+); diff --git a/components/article/sections/ArticleSectionFeatureImage.tsx b/components/article/sections/ArticleSectionFeatureImage.tsx new file mode 100644 index 0000000..f2f4464 --- /dev/null +++ b/components/article/sections/ArticleSectionFeatureImage.tsx @@ -0,0 +1,27 @@ +import { IFigureImage } from '../../../types/article'; + +interface Props { + featureImage: IFigureImage; +} + +export function ArticleSectionFeatureImage({ featureImage }: Props) { + return ( +
+
+
+ {featureImage.altText} +
+
+ +
{featureImage.description}
+
+ ); +} diff --git a/components/article/sections/ArticleSectionTitle.tsx b/components/article/sections/ArticleSectionTitle.tsx new file mode 100644 index 0000000..0a95090 --- /dev/null +++ b/components/article/sections/ArticleSectionTitle.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { UI } from '../../../constants'; +import { IAuthor } from '../../../types/article'; +import { Contained } from '../../Contained'; +import { ArticleWidgetAuthor } from '../widgets/ArticleWidgetAuthor'; + +interface Props { + title: string; + author: IAuthor; + date: string; +} + +export function ArticleSectionTitle(props: Props) { + const { title, author, date } = props; + + return ( + +
+
+

+ {title} +

+
+ + +
+
+ ); +} diff --git a/components/article/sections/ArticleSubtitleSection.tsx b/components/article/sections/ArticleSubtitleSection.tsx new file mode 100644 index 0000000..f50e741 --- /dev/null +++ b/components/article/sections/ArticleSubtitleSection.tsx @@ -0,0 +1,26 @@ +import { useContext } from 'react'; +import { ScreenContext } from '../../../contexts/screen'; +import { Contained } from '../../Contained'; + +interface Props { + subtitle: string; +} + +export function ArticleSubtitleSection({ subtitle }: Props) { + const { isMobile, isDesktop } = useContext(ScreenContext); + + return ( + +
+ + {subtitle} + +
+
+ ); +} diff --git a/components/article/widgets/ArticleFeatureVideoWidget.tsx b/components/article/widgets/ArticleFeatureVideoWidget.tsx new file mode 100644 index 0000000..7842e32 --- /dev/null +++ b/components/article/widgets/ArticleFeatureVideoWidget.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import YouTube from 'react-youtube'; + +interface Props { + video: string; +} + +export function ArticleFeatureVideoWidget({ video }: Props) { + return ( +
+
+ user started video + // onPlay={() => } + /> + + {/*
+
+
+
+
*/} +
+ +
+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Soluta labore + eius iure aliquid asperiores cum, quibusdam sapiente! +
+
+ ); +} diff --git a/components/article/widgets/ArticleShareWidget.tsx b/components/article/widgets/ArticleShareWidget.tsx new file mode 100644 index 0000000..a9a9d2d --- /dev/null +++ b/components/article/widgets/ArticleShareWidget.tsx @@ -0,0 +1,105 @@ +import { useRouter } from 'next/router'; +import React, { useState } from 'react'; +// import ShareSVG from '../../../assets/svgs/share.svg'; +import { + shareToFacebook, + shareToReddit, + shareToTwitter, + shareToWhatsApp, +} from '../../../utils/share'; +import { Button } from '../../Button'; +import { Dropdown } from '../../Dropdown'; +import { DropdownItem } from '../../DropdownItem'; +import { Input } from '../../Input'; +import { InputGroup } from '../../InputGroup'; + +interface IShareDropdownItems { + id: string; + name: string; + onClick: () => void; +} + +interface Props { + id: string; + title: string; + slug: string; +} + +export function ArticleShareWidget(props: Props) { + const { id, title, slug } = props; + + const router = useRouter(); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const articleUrl = `oxen.io${router.asPath}`; + + const dropdownItems: Array = [ + { + id: 'share-to-facebook', + name: 'Facebook', + onClick: () => shareToFacebook(title, slug), + }, + { + id: 'share-to-twitter', + name: 'Twitter', + onClick: () => shareToTwitter(title, slug), + }, + { + id: 'share-to-whatsapp', + name: 'WhatsApp', + onClick: () => shareToWhatsApp(title, slug), + }, + { + id: 'share-to-reddit', + name: 'Reddit', + onClick: () => shareToReddit(title, slug), + }, + ]; + + return ( +
+
+
setIsDropdownOpen(true)} + > + {/* */} + Share +
+ + setIsDropdownOpen(false)} + pull="center" + offsetX={-50} + offsetY={25} + > + <> +
+ + + + + +
+ + {dropdownItems.map(item => ( + +
{item.name}
+
+ ))} + +
+
+
+ ); +} diff --git a/components/article/widgets/ArticleWidgetAuthor.tsx b/components/article/widgets/ArticleWidgetAuthor.tsx new file mode 100644 index 0000000..84e05a6 --- /dev/null +++ b/components/article/widgets/ArticleWidgetAuthor.tsx @@ -0,0 +1,22 @@ +import { IAuthor } from '../../../types/article'; +// import { Avatar } from '../../Avatar'; + +interface Props { + author: IAuthor; + date: string; +} + +export function ArticleWidgetAuthor({ author, date }: Props) { + return ( +
+ {/* */} + +
+ + By: {author.name} + + {date} +
+
+ ); +} diff --git a/components/cards/ArticleCard.tsx b/components/cards/ArticleCard.tsx new file mode 100644 index 0000000..ac18bb6 --- /dev/null +++ b/components/cards/ArticleCard.tsx @@ -0,0 +1,97 @@ +import classNames from 'classnames'; +import router from 'next/dist/client/router'; +import { SyntheticEvent } from 'react'; +import { useMeasure } from 'react-use'; +import { ISanityArticle } from '../../types/article'; +import { generateURL } from '../../utils/routing'; +import { titleCase } from '../../utils/text'; +import { OutlineBlock } from '../OutlineBlock'; + +export function ArticleCard(props: ISanityArticle): JSX.Element { + const { + featureImage, + title, + paragraph, + /*tags*/ slug, + city, + category, + } = props; + + const [ref, { width }] = useMeasure(); + const isSmall = width < 130; + + const handleClick = (e: SyntheticEvent) => { + const { href, as } = generateURL(slug); + + e.preventDefault(); + router.push(href, as); + }; + + const tags = ['crepes', 'sweet']; + + return ( +
handleClick(e)} + > +
+ {featureImage.source && ( +
+ {featureImage?.altText} +
+ )} +
+ +
+
+
+ {title} +
+

{paragraph}

+
+ +
+ {tags + .filter(tag => Boolean(tag)) + // Maximum of three tags + .slice(0, 3) + .map(tag => ( +
+ {isSmall ? ( + + {titleCase(tag)} + + ) : ( + + {titleCase(tag)} + + )} +
+ ))} +
+
+
+ ); +} diff --git a/components/cards/ArticleCardFavourite.tsx b/components/cards/ArticleCardFavourite.tsx new file mode 100644 index 0000000..35b1e1f --- /dev/null +++ b/components/cards/ArticleCardFavourite.tsx @@ -0,0 +1,81 @@ +import classNames from 'classnames'; +import router from 'next/dist/client/router'; +import Link from 'next/link'; +import React, { SyntheticEvent, useContext } from 'react'; +import { useMeasure } from 'react-use'; +// import ShareSVG from '../../assets/svgs/share.svg'; +import { ScreenContext } from '../../contexts/screen'; +import { ISanityArticle } from '../../types/article'; +import { generateURL } from '../../utils/routing'; + +interface Props extends Partial { + isFavourite: boolean; +} + +export function ArticleCardFavourite(props: Props): JSX.Element { + const { id, featureImage, title, city, slug, category, isFavourite } = props; + const { isMobile, isTablet, isDesktop, isHuge } = useContext(ScreenContext); + + const [ref, { width }] = useMeasure(); + const isSmall = width < 130; + + const { href, as } = generateURL(slug); + const handleClick = (e: SyntheticEvent) => { + e.preventDefault(); + router.push(href, as); + }; + + return ( +
+
handleClick(e)} + style={{ paddingBottom: '80%' }} + className={classNames( + 'relative w-full h-0 overflow-hidden bg-primary bg-opacity-10', + isSmall ? 'rounded-lg' : 'rounded-xl', + )} + > + {featureImage.source && ( +
+ {featureImage?.altText} +
+ )} +
+ +
+
+ + {title} + +
+ +
+
+ {/* */} + {!isMobile && 'Share'} +
+
+
+
+ ); +} diff --git a/components/cards/ArticleCardRow.tsx b/components/cards/ArticleCardRow.tsx new file mode 100644 index 0000000..bcb5264 --- /dev/null +++ b/components/cards/ArticleCardRow.tsx @@ -0,0 +1,74 @@ +import Link from 'next/link'; +import React, { useContext } from 'react'; +import { ScreenContext } from '../../contexts/screen'; +import { IArticle } from '../../types/article'; +import { generateURL } from '../../utils/routing'; + +export function ArticleCardRow(post: IArticle) { + const { isMobile } = useContext(ScreenContext); + const { city, slug, category } = post; + const { href, as } = generateURL(slug); + + const ArticlePreviewContent = () => ( +

+ {post.subtitle} +

+ ); + + const ArticlePreviewImage = () => ( +
+ {post?.featureImage?.source && ( + {post.featureImage.altText} + )} +
+ ); + + return ( + <> + {isMobile ? ( +
+
+ +
+

+ {post.title} +

+
+
+ + +
+ ) : ( +
+ +
+ + {post.title} + + + +
+
+ )} + + ); +} diff --git a/components/cards/CardGrid.tsx b/components/cards/CardGrid.tsx new file mode 100644 index 0000000..497374b --- /dev/null +++ b/components/cards/CardGrid.tsx @@ -0,0 +1,37 @@ +import classNames from 'classnames'; +import _ from 'lodash'; +import React, { useContext } from 'react'; +import { v4 as uuid } from 'uuid'; +import { ScreenContext } from '../../contexts/screen'; + +interface Props { + children: JSX.Element[]; +} + +export function CardGrid({ children }: Props) { + const { isMobile, isTablet, isDesktop, isHuge } = useContext(ScreenContext); + + return ( +
+ {_.chunk(children, isHuge ? 4 : isDesktop || isTablet ? 3 : 2).map( + group => ( +
+ {group.map((item, index) => ( +
+ {item} +
+ ))} +
+ ), + )} +
+ ); +} diff --git a/components/design/DesignColorPalette.tsx b/components/design/DesignColorPalette.tsx new file mode 100644 index 0000000..7672e83 --- /dev/null +++ b/components/design/DesignColorPalette.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { SectionTitle } from '../SectionTitle'; + +export function ColorPalette() { + return ( + <> +
+ Color Palette +
+
+
+
+ 0 +
+
+ 1 +
+
+ 2 +
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+ + ); +} diff --git a/components/header/Header.tsx b/components/header/Header.tsx new file mode 100644 index 0000000..d520bb6 --- /dev/null +++ b/components/header/Header.tsx @@ -0,0 +1,121 @@ +import Link from 'next/link'; +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +// import SearchPrimarySVG from '../../assets/svgs/search-primary.svg'; +import { UI } from '../../constants'; +import { ScreenContext } from '../../contexts/screen'; +import { expandSearchOverlay } from '../../state/navigation'; +import { IState } from '../../state/reducers'; +import { Contained } from '../Contained'; +import { HeaderSearch } from './HeaderSearch'; + +export function Header() { + const { isMobile } = useContext(ScreenContext); + + return ( +
+
{isMobile ? : }
+
+ ); +} + +function MobileHeader() { + const navigationState = useSelector((state: IState) => state.navigation); + const searchState = useSelector((state: IState) => state.search); + const dispatch = useDispatch(); + + const handleExpandSearch = (e: React.MouseEvent) => { + // Timeout to prevent action immediately firing on the elemnt under with onMouseUp + setTimeout(() => dispatch(expandSearchOverlay()), 50); + e.stopPropagation(); + }; + + return ( +
+
+
+ {/* */} +
+ + +
+
+ ); +} + +function DesktopHeader() { + const { searchOverlayExpanded } = useSelector( + (state: IState) => state.navigation, + ); + const { searchBarPinnedToHeader } = useSelector( + (state: IState) => state.search, + ); + + // We only wnat the searchbar to be invisible on the home page + // and given that they have not scrolled past the main home search + const [searchIsShown, setSearchIsShown] = useState(false); + useEffect(() => { + if ( + (location.pathname === '/' && searchBarPinnedToHeader) || + location.pathname !== '/' + ) { + if (!searchIsShown) { + setSearchIsShown(true); + } + } else { + if (searchIsShown) { + setSearchIsShown(false); + } + } + }, [location.pathname, searchBarPinnedToHeader]); + + const navBarRef = useRef(null); + console.log('Header ➡️ location.pathname:', location.pathname); + + return ( +
+ +
+
+
+ + + {/* */} + + + +
+
+
+
+
+ ); +} diff --git a/components/header/HeaderSearch.tsx b/components/header/HeaderSearch.tsx new file mode 100644 index 0000000..b943685 --- /dev/null +++ b/components/header/HeaderSearch.tsx @@ -0,0 +1,62 @@ +import classNames from 'classnames'; +import React, { CSSProperties, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { UI } from '../../constants'; +import { expandSearchOverlay } from '../../state/navigation'; +import { IState } from '../../state/reducers'; +import { SearchDropdown } from '../search/SearchDropdown'; +import { SearchInput } from '../search/SearchInput'; + +interface Props { + isShown: boolean; + innerOverlayStyle: CSSProperties; +} + +export function HeaderSearch({ isShown, innerOverlayStyle = {} }: Props) { + const navigationState = useSelector((state: IState) => state.navigation); + const searchState = useSelector((state: IState) => state.search); + const { searchOverlayExpanded } = navigationState; + const dispatch = useDispatch(); + + const searchRef = useRef(null); + + return ( +
+
+ dispatch(expandSearchOverlay())} + /> +
+ +
+ +
+
+ ); +} diff --git a/components/layout/index.tsx b/components/layout/index.tsx new file mode 100644 index 0000000..f8b7c84 --- /dev/null +++ b/components/layout/index.tsx @@ -0,0 +1,25 @@ +import React, { ReactNode } from 'react'; +import { Footer } from '../Footer'; +import { Header } from '../header/Header'; +import { SearchOverlay } from '../search/SearchOverlay'; + +interface Props { + children: ReactNode; +} + +export default function Layout({ children }: Props) { + return ( +
+
+ +
+ +
{children}
+
+ +
+
+
+
+ ); +} diff --git a/components/modals/LoginModal.tsx b/components/modals/LoginModal.tsx new file mode 100644 index 0000000..6ea5dcb --- /dev/null +++ b/components/modals/LoginModal.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +// import OxenLogo from '../../assets/svgs/brand.svg'; +// import EmailLogoSVG from '../../assets/svgs/hot.svg'; +import { ModalInstance } from '../../state/navigation'; +import { Button } from '../Button'; +import { Modal } from '../Modal'; + +interface Props { + isOpen: boolean; + close?: () => void; +} + +export function LoginModal(props: Props) { + return ( + +
+
+ {/* */} + +

Hello!

+ + +
+ +
+ By proceeding, you agree to our{' '} + + Terms of Use + +
+
+
+ ); +} diff --git a/components/search/SearchDropdown.tsx b/components/search/SearchDropdown.tsx new file mode 100644 index 0000000..f34e448 --- /dev/null +++ b/components/search/SearchDropdown.tsx @@ -0,0 +1,59 @@ +import classNames from 'classnames'; +import React, { CSSProperties, useRef } from 'react'; +import { useSelector } from 'react-redux'; +import { UI } from '../../constants'; +import { IState } from '../../state/reducers'; +import { SearchOverlayInner } from './SearchOverlayInner'; + +interface Props { + isShown: boolean; + innerOverlayStyle?: CSSProperties; +} + +export function SearchDropdown({ isShown, innerOverlayStyle = {} }: Props) { + const { searchOverlayExpanded } = useSelector( + (state: IState) => state.navigation, + ); + const { searchBarPinnedToHeader } = useSelector( + (state: IState) => state.search, + ); + + const overlayContentRef = useRef(null); + + // Desktop overlay styles depend on wether or not the search bar is + // in the navbar or not + const desktopOverlayStyles = { + zIndex: searchOverlayExpanded && isShown ? UI.Z_INDEX_SEARCH_DROPDOWN : -1, + display: searchOverlayExpanded && isShown ? 'block' : 'none', + minHeight: '600px', + }; + + return ( + <> +
+
+
+ +
+
+
+ + ); +} diff --git a/components/search/SearchInput.tsx b/components/search/SearchInput.tsx new file mode 100644 index 0000000..2024469 --- /dev/null +++ b/components/search/SearchInput.tsx @@ -0,0 +1,184 @@ +import classNames from 'classnames'; +import { useRouter } from 'next/router'; +import React, { ChangeEvent, ReactNode, useEffect, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useKey, useStartTyping } from 'react-use'; +import { + collapseSearchOverlay, + expandSearchOverlay, +} from '../../state/navigation'; +import { IState } from '../../state/reducers'; +import { setSearchQuery } from '../../state/search'; + +interface Props { + placeholder?: string; + autofocus?: boolean; + className?: string; + inputClassName?: string; + + prefix?: ReactNode; + searchIcon?: 'primary' | 'secondary'; + + // A dummy input is purely visual. + dummy?: boolean; + dummyOnClick?(): void; + onFocus?(): void; + onChange?(value: string): void; +} + +// Pure functionality. This component is not intended to be used as a standalone. +// Its parent should provide styling +export function SearchInput(props: Props) { + const { + autofocus = false, + placeholder, + className, + inputClassName, + searchIcon, + prefix, + dummy, + dummyOnClick, + onFocus, + onChange, + } = props; + + // State + // const navigationState = useSelector((state: IState) => state.navigation); + const searchState = useSelector((state: IState) => state.search); + // const { searchOverlayExpanded } = navigationState; + const searchOverlayExpanded = true; + const dispatch = useDispatch(); + + // References + const inputRef = useRef(null); + + // Hooks + const router = useRouter(); + + // Force focus when user starts typing + useStartTyping(() => inputRef?.current?.focus()); + const inputValue = searchState.searchQuery; + + // Internal functions + const pushToSearchPage = () => { + const input = inputRef.current as HTMLInputElement; + if (input?.value?.length && input?.focus) { + router.push({ + pathname: '/search', + query: { s: encodeURI(input?.value) }, + }); + } + + dispatch(collapseSearchOverlay()); + }; + + // Handler Functions + const handleFocus = () => { + if (onFocus) { + onFocus(); + } + + inputRef?.current?.focus(); + if (!searchOverlayExpanded) { + dispatch(expandSearchOverlay()); + } + }; + + const handleOnChange = (event: ChangeEvent) => { + const value = String(event.target.value); + dispatch(setSearchQuery(String(value))); + + if (onChange) { + onChange(value); + } + + // Bring up overlay when they start typing + if (String(value).length > 0) { + dispatch(expandSearchOverlay()); + } + + inputRef?.current?.focus(); + }; + + // Search on enter + useKey('Enter', pushToSearchPage); + + // Effects + useEffect(() => { + // search(String(inputValue)); + }, [inputValue]); + + // Autofocus + useEffect(() => { + setTimeout(() => { + if (autofocus) { + inputRef?.current?.focus(); + } + }, 50); + }, []); + + return ( +
+ {prefix &&
{prefix}
} + + {dummy ? ( +
dummyOnClick && dummyOnClick()} + className={classNames( + 'flex', + 'flex-grow', + 'border-none', + 'cursor-text', + 'outline-none', + 'opacity-50', + 'w-0', + inputClassName, + )} + > + {placeholder} +
+ ) : ( + inputRef?.current?.focus()} + onFocus={handleFocus} + onChange={handleOnChange} + /> + )} + + {searchIcon && ( + // Internal elements +
{ + if (searchOverlayExpanded) { + pushToSearchPage(); + } else { + dispatch(expandSearchOverlay()); + } + }} + > + {/* {searchIcon === 'primary' && ( + + )} + {searchIcon === 'secondary' && ( + + )} */} +
+ )} +
+ ); +} diff --git a/components/search/SearchItem.tsx b/components/search/SearchItem.tsx new file mode 100644 index 0000000..b90c89c --- /dev/null +++ b/components/search/SearchItem.tsx @@ -0,0 +1,50 @@ +import classNames from 'classnames'; +import { useRouter } from 'next/dist/client/router'; +import React, { SyntheticEvent, useContext } from 'react'; +import { useDispatch } from 'react-redux'; +import { ScreenContext } from '../../contexts/screen'; +import { ISanityArticle } from '../../types/article'; +import { generateURL } from '../../utils/routing'; + +export function SearchItem(props: ISanityArticle) { + const dispatch = useDispatch(); + const router = useRouter(); + const { title, featureImage, city, category, slug } = props; + + const { isMobile } = useContext(ScreenContext); + + const handleClick = (e: SyntheticEvent) => { + const { href, as } = generateURL(slug); + e.preventDefault(); + router.push(href, as); + }; + + return ( +
handleClick(e)} + > +
+ {featureImage.altText} +
+ +
+
{title}
+
+
+ ); +} diff --git a/components/search/SearchOverlay.tsx b/components/search/SearchOverlay.tsx new file mode 100644 index 0000000..996b82f --- /dev/null +++ b/components/search/SearchOverlay.tsx @@ -0,0 +1,19 @@ +import React, { useContext } from 'react'; +import { useDispatch } from 'react-redux'; +import { useKey } from 'react-use'; +import { ScreenContext } from '../../contexts/screen'; +import { collapseSearchOverlay } from '../../state/navigation'; +import { SearchOverlayBackdrop } from './SearchOverlayBackdrop'; +import { SearchOverlayMobile } from './SearchOverlayMobile'; + +// Search overlay includes the backdrop and the mobile overlay. +// Search dropdown is desktop only and is rendered per component +export function SearchOverlay() { + const { isMobile } = useContext(ScreenContext); + const dispatch = useDispatch(); + + // Close on escape + useKey('Escape', () => dispatch(collapseSearchOverlay())); + + return <>{isMobile ? : }; +} diff --git a/components/search/SearchOverlayBackdrop.tsx b/components/search/SearchOverlayBackdrop.tsx new file mode 100644 index 0000000..954abf6 --- /dev/null +++ b/components/search/SearchOverlayBackdrop.tsx @@ -0,0 +1,36 @@ +import classNames from 'classnames'; +import { useDispatch, useSelector } from 'react-redux'; +import { UI } from '../../constants'; +import { collapseSearchOverlay } from '../../state/navigation'; +import { IState } from '../../state/reducers'; + +export function SearchOverlayBackdrop() { + const navigationState = useSelector((state: IState) => state.navigation); + const dispatch = useDispatch(); + + const { searchOverlayExpanded } = navigationState; + const onClickAway = () => { + if (searchOverlayExpanded) { + dispatch(collapseSearchOverlay()); + } + }; + + return ( +
+ ); +} diff --git a/components/search/SearchOverlayInner.tsx b/components/search/SearchOverlayInner.tsx new file mode 100644 index 0000000..16d4341 --- /dev/null +++ b/components/search/SearchOverlayInner.tsx @@ -0,0 +1,131 @@ +import classNames from 'classnames'; +import { useRouter } from 'next/router'; +import React, { useContext } from 'react'; +import { useSelector } from 'react-redux'; +import { ScreenContext } from '../../contexts/screen'; +import { useSearch } from '../../hooks/search'; +import { IState } from '../../state/reducers'; +import { SVG } from '../../types'; +import { Button } from '../Button'; +import { ArticleCard } from '../cards/ArticleCard'; + +interface IDynamicOptions { + name: string; + href: string; + svg: SVG; +} + +const dynamicCategories: Array = [ + { name: 'Trending', href: '/search/trending', svg: null as SVG }, + { name: 'New!', href: '/search/new', svg: null as SVG }, +]; + +export function SearchOverlayInner() { + const searchState = useSelector((state: IState) => state.search); + + const renderSearchResults = + searchState.searchQuery.length > 0 && + searchState.searchResultItems?.length > 0; + + const renderSearchDefaltTemplate = + searchState?.searchQuery?.length === 0 || + (searchState?.searchQuery?.length > 0 && + searchState.searchResultItems?.length === 0); + + const renderSearchNoResults = + searchState.searchQuery.length > 0 && + searchState?.searchResultItems?.length === 0; + + const { isMobile } = useContext(ScreenContext); + const router = useRouter(); + + return ( + <> +
+
+
+ + {renderSearchResults && } + {renderSearchDefaltTemplate && } + + ); +} + +function SearchOverlayInnerDefault() { + const { searchBarPinnedToHeader } = useSelector( + (state: IState) => state.search, + ); + + const { isMobile } = useContext(ScreenContext); + const router = useRouter(); + + return ( +
+ {/* FEATURED DYNAMIC CATEGORIES */} +
+ {dynamicCategories.map(category => ( +
router.push(category.href)} + className="flex items-center text-lg cursor-pointer text-primary font-roboto font-medium rounded-lg hover:bg-primary hover:bg-opacity-10" + > + + {category.name} +
+ ))} +
+
+ ); +} + +function SearchOverlayInnerResults() { + const { results: allResults, query: searchQuery } = useSearch(); + const { isMobile } = useContext(ScreenContext); + const router = useRouter(); + + // Sort results by popularity and filter down to four results + console.log('Results', allResults); + const results = allResults?.slice(0, 4); + + return ( + <> +
+ {results?.map(card => ( +
+ +
+ ))} +
+
+ +
+ + ); +} diff --git a/components/search/SearchOverlayMobile.tsx b/components/search/SearchOverlayMobile.tsx new file mode 100644 index 0000000..4821e8e --- /dev/null +++ b/components/search/SearchOverlayMobile.tsx @@ -0,0 +1,57 @@ +import classNames from 'classnames'; +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +// import BackSVG from '../../assets/svgs/back.svg'; +import { UI } from '../../constants'; +import { collapseSearchOverlay } from '../../state/navigation'; +import { IState } from '../../state/reducers'; +import { Contained } from '../Contained'; +import { SearchInput } from './SearchInput'; +import { SearchOverlayInner } from './SearchOverlayInner'; + +export function SearchOverlayMobile() { + const navigationState = useSelector((state: IState) => state.navigation); + const { searchOverlayExpanded } = navigationState; + + const dispatch = useDispatch(); + + // Internal functions + const handleExit = (e: React.MouseEvent) => { + // Timeout to prevent action immediately firing on the elemnt under with onMouseUp + setTimeout(() => dispatch(collapseSearchOverlay()), 50); + e.stopPropagation(); + }; + + // Internal elements + const searchInputPrefix = ( + + {/* */} + + ); + + return ( +
+
+ + + + + +
+
+ ); +} diff --git a/constants/firebase.ts b/constants/firebase.ts new file mode 100644 index 0000000..9bb5ae3 --- /dev/null +++ b/constants/firebase.ts @@ -0,0 +1,30 @@ +export interface IFirestore { + data: any; +} + +// const CERT = { +// privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'), +// clientEmail: process.env.FIREBASE_CLIENT_EMAIL, +// projectId: process.env.FIREBASE_PROJECT_ID, +// }; + +// const FIREBASE = { +// CLIENT_CONFIG: { +// apiKey: process.env.FIREBASE_API_KEY, +// authDomain: process.env.FIREBASE_AUTH_DOMAIN, +// databaseURL: process.env.FIREBASE_DATABASE_URL, +// projectId: process.env.FIREBASE_PROJECT_ID, +// storageBucket: process.env.FIREBASE_STORAGE_BUCKET, +// messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID, +// appId: process.env.FIREBASE_APP_ID, +// measurementId: process.env.FIREBASE_MEASUREMENT_ID, +// }, +// // ADMIN_CONFIG: { +// // credential: firebaseAdmin.credential.cert(CERT), +// // databaseURL: process.env.FIREBASE_DATABASE_URL, +// // }, +// // react-redux-firebase config +// RRF_CONFIG: {}, +// }; + +// export default FIREBASE; diff --git a/constants/index.ts b/constants/index.ts new file mode 100644 index 0000000..c7615a6 --- /dev/null +++ b/constants/index.ts @@ -0,0 +1,6 @@ +// import FIREBASE from './firebase'; +import METADATA from './metadata'; +import SEARCH from './search'; +import UI from './ui'; + +export { UI, METADATA, SEARCH }; diff --git a/constants/metadata.tsx b/constants/metadata.tsx new file mode 100644 index 0000000..d1d33af --- /dev/null +++ b/constants/metadata.tsx @@ -0,0 +1,6 @@ +const METADATA = { + OXEN_HOST_URL: 'https://oxen.io', + TITLE_SUFFIX: 'Oxen | Privacy made simple.', +}; + +export default METADATA; diff --git a/constants/search.ts b/constants/search.ts new file mode 100644 index 0000000..95752b3 --- /dev/null +++ b/constants/search.ts @@ -0,0 +1,6 @@ +const SEARCH = { + SEARCH_ITEMS_PER_PAGE: 10, + SOFT_LIMIT_SEARCH_RESULTS_OVERLAY: 4, +}; + +export default SEARCH; diff --git a/constants/ui.ts b/constants/ui.ts new file mode 100644 index 0000000..5ab55e7 --- /dev/null +++ b/constants/ui.ts @@ -0,0 +1,21 @@ +const UI = { + MOBILE_BREAKPOINT: 500, + TABLET_BREAKPOINT: 715, + DESKTOP_BREAKPOINT: 1100, + MAX_CONTENT_WIDTH: 1300, + PAGE_CONTAINED_PADDING_VW: 5, + + USER_QUERY_404_MAX_LEN: 500, + + Z_INDEX_HEADER: 1000, + Z_INDEX_SEARCH_OVERLAY: 20000, + Z_INDEX_HEADER_SEARCH: 20001, + Z_INDEX_SEARCH_DROPDOWN: 20002, + Z_INDEX_MODAL_OVERLAY: 33333, + + ARTICLE: { + TITLE_MAX_WIDTH_REM: 29, + }, +}; + +export default UI; diff --git a/contexts/screen.tsx b/contexts/screen.tsx new file mode 100644 index 0000000..993fd2d --- /dev/null +++ b/contexts/screen.tsx @@ -0,0 +1,30 @@ +import React, { useEffect } from 'react'; +import { useScreenSize } from '../hooks/screen'; + +interface IScreen { + isMobile: boolean; + isTablet: boolean; + isDesktop: boolean; + isHuge: boolean; +} + +export const ScreenContext = React.createContext(undefined); + +const ScreenProvider = ({ children }) => { + const screenParams: IScreen = useScreenSize(); + + useEffect(() => { + console.log('screen ➡️ screenParams.isMobile:', screenParams.isMobile); + console.log('screen ➡️ screenParams.isTablet:', screenParams.isTablet); + console.log('screen ➡️ screenParams.isDesktop', screenParams.isDesktop); + console.log('screen ➡️ screenParams.isHuge', screenParams.isHuge); + }, [screenParams]); + + return ( + + {children} + + ); +}; + +export default ScreenProvider; diff --git a/hooks/screen.ts b/hooks/screen.ts new file mode 100644 index 0000000..f065480 --- /dev/null +++ b/hooks/screen.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from 'react'; +import { useWindowSize } from 'react-use'; +import { UI } from '../constants'; + +export function useScreenSize() { + // Default to mobile view + // const isMobile = useMedia(`(max-width: ${UI.TABLET_BREAKPOINT}px)`, true); + // const isTablet = useMedia( + // `(min-width: ${UI.MOBILE_BREAKPOINT}px) and (max-width: ${UI.TABLET_BREAKPOINT}px)`, + // false, + // ); + // const isDesktop = useMedia(`(min-width: ${UI.TABLET_BREAKPOINT}px)`, false); + // const isHuge = useMedia(`(min-width: ${UI.DESKTOP_BREAKPOINT}px)`, false); + + // console.log('screen ➡️ isMobile:', isMobile); + // console.log('screen ➡️ isTablet:', isTablet); + // console.log('screen ➡️ isDesktop:', isDesktop); + // console.log('screen ➡️ isHuge:', isHuge); + + // Use width rather than media queries for improved performance. + const { width } = useWindowSize(); + + // Default to mobile view + const [isMobile, setIsMobile] = useState(true); + const [isTablet, setIsTablet] = useState(false); + const [isDesktop, setIsDesktop] = useState(false); + const [isHuge, setIsHuge] = useState(false); + + useEffect(() => { + const _isMobile = width <= UI.TABLET_BREAKPOINT; + const _isTablet = + width > UI.MOBILE_BREAKPOINT && width <= UI.TABLET_BREAKPOINT; + const _isDesktop = width > UI.TABLET_BREAKPOINT; + const _isHuge = width > UI.DESKTOP_BREAKPOINT; + + if (isMobile !== _isMobile) setIsMobile(_isMobile); + if (isTablet !== _isTablet) setIsTablet(_isTablet); + if (isDesktop !== _isDesktop) setIsDesktop(_isDesktop); + if (isHuge !== _isHuge) setIsHuge(_isHuge); + }, [width]); + + return { isMobile, isTablet, isDesktop, isHuge }; +} diff --git a/hooks/search.ts b/hooks/search.ts new file mode 100644 index 0000000..7a61748 --- /dev/null +++ b/hooks/search.ts @@ -0,0 +1,66 @@ +import groq from 'groq'; +import { useDispatch, useSelector } from 'react-redux'; +import client from '../client'; +import { IState } from '../state/reducers'; +import { setSearchResultItems } from '../state/search'; +import { ISanityArticle } from '../types/article'; + +export const sanityPostQuery = ` +"id": _id, +"updatedAt": _updatedAt, +title, +subtitle, +body, +publishedAt, +"backdropSVG": backdropSVG.asset->url, +"video": {"link": video.link, "description": video.description}, +"location": {"lat": location.lat, "lng": location.lng}, +"author": {"name": author->name, "imageSrc": author->image.asset->url }, +"featureImage": {"source": featureImage.image.asset->url, "altText": featureImage.altText, "description": featureImage.description }, +"category": category->title, +"city": city->title, +"tags": tags[]->title, +"slug": slug.current, +`; + +export function useSearch() { + const searchState = useSelector((state: IState) => state.search); + const results = searchState?.searchResultItems ?? []; + const query = searchState.searchQuery ?? ''; + const dispatch = useDispatch(); + + // const throttledSetSearchResultItems = _.debounce( + // (items: ISanityArticle[]) => dispatch(setSearchResultItems(items)), + // 100, + // ); + + const search = async (query: string): Promise> => { + const sanityQuery = groq`*[_type == "post" && title match "${query}*"][0..5] { + ${sanityPostQuery} + }`; + + let posts: ISanityArticle[]; + try { + posts = await client.fetch(sanityQuery); + } catch (error) { + console.warn('Error: ', error); + } + + const results = posts?.filter(post => + // Ensure all values are present in each post + Object.values(post).every(value => Boolean(value)), + ); + // .map(post => ({ + // ...post, + // })); + + console.log('search ➡️ posts:', posts); + console.log('search ➡️ results:', results); + + // throttledSetSearchResultItems(results); + dispatch(setSearchResultItems(results)); + return results; + }; + + return { search, query, results }; +} diff --git a/lib/mapbox.ts b/lib/mapbox.ts new file mode 100644 index 0000000..ca56ac5 --- /dev/null +++ b/lib/mapbox.ts @@ -0,0 +1,14 @@ +const MAP_BOX_USERNAME = 'oxenvince'; +const MAP_BOX_STYLE_ID = 'ckj6mv0zb04uz1amskq1bpi3u'; + +export const getMapBoxStaticSource = ( + lat: number, + lng: number, + width?: number, + height?: number, +) => + `https://api.mapbox.com/styles/v1/${MAP_BOX_USERNAME}/${MAP_BOX_STYLE_ID}/static/${lat},${lng},8.5,0,60/${ + width ?? 1200 + }x${ + height ?? 300 + }?access_token=pk.eyJ1IjoidGFzdGllc3R2aW5jZSIsImEiOiJja2VnaXp0bzkwZWM0MzJxYng3OW9qZnY5In0.xA1wKv2WJEZUU9XvdlolLg`; diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..e69de29 diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..b030767 --- /dev/null +++ b/next.config.js @@ -0,0 +1,29 @@ +const withPlugins = require('next-compose-plugins'); +const withSass = require('@zeit/next-sass'); +const withFonts = require('next-fonts'); +const withSvgr = require('next-svgr'); + +const nextConfig = { + webpack(config) { + const rules = [{}]; + + return { + ...config, + module: { + ...config.module, + rules: [...config.module.rules, ...rules], + }, + node: { + fs: 'empty', + }, + }; + }, + env: { + NEXT_PUBLIC_CONTENTFUL_ACCESS_TOKEN: + process.env.NEXT_PUBLIC_CONTENTFUL_ACCESS_TOKEN, + NEXT_PUBLIC_CONTENTFUL_SPACE_ID: + process.env.NEXT_PUBLIC_CONTENTFUL_SPACE_ID, + }, +}; + +module.exports = withPlugins([withSass, withFonts, withSvgr], nextConfig); diff --git a/package.json b/package.json new file mode 100644 index 0000000..f3fa77e --- /dev/null +++ b/package.json @@ -0,0 +1,93 @@ +{ + "name": "oxen.io", + "version": "0.1.0", + "license": "UNLICENSED", + "scripts": { + "now": "npm run now", + "dev": "NODE_ENV=development next dev", + "dev:https": "NODE_ENV=development node server.js", + "build": "next build", + "start": "next start", + "staging": "func() { git add . && git commit -m \"$1\" && git push -u origin staging; }; func", + "lint": "eslint '*/**/*.{js,ts,tsx}' --quiet --fix", + "lint:fix": "npm run lint -- --fix" + }, + "dependencies": { + "@ant-design/icons": "^4.2.2", + "@sanity/block-content-to-react": "^2.0.7", + "@sanity/client": "^1.149.18", + "@sanity/image-url": "^0.140.19", + "@tailwindcss/aspect-ratio": "^0.2.0", + "@types/lodash": "^4.14.161", + "@types/moment": "^2.13.0", + "@types/react-redux": "^7.1.9", + "@types/redux": "^3.6.0", + "@types/styled-components": "^5.1.3", + "@zeit/next-css": "^1.0.1", + "@zeit/next-sass": "^1.0.1", + "D": "^1.0.0", + "autoprefixer": "^10.0.1", + "babel-plugin-tailwind": "^0.1.10", + "babel-preset-env": "^1.7.0", + "classnames": "^2.2.6", + "contentful": "^8.1.7", + "dotenv": "^8.2.0", + "eslint-plugin-jsx-a11y": "^6.3.1", + "firebase": "^8.2.1", + "firebase-admin": "^9.4.2", + "firebase-functions": "^3.13.0", + "firestore-pagination-hook": "^1.0.0", + "global": "^4.4.0", + "groq": "^1.149.16", + "moment": "^2.27.0", + "next": "^9.3.3", + "next-compose-plugins": "^2.2.0", + "next-fonts": "^1.4.0", + "next-images": "^1.4.0", + "next-session": "^3.3.2", + "next-svgr": "^0.0.2", + "node-sass": "^4.14.1", + "nookies": "^2.5.0", + "now": "^20.1.2", + "prop-types": "^15.7.2", + "react": "^17.0.0", + "react-dom": "^17.0.0", + "react-paginate": "^6.5.0", + "react-redux": "^7.2.1", + "react-redux-firebase": "^3.9.0", + "react-use": "^15.3.4", + "react-youtube": "^7.13.0", + "redux": "^4.0.5", + "redux-firestore": "^0.14.0", + "swr": "^0.3.9", + "tailwindcss": "^1.4.6", + "tailwindcss-children": "^2.1.0", + "uuid": "^8.3.2", + "vercel": "^20.1.2" + }, + "devDependencies": { + "@types/lodash.get": "^4.4.6", + "@types/lodash.set": "^4.3.6", + "@types/node": "^14.0.27", + "@types/react": "^16.9.53", + "@typescript-eslint/eslint-plugin": "^3.9.0", + "@typescript-eslint/parser": "^3.9.0", + "eslint": "^7.7.0", + "eslint-config-prettier": "^6.11.0", + "eslint-plugin-prettier": "^3.1.4", + "eslint-plugin-react": "^7.20.6", + "husky": "^4.2.5", + "lint-staged": "^10.2.11", + "prettier": "^2.0.5", + "typescript": "^3.9.7", + "webpack-cli": "^3.3.11" + }, + "lint-staged": { + "./**/*.{ts,tsx}": "npm run lint:fix" + }, + "husky": { + "hooks": { + "pre-commit": "lint-staged" + } + } +} diff --git a/pages/404.tsx b/pages/404.tsx new file mode 100644 index 0000000..ddb78f6 --- /dev/null +++ b/pages/404.tsx @@ -0,0 +1,150 @@ +import classNames from 'classnames'; +import Head from 'next/head'; +import Link from 'next/link'; +import React, { useContext } from 'react'; +// import _404 from '../assets/svgs/404.svg'; +import { UI } from '../constants'; +import { ScreenContext } from '../contexts/screen'; +import { generateTitle } from '../utils/metadata'; + +function oxen404() { + const { isMobile, isTablet, isDesktop, isHuge } = useContext(ScreenContext); + + const wrapperStyles = { + width: '100%', + maxWidth: '760px', + margin: isDesktop ? '50px auto 100px' : '-10px auto', + paddingLeft: isHuge ? '0' : '5vw', + paddingRight: isHuge ? '0' : '5vw', + paddingBottom: !isDesktop ? '33px' : '0px', + }; + + const svgStyles = { + top: isDesktop ? '125px' : isTablet ? '20px' : '85px', + left: isDesktop ? '-50px' : isTablet ? '30vw' : '-65px', + width: isDesktop ? '810px' : isTablet ? '833px' : '933px', + }; + + const _404SectionStyles = { + top: isDesktop ? '45px' : isTablet ? '35px' : '25px', + }; + + const _404TitleStyles = { + lineHeight: '0px', + paddingTop: '2.3rem', + paddingBottom: '3.3rem', + }; + + const _404TextStyles = { + lineHeight: '1.15em', + }; + + const absoluteBoxStyles = { + marginTop: isTablet ? '20px' : '0px', + minHeight: isTablet ? '330px' : '450px', + }; + + const goBackHomeStyles = { + width: '9rem', + }; + + return ( +
+ + {generateTitle('404')} + + +
+
+
+ {/* <_404 style={svgStyles} className="absolute top-0 z-0" /> */} +
+

+ 404 +

+

+ Nothing found here. +

+ + +
+ Discover food +
+ +
+
+ +
+
+

+ Something went wrong? +

+ +