diff options
Diffstat (limited to 'public/projects/react-small-apps/apps/notebook/src')
33 files changed, 1040 insertions, 0 deletions
diff --git a/public/projects/react-small-apps/apps/notebook/src/App.css b/public/projects/react-small-apps/apps/notebook/src/App.css new file mode 100644 index 0000000..76d2aec --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/App.css @@ -0,0 +1,40 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, + Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + font-size: 1rem; + line-height: 1.618; +} + +.app { + display: flex; + flex-flow: column nowrap; + gap: clamp(1rem, 3vw, 2rem); + min-height: 100vh; + width: min(calc(100vw - 2rem), 1000px); + margin: auto; +} + +.notebook { + background: hsl(0, 4%, 20%); + box-shadow: 2px 2px 2px 0 hsl(0, 0%, 13%), 2px 2px 5px 3px hsl(0, 0%, 40%); + display: grid; + grid-template-columns: repeat(2, 1fr); + min-height: 550px; + margin-top: 3rem; + padding: 0.8rem 1.3rem 1.2rem 1.3rem; + position: relative; +} + +@media screen and (max-width: 1024px) { + .notebook { + padding: 0.8rem 1.3rem 1.2rem 0.1rem; + } +} + +.notebook--cover { + padding: 1rem; +} + +a { + color: hsl(212, 46%, 34%); +} diff --git a/public/projects/react-small-apps/apps/notebook/src/App.js b/public/projects/react-small-apps/apps/notebook/src/App.js new file mode 100644 index 0000000..6057342 --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/App.js @@ -0,0 +1,174 @@ +import { Navigate, Route, Routes, useLocation } from "react-router-dom"; +import { Footer, Header, Main, Nav, Page } from "./components/layout"; +import { useCallback, useEffect, useState } from "react"; +import { defaultPages } from "./config/pages"; +import "./App.css"; + +let pageId = 0; + +function App() { + const storedPages = JSON.parse(localStorage.getItem("notebook-pages")); + const initialPages = storedPages || defaultPages; + const [pages, setPages] = useState(initialPages); + const [currentPage, setCurrentPage] = useState({}); + const [deletedPages, setDeletedPages] = useState([]); + const location = useLocation(); + + pageId = storedPages ? storedPages.at(storedPages.length - 1).id : pageId; + + const isCover = () => currentPage && currentPage.id === 0; + const isPageExists = useCallback( + (id) => { + const pageIndex = pages.findIndex((page) => page.id === id); + return pageIndex === -1 ? false : true; + }, + [pages] + ); + + const addNewPage = useCallback(() => { + pageId++; + const newPage = { + id: pageId, + body: "", + title: `Page ${pageId}`, + url: `/page/${pageId}`, + }; + setPages((previous) => [...previous, newPage]); + }, []); + + const removePage = useCallback(() => { + const currentPageId = currentPage.id; + const pagesCopy = pages.slice(0); + const currentPageIndex = pages.findIndex( + (page) => page.id === currentPageId + ); + setDeletedPages((prev) => [...prev, currentPage]); + pagesCopy.splice(currentPageIndex, 1); + const newPages = pagesCopy.map((page) => { + if (page.id <= currentPageId) return page; + const newId = page.id - 1; + const newURL = `/page/${newId}`; + return { ...page, id: newId, url: newURL }; + }); + setCurrentPage(...newPages.filter((page) => page.id === currentPageId)); + setPages(newPages); + pageId = pageId - 1; + }, [pages, currentPage]); + + const restorePage = useCallback(() => { + const deletedPage = deletedPages.pop(); + const pagesCopy = pages.slice(0); + const restoredPageIndex = pagesCopy.findIndex( + (page) => page.id === deletedPage.id + ); + const newPages = pagesCopy.map((page) => { + if (page.id < deletedPage.id) return page; + const newId = page.id + 1; + const newURL = `/page/${newId}`; + return { ...page, id: newId, url: newURL }; + }); + newPages.splice(restoredPageIndex, 0, deletedPage); + setCurrentPage(...newPages.filter((page) => page.id === deletedPage.id)); + setPages(newPages); + }, [pages, deletedPages]); + + useEffect(() => { + !isPageExists(1) && addNewPage(); + }, [isPageExists, addNewPage]); + + useEffect(() => { + const requestedPage = pages.find((page) => page.url === location.pathname); + if (requestedPage) { + setCurrentPage(requestedPage); + } else { + setCurrentPage(() => pages.find((page) => page.url === "/404")); + } + }, [location.pathname, pages]); + + useEffect(() => { + if (currentPage) document.title = `Notebook - ${currentPage.title}`; + }, [currentPage]); + + useEffect(() => { + setPages((prevPages) => { + return prevPages.map((page) => { + if (page.id !== currentPage.id) return page; + return { ...page, body: currentPage.body }; + }); + }); + }, [currentPage.id, currentPage.body]); + + useEffect(() => { + setPages((prevPages) => { + return prevPages.map((page) => { + if (page.id !== currentPage.id) return page; + return { ...page, title: currentPage.title }; + }); + }); + }, [currentPage.id, currentPage.title]); + + useEffect(() => { + localStorage.setItem("notebook-pages", JSON.stringify(pages)); + }, [pages]); + + return ( + <> + <Header /> + <Main> + <div className={`notebook ${isCover() ? "notebook--cover" : ""}`}> + <div className="notebook-page notebook-page--mirror"></div> + <Routes> + <Route + path="/" + element={ + <Page + page={currentPage} + setPage={setCurrentPage} + removePage={removePage} + restorePage={restorePage} + deletedPages={deletedPages} + /> + } + /> + <Route path="/page" element={<Navigate replace to="/404" />} /> + <Route path="/page/0" element={<Navigate replace to="/" />} /> + <Route + path="/page/:number" + element={ + <Page + page={currentPage} + setPage={setCurrentPage} + removePage={removePage} + restorePage={restorePage} + deletedPages={deletedPages} + /> + } + /> + <Route + element={ + <Page + page={currentPage} + setPage={setCurrentPage} + removePage={removePage} + restorePage={restorePage} + deletedPages={deletedPages} + /> + } + path="/404" + /> + <Route path="*" element={<Navigate replace to="/404" />} /> + </Routes> + </div> + <Nav + pages={pages} + currentPage={currentPage} + addNewPage={addNewPage} + isPageExists={isPageExists} + /> + </Main> + <Footer /> + </> + ); +} + +export default App; diff --git a/public/projects/react-small-apps/apps/notebook/src/App.test.js b/public/projects/react-small-apps/apps/notebook/src/App.test.js new file mode 100644 index 0000000..1f03afe --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/App.test.js @@ -0,0 +1,8 @@ +import { render, screen } from '@testing-library/react'; +import App from './App'; + +test('renders learn react link', () => { + render(<App />); + const linkElement = screen.getByText(/learn react/i); + expect(linkElement).toBeInTheDocument(); +}); diff --git a/public/projects/react-small-apps/apps/notebook/src/components/commons/Button/Button.css b/public/projects/react-small-apps/apps/notebook/src/components/commons/Button/Button.css new file mode 100644 index 0000000..16268d3 --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/commons/Button/Button.css @@ -0,0 +1,78 @@ +.btn { + cursor: pointer; +} + +.btn--delete { + background: hsl(0, 44%, 44%); + border: none; + border-radius: 50%; + box-shadow: 0 0 0 2px hsl(0, 44%, 29%), 1px 2px 1px 2px hsl(0, 44%, 29%); + width: 2.4rem; + height: 2.4rem; + padding: 0.5rem; + transition: transform 0.3s ease-in-out 0s; +} + +.btn--delete:hover, +.btn--delete:focus { + background: hsl(0, 44%, 50%); + transform: scale(1.1); +} + +.btn--delete:active { + background: hsl(0, 44%, 40%); + transform: scale(1); +} + +.btn .icon { + height: 100%; + width: 100%; +} + +.btn--delete #trash-lid-handle { + stroke: #fff; + stroke-width: 4; +} + +.btn--delete #trash-container, +.btn--delete #trash-lid { + fill: hsl(0, 44%, 49%); + stroke: #fff; + stroke-width: 5; +} + +.btn--delete #trash-stroke1, +.btn--delete #trash-stroke2, +.btn--delete #trash-stroke3 { + fill: #fff; + stroke: #fff; + stroke-width: 1; +} + +.btn--restore { + background: hsl(212, 44%, 44%); + border: none; + border-radius: 50%; + box-shadow: 0 0 0 2px hsl(212, 44%, 29%), 1px 2px 1px 2px hsl(212, 44%, 29%); + width: 2.4rem; + height: 2.4rem; + padding: 0.5rem; + transition: transform 0.3s ease-in-out 0s; +} + +.btn--restore:hover { + background: hsl(212, 44%, 50%); + transform: scale(1.1); +} + +.btn--restore:active { + background: hsl(212, 44%, 40%); + transform: scale(1); +} + +.btn--restore #restore-circle, +.btn--restore #restore-arrow, +.btn--restore #restore-first-clock-hand, +.btn--restore #restore-second-clock-hand { + fill: #fff; +} diff --git a/public/projects/react-small-apps/apps/notebook/src/components/commons/Button/Button.js b/public/projects/react-small-apps/apps/notebook/src/components/commons/Button/Button.js new file mode 100644 index 0000000..4580815 --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/commons/Button/Button.js @@ -0,0 +1,26 @@ +import "./Button.css"; + +function Button({ + children, + onClickHandler, + onBlurHandler, + modifier, + additionalClassnames, +}) { + let classNames = modifier ? `btn btn--${modifier}` : "btn"; + classNames = additionalClassnames + ? `${classNames} ${additionalClassnames}` + : classNames; + + return ( + <button + className={classNames} + onClick={onClickHandler} + onBlur={onBlurHandler} + > + {children} + </button> + ); +} + +export default Button; diff --git a/public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/Input/Input.js b/public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/Input/Input.js new file mode 100644 index 0000000..7d8cb45 --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/Input/Input.js @@ -0,0 +1,31 @@ +import { forwardRef } from "react"; + +function Input( + { + type = "text", + name, + value, + onChangeHandler, + onBlurHandler, + additionalClasses, + }, + ref +) { + const classNames = additionalClasses + ? `form__input ${additionalClasses}` + : "form__input"; + + return ( + <input + ref={ref} + type={type} + name={name} + className={classNames} + value={value} + onChange={onChangeHandler} + onBlur={onBlurHandler} + /> + ); +} + +export default forwardRef(Input); diff --git a/public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/TextArea/TextArea.js b/public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/TextArea/TextArea.js new file mode 100644 index 0000000..ca2a52e --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/TextArea/TextArea.js @@ -0,0 +1,52 @@ +import { forwardRef, useEffect, useState } from "react"; + +function autoGrow(field, initialValue = null) { + let fieldHeight = initialValue ?? field.style.height; + if (field.scrollHeight > field.clientHeight) { + fieldHeight = field.scrollHeight + "px"; + } + return fieldHeight; +} + +function isSetHeightNeeded(e) { + const key = e.key; + const isBackspace = key === "Backspace"; + const isDelete = key === "Delete"; + const isCtrlZ = e.ctrlKey && e.key === "z"; + const isCut = e.ctrlKey && e.key === "x"; + return isBackspace || isDelete || isCtrlZ || isCut; +} + +function TextArea( + { value, name, onBlurHandler, onChangeHandler, additionalClasses }, + ref +) { + const [fieldHeight, setFieldHeight] = useState(); + const classNames = additionalClasses + ? `form__textarea ${additionalClasses}` + : "form__textarea"; + + useEffect(() => { + ref && setFieldHeight(autoGrow(ref.current)); + }, [ref]); + + return ( + <textarea + ref={ref} + className={classNames} + name={name} + value={value} + onChange={(e) => { + onChangeHandler(e); + setFieldHeight(autoGrow(e.target)); + }} + onKeyDown={(e) => { + if (isSetHeightNeeded(e)) setFieldHeight(autoGrow(e.target, "auto")); + }} + onBlur={onBlurHandler} + style={{ height: fieldHeight }} + /> + ); +} + +export default forwardRef(TextArea); diff --git a/public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/index.js b/public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/index.js new file mode 100644 index 0000000..1d1f610 --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/index.js @@ -0,0 +1,4 @@ +import Input from "./Input/Input"; +import TextArea from "./TextArea/TextArea"; + +export { Input, TextArea }; diff --git a/public/projects/react-small-apps/apps/notebook/src/components/commons/List/List.css b/public/projects/react-small-apps/apps/notebook/src/components/commons/List/List.css new file mode 100644 index 0000000..ae897a5 --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/commons/List/List.css @@ -0,0 +1,3 @@ +.list__item + .list__item { + margin-top: 0.5rem; +} diff --git a/public/projects/react-small-apps/apps/notebook/src/components/commons/List/List.js b/public/projects/react-small-apps/apps/notebook/src/components/commons/List/List.js new file mode 100644 index 0000000..631e6a5 --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/commons/List/List.js @@ -0,0 +1,25 @@ +import "./List.css"; + +function List({ type = "ul", data = [], modifier = "" }) { + const classNames = modifier ? `list list--${modifier}` : "list"; + + const listItems = data.map((object) => { + return ( + <li key={object.id} className="list__item"> + {object.body} + </li> + ); + }); + + return ( + <> + {type === "ol" ? ( + <ol className={classNames}>{listItems}</ol> + ) : ( + <ul className={classNames}>{listItems}</ul> + )} + </> + ); +} + +export default List; diff --git a/public/projects/react-small-apps/apps/notebook/src/components/commons/index.js b/public/projects/react-small-apps/apps/notebook/src/components/commons/index.js new file mode 100644 index 0000000..f0ca17b --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/commons/index.js @@ -0,0 +1,5 @@ +import Button from "./Button/Button"; +import { Input, TextArea } from "./FormElements"; +import List from "./List/List"; + +export { Button, Input, List, TextArea }; diff --git a/public/projects/react-small-apps/apps/notebook/src/components/helpers/hooks/useToggle.js b/public/projects/react-small-apps/apps/notebook/src/components/helpers/hooks/useToggle.js new file mode 100644 index 0000000..0291324 --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/helpers/hooks/useToggle.js @@ -0,0 +1,10 @@ +import { useCallback, useState } from "react"; + +function useToggle(initialState = false) { + const [state, setState] = useState(initialState); + const toggle = useCallback(() => setState((state) => !state), []); + + return [state, toggle]; +} + +export default useToggle; diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Footer/Footer.css b/public/projects/react-small-apps/apps/notebook/src/components/layout/Footer/Footer.css new file mode 100644 index 0000000..31db439 --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Footer/Footer.css @@ -0,0 +1,8 @@ +.footer { + padding: 0 0 1rem; +} + +.footer__copyright { + font-size: 0.9rem; + text-align: center; +} diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Footer/Footer.js b/public/projects/react-small-apps/apps/notebook/src/components/layout/Footer/Footer.js new file mode 100644 index 0000000..20a87f2 --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Footer/Footer.js @@ -0,0 +1,11 @@ +import "./Footer.css"; + +function Footer() { + return ( + <footer className="footer"> + <p className="footer__copyright">Notebook. MIT 2021. Armand Philippot.</p> + </footer> + ); +} + +export default Footer; diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Header/Header.css b/public/projects/react-small-apps/apps/notebook/src/components/layout/Header/Header.css new file mode 100644 index 0000000..a413a4f --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Header/Header.css @@ -0,0 +1,7 @@ +.header { + padding: 1rem 0 0; +} + +.header__branding { + font-size: 2.5rem; +} diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Header/Header.js b/public/projects/react-small-apps/apps/notebook/src/components/layout/Header/Header.js new file mode 100644 index 0000000..03757fa --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Header/Header.js @@ -0,0 +1,12 @@ +import "./Header.css"; + +function Header({ children }) { + return ( + <header className="header"> + <h1 className="header__branding">Notebook</h1> + {children} + </header> + ); +} + +export default Header; diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Main/Main.css b/public/projects/react-small-apps/apps/notebook/src/components/layout/Main/Main.css new file mode 100644 index 0000000..f6ee8fe --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Main/Main.css @@ -0,0 +1,3 @@ +.main { + flex: 1; +} diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Main/Main.js b/public/projects/react-small-apps/apps/notebook/src/components/layout/Main/Main.js new file mode 100644 index 0000000..23e7b9d --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Main/Main.js @@ -0,0 +1,7 @@ +import "./Main.css"; + +function Main({ children }) { + return <main className="main">{children}</main>; +} + +export default Main; diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/Nav.css b/public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/Nav.css new file mode 100644 index 0000000..9f5f90c --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/Nav.css @@ -0,0 +1,65 @@ +.nav { + position: relative; + display: flex; + flex-flow: row wrap; + justify-content: center; + margin: 2rem auto 0; +} + +.nav .list--nav { + list-style-type: none; + position: absolute; + bottom: 100%; + width: 80vw; + margin: 0; + padding: 1rem; + background: #fff; + border: 1px solid #ccc; +} + +@media screen and (min-width: 1024px) { + .nav .list--nav { + width: 50%; + } +} + +.nav__link { + background: #fff; + border: none; + color: hsl(212, 46%, 34%); + text-decoration: underline; + display: inline-block; + margin: 0 1px; + padding: 0.8rem 1rem; +} + +.nav .list__link { + display: block; + padding: 0.2rem; +} + +.nav .list__link--current { + background: hsl(212, 46%, 34%); + color: #fff; +} + +.nav__link:hover, +.nav__link:focus, +.nav .list__link:hover, +.nav .list__link:focus { + text-decoration-thickness: 4px; +} + +.nav__link:focus, +.nav .list__link:focus { + color: inherit; +} + +.nav .list__link--current:focus { + color: hsl(0, 0%, 89%); +} + +.nav__link:active { + color: hsl(212, 46%, 20%); + text-decoration-thickness: 2px; +} diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/Nav.js b/public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/Nav.js new file mode 100644 index 0000000..4e2a916 --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/Nav.js @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { Button } from "../../commons"; +import NavJump from "./NavJump"; +import "./Nav.css"; + +function Nav({ pages, currentPage, addNewPage, isPageExists }) { + const [isJumpEnabled, setIsJumpEnabled] = useState(false); + + const isCover = () => currentPage && currentPage.id === 0; + const isFirstPage = () => currentPage && currentPage.id === 1; + const is404 = () => currentPage && currentPage.id === null; + + useEffect(() => { + setIsJumpEnabled(false); + }, [currentPage.id]); + + return ( + <nav + className="nav" + onBlur={(e) => !e.relatedTarget && setIsJumpEnabled(false)} + > + {!isCover() && ( + <Link className="nav__link" to="/" onClick={(e) => e.target.blur()}> + Back to cover + </Link> + )} + {!isCover() && !isFirstPage() && isPageExists(currentPage.id - 1) && ( + <Link + className="nav__link" + to={`/page/${currentPage.id - 1}`} + onFocus={() => setIsJumpEnabled(false)} + onClick={(e) => e.target.blur()} + > + Previous page + </Link> + )} + <Button + additionalClassnames="nav__link" + onClickHandler={() => setIsJumpEnabled(!isJumpEnabled)} + > + Jump to + </Button> + {isJumpEnabled && <NavJump pages={pages} />} + {!is404() && ( + <Link + className="nav__link" + to={`/page/${currentPage.id + 1}`} + onClick={(e) => { + !isPageExists(currentPage.id + 1) && addNewPage(); + e.target.blur(); + }} + onFocus={() => setIsJumpEnabled(false)} + > + Next page + </Link> + )} + </nav> + ); +} + +export default Nav; diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/NavJump.js b/public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/NavJump.js new file mode 100644 index 0000000..9d2a049 --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/NavJump.js @@ -0,0 +1,28 @@ +import { NavLink } from "react-router-dom"; +import { List } from "../../commons"; + +function NavJump({ pages }) { + const links = pages + .filter((page) => page.id > 0) + .map((page) => { + return { + id: page.id, + body: ( + <NavLink + key={page.id} + className={({ isActive }) => + isActive ? "list__link--current" : "list__link" + } + aria-current="page" + to={page.url} + > + {page.title} + </NavLink> + ), + }; + }); + + return <List data={links} modifier="nav" />; +} + +export default NavJump; diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Cover.css b/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Cover.css new file mode 100644 index 0000000..bf915dd --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Cover.css @@ -0,0 +1,30 @@ +.notebook--cover .notebook-page--mirror { + display: none; +} + +.notebook--cover .notebook-page { + grid-column: 1 / -1; + background: hsl(0, 4%, 20%); + color: rgb(234, 235, 236); + letter-spacing: 1px; + text-shadow: 1px 1px 0 #000; + box-shadow: none; +} + +.notebook--cover .notebook-page { + justify-content: center; + text-align: center; +} + +.notebook--cover .notebook-page__title { + font-size: 3rem; + font-weight: 600; +} + +.notebook--cover .notebook-page .notebook-page__content { + flex: 0; +} + +.notebook--cover .notebook-page .notebook-page__title .form__input { + text-align: center; +} diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Page.css b/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Page.css new file mode 100644 index 0000000..873df47 --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Page.css @@ -0,0 +1,83 @@ +.notebook-page { + display: flex; + flex-flow: column nowrap; + padding: clamp(1rem, 3vw, 2rem) 0; + background: #fff; + border: 1px solid #cacaca; + box-shadow: 1px 1px 0 0 #ebebeb, 1px 1px 0 1px #bebebe, 2px 2px 0 1px #ebebeb, + 2px 2px 0 2px #bebebe, 3px 3px 0 2px #ebebeb, 3px 3px 0 3px #bebebe; +} + +.notebook-page--mirror { + box-shadow: -1px 1px 0 0 #ebebeb, -1px 1px 0 1px #bebebe, + -2px 2px 0 1px #ebebeb, -2px 2px 0 2px #bebebe, -3px 3px 0 2px #ebebeb, + -3px 3px 0 3px #bebebe; +} + +@media screen and (max-width: 1023px) { + .notebook-page { + grid-column: 1/-1; + } + .notebook-page--mirror { + display: none; + } +} + +.notebook-page__header, +.notebook-page__footer, +.notebook-page__content { + padding: 0 3rem 0 2rem; +} + +.notebook-page__title { + font-size: 1.8rem; + font-weight: 600; + margin: 1rem 0 0; +} + +.notebook-page__title .form__input { + border: none; + font-weight: inherit; + padding: 0; + width: 100%; +} + +.notebook-page__title .form__input:focus { + outline: none; +} + +.notebook-page__content { + flex: 1; + display: flex; + margin: clamp(1rem, 3vw, 2rem) 0 clamp(2rem, 3vw, 3rem); + white-space: pre-wrap; + word-break: break-all; + hyphens: auto; +} + +.notebook-page__content .form__textarea { + border: none; + line-height: inherit; + width: 100%; + height: 100%; + overflow: hidden; + padding: 0; +} + +.notebook-page__content .form__textarea:focus { + outline: none; + resize: none; +} + +.notebook-page__footer { + text-align: right; +} + +.notebook-page__toolbar { + display: flex; + flex-flow: row; + gap: 1rem; + position: absolute; + top: -4rem; + right: 1rem; +} diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Page.js b/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Page.js new file mode 100644 index 0000000..19e072c --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Page.js @@ -0,0 +1,107 @@ +import { useEffect, useRef } from "react"; +import { Input, TextArea } from "../../commons"; +import useToggle from "../../helpers/hooks/useToggle"; +import PageToolbar from "./PageToolbar"; +import "./Cover.css"; +import "./Page.css"; + +function Page({ page, setPage, removePage, restorePage, deletedPages }) { + const [isTitleEditable, setIsTitleEditable] = useToggle(); + const [isBodyEditable, setIsBodyEditable] = useToggle(); + const inputRef = useRef(null); + const textareaRef = useRef(null); + + const isCover = () => page && page.id === 0; + const is404 = () => page && page.id === null; + + useEffect(() => { + inputRef.current && inputRef.current.focus(); + textareaRef.current && textareaRef.current.focus(); + }); + + const handleSubmit = (e) => { + e.preventDefault(); + }; + + const handleOnChange = (e) => { + let newValue = {}; + + switch (e.target.name) { + case "notebook-title": + newValue = { title: e.target.value }; + break; + case "notebook-body": + newValue = { body: e.target.value }; + break; + default: + break; + } + + setPage((previous) => { + return { ...previous, ...newValue }; + }); + }; + + return ( + <article + className={`notebook-page ${isCover() ? "notebook-page--cover" : ""}`} + > + <header className="notebook-page__header"> + {!isTitleEditable && ( + <h2 + className="notebook-page__title" + onClick={() => { + if (!is404()) setIsTitleEditable(); + }} + > + {page.title} + </h2> + )} + {isTitleEditable && ( + <form className="notebook-page__title" onSubmit={handleSubmit}> + <Input + ref={inputRef} + name="notebook-title" + value={page.title} + onChangeHandler={handleOnChange} + onBlurHandler={setIsTitleEditable} + /> + </form> + )} + </header> + {!isBodyEditable && ( + <div + className="notebook-page__content" + onClick={() => { + if (!is404()) setIsBodyEditable(); + }} + > + {page.body} + </div> + )} + {isBodyEditable && ( + <form className="notebook-page__content" onSubmit={handleSubmit}> + <TextArea + ref={textareaRef} + name="notebook-body" + value={page.body} + onChangeHandler={handleOnChange} + onBlurHandler={setIsBodyEditable} + /> + </form> + )} + <footer className="notebook-page__footer"> + {!isCover() && <div className="notebook-page__number">{page.id}</div>} + {!isCover() && ( + <PageToolbar + removePage={removePage} + restorePage={restorePage} + deletedPages={deletedPages} + /> + )} + </footer> + </article> + ); +} + +export default Page; diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/PageToolbar.js b/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/PageToolbar.js new file mode 100644 index 0000000..a16aa22 --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/PageToolbar.js @@ -0,0 +1,27 @@ +import { Button } from "../../commons"; +import { ReactComponent as TrashIcon } from "../../../images/trash.svg"; +import { ReactComponent as RestoreIcon } from "../../../images/restore.svg"; + +function PageToolbar({ removePage, restorePage, deletedPages }) { + return ( + <div className="notebook-page__toolbar toolbar"> + <div className="toolbar__item"> + {deletedPages.length > 0 && ( + <Button modifier="restore" onClickHandler={restorePage}> + <RestoreIcon + title="Undo page deletion" + className="icon icon--restore" + /> + </Button> + )} + </div> + <div className="toolbar__item"> + <Button modifier="delete" onClickHandler={removePage}> + <TrashIcon title="Delete this page" className="icon icon--trash" /> + </Button> + </div> + </div> + ); +} + +export default PageToolbar; diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/index.js b/public/projects/react-small-apps/apps/notebook/src/components/layout/index.js new file mode 100644 index 0000000..1b8d583 --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/index.js @@ -0,0 +1,7 @@ +import Footer from "./Footer/Footer"; +import Header from "./Header/Header"; +import Main from "./Main/Main"; +import Nav from "./Nav/Nav"; +import Page from "./Page/Page"; + +export { Footer, Header, Main, Nav, Page }; diff --git a/public/projects/react-small-apps/apps/notebook/src/config/pages.js b/public/projects/react-small-apps/apps/notebook/src/config/pages.js new file mode 100644 index 0000000..0836aba --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/config/pages.js @@ -0,0 +1,16 @@ +const defaultPages = [ + { + id: null, + body: "", + title: "Page not found", + url: "/404", + }, + { + id: 0, + body: "", + title: "My Notebook", + url: "/", + }, +]; + +export { defaultPages }; diff --git a/public/projects/react-small-apps/apps/notebook/src/images/restore.svg b/public/projects/react-small-apps/apps/notebook/src/images/restore.svg new file mode 100644 index 0000000..c2977d6 --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/images/restore.svg @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + viewBox="0 0 80 80" + xmlns="http://www.w3.org/2000/svg"> + <path + id="restore-circle" + d="M 43.267598,3.2675985 C 23.008066,3.2675985 6.5351966,19.740468 6.5351966,40 c 0,0.171468 0.00482,0.342043 0.00717,0.512962 h 4.5915504 c -0.0027,-0.170945 -0.0072,-0.341371 -0.0072,-0.512962 0,-17.778077 14.362775,-32.1408513 32.140851,-32.1408513 17.778077,0 32.140852,14.3627743 32.140852,32.1408513 0,17.778076 -14.362775,32.140851 -32.140852,32.140851 -8.338958,0 -15.925965,-3.160587 -21.6323,-8.350881 H 15.300394 C 22.042368,71.704396 32.076566,76.732402 43.267598,76.732402 63.52713,76.732402 80,60.259532 80,40 80,19.740468 63.52713,3.2675985 43.267598,3.2675985 Z" /> + <path + id="restore-arrow" + d="m 17.457497,40.45113 h -17.45749705 L 8.7287481,50.692775 Z" /> + <rect + id="restore-first-clock-hand" + width="5" + height="20" + x="37.5" + y="22.075739" /> + <rect + id="restore-second-clock-hand" + width="5" + height="20" + x="1.6809042" + y="56.336418" + transform="rotate(-40)" /> +</svg> diff --git a/public/projects/react-small-apps/apps/notebook/src/images/trash.svg b/public/projects/react-small-apps/apps/notebook/src/images/trash.svg new file mode 100644 index 0000000..3c11d3b --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/images/trash.svg @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + width="80" + height="80" + viewBox="0 0 80 80" + xmlns="http://www.w3.org/2000/svg"> + <path + fill="none" + stroke="#000000" + stroke-width="4" + id="trash-lid-handle" + d="M 39.999977,2.0049906 A 10.941541,10.901606 0 0 0 29.380829,10.392059 c 6.823427,-0.19706 14.435517,-0.202894 21.257435,-0.01905 A 10.941541,10.901606 0 0 0 39.999977,2.00502 Z M 39.865976,23.799924 a 10.941541,10.901606 0 0 0 0.134001,0.0096 10.941541,10.901606 0 0 0 0.358917,-0.0096 z" /> + <path + fill="#ffffff" + stroke="#000000" + stroke-width="4" + id="trash-container" + d="m 15.029909,24.102259 h 49.940182 v 53.89774 H 15.029909 Z" /> + <path + fill="#ffffff" + stroke="#000000" + stroke-width="4" + id="trash-lid" + d="m 70.398893,12.759297 c 0,3.026127 0,11.039803 0,11.039803 H 9.601107 c 0,0 0,-7.816365 0,-11.039803 0,-3.2958374 60.797786,-3.4333596 60.797786,0 z" /> + <rect + fill="#000000" + id="trash-stroke1" + width="3.0539479" + height="32.859192" + x="23.934427" + y="34.621532" /> + <rect + fill="#000000" + id="trash-stroke2" + width="3.0539479" + height="32.859192" + x="38.47303" + y="34.621532" /> + <rect + fill="#000000" + id="trash-stroke3" + width="3.0539479" + height="32.859192" + x="53.011623" + y="34.621532" /> +</svg> diff --git a/public/projects/react-small-apps/apps/notebook/src/index.js b/public/projects/react-small-apps/apps/notebook/src/index.js new file mode 100644 index 0000000..7f2be6e --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/index.js @@ -0,0 +1,20 @@ +import "modern-normalize"; +import React from "react"; +import { render } from "react-dom"; +import { BrowserRouter } from "react-router-dom"; +import reportWebVitals from "./reportWebVitals"; +import App from "./App"; + +render( + <BrowserRouter basename={process.env.PUBLIC_URL}> + <React.StrictMode> + <App /> + </React.StrictMode> + </BrowserRouter>, + document.getElementById("app") +); + +// If you want to start measuring performance in your app, pass a function +// to log results (for example: reportWebVitals(console.log)) +// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals +reportWebVitals(); diff --git a/public/projects/react-small-apps/apps/notebook/src/logo.svg b/public/projects/react-small-apps/apps/notebook/src/logo.svg new file mode 100644 index 0000000..9dfc1c0 --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/logo.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
\ No newline at end of file diff --git a/public/projects/react-small-apps/apps/notebook/src/reportWebVitals.js b/public/projects/react-small-apps/apps/notebook/src/reportWebVitals.js new file mode 100644 index 0000000..5253d3a --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/reportWebVitals.js @@ -0,0 +1,13 @@ +const reportWebVitals = onPerfEntry => { + if (onPerfEntry && onPerfEntry instanceof Function) { + import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { + getCLS(onPerfEntry); + getFID(onPerfEntry); + getFCP(onPerfEntry); + getLCP(onPerfEntry); + getTTFB(onPerfEntry); + }); + } +}; + +export default reportWebVitals; diff --git a/public/projects/react-small-apps/apps/notebook/src/setupTests.js b/public/projects/react-small-apps/apps/notebook/src/setupTests.js new file mode 100644 index 0000000..8f2609b --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/src/setupTests.js @@ -0,0 +1,5 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom'; |
