├── .babelrc ├── .env.local.sample ├── .gitignore ├── LICENCE.txt ├── README.md ├── components ├── Footer.js ├── Header.js ├── ListItem.js ├── article │ ├── Comments.js │ ├── Markdown.js │ └── markdown │ │ └── ProgressiveImage.js ├── header │ ├── HamburgerMenu.js │ ├── Logo.js │ ├── SearchBar.js │ └── UsualMenu.js └── search │ └── SearchResult.js ├── context.js ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.js ├── _document.js ├── about.js ├── articles │ └── [id].js ├── categories │ └── [id].js ├── index.js └── search.js └── public ├── audio ├── switchOff.mp3 └── switchOn.mp3 ├── favicon.ico ├── img ├── logo.svg ├── me.jpg └── me.webp └── vercel.svg /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": [["styled-components", { "ssr": true }]] 4 | } -------------------------------------------------------------------------------- /.env.local.sample: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_HCMS_API_URL=XXX 2 | NEXT_PUBLIC_PROD_HOST=XXX 3 | NEXT_PUBLIC_DEV_HOST=XXX 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | -------------------------------------------------------------------------------- /LICENCE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 AlexTechNoir 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js Blog with Strapi 2 | 3 | Static demo blog. Deployed on [Vercel](https://vercel.com). 4 | 5 | --- 6 | 7 | WARNING! 8 | 9 | Strapi is deployed on Heroku. Due to Heroku's financial decision to shut down free plans, this project will most likely crash starting with 28.11.22. But all code examples in this repo are valid! You can still study it and learn how I've done some features! 10 | 11 | I tried some alternatives and with couple of failures, unfortunately, right now I don't have time to explore for more. 12 | 13 | If you also switching from Heroku, be careful with Railway - if you create an account with one e-mail and then link a GitHub account with another (different) e-mail, they may ban you by mistake for "multiple accounts". Tech support's not answering on ban appeals, at least in my case 14 | 15 | --- 16 | 17 | ## [Dependencies](https://github.com/AlexTechNoir/Next.js-Strapi-Blog/blob/master/package.json#L10) 18 | 19 | ## Features: 20 | 21 | - static 22 | - responsive 23 | - mobile first 24 | - built with [Strapi Headless CMS](https://github.com/strapi/strapi) 25 | - Strapi uses [MongoDB Atlas](https://www.mongodb.com/cloud/atlas) as database 26 | - images stored on [Cloudinary](https://cloudinary.com/) 27 | - Strapi deployed on [Heroku](https://www.heroku.com/home) 28 | - autocomplete search 29 | - search results with highlighted match (hardcoded) 30 | - sound 31 | - dark mode with autodetect system preference and saved user's choice in LocalStorage 32 | - hamburger menu for mobile layout 33 | - "Load More" pagination 34 | - SEO-friendly article list initially fetched on server-side (paginated - client-side) 35 | - progressive responsive images 36 | - categories 37 | - Disqus comment section 38 | 39 | ## How did I do... 40 | 41 |
42 | Search with highlighted results and cut text around match 43 |
44 |
    45 |
  1. When user enters value, we grab its changes.
  2. 46 |
  3. Search happens onSubmit.
  4. 47 |
  5. It's important not to use Next.js's dynamic routes here, because blog won't be static if we have pages, depending on user's request (but if you're OK with hybrid/dynamic blog, then it's alright to use them (btw, maybe optional catch all routes will somehow work with static site too, but I haven't tested it)). Instead we go to search page with query passed through "?" sign, here.
  6. 48 |
  7. Search page will fetch Strapi's API and will render the filtered results based on query parameter.
  8. 49 |
  9. In search result we convert markup to html and html to text.
  10. 50 |
  11. We divide text into array based on value match and create new text as array with "mark" tag around match.
  12. 51 |
  13. Then in useEffect, initially and every time the search value gets changed, we look for "mark" tags in text and cut the text around the first match (or just write "no match" if there is no match). The variable names should be self-explanatory here.
  14. 52 |
  15. It's important to wrap the text inside "p" tag with, for example, "span" tag to avoid React NotFoundError, which occurs when we re-render page (here: search for the second time) after manipulating the DOM (highlighting). Explained here.
  16. 53 |
  17. Last important thing: when we search for the second time and if in search result list we got result that was in the previous list (but with different match this time) then it will reflect previous highlighted match, which is not what we want. To avoid this we must explicitly remove old results and load new when we search. We can do this by triggering re-fetch - this will cause "isValidating" parameter to change on every search, replacing previous results with skeleton load and then load new results with correct highlighted match.
  18. 54 |
55 |
56 | 57 |
58 | "Load More" pagination 59 |
60 |
    61 |
  1. Featured articles are fetched on server-side as usual for SEO.
  2. 62 |
  3. We fetch paginated data on client-side with useSWRInfinite.
  4. 63 |
  5. We use "size" and "setSize" parameters to change page index.
  6. 64 |
  7. But instead of page index Strapi's API has Start param pointing at index from which data should be fetched and Limit param.
  8. 65 |
  9. In getKey function "pageIndex" parameter is the "size" parameter. It always starts with 0.
  10. 66 |
  11. We set limit param to 1 (fetch 1 article), start param - to "pageIndex + 7" (because first 7 artciles are already fetched on server-side).
  12. 67 |
  13. On every time user clicks "Load More" button, we increase size param to amount of times we need to fetch 1 artcile in one click (here we need 4, which depends on desktop layout).
  14. 68 |
  15. We also have to set initialSize parameter to 0, because we don't need paginated data initially, only on demand.
  16. 69 |
70 |
71 | 72 | ## What did I use to make this demo: 73 | 74 | - [Create Next App](https://nextjs.org/docs/getting-started#setup) 75 | - [styled-components](https://github.com/styled-components/styled-components) (examples: [global style](https://github.com/AlexTechNoir/Next.js-Strapi-Blog/blob/master/pages/_app.js#L77), [usual style](https://github.com/AlexTechNoir/Next.js-Strapi-Blog/blob/master/pages/_app.js#L168)) 76 | - [Material-UI](https://github.com/mui-org/material-ui) ([Autocomplete example](https://github.com/AlexTechNoir/Next.js-Strapi-Blog/blob/master/components/header/SearchBar.js#L72)) 77 | - [React Font Awesome](https://github.com/FortAwesome/react-fontawesome) ([example](https://github.com/AlexTechNoir/Next.js-Strapi-Blog/blob/master/components/header/UsualMenu.js#L75)) 78 | - [react-markdown](https://github.com/rexxars/react-markdown) ([example](https://github.com/AlexTechNoir/Next.js-Strapi-Blog/blob/master/components/article/Markdown.js#L58)) 79 | - [Showdown](https://github.com/showdownjs/showdown) ([example](https://github.com/AlexTechNoir/Next.js-Strapi-Blog/blob/master/components/search/SearchResult.js#L15)) 80 | - [SWR](https://github.com/vercel/swr) (examples: [client-side](https://github.com/AlexTechNoir/Next.js-Strapi-Blog/blob/master/pages/search.js#L13), [server-side](https://github.com/AlexTechNoir/Next.js-Strapi-Blog/blob/master/pages/categories/%5Bid%5D.js#L32)) 81 | - [disqus-react](https://github.com/disqus/disqus-react) (examples: [comments](https://github.com/AlexTechNoir/Next.js-Strapi-Blog/blob/master/components/article/Comments.js#L7), [comment count](https://github.com/AlexTechNoir/Next.js-Strapi-Blog/blob/master/components/ListItem.js#L48)) 82 | 83 | ## Notes: 84 | 85 | - at first load there is FOUC from Material-UI. MUI has [recommendations for server rendering](https://material-ui.com/guides/server-rendering/) and [example for Next.js](https://github.com/mui-org/material-ui/tree/master/examples/nextjs), however this measures doesn't work for everyone. There is fix, but it [works only in dev mode](https://github.com/vercel/next.js/issues/13058#issuecomment-666948357). Upd: as a temp fix, use [this](https://github.com/vercel/next.js/issues/13058#issuecomment-763746324). 86 | -------------------------------------------------------------------------------- /components/Footer.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export default function Footer() { 4 | return ( 5 | 6 |
This demo blog was created with Next.js and Strapi Headless CMS.
7 |
8 | ) 9 | } 10 | 11 | const StyledFooter = styled.footer` 12 | grid-area: 3 / 1 / 4 / 2; 13 | margin-top: 2em; 14 | padding: 1em; 15 | border-top: 2px solid #6c757d; 16 | 17 | @media only screen and (min-width: 1248px) { 18 | grid-area: 3 / 1 / 4 / 4; 19 | } 20 | ` 21 | -------------------------------------------------------------------------------- /components/Header.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { useState } from 'react' 3 | import useSWR from 'swr' 4 | import dynamic from 'next/dynamic' 5 | 6 | import Logo from './header/Logo' 7 | const SearchBar = dynamic(() => import('./header/SearchBar'), { ssr: false }) 8 | import HamburgerMenu from './header/HamburgerMenu' 9 | import UsualMenu from './header/UsualMenu' 10 | 11 | const fetcher = url => fetch(url).then(r => r.json()) 12 | 13 | export default function Header({ isDarkModeOn }) { 14 | const { data, error } = useSWR(`${process.env.NEXT_PUBLIC_HCMS_API_URL}/categories`, fetcher) 15 | const [ isMenuOpen, setIsMenuOpen ] = useState(false) 16 | 17 | return ( 18 | 19 | 20 | 21 |
22 |
23 | 30 |
31 |
32 | 38 |
39 |
40 |
41 | ) 42 | } 43 | 44 | const StyledHeader = styled.header` 45 | grid-area: 1 / 1 / 2 / 2; 46 | display: flex; 47 | justify-content: space-between; 48 | align-items: center; 49 | > :first-child > img { 50 | margin: 1em; 51 | } 52 | > :last-child { 53 | z-index: 1; 54 | > .usualMenu { 55 | display: none; 56 | margin-right: 1em; 57 | } 58 | > .hamburgerMenu { 59 | display: block; 60 | } 61 | } 62 | 63 | @media only screen and (min-width: 1024px) { 64 | > :last-child { 65 | > .usualMenu { 66 | display: block; 67 | } 68 | > .hamburgerMenu { 69 | display: none; 70 | } 71 | } 72 | } 73 | 74 | @media only screen and (min-width: 1248px) { 75 | grid-area: 1 / 1 / 2 / 4; 76 | } 77 | ` 78 | -------------------------------------------------------------------------------- /components/ListItem.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import Chip from '@material-ui/core/Chip' 3 | import styled from 'styled-components' 4 | import { useState, useEffect } from 'react' 5 | import Image from 'next/image' 6 | import { CommentCount } from 'disqus-react' 7 | 8 | export default function ListItem({ i }) { 9 | const [ isCategories, setIsCategories ] = useState(true) 10 | 11 | useEffect(() => { 12 | if (location.pathname.includes('categories')) { 13 | setIsCategories(true) 14 | } else { 15 | setIsCategories(null) 16 | } 17 | }, []) 18 | 19 | return ( 20 | 21 | 22 |
23 | {i.image[0].alternativeText} 30 |

{i.title}

31 | {isCategories ? null : ( 32 |
33 | {i.categories.map(category => { 34 | return ( 35 | 42 | ) 43 | })} 44 |
45 | )} 46 |
47 | 48 | 58 | {/* Placeholder Text */} 59 | Comments 60 | 61 | 62 | 65 |
66 |
67 |
68 | 69 | ) 70 | } 71 | 72 | const StyledLink = styled.a` 73 | text-decoration: none; 74 | margin-bottom: 2em; 75 | width: 100%; 76 | min-width: 288px; 77 | max-width: 425px; 78 | cursor: pointer; 79 | > div { 80 | display: flex; 81 | flex-direction: column; 82 | justify-content: space-between; 83 | border-radius: 15px; 84 | box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.3), 0 6px 20px 0 rgba(0, 0, 0, 0.3); 85 | transform: translateY(0); 86 | transition: transform .5s; 87 | color: black; 88 | &:hover { 89 | transform: translateY(-5px); 90 | } 91 | > :first-child { 92 | width: 100%; 93 | min-width: 288px; 94 | max-width: 425px; 95 | height: auto; 96 | border-radius: 15px 15px 0px 0px; 97 | } 98 | > h1 { 99 | margin: .5em .5em 0 .5em; 100 | } 101 | > .categories { 102 | display: flex; 103 | flex-direction: row; 104 | flex-wrap: wrap; 105 | margin: 1em 1em 0 1em; 106 | > div { 107 | cursor: pointer !important; 108 | margin: 0 .2em .2em 0; 109 | } 110 | } 111 | > :last-child { 112 | display: flex; 113 | justify-content: space-between; 114 | margin: 1em; 115 | > span > span { 116 | margin-right: .5em; 117 | } 118 | > time { 119 | text-align: right; 120 | } 121 | } 122 | } 123 | 124 | @media only screen and (min-width: 768px) { 125 | min-width: 100%; 126 | max-width: 100%; 127 | > div > div > img { 128 | min-width: 100%; 129 | max-width: 100%; 130 | } 131 | } 132 | ` 133 | -------------------------------------------------------------------------------- /components/article/Comments.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { DiscussionEmbed } from 'disqus-react' 3 | 4 | export default function Comments({ params }) { 5 | return ( 6 | 19 | ) 20 | } 21 | 22 | const Footer = styled.footer` 23 | width: 100%; 24 | padding: 0 1em 0 1em; 25 | ` 26 | -------------------------------------------------------------------------------- /components/article/Markdown.js: -------------------------------------------------------------------------------- 1 | import ReactMarkdown from 'react-markdown' 2 | import styled from 'styled-components' 3 | import { useEffect, useState } from 'react' 4 | 5 | import ProgressiveImage from './markdown/ProgressiveImage' 6 | 7 | export default function Markdown({ data }) { 8 | const [ headings, setHeadings ] = useState([]) 9 | 10 | useEffect(() => { 11 | const hTagsCollection = document.querySelectorAll('h2, h3, h4, h5, h6') 12 | const hTagsArr = [].slice.call(hTagsCollection) 13 | console.log(hTagsCollection, hTagsArr) 14 | setHeadings(hTagsArr) 15 | 16 | if (hTagsArr.length !== 0) { 17 | hTagsArr.map(el => el.setAttribute('id', el.textContent)) 18 | } 19 | },[]) 20 | 21 | return ( 22 | <> 23 |
24 |

{data.title}

25 | 28 | 29 | by {data.created_by.firstname} {data.created_by.lastname} 30 | 31 |
32 |
33 | 41 |
{data.image[0].caption}
42 |
43 | { 44 | headings.length === 0 ? null : 45 | 57 | } 58 | 62 | uri.startsWith('http') ? uri : `${process.env.NEXT_PUBLIC_HCMS_API_URL}${uri}` 63 | } 64 | /> 65 | 66 | ) 67 | } 68 | 69 | const Header = styled.header` 70 | align-self: center; 71 | margin-bottom: 1em; 72 | display: flex; 73 | flex-direction: column; 74 | align-items: center; 75 | > :first-child { 76 | font-size: 2em; 77 | margin-left: 1em; 78 | margin-right: 1em; 79 | } 80 | ` 81 | 82 | const Figure = styled.figure` 83 | margin: 0; 84 | > img { 85 | width: 100%; 86 | height: auto; 87 | } 88 | > figcaption { 89 | text-align: center; 90 | } 91 | ` 92 | 93 | const Nav = styled.nav` 94 | border: 2px solid black; 95 | border-radius: 15px; 96 | padding: 1em; 97 | margin-top: 1em; 98 | > p { 99 | margin-top: 0; 100 | } 101 | > ul { 102 | list-style-type: none; 103 | padding-left: 1em; 104 | > .H3 { 105 | padding-left: .5em; 106 | } 107 | > .H4 { 108 | padding-left: 1em; 109 | } 110 | > .H5 { 111 | padding-left: 1.5em; 112 | } 113 | > .H6 { 114 | padding-left: 2em; 115 | } 116 | } 117 | ` 118 | -------------------------------------------------------------------------------- /components/article/markdown/ProgressiveImage.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react' 2 | import styled from 'styled-components' 3 | 4 | export default function ProgressiveImage({ preview, smallImage, mediumImage, largeImage, sourceImage, alt }) { 5 | const [ isLoading, setIsLoading ] = useState(true) 6 | 7 | const isFirstRun = useRef(true) 8 | 9 | useEffect(() => { 10 | if (isFirstRun.current.complete) { 11 | setIsLoading(false) 12 | } 13 | 14 | isFirstRun.current.addEventListener('load', () => setIsLoading(false)) 15 | }, []) 16 | 17 | return ( 18 | 19 | {alt} 25 | 26 | 33 | {alt} 48 | 49 | 50 | ) 51 | } 52 | 53 | const WrappingDiv = styled.div` 54 | position: relative; 55 | overflow: hidden; 56 | transition: 0.5s filter linear; 57 | filter: ${props => props.isLoading ? 'blur(30px)' : ''}; 58 | img { 59 | max-width: 100%; 60 | height: auto; 61 | } 62 | picture > img { 63 | max-width: 100%; 64 | height: auto; 65 | position: absolute; 66 | top: 0; 67 | left: 0; 68 | } 69 | ` 70 | -------------------------------------------------------------------------------- /components/header/HamburgerMenu.js: -------------------------------------------------------------------------------- 1 | import { useState, useContext } from 'react' 2 | import styled from 'styled-components' 3 | import Link from 'next/link' 4 | import List from '@material-ui/core/List' 5 | import ListItem from '@material-ui/core/ListItem' 6 | import ListItemText from '@material-ui/core/ListItemText' 7 | import Collapse from '@material-ui/core/Collapse' 8 | import ListSubheader from '@material-ui/core/ListSubheader' 9 | import ExpandLess from '@material-ui/icons/ExpandLess' 10 | import ExpandMore from '@material-ui/icons/ExpandMore' 11 | import Chip from '@material-ui/core/Chip' 12 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 13 | import { faMoon, faVolumeUp, faVolumeMute } from '@fortawesome/free-solid-svg-icons' 14 | import { faSun } from '@fortawesome/free-regular-svg-icons' 15 | import Context from '../../context' 16 | 17 | export default function HamburgerMenu({ isMenuOpen, setIsMenuOpen, data, error }) { 18 | const { isDarkModeOn, toggleColorMode, isSoundOn, toggleSound } = useContext(Context) 19 | const [ isSublistOpen, setIsSublistOpen ] = useState(false) 20 | 21 | return ( 22 | 23 |
setIsMenuOpen(!isMenuOpen)} 26 | > 27 |
28 |
29 |
30 | 35 | Menu 36 | 37 | } 38 | > 39 | setIsSublistOpen(!isSublistOpen)}> 40 | 41 | {isSublistOpen ? : } 42 | 43 | 44 | 45 | {data ? ( 46 | data.map(category => ( 47 | 48 | {setIsMenuOpen(false); setIsSublistOpen(false)}}> 49 | 50 | 56 | 57 | 58 | 59 | )) 60 | ) : error ? ( 61 |
Error.
62 | ) : ( 63 |
Loading...
64 | )} 65 |
66 |
67 | 68 | setIsMenuOpen(false)}> 69 | 70 | 71 | 72 | 73 | 74 | toggleSound(e)} 77 | id={`${isSoundOn ? 'switchSoundOff' : 'switchSoundOn'}`} 78 | > 79 | { 80 | isSoundOn ? ( 81 | 82 | ) : ( 83 | 84 | ) 85 | } 86 | 87 | 88 | toggleColorMode(e)} 91 | id={`${isDarkModeOn ? 'swithToLightMode' : 'swithToDarkMode'}`} 92 | > 93 | { 94 | isDarkModeOn ? ( 95 | 96 | ) : ( 97 | 98 | ) 99 | } 100 | 101 | 102 |
103 |
104 |
105 | ) 106 | } 107 | 108 | const DivHamburgerMenu = styled.div` 109 | > .hamburgerMenuButton { 110 | margin-right: 1em; 111 | display: flex; 112 | justify-content: center; 113 | align-items: center; 114 | width: 48px; 115 | height: 48px; 116 | cursor: pointer; 117 | transition: all .2s ease-in-out; 118 | z-index: 2; 119 | > :first-child { 120 | width: 36px; 121 | height: 5px; 122 | background: black; 123 | border-radius: 5px; 124 | transition: all .2s ease-in-out; 125 | &::before, &::after { 126 | content: ''; 127 | position: absolute; 128 | width: 36px; 129 | height: 5px; 130 | background: black; 131 | border-radius: 5px; 132 | transition: all .2s ease-in-out; 133 | } 134 | &::before { 135 | transform: translateY(-15px); 136 | } 137 | &::after { 138 | transform: translateY(15px); 139 | } 140 | } 141 | } 142 | > .opened > :first-child { 143 | background: transparent; 144 | &::before { 145 | transform: rotate(45deg); 146 | } 147 | &::after { 148 | transform: rotate(-45deg); 149 | } 150 | } 151 | > :last-child { 152 | z-index: -1; 153 | position: fixed; 154 | top: 69px; 155 | left: 0; 156 | background-color: #FFFFFF; 157 | overflow: hidden; 158 | width: 100%; 159 | height: 0; 160 | transition: height .3s; 161 | > nav > :nth-child(4) > svg { 162 | margin-right: 1em; 163 | } 164 | > nav > :last-child > svg { 165 | margin-right: 1em; 166 | } 167 | } 168 | > .visible { 169 | height: 100%; 170 | a { 171 | text-decoration: none; 172 | color: black; 173 | > div > div { 174 | cursor: pointer; 175 | } 176 | } 177 | } 178 | ` 179 | -------------------------------------------------------------------------------- /components/header/Logo.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | 3 | export default function Logo({ setIsMenuOpen }) { 4 | return ( 5 | 6 | setIsMenuOpen(false)}> 7 | website logo 8 | 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /components/header/SearchBar.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import TextField from '@material-ui/core/TextField' 3 | import Autocomplete from '@material-ui/lab/Autocomplete' 4 | import CircularProgress from '@material-ui/core/CircularProgress' 5 | import IconButton from '@material-ui/core/IconButton' 6 | import { useState, useEffect, useRef } from 'react' 7 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 8 | import { faSearch } from '@fortawesome/free-solid-svg-icons' 9 | import { useRouter } from 'next/router' 10 | 11 | function sleep(delay = 0) { 12 | return new Promise(resolve => { 13 | setTimeout(resolve, delay) 14 | }) 15 | } 16 | 17 | export default function SearchBar() { 18 | const [ open, setOpen ] = useState(false) 19 | const [ options, setOptions ] = useState([]) 20 | const loading = open && options.length === 0 21 | 22 | const router = useRouter() 23 | const [ searchValue, setSearchValue ] = useState('') 24 | const inputRef = useRef(null) 25 | 26 | useEffect(() => { 27 | if (location.pathname.includes('search')) { 28 | inputRef.current.value = searchValue 29 | } else { 30 | inputRef.current.value = '' 31 | } 32 | }, []) 33 | 34 | useEffect(() => { 35 | let active = true 36 | 37 | if (!loading) { 38 | return undefined 39 | } 40 | 41 | (async () => { 42 | const res = await fetch(`${process.env.NEXT_PUBLIC_HCMS_API_URL}/articles`) 43 | await sleep(1000) 44 | const articles = await res.json() 45 | 46 | if (active) { 47 | setOptions(Object.keys(articles).map(i => articles[i].title)) 48 | } 49 | })() 50 | 51 | return () => { 52 | active = false 53 | } 54 | 55 | }, [loading]) 56 | 57 | useEffect(() => { 58 | if (!open) { 59 | setOptions([]) 60 | } 61 | }, [open]) 62 | 63 | const search = e => { 64 | e.preventDefault() 65 | if (searchValue !== '') { 66 | router.push(`/search?searchValue=${searchValue.replace(/ +/g, ' ').trim()}`) 67 | } else { return null } 68 | } 69 | 70 | return ( 71 | 72 | { 77 | setOpen(true) 78 | }} 79 | onClose={() => { 80 | setOpen(false) 81 | }} 82 | getOptionSelected={(option, value) => option.name === value.name} 83 | getOptionLabel={option => option} 84 | options={options} 85 | loading={loading} 86 | forcePopupIcon={false} 87 | onChange={(e, value) => setSearchValue(String(value))} 88 | renderInput={params => ( 89 | 96 | {loading ? : null} 97 | {params.InputProps.endAdornment} 98 | 99 | ) 100 | }} 101 | onChange={e => setSearchValue(String(e.target.value))} 102 | ref={inputRef} 103 | /> 104 | )} 105 | /> 106 | 107 | 108 | 109 | 110 | ) 111 | } 112 | 113 | const FormSearchBar = styled.form` 114 | margin: 0 0 .5em 0; 115 | display: flex; 116 | > :first-child { 117 | width: 109px !important; 118 | > div > div { 119 | padding-right: 0 !important; 120 | } 121 | } 122 | > :nth-child(2) { 123 | margin-top: .5em; 124 | } 125 | 126 | @media only screen and (min-width: 768px) { 127 | > :first-child { 128 | width: 150px !important; 129 | } 130 | } 131 | 132 | @media only screen and (min-width: 1024px) { 133 | margin-left: 275px; 134 | } 135 | ` 136 | -------------------------------------------------------------------------------- /components/header/UsualMenu.js: -------------------------------------------------------------------------------- 1 | import List from '@material-ui/core/List' 2 | import ListItem from '@material-ui/core/ListItem' 3 | import ListItemText from '@material-ui/core/ListItemText' 4 | import Collapse from '@material-ui/core/Collapse' 5 | import Chip from '@material-ui/core/Chip' 6 | import styled from 'styled-components' 7 | import Link from 'next/link' 8 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 9 | import { faMoon, faVolumeUp, faVolumeMute } from '@fortawesome/free-solid-svg-icons' 10 | import { faSun } from '@fortawesome/free-regular-svg-icons' 11 | import Context from '../../context' 12 | import { useContext } from 'react' 13 | 14 | export default function UsualMenu({ isMenuOpen, setIsMenuOpen, data, error }) { 15 | const { isDarkModeOn, toggleColorMode, isSoundOn, toggleSound } = useContext(Context) 16 | 17 | return ( 18 | 19 | setIsMenuOpen(!isMenuOpen)} className={`${isMenuOpen ? 'underscored' : ''}`}> 20 | 21 | 22 | 23 | 24 | {data ? ( 25 | data.map(category => ( 26 | 27 | setIsMenuOpen(false)}> 28 | 29 | 35 | 36 | 37 | 38 | )) 39 | ) : error ? ( 40 |
Error.
41 | ) : ( 42 |
Loading...
43 | )} 44 |
45 |
46 | 47 | setIsMenuOpen(false)}> 48 | 49 | 50 | 51 | 52 | 53 | toggleSound(e)} 56 | id={`${isSoundOn ? 'switchSoundOff' : 'switchSoundOn'}`} 57 | aria-label={`Press to ${isSoundOn ? 'switch sound off' : 'switch sound on'}`} 58 | > 59 | { 60 | isSoundOn ? ( 61 | 62 | ) : ( 63 | 64 | ) 65 | } 66 | 67 | toggleColorMode(e)} 70 | id={`${isDarkModeOn ? 'swithToLightMode' : 'swithToDarkMode'}`} 71 | aria-label={`Press to ${isDarkModeOn ? 'switch to light mode' : 'switch to dark mode'}`} 72 | > 73 | { 74 | isDarkModeOn ? ( 75 | 76 | ) : ( 77 | 78 | ) 79 | } 80 | 81 |
82 | ) 83 | } 84 | 85 | const ListUsualMenu = styled(List)` 86 | display: flex; 87 | > .underscored { 88 | border-bottom: 2px solid #B875CD; 89 | } 90 | > .dropDownMenuList { 91 | position: absolute; 92 | top: 57px; 93 | max-width: 282px; 94 | max-height: 250px; 95 | background-color: white; 96 | border-radius: 0 0 15px 15px; 97 | overflow: auto; 98 | } 99 | a { 100 | text-decoration: none; 101 | color: black; 102 | white-space: nowrap; 103 | > div > div { 104 | cursor: pointer; 105 | } 106 | } 107 | > :nth-child(3) { 108 | border-radius: 30px; 109 | } 110 | > :last-child { 111 | border-radius: 30px; 112 | } 113 | ` 114 | -------------------------------------------------------------------------------- /components/search/SearchResult.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import Link from 'next/link' 3 | import showdown from 'showdown' 4 | import { useEffect, useRef } from 'react' 5 | import Image from 'next/image' 6 | 7 | export default function SearchResult({ value, result }) { 8 | const { 9 | id, 10 | title, 11 | content, 12 | image 13 | } = result 14 | 15 | const htmlFromMarkup = new showdown.Converter().makeHtml(content) 16 | const textFromHtml = new DOMParser().parseFromString(htmlFromMarkup, 'text/html').body.textContent 17 | 18 | const p = useRef() 19 | 20 | useEffect(() => { 21 | const firstMatchIndex = p.current.innerHTML.toLowerCase().indexOf('') 22 | const lastMatchIndex = p.current.innerHTML.toLowerCase().lastIndexOf('') 23 | const matchInArticleText = firstMatchIndex !== -1 24 | const noMatchInArticleText = firstMatchIndex === -1 25 | 26 | if (matchInArticleText) { 27 | const rangeAroundMatch = 20 28 | const initialRangeBeforeMatch = firstMatchIndex - rangeAroundMatch 29 | const initialRangeAfterMatch = firstMatchIndex + ''.length + value.length + ''.length + rangeAroundMatch 30 | 31 | const finalRangeBeforeMatch = 32 | initialRangeBeforeMatch >= 0 33 | ? initialRangeBeforeMatch 34 | : firstMatchIndex - (rangeAroundMatch + initialRangeBeforeMatch) 35 | 36 | const finalRangeAfterMatch = 37 | initialRangeAfterMatch <= p.current.innerHTML.length 38 | ? initialRangeAfterMatch 39 | : (initialRangeAfterMatch + (initialRangeAfterMatch - p.current.innerHTML.length)) 40 | 41 | const oneMatchFound = firstMatchIndex === lastMatchIndex 42 | const moreThanOneMatchFound = firstMatchIndex !== lastMatchIndex 43 | 44 | if (oneMatchFound) { 45 | const newText = p.current.innerHTML.slice(finalRangeBeforeMatch, finalRangeAfterMatch) 46 | 47 | p.current.innerHTML = 48 | initialRangeBeforeMatch <= 0 && initialRangeAfterMatch >= p.current.innerHTML.length 49 | ? newText 50 | : initialRangeBeforeMatch > 0 && initialRangeAfterMatch < p.current.innerHTML.length 51 | ? '...' + newText + '...' 52 | : initialRangeBeforeMatch <= 0 53 | ? newText + '...' 54 | : initialRangeAfterMatch >= p.current.innerHTML.length 55 | ? '...' + newText 56 | : null 57 | 58 | } else if (moreThanOneMatchFound) { 59 | const amountOfMatches = p.current.innerHTML 60 | .toLowerCase() 61 | .match(new RegExp(value.trim().toLowerCase(), 'g')).length 62 | 63 | const newText = p.current.innerHTML.slice(finalRangeBeforeMatch, finalRangeAfterMatch) 64 | const message = '

' + `+ ${amountOfMatches - 1} more match(es).` 65 | 66 | p.current.innerHTML = 67 | initialRangeBeforeMatch <= 0 && initialRangeAfterMatch >= p.current.innerHTML.length 68 | ? newText + message 69 | : initialRangeBeforeMatch > 0 && initialRangeAfterMatch < p.current.innerHTML.length 70 | ? '...' + newText + '...' + message 71 | : initialRangeBeforeMatch <= 0 72 | ? newText + '...' + message 73 | : initialRangeAfterMatch >= p.current.innerHTML.length 74 | ? '...' + newText + message 75 | : null 76 | } 77 | } else if (noMatchInArticleText) { 78 | p.current.innerHTML = 'No matches in article text' 79 | } 80 | }, [value]) 81 | 82 | return ( 83 | 84 | 85 |
86 | {image[0].alternativeText} 93 |

94 | {title} 95 |

96 |

97 | 98 | { 99 | textFromHtml 100 | .split(new RegExp(`(${value})`, "ig")) 101 | .map(i => i.match(new RegExp(`(${value})`, "ig")) ? {i} : i) 102 | } 103 | 104 |

105 |
106 |
107 | 108 | ) 109 | } 110 | 111 | const StyledLink = styled.a` 112 | text-decoration: none; 113 | cursor: pointer; 114 | &:hover { 115 | text-decoration: underline; 116 | } 117 | > div { 118 | display: grid; 119 | grid-template-rows: auto auto; 120 | grid-template-columns: auto 1fr; 121 | column-gap: 1em; 122 | border: 1px solid black; 123 | border-radius: 15px; 124 | background: #f8f9fa; 125 | margin-bottom: 1em; 126 | padding: 1em; 127 | > img { 128 | grid-area: 1 / 1 / 2 / 2; 129 | } 130 | > h2 { 131 | grid-area: 1 / 2 / 2 / 3; 132 | margin: 0; 133 | } 134 | > :last-child { 135 | grid-area: 2 / 1 / 3 / 3; 136 | } 137 | } 138 | ` 139 | -------------------------------------------------------------------------------- /context.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | 3 | const Context = createContext() 4 | 5 | export default Context -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withCSS = require('@zeit/next-css') 2 | module.exports = withCSS({}) 3 | 4 | module.exports = { 5 | images: { 6 | domains: ['res.cloudinary.com'], 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-blog", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@fortawesome/fontawesome-svg-core": "^1.2.32", 12 | "@fortawesome/free-brands-svg-icons": "^5.15.1", 13 | "@fortawesome/free-regular-svg-icons": "^5.15.1", 14 | "@fortawesome/free-solid-svg-icons": "^5.15.1", 15 | "@fortawesome/react-fontawesome": "^0.1.13", 16 | "@material-ui/core": "^4.11.1", 17 | "@material-ui/icons": "^4.9.1", 18 | "@material-ui/lab": "^4.0.0-alpha.56", 19 | "disqus-react": "^1.0.11", 20 | "next": "^11.1.1", 21 | "react": "^17.0.1", 22 | "react-dom": "^17.0.1", 23 | "react-markdown": "^5.0.3", 24 | "showdown": "^1.9.1", 25 | "styled-components": "^5.2.1", 26 | "swr": "^0.3.9" 27 | }, 28 | "devDependencies": { 29 | "@zeit/next-css": "^1.0.1", 30 | "babel-plugin-styled-components": "^1.12.0", 31 | "css-loader": "^5.0.1", 32 | "style-loader": "^2.0.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import styled, { createGlobalStyle } from 'styled-components' 2 | import { config } from '@fortawesome/fontawesome-svg-core' 3 | import '@fortawesome/fontawesome-svg-core/styles.css' 4 | config.autoAddCss = false 5 | import { useState, useEffect } from 'react' 6 | import Context from '../context' 7 | 8 | import Header from '../components/Header' 9 | import Footer from '../components/Footer' 10 | 11 | export default function MyApp({ Component, pageProps }) { 12 | const [ isDarkModeOn, setIsDarkModeOn ] = useState(false) 13 | const [ isSoundOn, setIsSoundOn ] = useState(true) 14 | 15 | useEffect(() => { 16 | if (localStorage.getItem('userSelectedSoundOption') !== null) { 17 | if (localStorage.getItem('userSelectedSoundOption') === 'off') { 18 | setIsSoundOn(false) 19 | } else { 20 | setIsSoundOn(true) 21 | } 22 | } 23 | 24 | if (localStorage.getItem('userSelectedColorMode') !== null) { 25 | if (localStorage.getItem('userSelectedColorMode') === 'dark') { 26 | setIsDarkModeOn(true) 27 | } else { 28 | setIsDarkModeOn(false) 29 | } 30 | } else { 31 | if ( 32 | window.matchMedia && 33 | window.matchMedia('(prefers-color-scheme)').media !== 'not all' && 34 | window.matchMedia('(prefers-color-scheme: dark)').matches 35 | ) { 36 | setIsDarkModeOn(true) 37 | } 38 | } 39 | }, []) 40 | 41 | const toggleColorMode = e => { 42 | if (e.currentTarget.id === 'swithToDarkMode') { 43 | setIsDarkModeOn(true) 44 | localStorage.setItem('userSelectedColorMode', 'dark') 45 | if (isSoundOn) { 46 | new Audio('/audio/switchOff.mp3').play() 47 | } 48 | } else if (e.currentTarget.id === 'swithToLightMode') { 49 | setIsDarkModeOn(false) 50 | localStorage.setItem('userSelectedColorMode', 'light') 51 | if (isSoundOn) { 52 | new Audio('/audio/switchOn.mp3').play() 53 | } 54 | } 55 | } 56 | 57 | const toggleSound = e => { 58 | if (e.currentTarget.id === 'switchSoundOn') { 59 | setIsSoundOn(true) 60 | localStorage.setItem('userSelectedSoundOption', 'on') 61 | } else if (e.currentTarget.id === 'switchSoundOff') { 62 | setIsSoundOn(false) 63 | localStorage.setItem('userSelectedSoundOption', 'off') 64 | if (isSoundOn) { 65 | new Audio('/audio/switchOff.mp3').play() 66 | } 67 | } 68 | } 69 | 70 | return ( 71 | 77 | 78 | 79 |
80 | 81 |