├── styles ├── utils.module.css └── global.css ├── public ├── decor │ ├── hmmm.png │ ├── quari.png │ └── monstera.png └── email │ └── passwordReset.html ├── next.config.js ├── hooks ├── useRefName.js ├── useWarnError.js ├── index.js ├── useCalendar.js └── useForm.js ├── pages ├── api │ ├── auth │ │ ├── logout.js │ │ ├── getSession.js │ │ ├── login.js │ │ └── index.js │ ├── server │ │ ├── resolvers │ │ │ ├── demo │ │ │ │ ├── index.js │ │ │ │ ├── clearSampleData.js │ │ │ │ └── generateSampleData.js │ │ │ ├── validation.js │ │ │ └── mail.js │ │ ├── index.js │ │ └── schemas │ │ │ └── index.js │ ├── handleError.js │ ├── handleRequest.js │ └── index.js ├── 404.js ├── 500.js ├── settings.js ├── account.js ├── register.js ├── habits.js ├── _app.js ├── index.js ├── login.js ├── dashboard.js └── recover.js ├── components ├── MyData │ ├── Graph │ │ ├── graph.module.css │ │ ├── index.js │ │ └── graphConfig.js │ ├── Timeline │ │ ├── timeline.module.css │ │ └── index.js │ ├── myData.module.css │ ├── Calendar │ │ ├── calendar.module.css │ │ └── index.js │ └── index.js ├── Footer │ ├── index.js │ └── footer.module.css ├── ArrowNav │ ├── arrowNav.module.css │ └── index.js ├── ErrorPage │ ├── errorPage.module.css │ └── index.js ├── Form │ ├── Submit │ │ ├── submit.module.css │ │ └── index.js │ ├── Switch │ │ ├── index.js │ │ └── switch.module.css │ ├── Button │ │ ├── index.js │ │ └── button.module.css │ ├── index.js │ ├── Checkbox │ │ ├── index.js │ │ └── checkbox.module.css │ ├── Dropdown │ │ ├── dropdown.module.css │ │ └── index.js │ └── Input │ │ ├── input.module.css │ │ └── index.js ├── ViewOptions │ ├── index.js │ └── viewOptions.module.css ├── Nav │ ├── blob.svg │ ├── index.js │ └── nav.module.css ├── Tooltip │ ├── index.js │ └── tooltip.module.css ├── Loading │ ├── loading.module.css │ └── index.js ├── MyAccount │ ├── index.js │ ├── DeleteAccount.js │ ├── AccountDetails.js │ └── ChangePassword.js ├── MySettings │ ├── mySettings.module.css │ └── index.js ├── Modal │ ├── ModalForm.js │ ├── index.js │ ├── modal.module.css │ └── modalStore.js ├── DataForm │ └── dataForm.module.css ├── MiniCalendar │ ├── miniCalendar.module.css │ └── index.js ├── EmojiPicker │ ├── index.js │ └── emojiPicker.module.css ├── DashPanel │ ├── index.js │ └── dashPanel.module.css ├── Registration │ └── index.js └── MyHabits │ ├── HabitGrid.js │ ├── HabitList.js │ └── myHabits.module.css ├── layouts ├── index.js ├── HomepageLayout │ ├── index.js │ └── homepageLayout.module.css ├── DashboardLayout │ ├── Clock │ │ ├── clock.module.css │ │ └── index.js │ ├── index.js │ └── dashboardLayout.module.css └── AppLayout │ ├── appLayout.module.css │ └── index.js ├── lib ├── session.js └── prisma.js ├── .gitignore ├── contexts ├── index.js ├── MobileContext.js ├── ModalContext.js └── DataContext.js ├── utils └── index.js ├── package.json ├── prisma └── schema.prisma └── README.md /styles/utils.module.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/decor/hmmm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnoem/habitat/HEAD/public/decor/hmmm.png -------------------------------------------------------------------------------- /public/decor/quari.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnoem/habitat/HEAD/public/decor/quari.png -------------------------------------------------------------------------------- /public/decor/monstera.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnoem/habitat/HEAD/public/decor/monstera.png -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | serverRuntimeConfig: { 3 | PROJECT_ROOT: __dirname 4 | } 5 | } -------------------------------------------------------------------------------- /hooks/useRefName.js: -------------------------------------------------------------------------------- 1 | export const useRefName = (ref) => { 2 | const { current: refName } = ref; 3 | return refName; 4 | } -------------------------------------------------------------------------------- /pages/api/auth/logout.js: -------------------------------------------------------------------------------- 1 | import withSession from "../../../lib/session"; 2 | 3 | export default withSession(async (req, res) => { 4 | await req.session.destroy(); 5 | res.status(200).send({}); 6 | }); -------------------------------------------------------------------------------- /pages/api/auth/getSession.js: -------------------------------------------------------------------------------- 1 | import withSession from "../../../lib/session"; 2 | 3 | export default withSession(async (req, res) => { 4 | const user = req.session.get('user'); 5 | res.status(200).send(user); 6 | }); 7 | -------------------------------------------------------------------------------- /components/MyData/Graph/graph.module.css: -------------------------------------------------------------------------------- 1 | .Graph { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: flex-start; 5 | justify-content: center; 6 | } 7 | .Graph > div { 8 | position: relative; 9 | width: 100%; 10 | } -------------------------------------------------------------------------------- /components/Footer/index.js: -------------------------------------------------------------------------------- 1 | import styles from "./footer.module.css"; 2 | 3 | const Footer = ({ children }) => { 4 | return ( 5 |
6 | {children} 7 |
8 | ); 9 | } 10 | 11 | export default Footer; -------------------------------------------------------------------------------- /layouts/index.js: -------------------------------------------------------------------------------- 1 | import AppLayout from "./AppLayout"; 2 | import DashboardLayout, { DashboardWrapper } from "./DashboardLayout"; 3 | import HomepageLayout from "./HomepageLayout"; 4 | 5 | export { AppLayout, DashboardLayout, DashboardWrapper, HomepageLayout } -------------------------------------------------------------------------------- /pages/404.js: -------------------------------------------------------------------------------- 1 | import ErrorPage from "../components/ErrorPage" 2 | 3 | const Custom404 = () => { 4 | return ( 5 | 9 | ); 10 | } 11 | 12 | export default Custom404; -------------------------------------------------------------------------------- /pages/500.js: -------------------------------------------------------------------------------- 1 | import ErrorPage from "../components/ErrorPage" 2 | 3 | const Custom500 = () => { 4 | return ( 5 | 9 | ); 10 | } 11 | 12 | export default Custom500; -------------------------------------------------------------------------------- /pages/api/server/resolvers/demo/index.js: -------------------------------------------------------------------------------- 1 | import { recordsList, entriesList, habitsList } from "./generateSampleData"; 2 | import { clearSampleData } from "./clearSampleData"; 3 | 4 | export { 5 | recordsList, 6 | entriesList, 7 | habitsList, 8 | clearSampleData 9 | } -------------------------------------------------------------------------------- /hooks/useWarnError.js: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { ModalContext } from "../contexts"; 3 | 4 | export const useWarnError = () => { 5 | const { createModal } = useContext(ModalContext); 6 | return (type = 'somethingWentWrong', error = {}) => createModal(type, { error }); 7 | } -------------------------------------------------------------------------------- /components/ArrowNav/arrowNav.module.css: -------------------------------------------------------------------------------- 1 | .ArrowNav { 2 | display: inline-flex; 3 | align-items: center; 4 | margin-left: -0.25rem; 5 | position: relative; 6 | } 7 | .ArrowNav button { 8 | font-size: 1.25rem; 9 | line-height: 1.25rem; 10 | padding: 0 0.25rem; 11 | margin-right: 0.25rem; 12 | } -------------------------------------------------------------------------------- /components/ErrorPage/errorPage.module.css: -------------------------------------------------------------------------------- 1 | .ErrorPage { 2 | display: flex; 3 | align-items: center; 4 | } 5 | .ErrorPage h2 { 6 | padding-right: 1.5rem; 7 | margin-right: 1.5rem; 8 | border-right: 1px solid rgba(0, 0, 0, 0.5); 9 | } 10 | .ErrorPage p { 11 | font-family: var(--mono-font); 12 | } -------------------------------------------------------------------------------- /components/Form/Submit/submit.module.css: -------------------------------------------------------------------------------- 1 | .Submit { 2 | display: flex; 3 | flex-wrap: wrap; 4 | margin: 1.5rem -1rem -1rem 0; 5 | } 6 | .Submit button { 7 | margin: 0 1rem 1rem 0; 8 | } 9 | .Submit.compact { 10 | margin-top: 1rem; 11 | } 12 | .Submit.compact button { 13 | padding: 0.5rem 1rem; 14 | } 15 | -------------------------------------------------------------------------------- /pages/api/auth/login.js: -------------------------------------------------------------------------------- 1 | import withSession from "../../../lib/session"; 2 | 3 | export default withSession(async (req, res) => { 4 | const { user } = req.body; 5 | req.session.set('user', { 6 | createdAt: new Date(), 7 | ...user 8 | }); 9 | await req.session.save(); 10 | res.send({ user }); 11 | }); 12 | -------------------------------------------------------------------------------- /hooks/index.js: -------------------------------------------------------------------------------- 1 | import { useCalendar } from "./useCalendar"; 2 | import { useForm, useFormData, useFormError, useFormSubmit } from "./useForm"; 3 | import { useRefName } from "./useRefName"; 4 | import { useWarnError } from "./useWarnError"; 5 | 6 | export { useCalendar, useForm, useFormData, useFormError, useFormSubmit, useRefName, useWarnError } -------------------------------------------------------------------------------- /components/Form/Switch/index.js: -------------------------------------------------------------------------------- 1 | import styles from "./switch.module.css"; 2 | 3 | const Switch = ({ name, on, onChange }) => { 4 | return ( 5 |
6 | 7 |
8 |
9 | ); 10 | } 11 | 12 | export default Switch; -------------------------------------------------------------------------------- /lib/session.js: -------------------------------------------------------------------------------- 1 | import { withIronSession } from 'next-iron-session' 2 | 3 | export default function withSession(handler) { 4 | return withIronSession(handler, { 5 | password: process.env.NEXT_PUBLIC_COOKIE_AUTH, 6 | cookieName: 'habi', 7 | cookieOptions: { 8 | secure: process.env.NODE_ENV === 'production' ? true : false, 9 | }, 10 | }); 11 | } -------------------------------------------------------------------------------- /components/ViewOptions/index.js: -------------------------------------------------------------------------------- 1 | import styles from "./viewOptions.module.css"; 2 | import { fancyClassName } from "../../utils"; 3 | 4 | const ViewOptions = ({ children, className }) => { 5 | return ( 6 |
7 | {children} 8 |
9 | ); 10 | } 11 | 12 | export default ViewOptions; -------------------------------------------------------------------------------- /pages/api/server/index.js: -------------------------------------------------------------------------------- 1 | import { ApolloServer } from "apollo-server-micro"; 2 | import { typeDefs } from "./schemas"; 3 | import { resolvers } from "./resolvers"; 4 | 5 | const apolloServer = new ApolloServer({ typeDefs, resolvers }); 6 | 7 | export const config = { 8 | api: { 9 | bodyParser: false 10 | } 11 | } 12 | 13 | export default apolloServer.createHandler({ path: '/api/server' }); -------------------------------------------------------------------------------- /lib/prisma.js: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | 3 | let prisma; 4 | 5 | if (process.env.NODE_ENV === 'production') { 6 | prisma = new PrismaClient(); 7 | console.log('[PRODUCTION] creating new PrismaClient') 8 | } else { 9 | if (!global.prisma) { 10 | global.prisma = new PrismaClient(); 11 | console.log('[DEVELOPMENT] creating new PrismaClient') 12 | } 13 | prisma = global.prisma; 14 | } 15 | 16 | export default prisma; -------------------------------------------------------------------------------- /components/Footer/footer.module.css: -------------------------------------------------------------------------------- 1 | .Footer { 2 | font-size: 0.8rem; 3 | margin-top: 3rem; 4 | } 5 | 6 | @media screen and (max-width: 900px ){ 7 | .Footer { 8 | position: fixed; 9 | bottom: 4rem; 10 | left: 4rem; 11 | padding-right: 4rem; 12 | } 13 | } 14 | 15 | @media screen and (max-width: 600px ){ 16 | .Footer { 17 | position: static; 18 | padding-right: 0; 19 | margin-top: 2rem; 20 | font-size: 0.75rem; 21 | } 22 | } -------------------------------------------------------------------------------- /pages/settings.js: -------------------------------------------------------------------------------- 1 | import { auth } from "./api/auth"; 2 | import { DashboardLayout } from "../layouts"; 3 | import MySettings from "../components/MySettings"; 4 | 5 | const Settings = ({ user }) => { 6 | return ( 7 | 8 |

settings

9 | 10 |
11 | ); 12 | } 13 | 14 | export const getServerSideProps = auth({ shield: true, redirect: '/login' }); 15 | 16 | export default Settings; -------------------------------------------------------------------------------- /pages/account.js: -------------------------------------------------------------------------------- 1 | import { auth } from "./api/auth"; 2 | import { DashboardLayout } from "../layouts"; 3 | import MyAccount from "../components/MyAccount"; 4 | 5 | const Account = ({ user }) => { 6 | return ( 7 | 8 |

my account

9 | 10 |
11 | ); 12 | } 13 | 14 | export const getServerSideProps = auth({ shield: true, redirect: '/login' }); 15 | 16 | export default Account; -------------------------------------------------------------------------------- /pages/register.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import { RegistrationIsOpen, RegistrationIsClosed } from "../components/Registration"; 4 | 5 | const Register = () => { 6 | const [token, setToken] = useState(null); 7 | return ( 8 | <> 9 |

register

10 | {token 11 | ? 12 | : 13 | } 14 | 15 | ); 16 | } 17 | 18 | export default Register; -------------------------------------------------------------------------------- /components/ViewOptions/viewOptions.module.css: -------------------------------------------------------------------------------- 1 | .viewOptions { 2 | display: inline-flex; 3 | } 4 | .viewOptions > button { 5 | display: flex; 6 | flex-direction: column; 7 | align-items: center; 8 | justify-content: center; 9 | font-size: 0.8rem; 10 | opacity: 0.3; 11 | transition: 0.2s; 12 | } 13 | .viewOptions > button:hover { 14 | opacity: 1; 15 | } 16 | .viewOptions > button + button { 17 | margin-left: 1rem; 18 | } 19 | .viewOptions > button svg { 20 | margin-bottom: 0.25rem; 21 | } -------------------------------------------------------------------------------- /components/Nav/blob.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.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 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | .vercel 34 | sendgrid.env 35 | -------------------------------------------------------------------------------- /contexts/index.js: -------------------------------------------------------------------------------- 1 | import { DataContext, DataContextProvider } from "./DataContext"; 2 | import { MobileContext, MobileContextProvider } from "./MobileContext"; 3 | import { ModalContext, ModalContextProvider } from "./ModalContext"; 4 | 5 | const AppContextProvider = ({ children }) => { 6 | return ( 7 | 8 | 9 | {children} 10 | 11 | 12 | ); 13 | } 14 | 15 | export { DataContext, MobileContext, ModalContext, DataContextProvider, AppContextProvider } -------------------------------------------------------------------------------- /components/ErrorPage/index.js: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | 3 | import styles from "./errorPage.module.css"; 4 | import { Button } from "../Form"; 5 | 6 | const ErrorPage = ({ status, message }) => { 7 | const router = useRouter(); 8 | return ( 9 | <> 10 |
11 |

{status}

12 |

{message}

13 |
14 | 17 | 18 | ); 19 | } 20 | 21 | export default ErrorPage; -------------------------------------------------------------------------------- /pages/api/handleError.js: -------------------------------------------------------------------------------- 1 | export class FetchError extends Error { 2 | constructor(meta = {}) { 3 | super('No response from server'); 4 | Object.assign(this, meta); 5 | this.name = 'FetchError'; 6 | } 7 | } 8 | 9 | export class ServerError extends Error { 10 | constructor({ status, message, error } = { 11 | status: 500, 12 | message: 'Unknown server error', 13 | error: {} 14 | }) { 15 | super(`Server error ${status}: ${message}`); 16 | Object.assign(this, { status, message, error: JSON.stringify(error) }); 17 | this.name = 'ServerError'; 18 | } 19 | } -------------------------------------------------------------------------------- /components/Tooltip/index.js: -------------------------------------------------------------------------------- 1 | import styles from "./tooltip.module.css"; 2 | import { fancyClassName } from "../../utils"; 3 | 4 | const TooltipElement = ({ children, className, content }) => { 5 | return ( 6 |
7 | {children} 8 | 9 |
10 | ); 11 | } 12 | 13 | const Tooltip = ({ className, content }) => { 14 | return ( 15 |
16 | {content} 17 |
18 | ); 19 | } 20 | 21 | export default TooltipElement; -------------------------------------------------------------------------------- /components/Loading/loading.module.css: -------------------------------------------------------------------------------- 1 | .Loading { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | } 6 | .Loading svg { 7 | height: 100%; 8 | } 9 | .Loading circle { 10 | stroke: var(--loading-icon-color); 11 | } 12 | .PageLoading { 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | width: 100%; 17 | height: 20%; 18 | } 19 | .PageLoading .Loading { 20 | height: 2rem; 21 | max-height: 100%; 22 | } 23 | .PageLoading circle { 24 | stroke: #000; 25 | } 26 | 27 | .PageLoading.h125r { 28 | height: 1.25rem; 29 | } 30 | 31 | .PageLoading.h80p { 32 | height: 80%; 33 | } -------------------------------------------------------------------------------- /components/Form/Button/index.js: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | 3 | import styles from "./button.module.css"; 4 | import { fancyClassName } from "../../../utils"; 5 | 6 | const Button = ({ children, type, onClick, href, className, disabled }) => { 7 | const router = useRouter(); 8 | const handleClick = () => { 9 | if (onClick) return onClick(); 10 | if (href) router.push(href); 11 | } 12 | return ( 13 | 19 | ); 20 | } 21 | 22 | export default Button; -------------------------------------------------------------------------------- /pages/api/server/resolvers/validation.js: -------------------------------------------------------------------------------- 1 | export const validationError = (errorObject) => { 2 | const errors = Object.entries(errorObject).map(([key, value]) => { 3 | const parseValue = (value) => { 4 | if (Array.isArray(value)) return value[0]; 5 | return value; 6 | } 7 | return { 8 | location: key, 9 | message: parseValue(value) 10 | } 11 | }); 12 | return { 13 | __typename: 'FormErrorReport', 14 | errors 15 | } 16 | } 17 | 18 | export const habitLabelIsValid = (label) => { 19 | if (label.indexOf('{') === -1) return false; 20 | if (label.indexOf('}') === -1) return false; 21 | if (label.indexOf('{') > label.indexOf('}')) return false; 22 | return true; 23 | } -------------------------------------------------------------------------------- /components/ArrowNav/index.js: -------------------------------------------------------------------------------- 1 | import { faCaretLeft, faCaretRight } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | 4 | import styles from "./arrowNav.module.css"; 5 | import { fancyClassName } from "../../utils"; 6 | 7 | const ArrowNav = ({ ariaLabel, prev, next, className }) => { 8 | return ( 9 | 13 | ); 14 | } 15 | 16 | export default ArrowNav; -------------------------------------------------------------------------------- /pages/api/auth/index.js: -------------------------------------------------------------------------------- 1 | import withSession from "../../../lib/session"; 2 | 3 | export const auth = ({ shield, redirect }) => withSession(async function ({ req, _ }) { 4 | const user = req.session.get('user'); 5 | const shouldRedirect = shield ? !user : user; 6 | // shield = true when trying to access private resources, e.g. dashboard, and no session is found 7 | // false if redirecting because a session /is/ found, e.g. login -> dashboard 8 | if (shouldRedirect) { 9 | return { 10 | redirect: { 11 | destination: redirect, 12 | permanent: false 13 | } 14 | } 15 | } 16 | if (user) return { 17 | props: { user: req.session.get('user') } 18 | } 19 | return { 20 | props: {} 21 | } 22 | }); 23 | -------------------------------------------------------------------------------- /contexts/MobileContext.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | 3 | export const MobileContext = React.createContext(null); 4 | 5 | export const MobileContextProvider = ({ children }) => { 6 | const [isMobile, setIsMobile] = useState(null); 7 | useEffect(() => { 8 | setIsMobile(window.innerWidth <= 900); 9 | const checkWidth = () => { 10 | if (window.innerWidth <= 900) { 11 | if (!isMobile) setIsMobile(true); 12 | } 13 | else if (isMobile) setIsMobile(false); 14 | } 15 | window.addEventListener('resize', checkWidth); 16 | return () => window.removeEventListener('resize', checkWidth); 17 | }, [isMobile]); 18 | return ( 19 | 20 | {children} 21 | 22 | ); 23 | } -------------------------------------------------------------------------------- /layouts/HomepageLayout/index.js: -------------------------------------------------------------------------------- 1 | import styles from "./homepageLayout.module.css"; 2 | 3 | const HomepageWrapper = ({ children }) => { 4 | return ( 5 | <> 6 |
7 | 8 |
9 | {children} 10 | 11 | ); 12 | } 13 | 14 | const HomepageContent = ({ children }) => { 15 | return ( 16 |
17 |
18 |

habitat

19 | {children} 20 |
21 |
22 | ); 23 | } 24 | 25 | const HomepageLayout = ({ children }) => { 26 | return ( 27 | 28 | 29 | {children} 30 | 31 | 32 | ) 33 | } 34 | 35 | export default HomepageLayout; -------------------------------------------------------------------------------- /contexts/ModalContext.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | 3 | export const ModalContext = React.createContext(null); 4 | 5 | export const ModalContextProvider = ({ children }) => { 6 | const [modal, setModal] = useState(null); 7 | const createModal = (keyphrase, options) => { 8 | setModal({ keyphrase, options }); 9 | } 10 | const closeModal = () => { 11 | setModal(prevState => ({ 12 | ...prevState, 13 | selfDestruct: true 14 | })); 15 | } 16 | useEffect(() => { 17 | if (modal?.selfDestruct) { 18 | setTimeout(() => { 19 | setModal(null); 20 | }, 200); 21 | } 22 | }, [modal?.selfDestruct]); 23 | return ( 24 | 25 | {children} 26 | 27 | ); 28 | } -------------------------------------------------------------------------------- /utils/index.js: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | 3 | export const fancyClassName = ({ styles, className }) => { 4 | if (!className) return ''; 5 | if (!styles) return className; 6 | const classNameArray = className.split(' '); 7 | return classNameArray.map(classNameString => { 8 | return styles[classNameString] ?? classNameString; 9 | }).filter(el => el).toString().replace(/,/g, ' '); 10 | } 11 | 12 | export const getUnitFromLabel = (label) => { 13 | return label?.split('{')[1]?.split('}')[0]?.trim(); 14 | } 15 | 16 | export const getQueryParams = async ({ query }) => ({ query }); 17 | 18 | export const differenceInMinutes = (earlierDate, laterDate = new Date()) => { 19 | const createdAt = dayjs(earlierDate); 20 | const differenceInMinutes = dayjs(laterDate).diff(createdAt, 'minute'); 21 | return differenceInMinutes; 22 | } -------------------------------------------------------------------------------- /components/Form/Button/button.module.css: -------------------------------------------------------------------------------- 1 | .Button { 2 | display: inline-block; 3 | padding: 0.7rem 1.4rem; 4 | font-size: 0.9rem; 5 | color: #fff; 6 | position: relative; 7 | z-index: 0; 8 | transition: 0.2s; 9 | } 10 | .Button:hover { 11 | color: #000; 12 | } 13 | .Button:hover svg, .Button:hover svg * { 14 | stroke: #000; 15 | } 16 | .Button::before, .Button::after { 17 | content: ''; 18 | position: absolute; 19 | z-index: -1; 20 | bottom: -1px; 21 | left: 0; 22 | width: 100%; 23 | height: 100%; 24 | border: 1px solid #000; 25 | transform: skewX(-12deg); 26 | transition: 0.2s; 27 | } 28 | .Button::before { 29 | background: #000; 30 | } 31 | .Button:hover::before { 32 | opacity: 0; 33 | } 34 | .Button:hover::after { 35 | border-color: #000 !important; /* fuck it lol */ 36 | } 37 | .Button.compact { 38 | padding: 0.5rem 1rem; 39 | } -------------------------------------------------------------------------------- /pages/api/server/resolvers/demo/clearSampleData.js: -------------------------------------------------------------------------------- 1 | export const clearSampleData = async (demoTokenId, prisma) => { 2 | const deleteData = prisma.demoToken.update({ 3 | where: { 4 | id: demoTokenId 5 | }, 6 | data: { 7 | habits: { deleteMany: {} }, 8 | entries: { deleteMany: {} }, 9 | records: { deleteMany: {} } 10 | } 11 | }); 12 | const deleteToken = prisma.demoToken.delete({ 13 | where: { 14 | id: demoTokenId 15 | } 16 | }); 17 | const deleteEverything = [deleteData, deleteToken]; 18 | const settingsToDelete = await prisma.settings.findUnique({ where: { demoTokenId } }); 19 | if (settingsToDelete) { 20 | const deleteSettings = prisma.settings.delete({ where: { demoTokenId } }); 21 | deleteEverything.splice(1, deleteSettings); 22 | } 23 | return await prisma.$transaction(deleteEverything); 24 | } -------------------------------------------------------------------------------- /components/MyAccount/index.js: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | 3 | import { DataContext, MobileContext } from "../../contexts"; 4 | import AccountDetails from "./AccountDetails"; 5 | import ChangePassword from "./ChangePassword"; 6 | import DeleteAccount from "./DeleteAccount"; 7 | 8 | const MyAccount = () => { 9 | const isMobile = useContext(MobileContext); 10 | const { user, demoTokenId } = useContext(DataContext); 11 | const demo = !!demoTokenId; 12 | return ( 13 | <> 14 | {demo &&

note: these forms have been disabled for the demo account - i'm keeping them here so you can see what's going on in this section, but nothing will happen if you try to submit them

} 15 | 16 | 17 | 18 | 19 | ) 20 | } 21 | 22 | export default MyAccount; -------------------------------------------------------------------------------- /components/MyAccount/DeleteAccount.js: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | 3 | import { ModalContext } from "../../contexts"; 4 | import { Button } from "../Form"; 5 | 6 | const DeleteAccount = ({ demo, user }) => { 7 | const { createModal } = useContext(ModalContext); 8 | const confirmDeleteAccount = () => { 9 | if (demo) { 10 | console.log('cute'); 11 | createModal('niceTry'); 12 | return; 13 | } 14 | createModal('deleteAccount', { user }) 15 | } 16 | return ( 17 | <> 18 |

delete account

19 |

permanently delete your account, along with all data, habits, and settings. this is irreversible! if you really want to do this, click the button below. (you will be asked to confirm)

20 | 21 | 22 | ); 23 | } 24 | 25 | export default DeleteAccount; -------------------------------------------------------------------------------- /pages/habits.js: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | 3 | import { auth } from "./api/auth"; 4 | import { DataContext } from "../contexts"; 5 | import { DashboardLayout } from "../layouts"; 6 | import { PageLoading } from "../components/Loading"; 7 | import { MyHabits } from "../components/MyHabits"; 8 | 9 | const Habits = ({ user }) => { 10 | return ( 11 | 12 | 13 | 14 | ); 15 | } 16 | 17 | const HabitsContent = () => { 18 | const { user, habits } = useContext(DataContext); 19 | return ( 20 | <> 21 |

my habits

22 | {habits 23 | ? 27 | : 28 | } 29 | 30 | ); 31 | } 32 | 33 | export const getServerSideProps = auth({ shield: true, redirect: '/login' }); 34 | 35 | export default Habits; -------------------------------------------------------------------------------- /hooks/useCalendar.js: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | 3 | export const useCalendar = (calendarPeriod) => { 4 | const daysInMonth = new Array(dayjs(calendarPeriod).daysInMonth()).fill('').map((_, index) => { 5 | return dayjs(`${calendarPeriod}-${index + 1}`).format('YYYY-MM-DD'); 6 | }); 7 | const totalDaysInMonth = (() => { 8 | const monthStartsOnDay = dayjs(daysInMonth[0]).day(); 9 | const lastDayIndex = daysInMonth.length - 1; 10 | const monthEndsOnDay = dayjs(daysInMonth[lastDayIndex]).day(); 11 | let pre = (monthStartsOnDay !== 0) ? new Array(monthStartsOnDay).fill('') : []; 12 | let post = (monthEndsOnDay !== 6) ? new Array(6 - monthEndsOnDay).fill('') : []; 13 | return [ 14 | ...pre, 15 | ...daysInMonth, 16 | ...post 17 | ]; 18 | })(); 19 | const weekdays = (format) => new Array(7).fill('').map((_, index) => dayjs().day(index).format(format)); 20 | return { 21 | weekdays, 22 | totalDaysInMonth 23 | } 24 | } -------------------------------------------------------------------------------- /components/MySettings/mySettings.module.css: -------------------------------------------------------------------------------- 1 | .MySettings { 2 | width: 100%; 3 | max-width: 500px; 4 | } 5 | .MySettings + .MySettings { 6 | margin-top: 1rem; 7 | } 8 | .MySettings h2 { 9 | font-size: 1.75rem; 10 | margin-bottom: 1rem; 11 | } 12 | .MySettings h2:not(:first-of-type) { 13 | margin-top: 1.5rem; 14 | } 15 | .MySettings h3 { 16 | font-size: 1rem; 17 | margin: 1.5rem 0 1rem; 18 | text-transform: uppercase; 19 | } 20 | .MySettings > div { 21 | display: flex; 22 | align-items: center; 23 | justify-content: space-between; 24 | transition: 0.2s; 25 | } 26 | .MySettings > div + div { 27 | margin-top: 0.5rem; 28 | border-top: 1px solid rgba(0, 0, 0, 0.1); 29 | padding-top: 0.5rem; 30 | } 31 | 32 | .newHabitIconInput input { 33 | width: 2rem; 34 | font-size: 2rem; 35 | line-height: 0; 36 | border: 0; 37 | padding: 0; 38 | margin: 0; 39 | box-shadow: none; 40 | } 41 | 42 | .disabled { 43 | opacity: 0.3; 44 | pointer-events: none; 45 | } -------------------------------------------------------------------------------- /components/Loading/index.js: -------------------------------------------------------------------------------- 1 | import { fancyClassName } from "../../utils"; 2 | import styles from "./loading.module.css"; 3 | 4 | const Loading = ({ className }) => { 5 | return ( 6 |
7 | 8 | 9 | 10 | 11 | 12 |
13 | ); 14 | } 15 | 16 | export const PageLoading = ({ className }) => { 17 | return ( 18 |
19 | 20 |
21 | ); 22 | } 23 | 24 | export default Loading; -------------------------------------------------------------------------------- /layouts/DashboardLayout/Clock/clock.module.css: -------------------------------------------------------------------------------- 1 | .Clock { 2 | position: fixed; 3 | top: 0; 4 | right: 0; 5 | margin: 2rem 3rem 0 0; 6 | display: flex; 7 | flex-direction: column; 8 | align-items: flex-end; 9 | font-size: 1rem; 10 | } 11 | .Clock .dd { 12 | font-family: var(--header-font); 13 | font-size: 5em; 14 | line-height: 1; 15 | } 16 | .Clock .mm { 17 | font-size: 1.5em; 18 | font-weight: bold; 19 | text-transform: uppercase; 20 | } 21 | .Clock .Time { 22 | margin-top: 1em; 23 | font-family: var(--mono-font); 24 | font-size: 1.2em; 25 | } 26 | 27 | @media screen and (max-width: 900px) { 28 | .Clock { 29 | position: absolute; 30 | margin: 1rem 1.5rem; 31 | font-size: 0.7rem; 32 | } 33 | .Clock .Time { 34 | margin-top: 0.5em; 35 | font-size: 1.5em; 36 | } 37 | } 38 | 39 | @media screen and (max-width: 600px) { 40 | .Clock { 41 | margin: 0.5rem 1rem; 42 | font-size: 0.5rem; 43 | } 44 | .Clock .Time { 45 | font-size: 1.75em; 46 | } 47 | } -------------------------------------------------------------------------------- /components/Form/Switch/switch.module.css: -------------------------------------------------------------------------------- 1 | .Switch { 2 | position: relative; 3 | cursor: pointer; 4 | width: 2.5rem; 5 | height: 1.25rem; 6 | } 7 | .Switch div { 8 | width: 100%; 9 | height: 100%; 10 | padding: 1px; 11 | border: 1px solid #000; 12 | background: #000; 13 | border-radius: 999px; 14 | opacity: 0.3; 15 | transition: 0.2s; 16 | } 17 | .Switch input { 18 | position: absolute; 19 | z-index: 10; 20 | top: 0; 21 | left: 0; 22 | width: 100%; 23 | height: 100%; 24 | margin: 0; 25 | opacity: 0; 26 | cursor: pointer; 27 | border-radius: 999px; 28 | } 29 | .Switch span { 30 | display: inline-block; 31 | width: 1rem; 32 | height: 1rem; 33 | border: 1px solid #fff; 34 | border-radius: 999px; 35 | transition: 0.2s; 36 | pointer-events: none; 37 | } 38 | .Switch:hover div { 39 | opacity: 0.4; 40 | } 41 | .Switch input:checked + div { 42 | opacity: 1; 43 | } 44 | .Switch input:checked + div span { 45 | transform: translate3d(1.25rem, 0, 0); 46 | background: #fff; 47 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "learn-starter", 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.35", 12 | "@fortawesome/free-brands-svg-icons": "^5.15.3", 13 | "@fortawesome/free-solid-svg-icons": "^5.15.3", 14 | "@fortawesome/react-fontawesome": "^0.1.14", 15 | "@prisma/client": "^2.22.1", 16 | "@sendgrid/mail": "^7.4.4", 17 | "apollo-server-micro": "^2.22.2", 18 | "bcryptjs": "^2.4.3", 19 | "chart.js": "^3.1.1", 20 | "dayjs": "^1.10.4", 21 | "emoji-picker-react": "^3.4.4", 22 | "graphql": "^15.5.0", 23 | "handlebars": "^4.7.7", 24 | "next": "^10.0.0", 25 | "next-iron-session": "^4.1.12", 26 | "nprogress": "^0.2.0", 27 | "react": "17.0.1", 28 | "react-chartjs-2": "^2.11.1", 29 | "react-dom": "17.0.1", 30 | "react-flip-toolkit": "^7.0.13", 31 | "validatorjs": "^3.22.1" 32 | }, 33 | "devDependencies": { 34 | "prisma": "^2.22.1" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /components/Form/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useFormSubmit } from "../../hooks"; 3 | import { fancyClassName } from "../../utils"; 4 | import Button from "./Button"; 5 | import Checkbox from "./Checkbox"; 6 | import Dropdown from "./Dropdown"; 7 | import Input from "./Input"; 8 | import Submit from "./Submit"; 9 | import Switch from "./Switch"; 10 | 11 | const Form = ({ children, title, submit, onSubmit, onSuccess, handleFormError, behavior, className }) => { 12 | const { handleSubmit, successPending, successAnimation } = useFormSubmit({ onSubmit, onSuccess, handleFormError, behavior }); 13 | const submitProps = { successPending, successAnimation }; 14 | const customSubmit = submit ? React.cloneElement(submit, submitProps) : null; 15 | return ( 16 |
17 | {title &&

{title}

} 18 | {children} 19 | {(submit === false) || (customSubmit ?? )} 20 | 21 | ); 22 | } 23 | 24 | export default Form; 25 | export { Button, Checkbox, Dropdown, Input, Submit, Switch } -------------------------------------------------------------------------------- /layouts/HomepageLayout/homepageLayout.module.css: -------------------------------------------------------------------------------- 1 | .Homepage { 2 | width: 100%; 3 | height: 100%; 4 | overflow: auto; 5 | display: grid; 6 | grid-template: 'main spacer' 100% / minmax(auto, 700px) calc(500px + 5rem); 7 | } 8 | .Main { 9 | grid-area: main; 10 | padding: 5rem; 11 | } 12 | .Homepage h1 { 13 | font-weight: normal; 14 | font-size: 5rem; 15 | line-height: 1; 16 | margin-bottom: 2rem; 17 | text-transform: lowercase; 18 | } 19 | .Homepage p { 20 | text-align: justify; 21 | } 22 | .Homepage nav { 23 | margin: 1.5rem 0; 24 | width: 80%; 25 | display: flex; 26 | flex-direction: column; 27 | align-items: center; 28 | } 29 | .Homepage nav button + button { 30 | margin-top: 1rem; 31 | margin-left: 3rem; 32 | } 33 | 34 | @media screen and (max-width: 900px) { 35 | .Main { 36 | padding: 4rem; 37 | padding-top: 2rem; 38 | } 39 | .Homepage { 40 | display: block; 41 | overflow: visible; 42 | } 43 | .Homepage h1 { 44 | font-size: 3rem; 45 | } 46 | .Homepage nav { 47 | margin-top: 2rem; 48 | } 49 | } 50 | 51 | @media screen and (max-width: 600px) { 52 | .Main { 53 | padding: 2rem; 54 | } 55 | .Homepage nav { 56 | margin-top: 1.5rem; 57 | } 58 | } -------------------------------------------------------------------------------- /pages/api/server/resolvers/mail.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { promises as fs } from "fs"; 3 | import getConfig from "next/config"; 4 | 5 | import handlebars from "handlebars"; 6 | import sgMail from "@sendgrid/mail"; 7 | 8 | const { serverRuntimeConfig } = getConfig(); 9 | 10 | sgMail.setApiKey(process.env.SENDGRID_API_KEY); 11 | 12 | const getPath = (pathToFile) => path.join(serverRuntimeConfig.PROJECT_ROOT, pathToFile); 13 | 14 | const readHTMLFile = (pathToFile) => { 15 | return fs.readFile(pathToFile, { encoding: 'utf-8' }, (err, html) => { 16 | if (err) throw err; 17 | return html; 18 | }); 19 | } 20 | 21 | export const sendPasswordResetEmail = async ({ to, subject, resetLink }) => { 22 | const html = await readHTMLFile(getPath('./public/email/passwordReset.html')); 23 | const template = handlebars.compile(html); 24 | const replacements = { 25 | email: to, 26 | resetLink 27 | } 28 | const htmlToSend = template(replacements); 29 | await sgMail.send({ 30 | from: `habitat `, 31 | to, 32 | subject, 33 | plaintext: 'hi \n this is habitat', // todo better plaintext option 34 | html: htmlToSend 35 | }); 36 | return `Sent password recovery email to ${to}`; 37 | } -------------------------------------------------------------------------------- /components/Form/Checkbox/index.js: -------------------------------------------------------------------------------- 1 | import styles from "./checkbox.module.css"; 2 | import { fancyClassName } from "../../../utils"; 3 | 4 | const Checkbox = ({ label, name, detailedLabel, checkboxAfter, className, checked, onChange }) => { 5 | const checkboxClassName = () => { 6 | let stringToReturn = ''; 7 | if (detailedLabel) stringToReturn += `${styles.detailed}`; 8 | if (checkboxAfter) stringToReturn += ` ${styles.checkboxAfter}`; 9 | if (className) stringToReturn += ` ${fancyClassName({ styles, className })}`; 10 | return stringToReturn; 11 | } 12 | return ( 13 |
14 |
15 | 16 | 17 | 18 | 19 |
20 | {label 21 | ? 22 | :
23 | 24 | {detailedLabel[1]} 25 |
26 | } 27 |
28 | ); 29 | } 30 | 31 | export default Checkbox; -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import Router, { useRouter } from "next/router"; 3 | 4 | import nprogress from "nprogress"; 5 | 6 | import "../styles/global.css"; 7 | import { AppContextProvider, ModalContext } from "../contexts"; 8 | import { AppLayout, DashboardWrapper, HomepageLayout } from "../layouts"; 9 | import Modal from "../components/Modal"; 10 | 11 | Router.events.on('routeChangeStart', () => nprogress.start()); 12 | Router.events.on('routeChangeComplete', () => nprogress.done()); 13 | Router.events.on('routeChangeError', () => nprogress.done()); 14 | 15 | const App = ({ Component, pageProps }) => { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | } 24 | 25 | const AppContent = ({ Component, pageProps }) => { 26 | const { modal } = useContext(ModalContext); 27 | const router = useRouter(); 28 | const protectedRoutes = ['/dashboard', '/habits', '/account', '/settings']; 29 | const PageWrapper = (protectedRoutes.includes(router.pathname)) 30 | ? DashboardWrapper 31 | : HomepageLayout; 32 | return ( 33 | 34 | 35 | 36 | 37 | ); 38 | } 39 | 40 | export default App; -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect } from "react"; 2 | 3 | import { faGithub } from "@fortawesome/free-brands-svg-icons"; 4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 5 | 6 | import { ModalContext } from "../contexts"; 7 | import { getQueryParams } from "../utils"; 8 | import Footer from "../components/Footer"; 9 | import { Button } from "../components/Form"; 10 | 11 | const Home = ({ query }) => { 12 | const { createModal } = useContext(ModalContext); 13 | useEffect(() => { 14 | const { demo } = query; 15 | if (demo == null) return; 16 | createModal('demoMessage'); 17 | }, []); 18 | return ( 19 | <> 20 |

habitat is a digital habit tracker designed & created by naomi g.w. as a project for her portfolio, and also as a personal tool to track & organize her life.

21 | 25 |

» read more about this project on github

26 |
copyright 2021 naomi g.w. • all rights reserved
27 | 28 | ); 29 | } 30 | 31 | Home.getInitialProps = getQueryParams; 32 | 33 | export default Home; -------------------------------------------------------------------------------- /layouts/AppLayout/appLayout.module.css: -------------------------------------------------------------------------------- 1 | .AppLayout { 2 | overflow: hidden; 3 | width: 100%; 4 | height: 100%; 5 | position: relative; 6 | z-index: 1; 7 | } 8 | .AppLayout::before, .f, .base { 9 | display: block; 10 | position: fixed; 11 | z-index: -1; 12 | top: 0; 13 | left: 0; 14 | width: 100%; 15 | height: 100%; 16 | transition: 1.3s ease-in-out; 17 | } 18 | .base { 19 | background: #fde8ff; 20 | } 21 | .f { 22 | width: 130%; 23 | height: 130%; 24 | } 25 | 26 | .full { 27 | width: 100%; 28 | height: 100%; 29 | } 30 | 31 | .AppLayout figure { 32 | position: fixed; 33 | bottom: 5rem; 34 | right: 5rem; 35 | width: 500px; 36 | height: auto; 37 | margin: 0; 38 | } 39 | .AppLayout img { 40 | width: auto; 41 | height: 100%; 42 | } 43 | 44 | @media screen and (max-width: 900px) { 45 | .AppLayout { 46 | overflow: auto; 47 | display: flex; 48 | flex-direction: column; 49 | font-size: 0.9rem; 50 | } 51 | .AppLayout figure { 52 | position: static; 53 | width: auto; 54 | height: 20%; 55 | padding: 4rem 0 0 4rem; 56 | padding-bottom: 0; 57 | } 58 | .AppLayout figure img { 59 | width: auto; 60 | height: auto; 61 | max-width: 100%; 62 | max-height: 100%; 63 | } 64 | } 65 | 66 | @media screen and (max-width: 600px) { 67 | .AppLayout figure { 68 | padding: 2rem 0 0 2rem; 69 | } 70 | } -------------------------------------------------------------------------------- /components/Form/Submit/index.js: -------------------------------------------------------------------------------- 1 | import styles from "./submit.module.css"; 2 | import { fancyClassName } from "../../../utils"; 3 | import Button from "../Button"; 4 | import Loading from "../../Loading"; 5 | 6 | const Submit = ({ value, onClick, cancel, onCancel, successPending, successAnimation, className, disabled }) => { 7 | const hideText = !!successPending || !!successAnimation; 8 | return ( 9 |
10 | 14 | {(cancel !== false) && } 15 |
16 | ); 17 | } 18 | 19 | const StatusIcon = ({ successPending, successAnimation }) => { 20 | if (!successPending && !successAnimation) return null; 21 | if (successPending) return ; 22 | if (successAnimation) return ; 23 | } 24 | 25 | const SuccessAnimation = ({ status }) => { 26 | return ( 27 |
28 | 29 | 30 | 31 |
32 | ); 33 | } 34 | 35 | export default Submit; -------------------------------------------------------------------------------- /components/Tooltip/tooltip.module.css: -------------------------------------------------------------------------------- 1 | .TooltipElement { 2 | position: relative; 3 | } 4 | .Tooltip { 5 | position: absolute; 6 | z-index: 10; 7 | color: #fff; 8 | background: #000; 9 | padding: 0.375rem; 10 | font-family: var(--default-font); 11 | font-size: 0.75rem; 12 | opacity: 0; 13 | pointer-events: none; 14 | transition: 0.2s; 15 | } 16 | .Tooltip::before { 17 | content: ''; 18 | position: absolute; 19 | display: block; 20 | width: 0.5rem; 21 | height: 0.5rem; 22 | background: inherit; 23 | } 24 | .Tooltip::after { 25 | content: ''; 26 | position: absolute; 27 | z-index: -1; 28 | bottom: 0; 29 | left: 0; 30 | width: calc(100% + 0.5rem); 31 | height: 100%; 32 | transform: translate3d(-0.25rem, 0, 0) skew(-12deg); 33 | background: inherit; 34 | } 35 | .TooltipElement:hover .Tooltip { 36 | opacity: 1; 37 | pointer-events: default; 38 | } 39 | .Tooltip.above, .Tooltip.below { 40 | left: 50%; 41 | transform: translate3d(-50%, 0, 0); 42 | margin: 0.5rem 0; 43 | } 44 | .Tooltip.above { 45 | bottom: 100%; 46 | } 47 | .Tooltip.below { 48 | top: 100%; 49 | } 50 | .Tooltip.above::before { 51 | top: 100%; 52 | left: 50%; 53 | transform: translate3d(-50%, -0.25rem, 0) rotate(45deg); 54 | } 55 | .Tooltip.below::before { 56 | bottom: 100%; 57 | left: 50%; 58 | transform: translate3d(-50%, 0.25rem, 0) rotate(45deg); 59 | } 60 | .Tooltip.nowrap { 61 | white-space: nowrap; 62 | } -------------------------------------------------------------------------------- /components/Modal/ModalForm.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | 3 | import { ModalContext } from "../../contexts"; 4 | import Form, { Submit } from "../Form"; 5 | 6 | const ModalForm = (props) => { 7 | const { children, onSuccess, submit } = props; 8 | const { closeModal } = useContext(ModalContext); 9 | const handleSuccess = (result) => { 10 | onSuccess(result); 11 | closeModal(); 12 | } 13 | const submitProps = { 14 | onCancel: closeModal 15 | } 16 | const modalSubmit = submit ? React.cloneElement(submit, submitProps) : ; 17 | return ( 18 |
19 | {children} 20 |
21 | ); 22 | } 23 | 24 | export const ModalizedForm = ({ originalFormComponent, originalFormProps }) => { 25 | const { closeModal, submit } = useContext(ModalContext); 26 | const { onSuccess } = originalFormProps; 27 | const handleSuccess = () => { 28 | onSuccess?.(); 29 | closeModal(); 30 | } 31 | const submitProps = { 32 | onCancel: closeModal 33 | } 34 | const modalSubmit = submit ? React.cloneElement(submit, submitProps) : ; 35 | const componentToReturn = React.cloneElement(originalFormComponent, { 36 | onSuccess: handleSuccess, 37 | submit: modalSubmit, 38 | ...originalFormProps 39 | }); 40 | return componentToReturn; 41 | } 42 | 43 | export default ModalForm; -------------------------------------------------------------------------------- /components/DataForm/dataForm.module.css: -------------------------------------------------------------------------------- 1 | .DataForm { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | .DataFormField { 6 | display: flex; 7 | align-items: center; 8 | justify-content: flex-start; 9 | } 10 | .DataFormField + .DataFormField { 11 | margin-top: 0.5rem; 12 | } 13 | .DataFormField > span:first-child { 14 | font-size: 1.5rem; 15 | margin-right: 0.5rem; 16 | align-self: flex-start; 17 | filter: grayscale(); 18 | transition: 0.2s; 19 | } 20 | .DataFormField > span.check:first-child { 21 | filter: none; 22 | } 23 | .DataFormField > span:last-child { 24 | display: flex; 25 | align-items: center; 26 | justify-content: flex-start; 27 | } 28 | .DataForm input { 29 | border: 0; 30 | background: #fff; 31 | } 32 | .DataForm input[type=number] { 33 | width: 4rem; 34 | padding-right: 0; 35 | margin: 0 0.4rem; 36 | line-height: 1.4rem; 37 | } 38 | .DataFormDateInputNav { 39 | margin-left: 1rem; 40 | } 41 | .DataFormDateInputNav > button { 42 | font-size: 1rem; 43 | } 44 | button.deleteEntry { 45 | font-size: 0.5rem; 46 | padding: 0.3rem 0.6rem; 47 | margin-top: 1rem; 48 | align-self: center; 49 | } 50 | button.deleteEntry > span { 51 | display: inline-block; 52 | margin-left: 0.375rem; 53 | font-size: 0.75rem; 54 | } 55 | .noHabits p { 56 | margin: 0.5rem 0 1.5rem; 57 | opacity: 0.6; 58 | } 59 | 60 | @media screen and (max-width: 900px) { 61 | .DataForm input { 62 | background: #f7f7f7; 63 | } 64 | .DataFormField > span:first-child { 65 | margin-right: 0.75rem; 66 | } 67 | } -------------------------------------------------------------------------------- /components/MiniCalendar/miniCalendar.module.css: -------------------------------------------------------------------------------- 1 | .MiniCalendarWrapper { 2 | width: 100%; 3 | padding: 1rem 0 0; 4 | } 5 | .MiniCalendar { 6 | width: 100%; 7 | display: grid; 8 | grid-template-columns: repeat(7, 1fr); 9 | gap: 0.25rem; 10 | margin-bottom: 1rem; 11 | } 12 | .WeekLabels { 13 | font-family: var(--mono-font); 14 | text-transform: lowercase; 15 | border-bottom: 2px solid #000; 16 | margin-bottom: 0.25rem; 17 | } 18 | .MiniCalendar > div { 19 | text-align: center; 20 | } 21 | .Header { 22 | display: flex; 23 | align-items: center; 24 | justify-content: space-between; 25 | margin-bottom: 1rem; 26 | font-family: var(--mono-font); 27 | font-size: 1.2rem; 28 | font-weight: 300; 29 | text-transform: uppercase; 30 | } 31 | .CalendarDay:not(.filler) { 32 | font-size: 0.75rem; 33 | padding: 0.5rem; 34 | cursor: pointer; 35 | } 36 | .CalendarDay > span { 37 | position: relative; 38 | display: flex; 39 | align-items: center; 40 | justify-content: center; 41 | transition: 0.2s; 42 | } 43 | .CalendarDay > span::before { 44 | content: ''; 45 | position: absolute; 46 | z-index: -1; 47 | background: #fff; 48 | width: 1.75rem; 49 | height: 1.75rem; 50 | border-radius: 999px; 51 | transition: 0.2s; 52 | } 53 | .CalendarDay:hover > span { 54 | transform: scale(1.2); 55 | } 56 | .CalendarDay:hover > span::before { 57 | box-shadow: 0.125rem 0.125rem 0.5rem rgba(0, 0, 0, 0.1); 58 | } 59 | 60 | @media screen and (max-width: 900px) { 61 | .MiniCalendarWrapper { 62 | min-height: 75vh; 63 | } 64 | .CalendarDay > span::before { 65 | background: #f7f7f7; 66 | } 67 | } -------------------------------------------------------------------------------- /components/MyAccount/AccountDetails.js: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | 3 | import { User } from "../../pages/api"; 4 | import { DataContext } from "../../contexts"; 5 | import { useForm } from "../../hooks"; 6 | import Form, { Input, Submit } from "../Form"; 7 | 8 | const AccountDetails = ({ demo, user, isMobile }) => { 9 | const { setUser } = useContext(DataContext); 10 | const { formData, handleFormError, inputProps } = useForm({ 11 | id: user.id, 12 | name: user.name ?? '', 13 | email: user.email ?? '' 14 | }); 15 | const handleSubmit = () => { 16 | if (demo) { 17 | console.log('cute'); 18 | return Promise.reject({ __typename: 'NiceTry' }); 19 | } 20 | return User.edit(formData); 21 | } 22 | const handleSuccess = ({ editUser }) => setUser(editUser); 23 | return ( 24 |
} 27 | title="account details"> 28 | 36 | 44 |
45 | ); 46 | } 47 | 48 | export default AccountDetails; -------------------------------------------------------------------------------- /components/Form/Dropdown/dropdown.module.css: -------------------------------------------------------------------------------- 1 | .Dropdown { 2 | position: relative; 3 | display: inline-block; 4 | font-family: var(--mono-font); 5 | cursor: pointer; 6 | } 7 | .Display, .List button { 8 | background: #fff; 9 | line-height: 2rem; 10 | padding: 0 0.75rem; 11 | display: flex; 12 | align-items: center; 13 | justify-content: space-between; 14 | } 15 | .Display::before { 16 | content: ''; 17 | display: block; 18 | position: absolute; 19 | z-index: -1; 20 | top: 0; 21 | left: 0; 22 | width: calc(100% + 0.5rem); 23 | height: 100%; 24 | background: inherit; 25 | transform: translate3d(-0.25rem, 0, 0) skew(-12deg); 26 | box-shadow: 0.25rem 0.25rem 0.5rem rgba(0, 0, 0, 0.1); 27 | } 28 | .Display span { 29 | display: block; 30 | margin-right: 3rem; 31 | } 32 | .Display svg { 33 | display: block; 34 | transition: 0.2s; 35 | } 36 | .List { 37 | position: absolute; 38 | z-index: 11; 39 | top: 100%; 40 | left: 0; 41 | width: 100%; 42 | margin-top: 0.25rem; 43 | box-shadow: 0.25rem 0.25rem 0.5rem rgba(0, 0, 0, 0.1); 44 | display: flex; 45 | flex-direction: column; 46 | } 47 | .List button { 48 | max-height: 0; 49 | overflow: hidden; 50 | transition: 0.2s; 51 | } 52 | .List button:hover { 53 | background: #f3f3f3; 54 | } 55 | .List button span { 56 | display: block; 57 | opacity: 0; 58 | transform: scale(0.8); 59 | transition: 0.2s; 60 | } 61 | 62 | .Dropdown.expanded .Display svg { 63 | transform: rotate(-90deg); 64 | } 65 | .Dropdown.expanded .List button { 66 | max-height: 2rem; 67 | } 68 | .Dropdown.expanded .List button span { 69 | opacity: 1; 70 | transform: scale(1); 71 | } -------------------------------------------------------------------------------- /public/email/passwordReset.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | your habitat account ✨ 5 | 6 | 7 |
8 |
9 | 10 | 11 |

habitat

12 |
13 |
14 |

hi there!

15 |

i received a request to reset the password for the habitat account associated with this email address. you can reset your password by clicking on the button below:

16 |

17 | 18 | reset password 19 | 20 |

21 |

if you have any questions or concerns, feel free to reply to this email. thanks so much!

22 |

— naomi 🧡

23 |
24 |
25 |
26 | 27 | -------------------------------------------------------------------------------- /layouts/DashboardLayout/Clock/index.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import dayjs from "dayjs"; 3 | import styles from "./clock.module.css"; 4 | 5 | const Clock = ({ user }) => { 6 | const [time, setTime] = useState(new Date()); 7 | useEffect(() => { 8 | const counter = setInterval(() => { 9 | setTime(new Date()); 10 | }, 1 * 1000); 11 | return () => clearInterval(counter); 12 | }, []); 13 | const { appearance__showClock, appearance__24hrClock, appearance__showClockSeconds } = user?.settings ?? { 14 | appearance__showClock: true, 15 | appearance__24hrClock: false, 16 | appearance__showClockSeconds: true 17 | } 18 | return ( 19 |
20 | {dayjs(time).format('DD')} 21 | {dayjs(time).format('MMMM')} 22 | {appearance__showClock && ( 23 |
30 | ); 31 | } 32 | 33 | const Time = ({ time, appearance__24hrClock, appearance__showClockSeconds }) => { 34 | let [hours, minutes, seconds] = [time.getHours(), time.getMinutes(), time.getSeconds()]; 35 | const ampm = appearance__24hrClock ? null : (hours >= 12) ? 'pm' : 'am'; 36 | hours = appearance__24hrClock ? hours : hours % 12; 37 | hours = hours ? hours : 12; // the hour '0' should be '12' 38 | const addZero = (num) => { 39 | let fixed = num < 10 ? '0' + num : num; 40 | return fixed; 41 | } 42 | minutes = addZero(minutes); 43 | seconds = appearance__showClockSeconds ? `:${addZero(seconds)}` : null; 44 | return ( 45 |
{hours}:{minutes}{seconds} {ampm}
46 | ); 47 | } 48 | 49 | export default Clock; -------------------------------------------------------------------------------- /components/Modal/index.js: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useRef } from "react"; 2 | 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 4 | import { faTimes } from "@fortawesome/free-solid-svg-icons"; 5 | 6 | import styles from "./modal.module.css"; 7 | import { modalStore } from "./modalStore"; 8 | import { ModalContext } from "../../contexts"; 9 | import { useRefName } from "../../hooks"; 10 | 11 | const Modal = ({ keyphrase, options, selfDestruct }) => { 12 | const { closeModal } = useContext(ModalContext); 13 | if (!keyphrase) return null; 14 | const modalProps = { 15 | ...options, 16 | closeModal 17 | } 18 | return ( 19 | 20 | {modalStore[keyphrase]?.(modalProps) ?? 'nothing to see here'} 21 | 22 | ); 23 | } 24 | 25 | const ModalWrapper = ({ children, selfDestruct, closeModal }) => { 26 | const modalRef = useRef(null); 27 | const modalContentRef = useRef(null); 28 | useEffect(() => { 29 | const { current: modalContent } = modalContentRef; 30 | const handleClick = (e) => { 31 | if (modalContent.contains(e.target)) return; 32 | closeModal(); 33 | } 34 | window.addEventListener('click', handleClick); 35 | return () => window.removeEventListener('click', handleClick); 36 | }, []); 37 | return ( 38 |
39 |
40 |
41 | 42 |
43 | {children} 44 |
45 |
46 |
47 |
48 | ); 49 | } 50 | 51 | export default Modal; -------------------------------------------------------------------------------- /components/MyData/Timeline/timeline.module.css: -------------------------------------------------------------------------------- 1 | .timelineEntries { 2 | display: flex; 3 | flex-direction: column; 4 | animation: timelineEntriesAnimation 0.2s 1 forwards; 5 | } 6 | .DashboardEntry { 7 | display: flex; 8 | flex-direction: row; 9 | } 10 | .DashboardEntry:not(:first-of-type) > * { 11 | padding-top: 1.5rem; 12 | } 13 | .entryTitle { 14 | padding-right: 0.75rem; 15 | margin-right: 1rem; 16 | border-right: 1px solid #000; 17 | display: flex; 18 | flex-direction: column; 19 | white-space: nowrap; 20 | width: 3rem; 21 | } 22 | .entryTitle span { 23 | text-transform: uppercase; 24 | display: block; 25 | text-align: right; 26 | line-height: 1; 27 | } 28 | .entryTitle span.day { 29 | font-family: var(--default-font); 30 | font-weight: bold; 31 | font-size: 1.5rem; 32 | margin-bottom: 0.125rem; 33 | } 34 | .entryTitle span.month { 35 | font-family: var(--mono-font); 36 | font-size: 0.8rem; 37 | } 38 | .entryTitle button { 39 | align-self: flex-end; 40 | text-align: right; 41 | margin-top: 0.5rem; 42 | } 43 | .entryTitle button svg { 44 | opacity: 0.3; 45 | transition: 0.2s; 46 | } 47 | .entryTitle button:hover svg { 48 | opacity: 0.7; 49 | } 50 | .entryBody > div { 51 | display: grid; 52 | grid-template: 'icon label' 100% / 1.9rem 1fr; 53 | align-items: center; 54 | } 55 | .entryBody > div span:first-of-type { 56 | font-size: 1.3rem; 57 | } 58 | .entryBody > div b { 59 | font-weight: 600; 60 | } 61 | 62 | @media screen and (max-width: 900px) { 63 | .entryTitle { 64 | width: 2.5rem; 65 | margin-right: 0.75rem; 66 | } 67 | .entryTitle span.day { 68 | font-size: 1.3rem; 69 | } 70 | .entryBody > div { 71 | font-size: 0.8rem; 72 | grid-template: 'icon label' 100% / 1.625rem 1fr; 73 | } 74 | .entryBody > div span:first-of-type { 75 | font-size: 1.125rem; 76 | } 77 | } -------------------------------------------------------------------------------- /components/MyData/myData.module.css: -------------------------------------------------------------------------------- 1 | @keyframes jumpToDateAnimation { 2 | from { 3 | opacity: 0; 4 | transform: translate3d(-1rem, 0, 0); 5 | } 6 | to { 7 | opacity: 1; 8 | transform: translate3d(0, 0, 0); 9 | } 10 | } 11 | @keyframes dataAnimation { 12 | from { 13 | opacity: 0; 14 | transform: translate3d(0, 1rem, 0); 15 | } 16 | to { 17 | opacity: 1; 18 | transform: translate3d(0, 0, 0); 19 | } 20 | } 21 | 22 | .MyDataHeader { 23 | margin-bottom: 2rem; 24 | } 25 | .MyDataNav { 26 | display: inline-flex; 27 | align-items: center; 28 | position: relative; 29 | } 30 | button.jumpToCurrentMonth { 31 | font-size: 0.8rem; 32 | line-height: 1.25rem; 33 | margin-left: 0.5rem; 34 | white-space: nowrap; 35 | animation: jumpToDateAnimation 0.2s 1 forwards; 36 | } 37 | button.jumpToCurrentMonth::before { 38 | content: '\00BB'; 39 | display: inline-block; 40 | margin-right: 0.5em; 41 | } 42 | button.jumpToCurrentMonth::after { 43 | opacity: 0; 44 | } 45 | button.jumpToCurrentMonth:hover::after { 46 | opacity: 1; 47 | } 48 | 49 | .calendarPeriod { 50 | position: relative; 51 | font-family: var(--mono-font); 52 | font-size: 1.5rem; 53 | font-weight: 300; 54 | line-height: 0.8; 55 | text-transform: uppercase; 56 | margin-top: 0.75rem; 57 | } 58 | 59 | .MyDataViewOptions { 60 | position: absolute; 61 | bottom: 0; 62 | right: 0; 63 | } 64 | div button.currentDataView { /* including div for specificity */ 65 | opacity: 1; 66 | } 67 | 68 | .MyDataContent > * { 69 | animation: dataAnimation 0.2s 1 forwards; 70 | } 71 | 72 | .noData { 73 | margin-top: 2rem; 74 | font-size: 1.2rem; 75 | animation: dataAnimation 0.2s 1 forwards; 76 | } 77 | .noData > span { 78 | opacity: 0.4; 79 | } 80 | 81 | @media screen and (max-width: 900px) { 82 | .calendarPeriod { 83 | font-size: 1.2rem; 84 | } 85 | } -------------------------------------------------------------------------------- /components/EmojiPicker/index.js: -------------------------------------------------------------------------------- 1 | import dynamic from "next/dynamic"; 2 | import { useEffect, useRef, useState } from "react"; 3 | import { fancyClassName } from "../../utils"; 4 | 5 | import styles from "./emojiPicker.module.css"; 6 | 7 | const EmojiPickerNoSSRWrapper = dynamic( 8 | () => import("emoji-picker-react"), 9 | { 10 | ssr: false, 11 | loading: () => , 12 | } 13 | ); 14 | 15 | const EmojiPicker = ({ className, formFieldName, setFormData }) => { 16 | const [expanded, setExpanded] = useState(false); 17 | const containerRef = useRef(null); 18 | const toggleExpanded = () => { 19 | if (expanded) setExpanded('closing'); 20 | else setExpanded(true); 21 | } 22 | useEffect(() => { 23 | if (!expanded) return; 24 | if (expanded === 'closing') { 25 | setTimeout(() => { 26 | setExpanded(false) 27 | }, 200); 28 | } 29 | const closePicker = (e) => { 30 | if (!containerRef.current?.contains(e.target)) { 31 | setExpanded('closing'); 32 | } 33 | } 34 | window.addEventListener('click', closePicker); 35 | return () => window.removeEventListener('click', closePicker); 36 | }, [expanded]); 37 | const handleClick = (_, obj) => { 38 | setExpanded('closing'); 39 | setFormData(prevData => ({ 40 | ...prevData, 41 | [formFieldName]: obj.emoji 42 | })); 43 | } 44 | return ( 45 |
46 | 51 | {expanded && } 52 |
53 | ); 54 | } 55 | 56 | export default EmojiPicker; -------------------------------------------------------------------------------- /pages/login.js: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { useRouter } from "next/router"; 3 | 4 | import { handleRequest, User } from "./api"; 5 | import { auth } from "./api/auth"; 6 | import { ModalContext } from "../contexts"; 7 | import { useForm } from "../hooks"; 8 | import Form, { Input, Submit } from "../components/Form"; 9 | 10 | const Login = () => { 11 | const router = useRouter(); 12 | const { formData, handleFormError, inputProps } = useForm({ 13 | email: '', 14 | password: '' 15 | }); 16 | const handleSubmit = () => User.login(formData); 17 | const handleSuccess = async ({ login: user }) => { 18 | const { id, demoTokenId } = user; 19 | await handleRequest('/api/auth/login', { 20 | user: { id, demoTokenId } 21 | }); 22 | router.push('/dashboard'); 23 | } 24 | return ( 25 | <> 26 |

login

27 |
router.back()} />}> 30 | 36 | } 41 | {...inputProps} 42 | /> 43 |
44 | 45 | ); 46 | } 47 | 48 | const ForgotPassword = () => { 49 | const { createModal } = useContext(ModalContext); 50 | return ( 51 | 57 | ); 58 | } 59 | 60 | export const getServerSideProps = auth({ shield: false, redirect: '/dashboard' }); 61 | 62 | export default Login; -------------------------------------------------------------------------------- /components/MyAccount/ChangePassword.js: -------------------------------------------------------------------------------- 1 | import { User } from "../../pages/api"; 2 | import { useForm } from "../../hooks"; 3 | import Form, { Input, Submit } from "../Form"; 4 | 5 | const ChangePassword = ({ demo, user, isMobile }) => { 6 | const { formData, handleFormError, inputProps, resetForm } = useForm({ 7 | id: user.id, 8 | password: '', 9 | confirmPassword: '' 10 | }); 11 | const handleSubmit = () => { 12 | if (demo) { 13 | console.log('cute'); 14 | return Promise.reject({ __typename: 'NiceTry' }); 15 | } 16 | return User.editPassword(formData); 17 | } 18 | const handleSuccess = () => resetForm(); 19 | const passwordIsOk = (() => { 20 | if (!formData.password || !formData.confirmPassword) return false; 21 | if (formData.password.length < 6) return false; 22 | return formData.password === formData.confirmPassword; 23 | })(); 24 | const formSubmit = ( 25 | 32 | ); 33 | return ( 34 |
38 | 47 | 55 |
56 | ); 57 | } 58 | 59 | export default ChangePassword; -------------------------------------------------------------------------------- /layouts/DashboardLayout/index.js: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect } from "react"; 2 | 3 | import styles from "./dashboardLayout.module.css"; 4 | import { DataContext, DataContextProvider } from "../../contexts"; 5 | import { fancyClassName } from "../../utils"; 6 | import Nav from "../../components/Nav"; 7 | import { PageLoading } from "../../components/Loading"; 8 | import Clock from "./Clock"; 9 | 10 | export const DashboardWrapper = ({ children }) => { 11 | return ( 12 | 13 | {children} 14 | 15 | ); 16 | } 17 | 18 | const DashboardLayout = ({ children, userId, sidebar, className, dim, dimOnClick }) => { 19 | const { user, getUser, habits, getHabits, entries, getEntries } = useContext(DataContext); 20 | useEffect(() => { 21 | if (user == null) return getUser(userId); 22 | }, [user]); 23 | useEffect(() => { 24 | if (user && !habits) return getHabits(); 25 | }, [user, habits]); 26 | useEffect(() => { 27 | if (user && !entries) return getEntries(); 28 | }, [user, entries]); 29 | if (!user) return ; 30 | return ( 31 |
32 | 33 |
42 | ); 43 | } 44 | 45 | export const Dim = ({ dim, dimOnClick }) => { 46 | return ( 47 |
48 | ); 49 | } 50 | 51 | export const Content = ({ children, className }) => { 52 | return ( 53 |
54 | {children} 55 |
56 | ); 57 | } 58 | 59 | export const Sidebar = ({ children }) => { 60 | if (!children) return null; 61 | return ( 62 |
63 | {children} 64 |
65 | ); 66 | } 67 | 68 | export default DashboardLayout; -------------------------------------------------------------------------------- /components/MyData/Calendar/calendar.module.css: -------------------------------------------------------------------------------- 1 | .Calendar { 2 | width: 100%; 3 | display: grid; 4 | grid-template-columns: repeat(7, 1fr); 5 | gap: 1rem; 6 | } 7 | .calendarWeekLabel { 8 | padding: 0 0.5rem; 9 | font-family: var(--mono-font); 10 | font-size: 1.3rem; 11 | font-weight: 300; 12 | color: #000; 13 | line-height: 1; 14 | padding-bottom: 0.25rem; 15 | border-bottom: 0.25rem solid #000; 16 | text-transform: lowercase; 17 | } 18 | .calendarDay { 19 | border: 1px solid #000; 20 | border-width: 1px 0 0 1px; 21 | padding: 0 0.5rem 0.5rem; 22 | min-height: 4rem; 23 | cursor: pointer; 24 | } 25 | .calendarDay > div.title { 26 | font-family: var(--mono-font); 27 | font-weight: 400; 28 | font-size: 1rem; 29 | background: #fff; 30 | display: inline-block; 31 | width: 2rem; 32 | height: 2rem; 33 | border-radius: 999px; 34 | text-align: center; 35 | line-height: 2rem; 36 | transform: translate3d(0, -0.5rem, 0); 37 | box-shadow: 0.25rem 0.25rem 0.5rem rgba(0, 0, 0, 0); 38 | } 39 | .calendarDay > div:last-of-type { 40 | display: flex; 41 | flex-wrap: wrap; 42 | font-size: 1.2rem; 43 | line-height: 1; 44 | margin: 0.25rem -0.25em -0.25em 0; 45 | } 46 | .calendarDay > div:last-of-type > div { 47 | margin: 0 0.25em 0.25em 0; 48 | } 49 | .calendarDay.filler { 50 | border: 0; 51 | background: #000; 52 | opacity: 0.05; 53 | cursor: default; 54 | } 55 | .calendarDay.filler * { 56 | display: none; 57 | } 58 | 59 | @media screen and (max-width: 900px) { 60 | .Calendar { 61 | gap: 0.25rem; 62 | } 63 | .calendarWeekLabel { 64 | overflow: hidden; 65 | font-size: 1rem; 66 | } 67 | .calendarDay { 68 | padding: 0 0.25rem 0.25rem; 69 | min-height: unset; 70 | text-align: center; 71 | } 72 | .calendarDay > div.title { 73 | line-height: 1; 74 | transform: none; 75 | box-shadow: none; 76 | width: auto; 77 | height: auto; 78 | background: none; 79 | } 80 | .calendarDay > div:last-of-type { 81 | font-size: 1rem; 82 | justify-content: center; 83 | margin: 0.25rem -0.5rem 0rem 0; 84 | } 85 | .calendarDay > div:last-of-type > div { 86 | text-align: center; 87 | } 88 | } -------------------------------------------------------------------------------- /pages/dashboard.js: -------------------------------------------------------------------------------- 1 | import { useContext, useMemo, useState } from "react"; 2 | 3 | import dayjs from "dayjs"; 4 | 5 | import { auth } from "./api/auth"; 6 | import { DataContext, MobileContext } from "../contexts"; 7 | import { DashboardLayout } from "../layouts"; 8 | import { PageLoading } from "../components/Loading"; 9 | import DashPanel from "../components/DashPanel"; 10 | import MyData from "../components/MyData"; 11 | 12 | const Dashboard = ({ user }) => { 13 | const isMobile = useContext(MobileContext); 14 | const [dashPanel, setDashPanel] = useState(null); 15 | const [calendarPeriod, updateCalendarPeriod] = useState(dayjs().format('YYYY-MM')); 16 | const updateDashPanel = (view, options) => setDashPanel({ view, options }); 17 | return ( 18 | setDashPanel(null)} 22 | sidebar={}> 23 | 24 | 25 | ); 26 | } 27 | 28 | const DashboardContent = ({ dashPanel, updateDashPanel, calendarPeriod, updateCalendarPeriod }) => { 29 | const { user, habits, entries } = useContext(DataContext); 30 | const entriesToDisplay = useMemo(() => { 31 | if (!entries) return []; 32 | return entries.filter(entry => { 33 | const [currentYear, currentMonth] = calendarPeriod.split('-'); 34 | const [entryYear, entryMonth] = entry.date.split('-'); 35 | if ((entryYear === currentYear) && (entryMonth === currentMonth)) return entry; 36 | }); 37 | }, [entries, calendarPeriod]); 38 | return ( 39 | <> 40 |

dashboard

41 | {(!habits || !entries) && } 42 | {(habits && entries) && ( 43 | 52 | )} 53 | 54 | ); 55 | } 56 | 57 | export const getServerSideProps = auth({ shield: true, redirect: '/login' }); 58 | 59 | export default Dashboard; -------------------------------------------------------------------------------- /components/Form/Input/input.module.css: -------------------------------------------------------------------------------- 1 | .Input + .Input, form > div + .Input { 2 | margin-top: 1rem; 3 | } 4 | form > div > .Input + .Input { 5 | margin-top: 0; 6 | margin-left: 1rem; 7 | } 8 | .Input { 9 | display: flex; 10 | flex-direction: column; 11 | align-items: flex-start; 12 | } 13 | .Input > label { 14 | width: 100%; 15 | display: flex; 16 | justify-content: space-between; 17 | } 18 | .Input > div { 19 | display: inline-block; 20 | position: relative; 21 | } 22 | .Input > div + span { 23 | display: inline-block; 24 | margin-top: 0.3rem; 25 | font-size: 0.7rem; 26 | letter-spacing: 0ch; 27 | } 28 | 29 | .Input.inline { 30 | display: inline-flex; 31 | } 32 | .Input.stretch > div, .Input.stretch > div > input, .Input > div + span { 33 | width: 100%; 34 | } 35 | .Input.alertInside .Alert { 36 | left: unset; 37 | right: 1.25rem; 38 | } 39 | 40 | .Alert { 41 | position: absolute; 42 | z-index: 99; 43 | top: 50%; 44 | left: 100%; 45 | transform: translate3d(0.5rem, -50%, 0); 46 | } 47 | .Alert > div { 48 | width: 1.25rem; 49 | height: 1.25rem; 50 | text-align: center; 51 | font-size: 1rem; 52 | color: #000; 53 | border-radius: 999px; 54 | cursor: help; 55 | } 56 | .Alert > span { 57 | position: absolute; 58 | top: calc(100% + 0.25rem); 59 | right: -0.5rem; 60 | background: #fff; 61 | color: #000; 62 | font-size: 0.75rem; 63 | line-height: 2; 64 | padding: 0 0.5rem; 65 | box-shadow: 0.2rem 0.2rem 1rem rgba(0, 0, 0, 0.1); 66 | white-space: nowrap; 67 | opacity: 0; 68 | transform: scale(0.9); 69 | transform-origin: top right; 70 | pointer-events: none; 71 | transition: 0.2s; 72 | } 73 | .Alert > span > span { 74 | display: block; 75 | transform: skew(12deg); 76 | } 77 | .Alert > div:hover + span { 78 | pointer-events: unset; 79 | transform: scale(1) skew(-12deg); 80 | opacity: 1; 81 | } 82 | 83 | .Input input[type=color] { 84 | width: 2rem; 85 | height: 2rem; 86 | padding: 0; 87 | opacity: 0; 88 | cursor: pointer; 89 | } 90 | .ColorInput { 91 | position: absolute; 92 | top: 0; 93 | left: 0; 94 | width: 2rem; 95 | height: 2rem; 96 | background: #fff; 97 | border: 1px solid #000; 98 | border-radius: 999px; 99 | cursor: pointer; 100 | } 101 | -------------------------------------------------------------------------------- /pages/api/handleRequest.js: -------------------------------------------------------------------------------- 1 | import { FetchError } from "./handleError"; 2 | 3 | export const handleRequest = (...args) => { 4 | return Promise.race([ 5 | handleFetch(...args), 6 | new Promise((_, reject) => { 7 | setTimeout(() => reject(new Error('Timeout')), 20000); 8 | }) 9 | ]).catch(err => { 10 | if (err.message === 'Timeout') throw new FetchError({ message: 'The connection timed out.' }); 11 | console.log(`caught err in handleRequest:`); 12 | console.dir(err); 13 | throw err; // todo add catch block whenever await handleRequest occurs in client 14 | }); 15 | } 16 | 17 | const handleFetch = async (route, body) => { 18 | const options = { 19 | method: 'POST', 20 | headers: { 21 | 'Content-Type': 'application/json' 22 | }, 23 | body: JSON.stringify(body) 24 | }; 25 | const response = await fetch(route, options); 26 | const data = await response.json(); 27 | if (!response.ok) { 28 | // 400 or 500 server error: 29 | console.log('throwing err in handleFetch'); 30 | // todo create class ServerError - should include response.status, response.statusText, and data.errors (if it exists) 31 | throw new Error(data?.errors ? JSON.stringify(data?.errors) : `fetch response is not ok: ${response.status} ${response.statusText}`); 32 | } 33 | return data; 34 | } 35 | 36 | export const handleQuery = (query = {}, variables = {}) => { 37 | return handleRequest('/api/server', { query, variables }).then(body => { 38 | if (body?.errors) { 39 | // these will be server/graphQL errors 40 | console.warn('body contained errors: ', body.errors); 41 | throw { 42 | caughtErrors: JSON.stringify(body.errors) 43 | } 44 | } 45 | if (body?.data) { 46 | const result = Object.values(body.data)[0]; 47 | if (result?.__typename === 'FormErrorReport') throw result; 48 | // maybe better to parse error report here before returning/throwing back to client? todo think about it 49 | return body.data; 50 | } 51 | console.dir('no body.data'); 52 | throw body; 53 | }).catch(err => { 54 | // errors might arrive via makeRequest catch block or via then block directly above this 55 | console.log(`caught err in handleQuery, now forwarding to client`); 56 | // passing error to whatever handles error on the client side, e.g. handleError in Form.js: 57 | throw err; 58 | }); 59 | } -------------------------------------------------------------------------------- /components/Form/Dropdown/index.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | 3 | import { faCaretLeft } from "@fortawesome/free-solid-svg-icons"; 4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 5 | 6 | import styles from "./dropdown.module.css"; 7 | import { useRefName } from "../../../hooks"; 8 | 9 | const Dropdown = ({ name, defaultValue, listItems, onChange }) => { 10 | const [displayValue, setDisplayValue] = useState(defaultValue ?? 'Select one...'); 11 | const [expanded, setExpanded] = useState(false); 12 | const toggleExpanded = () => setExpanded(state => !state); 13 | const dropdownRef = useRef(null); 14 | useEffect(() => { 15 | const dropdown = useRefName(dropdownRef); 16 | if (!dropdown) return; 17 | dropdown.style.width = dropdown.scrollWidth + 'px'; 18 | const closeDropdown = (e) => { 19 | if (!dropdown.contains(e.target)) { 20 | setExpanded(false); 21 | } 22 | } 23 | window.addEventListener('click', closeDropdown); 24 | return () => window.removeEventListener('click', closeDropdown); 25 | }, [dropdownRef]); 26 | return ( 27 |
28 | {displayValue} 29 | onChange(name, value), 32 | updateDisplayValue: setDisplayValue, 33 | updateExpanded: setExpanded 34 | }} /> 35 |
36 | ); 37 | } 38 | 39 | const Display = ({ children, toggleExpanded }) => { 40 | return ( 41 |
42 | {children} 43 | 44 |
45 | ); 46 | } 47 | 48 | const List = ({ listItems, onChange, updateDisplayValue, updateExpanded }) => { 49 | const listButtons = listItems.map(({ value, display }) => { 50 | const handleClick = () => { 51 | onChange(value); 52 | updateDisplayValue(display); 53 | updateExpanded(false); 54 | } 55 | return ( 56 | 62 | ); 63 | }); 64 | return ( 65 |
66 | {listButtons} 67 |
68 | ); 69 | } 70 | 71 | export default Dropdown; -------------------------------------------------------------------------------- /pages/api/server/resolvers/demo/generateSampleData.js: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | 3 | const habitDefs = [ 4 | { name: 'Hydration', icon: '💦', color: '#94e4ff', label: 'Drank {oz} of water', complex: true, retired: false, range: '35-60' }, 5 | { name: 'Sleep', icon: '💤', color: '#bfa8ff', label: 'Slept {hours}', complex: true, retired: false, range: '5-9' }, 6 | { name: 'Cardio', icon: '💓', color: '#ff94d2', label: 'Did cardio', complex: false, retired: false }, 7 | { name: 'Plants', icon: '🌱', color: '#80ff8e', label: 'Watered plants', complex: false, retired: false }, 8 | { name: 'Sunshine', icon: '☀️', color: '#ffe74d', label: 'Spent time outdoors', complex: false, retired: false } 9 | ] 10 | 11 | const ids = habitDefs.map((_, index) => { 12 | return `demo-${index}`; 13 | }); 14 | 15 | // for each entry that will be in entriesArray (28-31 depending on calendar period), create a record for each habit in habitDefs 16 | export const recordsList = (demoTokenId, calendarPeriod) => { 17 | return new Array(dayjs(calendarPeriod).daysInMonth()).fill('').map(() => { 18 | return habitDefs.map((habit, index) => { 19 | const randomAmount = () => { 20 | let [min, max] = habit.range.split('-'); 21 | min = parseInt(min); 22 | max = parseInt(max); 23 | const randomInt = Math.floor(Math.random() * (max - min + 1) + min); 24 | return randomInt; 25 | } 26 | const randomBool = () => Math.random() < 0.5; // 50% probability of getting true 27 | return { 28 | habitId: `${ids[index]}-${demoTokenId}`, 29 | amount: habit.complex ? randomAmount() : null, 30 | check: habit.complex ? true : randomBool() 31 | } 32 | }); 33 | }); 34 | } 35 | 36 | // entries for the whole current month 37 | export const entriesList = (userId, demoTokenId, calendarPeriod) => recordsList(calendarPeriod).map((_, index) => { 38 | const dateForThisEntry = `${calendarPeriod}-${index + 1}`; 39 | return { 40 | date: dayjs(dateForThisEntry).format('YYYY-MM-DD'), 41 | userId, 42 | demoTokenId 43 | } 44 | }); 45 | 46 | export const habitsList = (userId, demoTokenId) => habitDefs.map((habit, index) => { 47 | const sanitizedHabit = {...habit}; 48 | delete sanitizedHabit.range; // range is only there for data generation, will cause server error if we try to write it to db 49 | return { 50 | id: `${ids[index]}-${demoTokenId}`, 51 | ...sanitizedHabit, 52 | userId, 53 | demoTokenId 54 | } 55 | }); -------------------------------------------------------------------------------- /components/DashPanel/index.js: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect } from "react"; 2 | 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 4 | import { faCalendarAlt, faPlus } from "@fortawesome/free-solid-svg-icons"; 5 | 6 | import styles from "./dashPanel.module.css"; 7 | import DataForm from "../DataForm"; 8 | import { DataContext } from "../../contexts"; 9 | import MiniCalendar from "../MiniCalendar"; 10 | 11 | const DashPanel = ({ dashPanel, updateDashPanel, calendarPeriod, updateCalendarPeriod }) => { 12 | const { habits } = useContext(DataContext); 13 | const { view: panelName, options: dashPanelOptions } = dashPanel ?? {}; 14 | const handleNavClick = (newPanelName) => { 15 | if (panelName === newPanelName) updateDashPanel(null); 16 | else updateDashPanel(newPanelName); 17 | } 18 | const isActiveClassName = (name) => { 19 | if (panelName == null) return ''; 20 | return panelName === name ? styles.active : styles.inactive; 21 | } 22 | const isActive = (name) => { 23 | if (panelName == null) return false; 24 | return panelName === name; 25 | } 26 | return ( 27 |
28 | 38 | 46 |
47 | ); 48 | } 49 | 50 | const PanelContent = ({ view, habits, dashPanelOptions, updateDashPanel, calendarPeriod, updateCalendarPeriod }) => { 51 | return ( 52 |
53 | {(view === 'data') && } 54 | {(view === 'calendar') && } 55 |
56 | ); 57 | } 58 | 59 | export default DashPanel; -------------------------------------------------------------------------------- /components/Form/Input/index.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | 3 | import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons"; 4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 5 | 6 | import styles from "./input.module.css"; 7 | import { fancyClassName } from "../../../utils"; 8 | import { useRefName } from "../../../hooks"; 9 | 10 | const Input = ({ 11 | type, 12 | name, 13 | label, 14 | value, 15 | defaultValue, 16 | placeholder, 17 | disabled, 18 | readOnly, 19 | onChange, onInput, onClick, 20 | note, 21 | alert, 22 | tool, 23 | className, 24 | maxLength, 25 | min 26 | }) => { 27 | const inputRef = useRef(null); 28 | return ( 29 |
30 | {label && } 31 |
32 | {(type === 'color') && } 33 | 47 | {tool} 48 | 49 |
50 | {note && {note}} 51 |
52 | ); 53 | } 54 | 55 | const Alert = ({ alert, name, inputRef }) => { 56 | const spanRef = useRef(null); 57 | useEffect(() => { 58 | const input = useRefName(inputRef); 59 | const span = useRefName(spanRef); 60 | if (!input || !span) return; 61 | if (span.scrollWidth > input.scrollWidth) { 62 | span.style.width = input.scrollWidth * 0.9 + 'px'; 63 | span.style.paddingTop = '0.5rem'; 64 | span.style.paddingBottom = '0.5rem'; 65 | span.style.lineHeight = '1.2'; 66 | span.style.whiteSpace = 'normal'; 67 | } 68 | }, [alert, name, inputRef, spanRef]); 69 | if (!alert(name)) return null; 70 | return ( 71 |
72 |
73 | {alert(name).message} 74 |
75 | ); 76 | } 77 | 78 | const ColorInput = ({ color }) => { 79 | return ( 80 | 81 | ); 82 | } 83 | 84 | export default Input; -------------------------------------------------------------------------------- /components/Modal/modal.module.css: -------------------------------------------------------------------------------- 1 | @keyframes fadeIn { 2 | from { 3 | opacity: 0; 4 | } 5 | to { 6 | opacity: 1; 7 | } 8 | } 9 | @keyframes fadeOut { 10 | from { 11 | opacity: 1; 12 | } 13 | to { 14 | opacity: 0; 15 | } 16 | } 17 | @keyframes scaleIn { 18 | from { 19 | transform: scale(0.8); 20 | } 21 | to { 22 | transform: scale(1); 23 | } 24 | } 25 | @keyframes scaleOut { 26 | from { 27 | transform: scale(1); 28 | } 29 | to { 30 | transform: scale(0.8); 31 | } 32 | } 33 | 34 | .Modal { 35 | position: fixed; 36 | z-index: 999; 37 | top: 0; 38 | left: 0; 39 | width: 100vw; 40 | height: 100vh; 41 | padding: 4rem; 42 | background: rgba(0, 0, 0, 0.5); 43 | animation: fadeIn 0.2s 1 forwards; 44 | } 45 | .modalContainer { 46 | position: relative; 47 | display: grid; 48 | place-items: center; 49 | width: 100%; 50 | height: 100%; 51 | } 52 | .modalContent { 53 | position: relative; 54 | width: 400px; 55 | background: #fff; 56 | animation: fadeIn 0.2s 1 forwards, scaleIn 0.2s 1 forwards; 57 | } 58 | .modalContent > div { 59 | width: 100%; 60 | height: 100%; 61 | max-height: calc(100vh - 4rem); 62 | overflow-x: hidden; 63 | overflow-y: auto; 64 | padding: 2rem; 65 | } 66 | .Modal.goodbye { 67 | animation: fadeOut 0.2s 1 forwards; 68 | } 69 | .Modal.goodbye .modalContent { 70 | animation: fadeOut 0.2s 1 forwards, scaleOut 0.2s 1 forwards; 71 | } 72 | .exit { 73 | position: absolute; 74 | z-index: 150; 75 | top: -0.5rem; 76 | right: -0.5rem; 77 | width: 1.5rem; 78 | height: 1.5rem; 79 | background: #000; 80 | border-radius: 999px; 81 | display: flex; 82 | align-items: center; 83 | justify-content: center; 84 | color: #fff; 85 | } 86 | .exit > * { 87 | display: block; 88 | position: absolute; 89 | top: 50%; 90 | left: 50%; 91 | transform: translate3d(-50%, -50%, 0); 92 | } 93 | 94 | @media screen and (max-width: 900px) { 95 | .Modal { 96 | padding: 1.5rem; 97 | } 98 | .modalContent { 99 | width: 500px; 100 | } 101 | .modalContent > div { 102 | max-height: calc(100vh - 3rem); /* 3rem because of (2 * 1.5rem) padding on .Modal */ 103 | } 104 | .Modal div[class^='submit_Submit'] { 105 | justify-content: center; 106 | } 107 | .Modal div[class^='submit_Submit'] button { 108 | padding: 0.5rem 1rem; 109 | } 110 | } 111 | 112 | @media screen and (max-width: 600px) { 113 | .modalContent { 114 | width: 100%; 115 | } 116 | .modalContent > div { 117 | padding: 1.5rem; 118 | } 119 | } -------------------------------------------------------------------------------- /layouts/DashboardLayout/dashboardLayout.module.css: -------------------------------------------------------------------------------- 1 | .Dashboard { 2 | width: 100%; 3 | height: 100%; 4 | overflow: auto; 5 | font-size: 0.9rem; 6 | } 7 | .Dashboard h1 { 8 | font-size: 3rem; 9 | margin-bottom: 2.5rem; 10 | position: relative; 11 | } 12 | .Dashboard h1::after { 13 | display: inline-block; 14 | position: absolute; 15 | top: 100%; 16 | left: 2rem; 17 | margin-top: 1.5rem; 18 | width: 100px; 19 | height: 0.4rem; 20 | background: #000; 21 | } 22 | .Main { 23 | display: grid; 24 | grid-template: 'nav content sidebar' 100% / calc(2 * var(--dashboard-nav-width)) 1fr 300px; 25 | flex: 0 0 calc(100% - (3 * var(--dashboard-nav-width))); 26 | text-align: left; 27 | overflow: hidden; 28 | } 29 | .Content { 30 | grid-area: content; 31 | margin: 5rem 0; 32 | padding-right: 2rem; 33 | width: 100%; 34 | } 35 | .Sidebar { 36 | position: fixed; 37 | top: 250px; 38 | right: 0; 39 | max-width: var(--dashpanel-sidebar-width); 40 | max-height: calc(100% - 250px); 41 | padding: 0 3rem 3rem 0; 42 | font-size: 0; 43 | } 44 | 45 | .Dashboard > div p { 46 | text-align: inherit; 47 | } 48 | .Dashboard > img { 49 | position: fixed; 50 | bottom: 0; 51 | left: calc(0.5 * var(--Dashboardboard-sidebar-width)); 52 | width: auto; 53 | height: 40%; 54 | } 55 | 56 | .Content.Account form { 57 | margin-bottom: 2rem; 58 | } 59 | 60 | .Dim { 61 | position: fixed; 62 | z-index: 250; 63 | top: 0; 64 | left: 0; 65 | width: 100%; 66 | height: 100%; 67 | background: rgba(0, 0, 0, 0.5); 68 | opacity: 0; 69 | transition: 0.2s; 70 | pointer-events: none; 71 | } 72 | .Dim.dimmed { 73 | opacity: 1; 74 | pointer-events: auto; 75 | } 76 | 77 | @media screen and (max-width: 900px) { 78 | .Dashboard h1 { 79 | font-size: 2rem; 80 | margin-bottom: 1.5rem; 81 | } 82 | .Main { 83 | overflow-y: auto; 84 | width: 100%; 85 | height: 100%; 86 | grid-template: 87 | 'content' 1fr 88 | 'sidebar' auto / 100%; 89 | position: relative; /* for Date */ 90 | } 91 | .Content { 92 | margin: 0; 93 | margin-top: 5rem; 94 | padding: 3rem; 95 | padding-bottom: 4rem; 96 | } 97 | .Sidebar { 98 | position: fixed; 99 | z-index: 300; 100 | top: unset; 101 | bottom: 0; 102 | right: 0; 103 | width: 100%; 104 | max-width: 100%; 105 | max-height: 75%; 106 | padding: 0; 107 | } 108 | } 109 | 110 | @media screen and (max-width: 600px) { 111 | .Content { 112 | padding: 1.5rem; 113 | padding-bottom: 2.5rem; 114 | } 115 | } -------------------------------------------------------------------------------- /components/MyData/Calendar/index.js: -------------------------------------------------------------------------------- 1 | import styles from "./calendar.module.css"; 2 | import { getUnitFromLabel } from "../../../utils"; 3 | import TooltipElement from "../../Tooltip"; 4 | import { useCalendar } from "../../../hooks"; 5 | 6 | const Calendar = ({ habits, entries, calendarPeriod, updateDashPanel }) => { 7 | const { weekdays, totalDaysInMonth } = useCalendar(calendarPeriod); 8 | return ( 9 |
10 | 11 | 12 |
13 | ); 14 | } 15 | 16 | const CalendarWeekLabels = ({ weekdays }) => { 17 | return weekdays('ddd').map(weekName => ( 18 |
19 | {weekName} 20 |
21 | )); 22 | } 23 | 24 | const CalendarDays = ({ habits, entries, totalDaysInMonth, updateDashPanel }) => { 25 | return totalDaysInMonth.map((date, index) => { 26 | const isFiller = date === ''; 27 | const { records } = entries.find(entry => entry.date === date) ?? {}; 28 | const recordIcons = records?.map(({ habitId, amount, check }) => { 29 | if (!check) return null; 30 | const { name, icon, label, complex } = habits.find(habit => habit.id === habitId); 31 | const unit = complex ? getUnitFromLabel(label) : null; 32 | const recordDetails = ( 33 | 34 | {complex 35 | ? <>{name}: {amount} {unit} 36 | : <>{label} 37 | } 38 | 39 | ); 40 | return ( 41 | 45 | {icon} 46 | 47 | ); 48 | }); 49 | return ( 50 | 56 | {isFiller ? null : ( 57 |
58 | {date.split('-')[2]} 59 |
60 | )} 61 |
{recordIcons}
62 |
63 | ); 64 | }); 65 | } 66 | 67 | const CalendarDay = ({ children, isFiller, date, updateDashPanel }) => { 68 | const handleClick = () => { 69 | if (isFiller) return; 70 | updateDashPanel('data', { date }) 71 | } 72 | return ( 73 |
75 | {children} 76 |
77 | ); 78 | } 79 | 80 | export default Calendar; -------------------------------------------------------------------------------- /contexts/DataContext.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { useRouter } from "next/router"; 3 | 4 | import { User, Habit, Entry, handleRequest } from "../pages/api"; 5 | import { differenceInMinutes } from "../utils"; 6 | 7 | export const DataContext = React.createContext(null); 8 | 9 | export const DataContextProvider = ({ children }) => { 10 | const router = useRouter(); 11 | const [user, setUser] = useState(null); 12 | const [habits, setHabits] = useState(null); 13 | const [entries, setEntries] = useState(null); 14 | const [demoTokenId, setDemoTokenId] = useState(null); 15 | const [demoGenOption, setDemoGenOption] = useState(true); 16 | const getUser = async (userId = user.id) => { 17 | if (!userId) return console.log('userId is undefined!'); 18 | const { demoTokenId: tokenId, createdAt } = await handleRequest('/api/auth/getSession'); 19 | setDemoTokenId(tokenId); 20 | const { user } = await User.get({ id: userId, demoTokenId: tokenId }); 21 | if ((user == null) || (tokenId && differenceInMinutes(createdAt) > 120)) { // if user does not exist OR if user is demo user and session is older than 2hrs 22 | if (tokenId) User.clearDemoData({ demoTokenId: tokenId }); 23 | await handleRequest('/api/auth/logout'); 24 | router.push('/'); 25 | return; 26 | } 27 | setUser(user); 28 | return user; 29 | } 30 | const getHabits = async () => { 31 | if (!user) return console.log('User not stored'); 32 | const { habits } = await Habit.get({ userId: user.id, demoTokenId }); 33 | if (user.email === 'demo') { 34 | // check if user has created any of their own habits, in which case they can't use feature to auto generate data 35 | // only demo habits will have ids of the form demo-0, demo-1, demo-2, etc, all others will be a string of letters & numbers 36 | const containsOnlyDemoHabits = habits.every(habit => habit.id.split('-')[0] === 'demo'); 37 | if (containsOnlyDemoHabits) setDemoGenOption(true); 38 | else setDemoGenOption(false); 39 | } 40 | setHabits(habits); 41 | return habits; 42 | } 43 | const getEntries = async () => { 44 | if (!user) return console.log('User not stored'); 45 | const { entries } = await Entry.get({ userId: user.id, demoTokenId }); 46 | setEntries(entries); 47 | return entries; 48 | } 49 | const dataContext = { 50 | user, setUser, getUser, 51 | habits, setHabits, getHabits, 52 | entries, setEntries, getEntries, 53 | demoTokenId, setDemoTokenId, 54 | demoGenOption 55 | } 56 | return ( 57 | 58 | {children} 59 | 60 | ); 61 | } -------------------------------------------------------------------------------- /components/Registration/index.js: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import Link from "next/link"; 3 | 4 | import { handleRequest, User } from "../../pages/api"; 5 | import { useForm } from "../../hooks"; 6 | import Footer from "../Footer"; 7 | import Form, { Input, Submit } from "../Form"; 8 | 9 | export const RegistrationIsOpen = ({ token }) => { 10 | const router = useRouter(); 11 | const { formData, handleFormError, inputProps } = useForm({ 12 | email: '', 13 | password: '', 14 | token 15 | }); 16 | const handleSubmit = () => User.create(formData); 17 | const handleSuccess = async ({ createUser }) => { 18 | await handleRequest('/api/auth/login', { user: createUser }); 19 | router.push('/dashboard'); 20 | } 21 | return ( 22 | <> 23 |

create an account by filling out the fields below:

24 |
}> 27 | 28 | 29 |
30 |
*i promise i will never send you marketing emails or share your data with 3rd parties. i just need your email to verify your account and so that you can reset your password if you forget what it is. thank you!
31 | 32 | ); 33 | } 34 | 35 | export const RegistrationIsClosed = ({ updateToken }) => { 36 | return ( 37 | <> 38 |

sorry, account registration is closed for the time being!* if i've given you a registration code, you can enter it below:

39 | 40 |
*you can still demo the app by logging in with the username demo and password habitat. if you want to join for real, contact me and we'll talk 🧡
41 | 42 | ); 43 | } 44 | 45 | const RegistrationCodeForm = ({ updateToken }) => { 46 | const router = useRouter(); 47 | const { formData, handleFormError, inputProps } = useForm({ 48 | tokenId: '' 49 | }); 50 | const handleSubmit = () => User.validateSignupToken(formData); 51 | const handleSuccess = ({ validateSignupToken }) => { 52 | updateToken(validateSignupToken.id); 53 | } 54 | return ( 55 |
router.back()} />}> 60 | 61 |
62 | ); 63 | } -------------------------------------------------------------------------------- /components/MyData/Timeline/index.js: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { faPen } from "@fortawesome/free-solid-svg-icons"; 4 | 5 | import styles from "./timeline.module.css"; 6 | import { getUnitFromLabel } from "../../../utils"; 7 | import TooltipElement from "../../Tooltip"; 8 | 9 | const Timeline = ({ habits, entries, calendarPeriod, updateDashPanel }) => { 10 | const timelineEntries = () => { 11 | return ( 12 |
13 | {entries.map(entry => )} 14 |
15 | ); 16 | } 17 | return ( 18 |
19 | {timelineEntries()} 20 |
21 | ); 22 | } 23 | 24 | const DashboardEntry = ({ entry, habits, updateDashPanel }) => { 25 | const { date, records } = entry; 26 | const getHabitObject = { 27 | fromId: (id) => { 28 | const index = habits.findIndex(habit => habit.id === id); 29 | return (index !== -1) ? habits[index] : {}; 30 | } 31 | } 32 | const entryDate = () => { 33 | const month = dayjs(date).format('MMM'); 34 | const day = dayjs(date).format('DD'); 35 | const editEntry = () => { 36 | updateDashPanel('data', { date }); 37 | } 38 | return ( 39 |
40 | {day} 41 | {month} 42 | 47 |
48 | ); 49 | } 50 | const recordsList = records?.map(record => { 51 | const habit = getHabitObject.fromId(record.habitId); 52 | return 53 | }); 54 | return ( 55 |
56 | {entryDate(date)} 57 |
{recordsList}
58 |
59 | ); 60 | } 61 | 62 | const EntryRecord = ({ habit, record }) => { 63 | const { amount, check } = record; 64 | const { icon, label, complex } = habit; 65 | if (!check || ((complex && check) && !amount)) return null; 66 | const preparedLabel = () => { 67 | if (!complex) return {label}; 68 | const pre = label.split('{')[0].trim(); 69 | const post = label.split('}')[1].trim(); 70 | const unit = getUnitFromLabel(label); 71 | return ( 72 | {pre} {amount ?? 'some'} {unit} {post} 73 | ); 74 | } 75 | return ( 76 |
77 | {icon} 78 | {preparedLabel()} 79 |
80 | ); 81 | } 82 | 83 | export default Timeline; -------------------------------------------------------------------------------- /components/Form/Checkbox/checkbox.module.css: -------------------------------------------------------------------------------- 1 | .Checkbox { 2 | display: inline-flex; 3 | align-items: center; 4 | } 5 | .Checkbox + .Checkbox { 6 | margin-top: 0.5rem; 7 | } 8 | .Checkbox label { 9 | font-family: var(--default-font); 10 | font-weight: normal; 11 | font-size: 0.9rem; 12 | margin-bottom: 0; 13 | text-transform: none; 14 | line-height: 1.3; 15 | } 16 | .checkboxElement { 17 | position: relative; 18 | width: 1rem; 19 | height: 1rem; 20 | margin-right: 0.4rem; 21 | border-radius: 999px; 22 | cursor: pointer; 23 | flex: 0 0 auto; 24 | } 25 | .checkboxElement span.svg { 26 | transform: translate3d(0,0,0) scale(1); 27 | position: absolute; 28 | width: 100%; 29 | height: 100%; 30 | border-radius: 999px; 31 | border: 1px solid #ddd; 32 | background: #fff; 33 | transition: 0.2s ease; 34 | } 35 | .checkboxElement span.svg::before { 36 | content: ''; 37 | width: 1rem; 38 | height: 1rem; 39 | margin: -1px; /* to offset 1px border on span.svg */ 40 | background: #000; 41 | display: block; 42 | transform: scale(0); 43 | opacity: 1; 44 | border-radius: 999px; 45 | transition-delay: 0.2s; 46 | transform-origin: center; 47 | } 48 | .checkboxElement span.svg svg { 49 | position: absolute; 50 | z-index: 1; 51 | top: 0; 52 | left: 0; 53 | width: 100%; 54 | height: 100%; 55 | fill: none; 56 | stroke: #fff; 57 | stroke-width: 2; 58 | stroke-linecap: round; 59 | stroke-linejoin: round; 60 | stroke-dasharray: 1rem; 61 | stroke-dashoffset: 1rem; 62 | transition: all 0.3s ease; 63 | transition-delay: 0.2s; 64 | transform: translate3d(0,0,0) scale(0.8); 65 | } 66 | .Checkbox input[type=checkbox] { 67 | position: absolute; 68 | z-index: 3; 69 | top: 0; 70 | left: 0; 71 | width: 1rem; 72 | height: 1rem; 73 | margin-bottom: 0; 74 | opacity: 0; 75 | user-select: none; 76 | cursor: pointer; 77 | } 78 | .Checkbox input[type=checkbox]:checked + span.svg { 79 | border: 1px solid #000; 80 | background: #000; 81 | animation: pulse 0.6s ease; 82 | } 83 | .Checkbox input[type=checkbox]:checked + span.svg::before { 84 | transform: scale(2.2); 85 | opacity: 0; 86 | transition: all 0.6s ease; 87 | } 88 | .Checkbox input[type=checkbox]:checked + span.svg svg { 89 | stroke-dashoffset: 0; 90 | } 91 | .checkboxElement:hover span.svg { 92 | border-color: #000; 93 | } 94 | 95 | .Checkbox.detailed { 96 | display: flex; 97 | align-items: flex-start; 98 | justify-content: flex-start; 99 | } 100 | .Checkbox.detailed label { 101 | margin-bottom: 0.2rem; 102 | } 103 | .Checkbox.detailed .label span { 104 | display: block; 105 | font-size: 0.7rem; 106 | line-height: 1.2; 107 | color: #666; 108 | } 109 | 110 | .Checkbox.checkboxAfter { 111 | flex-direction: row-reverse; 112 | } 113 | .Checkbox.checkboxAfter .checkboxElement { 114 | margin-right: 0; 115 | margin-left: 0.4rem; 116 | } -------------------------------------------------------------------------------- /layouts/AppLayout/index.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { useRouter } from "next/router"; 3 | import Head from "next/head"; 4 | 5 | import '@fortawesome/fontawesome-svg-core/styles.css'; 6 | // Prevent fontawesome from adding its CSS since we did it manually above: 7 | import { config } from '@fortawesome/fontawesome-svg-core'; 8 | config.autoAddCss = false; /* eslint-disable import/first */ 9 | 10 | import styles from "./appLayout.module.css"; 11 | 12 | const Layout = ({ children }) => { 13 | const { pathname } = useRouter(); 14 | return ( 15 |
16 |
17 | 18 | {/* does everytime context change mean the background backdrop shifts again? todo look into this more */} 19 | {children} 20 |
21 | ); 22 | } 23 | 24 | const Header = () => { 25 | return ( 26 | 27 | habitat 28 | 29 | 30 | ); 31 | } 32 | 33 | const Backdrop = ({ pathname }) => { 34 | const hue = useRef(0); 35 | const didMountRef = useRef(false); 36 | useEffect(() => { 37 | if (didMountRef.current) { 38 | //hue.current = Math.random() * (135 + 90) - 90; 39 | let [min, max] = [40, -40] 40 | hue.current = Math.random() * (max - min) + min; 41 | } else { 42 | didMountRef.current = true; 43 | } 44 | }, [pathname]); 45 | const startingColors = [ 46 | 'rgb(216, 255, 241)', 47 | 'rgb(234, 223, 255)', 48 | 'rgb(252, 255, 223)', 49 | 'rgb(233, 255, 199)', 50 | 'rgb(212, 255, 247)', 51 | 'rgb(255, 224, 245)' 52 | ]; 53 | const blobs = startingColors.map(color => { 54 | const translation = { 55 | x: Math.random() * (50 + 50) - 50, 56 | y: Math.random() * (50 + 50) - 50 57 | } 58 | const size = { 59 | x: (Math.random() * (130 - 80) + 80) / 100, 60 | y: (Math.random() * (130 - 80) + 80) / 100 61 | } 62 | return ( 63 | 64 | ) 65 | }); 66 | return ( 67 |
{blobs}
70 | ); 71 | } 72 | 73 | const Blob = ({ initial, hue, translation, size }) => { 74 | const transparentize = (rgb) => { 75 | // gradients that fade to transparent are interpreted/interpolated as ending in 'transparent black' in safari 76 | // this is a workaround 77 | return rgb.replace('rgb', 'rgba').split(')')[0] + ', 0)'; 78 | } 79 | return ( 80 |
85 | ); 86 | } 87 | 88 | export default Layout; -------------------------------------------------------------------------------- /components/EmojiPicker/emojiPicker.module.css: -------------------------------------------------------------------------------- 1 | .EmojiPicker { 2 | position: absolute; 3 | top: 0; 4 | right: 0; 5 | height: 100%; 6 | z-index: 95; 7 | } 8 | .EmojiPicker.active { 9 | width: 100%; 10 | } 11 | .EmojiPicker > button { 12 | position: absolute; 13 | top: 50%; 14 | right: 0.25rem; 15 | height: calc(100% - 0.5rem); 16 | font-size: 0.75rem; 17 | white-space: nowrap; 18 | text-transform: uppercase; 19 | transform: translate3d(0, -50%, 0); 20 | display: flex; 21 | justify-content: flex-end; 22 | } 23 | .EmojiPicker.active > button { 24 | width: 100%; 25 | } 26 | .EmojiPicker > button > span { 27 | padding: 0 0.5rem; 28 | display: flex; 29 | align-items: center; 30 | height: 100%; 31 | background: #eee; 32 | } 33 | .EmojiPicker > aside, .loading { 34 | top: calc(100% + 0.5rem); 35 | max-width: calc(100% + 3rem); 36 | min-width: 280px; 37 | animation: scaleIn 0.2s 1 forwards; 38 | max-height: 200px; 39 | } 40 | .EmojiPicker > aside { 41 | right: unset; 42 | left: 0; 43 | } 44 | .loading { 45 | position: absolute; 46 | right: 0; 47 | font-size: 0.75rem; 48 | background: #fff; 49 | border: 1px solid #efefef; 50 | padding: 0.5rem; 51 | border-radius: 0.25rem; 52 | box-shadow: 0 5px 10px #efefef; 53 | } 54 | .EmojiPicker.goodbye > aside { 55 | animation: scaleOut 0.2s 1 forwards; 56 | } 57 | .EmojiPicker > aside nav button { 58 | height: 2rem !important; 59 | background-size: 1rem !important; 60 | } 61 | .EmojiPicker > aside input:not([type=radio]) { 62 | height: 2rem; 63 | } 64 | .EmojiPicker > aside input:not([type=radio]) + ul li { 65 | width: 0.5rem; 66 | height: 0.5rem; 67 | border-radius: 999px; 68 | } 69 | .EmojiPicker > aside div[data-name]::before { 70 | padding: 0.5rem; 71 | /* display: flex; */ 72 | display: none; 73 | align-items: center; 74 | justify-content: flex-end; 75 | background: #fff; 76 | line-height: 1; 77 | } 78 | .EmojiPicker > aside ul[data-display-name]::before { 79 | font-family: var(--mono-font); 80 | font-size: 0.8rem; 81 | line-height: 2.2; 82 | background: #fff; 83 | } 84 | .EmojiPicker > aside ul[data-display-name] button > img { 85 | width: 1.5rem; 86 | height: 1.5rem; 87 | margin: 2px; 88 | } 89 | 90 | .EmojiPicker.newHabitIcon > button { 91 | opacity: 0; 92 | width: 2rem; 93 | white-space: normal; 94 | } 95 | .EmojiPicker.newHabitIcon > button > span { 96 | width: 100%; 97 | } 98 | .EmojiPicker.newHabitIcon > aside { 99 | left: unset; 100 | right: 0; 101 | margin: 0; 102 | } 103 | .EmojiPicker.newHabitIcon.active > button { 104 | width: 100%; 105 | } 106 | .EmojiPicker.newHabitIcon.active { 107 | width: auto; 108 | } 109 | 110 | @keyframes scaleIn { 111 | from { 112 | opacity: 0; 113 | transform: scale(0.8); 114 | } 115 | to { 116 | opacity: 1; 117 | transform: scale(1); 118 | } 119 | } 120 | @keyframes scaleOut { 121 | from { 122 | opacity: 1; 123 | transform: scale(1); 124 | } 125 | to { 126 | opacity: 0; 127 | transform: scale(0.8); 128 | } 129 | } -------------------------------------------------------------------------------- /components/Nav/index.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useRef, useState } from "react"; 2 | import { useRouter } from "next/router"; 3 | 4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 5 | import { faCog, faHome, faList, faSignOutAlt, faUser } from "@fortawesome/free-solid-svg-icons"; 6 | 7 | import styles from "./nav.module.css"; 8 | import { handleRequest, User } from "../../pages/api"; 9 | import { DataContext, MobileContext } from "../../contexts"; 10 | 11 | const Nav = () => { 12 | const router = useRouter(); 13 | const isMobile = useContext(MobileContext); 14 | const [showingNav, setShowingNav] = useState(false); 15 | const navButtonRef = useRef(null); 16 | const navButtonsRef = useRef(null); 17 | const toggleNav = () => setShowingNav(state => !state); 18 | useEffect(() => { 19 | if (!showingNav) return; 20 | const closeNav = (e) => { 21 | if (!navButtonRef.current || !navButtonsRef.current) return; 22 | if (navButtonRef.current.contains(e.target)) return; 23 | if (navButtonsRef.current.contains(e.target)) return; 24 | setShowingNav(false); 25 | } 26 | window.addEventListener('click', closeNav); 27 | return () => window.removeEventListener('click', closeNav); 28 | }, [showingNav, navButtonRef.current, navButtonsRef.current]); 29 | return ( 30 |
31 | {isMobile && ( 32 | 37 | )} 38 | 39 |
40 | ); 41 | } 42 | 43 | const NavButtons = React.forwardRef(({ router, showingNav, updateShowingNav }, ref) => { 44 | const { demoTokenId } = useContext(DataContext); 45 | const handleClick = (e) => { 46 | updateShowingNav(false); 47 | const path = `/${e.currentTarget.name}`; 48 | router.push(path); 49 | } 50 | const isActive = (path) => { 51 | if (router.pathname === path) return styles.active; 52 | return ''; 53 | } 54 | const handleLogout = async () => { 55 | if (demoTokenId) User.clearDemoData({ demoTokenId }); 56 | await handleRequest('/api/auth/logout'); 57 | router.push('/'); 58 | } 59 | return ( 60 | 82 | ); 83 | }); 84 | 85 | export default Nav; -------------------------------------------------------------------------------- /components/MiniCalendar/index.js: -------------------------------------------------------------------------------- 1 | import styles from "./miniCalendar.module.css"; 2 | import { useCalendar } from "../../hooks"; 3 | import dayjs from "dayjs"; 4 | import ArrowNav from "../ArrowNav"; 5 | import { useContext, useState } from "react"; 6 | import { MobileContext } from "../../contexts"; 7 | import { Button } from "../Form"; 8 | 9 | const MiniCalendarWrapper = ({ children, isMobile, miniCalendarPeriod, updateMiniCalendarPeriod }) => { 10 | const currentPeriod = dayjs().format('YYYY-MM'); 11 | const jumpToCurrentMonth = () => updateMiniCalendarPeriod(currentPeriod); 12 | return ( 13 |
14 |
15 | {children} 16 | {(miniCalendarPeriod === currentPeriod) 17 | ? null 18 | : isMobile 19 | ?
20 | : } 21 |
22 | ); 23 | } 24 | 25 | const Header = ({ miniCalendarPeriod, updateMiniCalendarPeriod }) => { 26 | const nav = (direction) => () => { 27 | const newPeriod = (direction === 'next') 28 | ? (period) => dayjs(period).add(1, 'month').format('YYYY-MM') 29 | : (period) => dayjs(period).subtract(1, 'month').format('YYYY-MM'); 30 | updateMiniCalendarPeriod(newPeriod); 31 | } 32 | return ( 33 |
34 | {dayjs(miniCalendarPeriod).format('MMMM YYYY')} 35 | 36 |
37 | ); 38 | } 39 | 40 | const MiniCalendar = ({ calendarPeriod, updateCalendarPeriod, updateDashPanel }) => { 41 | const isMobile = useContext(MobileContext); 42 | const [miniCalendarPeriod, updateMiniCalendarPeriod] = useState(calendarPeriod); 43 | const { weekdays, totalDaysInMonth } = useCalendar(miniCalendarPeriod); 44 | return ( 45 | 46 |
47 | 48 | 49 |
50 |
51 | ); 52 | } 53 | 54 | const WeekLabels = ({ weekdays }) => { 55 | return weekdays('dd').map(weekName => ( 56 |
57 | {weekName} 58 |
59 | )); 60 | } 61 | 62 | const DaysInMonth = ({ isMobile, totalDaysInMonth, updateCalendarPeriod, updateDashPanel }) => { 63 | return totalDaysInMonth.map((date, index) => { 64 | const handleClick = () => { 65 | updateCalendarPeriod(dayjs(date).format('YYYY-MM')); 66 | updateDashPanel(); // close it first to allow pretty opening animation 67 | setTimeout(() => { 68 | updateDashPanel('data', { date }); 69 | }, isMobile ? 0 : 100); 70 | } 71 | const dayNumber = () => { 72 | if (date === '') return null; 73 | const rawDate = date.split('-')[2]; 74 | return (parseInt(rawDate) > 9) ? rawDate : parseInt(rawDate).toString(); 75 | } 76 | return ( 77 |
81 | {(date === '') ? null : {dayNumber()}} 82 |
83 | ); 84 | }); 85 | } 86 | 87 | export default MiniCalendar; -------------------------------------------------------------------------------- /pages/recover.js: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from "react"; 2 | import { useRouter } from "next/router"; 3 | 4 | import { User, Token, handleRequest } from "./api"; 5 | import { ModalContext, MobileContext } from "../contexts"; 6 | import { useForm, useWarnError } from "../hooks"; 7 | import { getQueryParams } from "../utils"; 8 | import { PageLoading } from "../components/Loading"; 9 | import Form, { Input, Submit } from "../components/Form"; 10 | 11 | const ResetPassword = ({ query }) => { 12 | const [userId, setUserId] = useState(null); 13 | const [tokenIsValid, setTokenIsValid] = useState(null); 14 | const warnError = useWarnError(); 15 | useEffect(() => { 16 | const { token } = query; 17 | if (!token) return setTokenIsValid(false); 18 | Token.validate({ tokenId: token }).then(({ validatePasswordToken }) => { 19 | setUserId(validatePasswordToken.userId); 20 | setTokenIsValid(true); 21 | }).catch(err => { 22 | setTokenIsValid(false); 23 | if (err.__typename !== 'FormErrorReport') { 24 | warnError('somethingWentWrong', err); 25 | } 26 | }); 27 | }, []); 28 | const content = () => { 29 | if (tokenIsValid == null) return ; 30 | return tokenIsValid ? : ; 31 | } 32 | return ( 33 | <> 34 |

reset password

35 | {content()} 36 | 37 | ); 38 | } 39 | 40 | const InvalidToken = () => { 41 | const { createModal } = useContext(ModalContext); 42 | const handleClick = () => { 43 | createModal('forgotPassword'); 44 | } 45 | return ( 46 | <> 47 | sorry, this password reset link is invalid or expired! to request a new one, click 48 | 49 | ); 50 | } 51 | 52 | const ValidToken = ({ userId }) => { 53 | const router = useRouter(); 54 | const isMobile = useContext(MobileContext); 55 | const { formData, handleFormError, inputProps, resetForm } = useForm({ 56 | id: userId, 57 | password: '', 58 | confirmPassword: '', 59 | reset: true 60 | }); 61 | const handleSubmit = () => User.editPassword(formData); 62 | const handleSuccess = async ({ editPassword: user }) => { 63 | await handleRequest('/api/auth/login', { user }); 64 | router.push('/dashboard'); 65 | } 66 | const passwordIsOk = (() => { 67 | if (!formData.password || !formData.confirmPassword) return false; 68 | if (formData.password.length < 6) return false; 69 | return formData.password === formData.confirmPassword; 70 | })(); 71 | const formSubmit = ( 72 | 78 | ); 79 | return ( 80 |
84 | 92 | 99 |
100 | ); 101 | } 102 | 103 | ResetPassword.getInitialProps = getQueryParams; 104 | 105 | 106 | export default ResetPassword; -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "postgresql" 3 | url = env("DATABASE_URL") 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | previewFeatures = ["orderByRelation"] 9 | } 10 | 11 | model User { 12 | id String @default(cuid()) @id 13 | name String? 14 | email String @unique 15 | password String? 16 | habits Habit[] 17 | entries Entry[] 18 | records Record[] 19 | settings Settings? 20 | @@map(name: "users") 21 | } 22 | 23 | model Settings { 24 | id String @default(cuid()) @id 25 | dashboard__defaultView String? @default("list") 26 | habits__defaultView String? @default("list") 27 | habits__newHabitIcon String? @default("🐛") 28 | appearance__showClock Boolean? @default(true) 29 | appearance__24hrClock Boolean? @default(false) 30 | appearance__showClockSeconds Boolean? @default(true) 31 | user User? @relation(fields: [userId], references: [id]) 32 | userId String? @unique 33 | demoToken DemoToken? @relation(fields: [demoTokenId], references: [id]) 34 | demoTokenId String? @unique 35 | @@map(name: "settings") 36 | } 37 | 38 | model Habit { 39 | id String @default(cuid()) @id 40 | name String 41 | icon String? 42 | color String? 43 | label String? 44 | complex Boolean? 45 | retired Boolean @default(false) 46 | order Int? 47 | records Record[] 48 | user User? @relation(fields: [userId], references: [id]) 49 | userId String? 50 | demoToken DemoToken? @relation(fields: [demoTokenId], references: [id]) 51 | demoTokenId String? 52 | @@map(name: "habits") 53 | } 54 | 55 | model Entry { 56 | id String @default(cuid()) @id 57 | date String 58 | records Record[] 59 | user User @relation(fields: [userId], references: [id]) 60 | userId String 61 | demoToken DemoToken? @relation(fields: [demoTokenId], references: [id]) 62 | demoTokenId String? 63 | @@map(name: "entries") 64 | } 65 | 66 | model Record { 67 | id String @default(cuid()) @id 68 | habit Habit? @relation(fields: [habitId], references: [id]) 69 | habitId String? 70 | amount Int? 71 | check Boolean 72 | entry Entry? @relation(fields: [entryId], references: [id]) 73 | entryId String? 74 | user User? @relation(fields: [userId], references: [id]) 75 | userId String? 76 | demoToken DemoToken? @relation(fields: [demoTokenId], references: [id]) 77 | demoTokenId String? 78 | @@map(name: "records") 79 | } 80 | 81 | model SignupToken { 82 | id String @default(cuid()) @id 83 | createdAt DateTime @default(now()) 84 | @@map(name: "signupToken") 85 | } 86 | 87 | model PasswordToken { 88 | id String @default(cuid()) @id 89 | userId String? @unique 90 | createdAt DateTime @default(now()) 91 | @@map(name: "passwordToken") 92 | } 93 | 94 | model DemoToken { 95 | id String @default(cuid()) @id 96 | habits Habit[] 97 | entries Entry[] 98 | records Record[] 99 | settings Settings? 100 | createdAt DateTime @default(now()) 101 | @@map(name: "demoToken") 102 | } -------------------------------------------------------------------------------- /components/DashPanel/dashPanel.module.css: -------------------------------------------------------------------------------- 1 | .DashPanel { 2 | width: 100%; 3 | height: 100%; 4 | display: grid; 5 | grid-template: 6 | 'nav' auto 7 | 'content' 1fr / 100%; 8 | justify-items: flex-end; 9 | font-size: 0.9rem; 10 | } 11 | .DashPanel > nav { 12 | text-align: right; 13 | display: flex; 14 | flex-direction: column; 15 | align-items: flex-end; 16 | } 17 | .DashPanel > nav button { 18 | display: flex; 19 | flex-direction: row; 20 | align-items: center; 21 | justify-content: flex-end; 22 | max-height: 2rem; 23 | transition: 0.2s; 24 | } 25 | .DashPanel > nav button > span:first-child { 26 | font-size: 1rem; 27 | transition: 0.2s; 28 | position: relative; 29 | z-index: 5; 30 | } 31 | .DashPanel > nav button > span:first-child::before { 32 | content: ''; 33 | display: block; 34 | position: absolute; 35 | z-index: -1; 36 | top: 50%; 37 | left: -0.5rem; 38 | width: calc(100% + 1rem); 39 | height: 1rem; 40 | background: #fff; 41 | transform: skew(-12deg); 42 | /* box-shadow: 0 0 1rem rgba(0, 0, 0, 0.1); */ 43 | } 44 | .DashPanel > nav button > span:last-child { 45 | width: 2rem; 46 | height: 2rem; 47 | background: #000; 48 | border-radius: 999px; 49 | color: #fff; 50 | font-size: 1rem; 51 | line-height: 2rem; 52 | display: flex; 53 | align-items: center; 54 | justify-content: center; 55 | margin-left: 0.75rem; 56 | transition: 0.2s; 57 | } 58 | .DashPanel > nav button + button { 59 | margin-top: 1rem; 60 | } 61 | .DashPanel > nav button.active { 62 | margin-top: 0; 63 | } 64 | .DashPanel > nav button.active > span:first-child { 65 | font-size: 1.4rem; 66 | font-weight: 300; 67 | } 68 | .DashPanel > nav button.active > span:last-child { 69 | transform: rotate(-45deg); 70 | } 71 | .DashPanel > nav button.inactive { 72 | opacity: 0; 73 | margin-top: 0; 74 | max-height: 0; 75 | padding: 0; 76 | overflow: visible; 77 | pointer-events: none; 78 | } 79 | 80 | .PanelContent { 81 | opacity: 0; 82 | margin-top: 1rem; 83 | transform: scale(0) translate3d(0, 50%, 0); 84 | transform-origin: top right; 85 | max-height: 100%; 86 | transition: 0.2s; 87 | } 88 | .PanelContent.active { 89 | transform: scale(1) translate3d(0, 0, 0); 90 | opacity: 1; 91 | width: var(--dashpanel-sidebar-width); 92 | } 93 | .PanelContent > * { 94 | border-top: 1px solid #000; 95 | padding-top: 1rem; 96 | } 97 | 98 | @media screen and (max-width: 900px) { 99 | .DashPanel { 100 | display: inline-block; 101 | } 102 | .DashPanel > nav { 103 | flex-direction: row-reverse; 104 | justify-content: flex-start; 105 | padding: 0 1rem 1rem 0rem; 106 | } 107 | .DashPanel > nav button > span:first-child { 108 | display: none; 109 | } 110 | .DashPanel > nav button > span:last-child { 111 | margin: 0; 112 | } 113 | .DashPanel > nav button + button, 114 | .DashPanel > nav button + button.active { 115 | margin-top: 0; 116 | margin-right: 0.5rem; 117 | } 118 | .DashPanel > nav button.inactive { 119 | max-width: 0; 120 | transform: scale(0); 121 | overflow: visible; 122 | transition: transform 0.2s, max-width 0s 0.2s; 123 | } 124 | .DashPanel > nav.active { 125 | padding-bottom: 0; 126 | height: 0; 127 | overflow: visible; 128 | } 129 | .DashPanel > nav button.active { 130 | transform: translate3d(0, 1rem, 0); 131 | position: relative; 132 | z-index: 51; 133 | } 134 | .DashPanel > nav button + button.active { 135 | margin-right: 0; 136 | } 137 | 138 | .PanelContent { 139 | width: 100%; 140 | height: 100%; 141 | overflow: hidden; 142 | background: #fff; 143 | transform: translate3d(0, 100%, 0); 144 | margin-top: 0; 145 | max-height: 0; 146 | opacity: 1; 147 | transition: 0.2s; 148 | } 149 | .PanelContent.active { 150 | transform: translate3d(0, 0, 0); 151 | max-height: 75vh; 152 | width: 100%; 153 | } 154 | .PanelContent > div { 155 | padding: 2rem; 156 | } 157 | } 158 | 159 | @media screen and (max-width: 600px) { 160 | .PanelContent > div { 161 | padding: 1.5rem; 162 | } 163 | } -------------------------------------------------------------------------------- /pages/api/server/schemas/index.js: -------------------------------------------------------------------------------- 1 | import { gql } from "apollo-server-micro"; 2 | 3 | export const typeDefs = gql` 4 | type User { 5 | id: String 6 | name: String 7 | email: String 8 | password: String 9 | habits: [Habit] 10 | entries: [Entry] 11 | settings: Settings 12 | demoTokenId: String 13 | } 14 | 15 | type Settings { 16 | userId: String, 17 | dashboard__defaultView: String, 18 | habits__defaultView: String, 19 | habits__newHabitIcon: String, 20 | appearance__showClock: Boolean, 21 | appearance__24hrClock: Boolean, 22 | appearance__showClockSeconds: Boolean 23 | } 24 | 25 | type Habit { 26 | id: String 27 | name: String 28 | icon: String 29 | color: String 30 | label: String 31 | complex: Boolean 32 | retired: Boolean 33 | userId: String 34 | } 35 | 36 | type Entry { 37 | id: String 38 | date: String 39 | records: [Record] 40 | user: User 41 | userId: String 42 | } 43 | 44 | type Record { 45 | id: String 46 | habitId: String 47 | amount: Int 48 | check: Boolean 49 | entryId: String 50 | } 51 | 52 | type FormErrorReport { 53 | errors: [FormError] 54 | } 55 | 56 | type FormError { 57 | message: String, 58 | location: String 59 | } 60 | 61 | type Success { 62 | success: Boolean 63 | } 64 | 65 | input UserInput { 66 | email: String 67 | password: String 68 | } 69 | 70 | input RecordInput { 71 | id: String 72 | habitId: String 73 | amount: Int 74 | check: Boolean 75 | } 76 | 77 | type Token { 78 | id: String 79 | userId: String 80 | } 81 | 82 | union UserResult = User | FormErrorReport 83 | 84 | union HabitResult = Habit | FormErrorReport 85 | 86 | union EntryResult = Entry | FormErrorReport 87 | 88 | union TokenResult = Token | FormErrorReport 89 | 90 | type Query { 91 | users: [User] 92 | login(email: String, password: String): UserResult 93 | user(id: String, demoTokenId: String): User 94 | settings(userId: String, demoTokenId: String): Settings 95 | habits(userId: String, demoTokenId: String): [Habit] 96 | entries(userId: String, demoTokenId: String): [Entry] 97 | records(entryId: String): [Record] 98 | } 99 | 100 | type Mutation { 101 | createUser(email: String, password: String, token: String): UserResult 102 | editUser(id: String, name: String, email: String): UserResult 103 | editPassword(id: String, password: String, confirmPassword: String, reset: Boolean): UserResult 104 | editSettings( 105 | userId: String, 106 | demoTokenId: String, 107 | dashboard__defaultView: String, 108 | habits__defaultView: String, 109 | habits__newHabitIcon: String, 110 | appearance__showClock: Boolean, 111 | appearance__24hrClock: Boolean, 112 | appearance__showClockSeconds: Boolean 113 | ): Settings 114 | deleteAccount(id: String): UserResult 115 | createHabit( 116 | name: String, 117 | icon: String, 118 | color: String, 119 | label: String, 120 | complex: Boolean, 121 | retired: Boolean, 122 | userId: String, 123 | demoTokenId: String 124 | ): HabitResult 125 | editHabit( 126 | id: String, 127 | name: String, 128 | icon: String, 129 | color: String, 130 | label: String, 131 | complex: Boolean, 132 | retired: Boolean 133 | ): HabitResult 134 | deleteHabit(id: String): Habit 135 | rearrangeHabits(array: [String]): Success 136 | createEntry(userId: String, date: String, records: [RecordInput], demoTokenId: String): EntryResult 137 | editEntry(id: String, date: String, records: [RecordInput], demoTokenId: String): EntryResult 138 | deleteEntry(id: String): Entry 139 | generateDemoData(id: String, demoTokenId: String, calendarPeriod: String, alsoHabits: Boolean): Success 140 | clearDemoData(demoTokenId: String): Success 141 | validateSignupToken(tokenId: String): TokenResult 142 | createPasswordToken(email: String): TokenResult 143 | validatePasswordToken(tokenId: String): TokenResult 144 | } 145 | `; -------------------------------------------------------------------------------- /components/Nav/nav.module.css: -------------------------------------------------------------------------------- 1 | .Nav { 2 | position: fixed; 3 | z-index: 200; 4 | top: 0; 5 | left: 0; 6 | width: var(--dashboard-nav-width); 7 | height: 100%; 8 | } 9 | .Nav nav { 10 | height: 70%; 11 | display: flex; 12 | flex-direction: column; 13 | align-items: center; 14 | justify-content: center; 15 | } 16 | .Nav nav button { 17 | margin: 0; 18 | padding: 0; 19 | border: 1px solid #000; 20 | background: transparent; 21 | font-size: 1.3rem; 22 | color: #000; 23 | width: 3rem; 24 | height: 3rem; 25 | border-radius: 999px; 26 | position: relative; 27 | display: flex; 28 | align-items: center; 29 | justify-content: center; 30 | transition: 0.2s; 31 | } 32 | .Nav nav button + button { 33 | margin-top: 1rem; 34 | } 35 | .Nav nav button:hover, .Nav nav button.active { 36 | background: #000; 37 | color: #fff; 38 | } 39 | .Nav nav button > span { 40 | display: block; 41 | position: absolute; 42 | bottom: 50%; 43 | left: 100%; 44 | font-family: var(--mono-font); 45 | font-size: 0.9rem; 46 | line-height: 1.2rem; 47 | color: #000; 48 | text-align: left; 49 | white-space: nowrap; 50 | margin-left: 1rem; 51 | transform: translate3d(0, 50%, 0) scale(0); 52 | transform-origin: center left; 53 | transition: 0.2s; 54 | } 55 | .Nav nav button:hover > span { 56 | transform: translate3d(0, 50%, 0) scale(1); 57 | } 58 | .Nav nav button.active > span { 59 | transform: translate3d(0, 50%, 0) scale(0); 60 | } 61 | .Nav nav button > span::before { 62 | content: ''; 63 | } 64 | .Nav nav button:hover > span::before { 65 | z-index: -1; 66 | position: absolute; 67 | bottom: 0; 68 | left: -0.5rem; 69 | width: calc(100% + 1rem); 70 | height: 1.2rem; 71 | background: #fff; 72 | transform: skew(-12deg); 73 | } 74 | 75 | @media screen and (max-width: 900px) { 76 | .MobileNavButton { 77 | pointer-events: auto; 78 | display: flex; 79 | align-items: center; 80 | justify-content: center; 81 | width: 5rem; 82 | height: 5rem; 83 | overflow: visible; 84 | position: relative; 85 | } 86 | .MobileNavButton::before { 87 | content: ''; 88 | position: absolute; 89 | z-index: -1; 90 | display: block; 91 | width: 140%; 92 | height: 140%; 93 | background: url('./blob.svg') center; 94 | background-size: 140% 140%; 95 | transition: 0.5s; 96 | } 97 | .MobileNavButton > span { 98 | width: 50%; 99 | height: 100%; 100 | display: flex; 101 | flex-direction: column; 102 | align-items: center; 103 | justify-content: center; 104 | } 105 | .MobileNavButton > span::before, .MobileNavButton > span::after { 106 | content: ''; 107 | display: block; 108 | width: 100%; 109 | height: 2px; 110 | background: #000; 111 | transition: 0.5s; 112 | } 113 | .MobileNavButton > span::after { 114 | transform: translate3d(0, 5px, 0); 115 | } 116 | .MobileNavButton.expanded::before { 117 | transform: scale(4, 9); 118 | } 119 | .MobileNavButton.expanded > span::before { 120 | transform-origin: center; 121 | transform: rotate(45deg); 122 | } 123 | .MobileNavButton.expanded > span::after { 124 | margin: 0; 125 | transform-origin: center; 126 | transform: rotate(-45deg) translate3d(1.5px, -1.5px, 0); 127 | } 128 | .Nav { 129 | height: auto; 130 | pointer-events: none; 131 | } 132 | .Nav nav { 133 | margin-top: 1rem; 134 | margin-left: 2rem; 135 | display: inline-flex; 136 | align-items: flex-start; 137 | width: auto; 138 | height: unset; 139 | transform-origin: top left; 140 | opacity: 0; 141 | transform: scale(0.7); 142 | transition: 0.2s; 143 | pointer-events: none; 144 | } 145 | .Nav nav.active { 146 | opacity: 1; 147 | transform: scale(1); 148 | transition: 0.5s 0.1s; 149 | pointer-events: auto; 150 | } 151 | .Nav nav button { 152 | width: 2.5rem; 153 | height: 2.5rem; 154 | font-size: 1.1rem; 155 | } 156 | .Nav nav button > span, 157 | .Nav nav button.active > span { 158 | line-height: 2.5rem; 159 | transform: translate3d(0, 50%, 0); 160 | margin-left: 0; 161 | padding-left: 0.75rem; 162 | padding-right: 2rem; 163 | } 164 | .Nav nav button + button { 165 | margin-top: 0.5rem; 166 | } 167 | .Nav nav button > span::before { 168 | content: none; 169 | } 170 | } -------------------------------------------------------------------------------- /components/MyData/Graph/index.js: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; 2 | 3 | import dayjs from "dayjs"; 4 | import { Chart } from "chart.js/dist/chart"; 5 | 6 | import styles from "./graph.module.css"; 7 | import graphConfig from "./graphConfig"; 8 | import { useRefName } from "../../../hooks"; 9 | import { MobileContext } from "../../../contexts"; 10 | 11 | const Graph = ({ habits, entries, calendarPeriod, updateDashPanel }) => { 12 | const [resizing, setResizing] = useState(false); 13 | useEffect(() => { 14 | let timeout; 15 | const doThing = () => { 16 | if (!resizing) setResizing(true); 17 | clearTimeout(timeout); 18 | timeout = setTimeout(() => setResizing(false), 500); 19 | } 20 | window.addEventListener('resize', doThing); 21 | return () => { 22 | window.removeEventListener('resize', doThing); 23 | clearTimeout(timeout); 24 | } 25 | }, [resizing]); 26 | if (resizing) return null; 27 | return ( 28 |
29 | 30 | 31 |
32 | ); 33 | } 34 | 35 | const ChartCanvas = ({ type, habits, entries, calendarPeriod, updateDashPanel, includeDateMarkers }) => { 36 | const [chartInstance, setChartInstance] = useState(null); 37 | const isMobile = useContext(MobileContext); 38 | const chartRef = useRef(null); 39 | const { data, config } = useMemo(() => { 40 | return graphConfig(habits, entries, calendarPeriod, type, isMobile, includeDateMarkers); 41 | }, [entries, isMobile]); 42 | useEffect(() => { 43 | if (!chartInstance) return; 44 | chartInstance.data = data; 45 | chartInstance.options = config.options; 46 | chartInstance.update(); 47 | }, [data, config]); 48 | const handleChartClick = useCallback((e) => { 49 | if (!chartInstance) return; 50 | const points = chartInstance.getElementsAtEventForMode(e, 'nearest', { intersect: true }, true); 51 | if (!points.length) return; 52 | const { index } = points[0]; 53 | const date = dayjs(data.labels[index])?.format(`${calendarPeriod}-DD`); 54 | if (date) updateDashPanel('data', { date }); 55 | }, [chartInstance, calendarPeriod]); 56 | useEffect(() => { 57 | const myCanvas = useRefName(chartRef); 58 | if (!chartInstance) { 59 | if (!myCanvas) { 60 | setChartInstance(null); 61 | chartInstance.destroy(); 62 | return; 63 | } 64 | const ctx = myCanvas.getContext('2d'); 65 | const chart = new Chart(ctx, config); 66 | setChartInstance(chart); 67 | } 68 | myCanvas.addEventListener('click', handleChartClick); 69 | return () => myCanvas.removeEventListener('click', handleChartClick); 70 | }, [chartRef, handleChartClick, config]); 71 | const chartHeight = () => { 72 | if (type === 'complex') return 250; 73 | if (habits.length <= 1) return 40; 74 | const num = includeDateMarkers ? 30 : 20; 75 | return habits.length * num; 76 | } 77 | return ( 78 |
79 | 80 |
81 | ); // height for simple habits: habitsActiveThisMonth.length * 40 82 | // visibility: hidden on canvas if habitsActiveThisMonth.length is 0 83 | } 84 | 85 | export const SimpleHabits = ({ habits, entries, calendarPeriod, updateDashPanel }) => { 86 | const simpleHabits = habits.filter(habit => !habit.complex); 87 | if (!simpleHabits.length) return null; 88 | const includeDateMarkers = simpleHabits.length === habits.length; 89 | return ( 90 | 98 | ); 99 | } 100 | 101 | export const ComplexHabits = ({ habits, entries, calendarPeriod, updateDashPanel }) => { 102 | const complexHabits = habits.filter(habit => habit.complex); 103 | if (!complexHabits.length) return null; 104 | return ( 105 | 112 | ); 113 | } 114 | 115 | export default Graph; -------------------------------------------------------------------------------- /components/MySettings/index.js: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect } from "react"; 2 | 3 | import styles from "./mySettings.module.css"; 4 | import { User } from "../../pages/api"; 5 | import { DataContext } from "../../contexts"; 6 | import { useForm } from "../../hooks"; 7 | import Form, { Dropdown, Input, Submit, Switch } from "../Form"; 8 | import EmojiPicker from "../EmojiPicker"; 9 | 10 | const MySettings = () => { 11 | const { user, getUser, demoTokenId } = useContext(DataContext); 12 | const { formData, setFormData, inputProps, checkboxProps, dropdownProps } = useForm({ 13 | userId: user.id, 14 | dashboard__defaultView: user.settings?.dashboard__defaultView ?? 'list', 15 | habits__defaultView: user.settings?.habits__defaultView ?? 'list', 16 | habits__newHabitIcon: user.settings?.habits__newHabitIcon ?? '🐛', 17 | appearance__showClock: user.settings?.appearance__showClock ?? true, 18 | appearance__24hrClock: user.settings?.appearance__24hrClock ?? false, 19 | appearance__showClockSeconds: user.settings?.appearance__showClockSeconds ?? true, 20 | demoTokenId 21 | }); 22 | const handleSubmit = async () => User.editSettings(formData); 23 | const handleSuccess = () => getUser(); 24 | return ( 25 |
}> 30 | 31 | 32 | ); 33 | } 34 | 35 | const DashboardSettings = ({ formData, setFormData, inputProps, dropdownProps, checkboxProps }) => { 36 | const dashboardViewListItems = [ 37 | { value: 'list', display: 'list' }, 38 | { value: 'grid', display: 'grid' }, 39 | { value: 'graph', display: 'graph' } 40 | ]; 41 | const dashboardViewDefaultValue = dashboardViewListItems.find(item => item.value === formData.dashboard__defaultView)?.display; 42 | const habitViewListItems = [ 43 | { value: 'list', display: 'list' }, 44 | { value: 'grid', display: 'grid' } 45 | ]; 46 | const habitViewDefaultValue = dashboardViewListItems.find(item => item.value === formData.habits__defaultView)?.display; 47 | return ( 48 |
49 |

dashboard

50 |
51 | default dashboard view 52 | 58 |
59 |

habits

60 |
61 | default habit view 62 | 68 |
69 |
70 | "create new habit" emoji 71 | } 78 | {...inputProps} 79 | /> 80 |
81 |

appearance

82 |

clock

83 |
84 | show clock 85 | 90 |
91 |
92 | 24 hr clock 93 | 98 |
99 |
100 | show seconds 101 | 106 |
107 |
108 | ); 109 | } 110 | 111 | export default MySettings; -------------------------------------------------------------------------------- /components/MyData/Graph/graphConfig.js: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { getUnitFromLabel } from "../../../utils"; 3 | 4 | const graphConfig = (habits, entries, calendarPeriod, type, isMobile, includeDateMarkers) => { 5 | const daysInMonth = new Array(dayjs(calendarPeriod).daysInMonth()).fill(''); 6 | const datesInMonth = daysInMonth.map((_, i) => { 7 | return dayjs(`${calendarPeriod}-${i + 1}`).format('YYYY-MM-DD'); 8 | }); 9 | const datasets = habits.map(habit => { 10 | const particularEntryRecord = (someEntry, habitId) => { 11 | return someEntry?.records?.find(record => record.habitId === habitId); 12 | } 13 | const activeThisMonth = entries.some(entry => particularEntryRecord(entry, habit.id)?.check); 14 | if (!activeThisMonth) return null; 15 | const data = datesInMonth.map(day => { 16 | const currentEntry = entries.find(entry => entry.date === day); 17 | if (!currentEntry) return habit.complex ? 0 : null; 18 | if (particularEntryRecord(currentEntry, habit.id)?.check) { 19 | return habit.complex 20 | ? particularEntryRecord(currentEntry, habit.id).amount 21 | : isMobile ? habit.icon : habit.name; 22 | } 23 | return habit.complex ? 0 : null; 24 | }); 25 | const unit = getUnitFromLabel(habit.label); 26 | const obj = { 27 | label: habit.name, 28 | unit, 29 | data, 30 | borderColor: '#000', 31 | borderWidth: 1, 32 | backgroundColor: habit.color || '#45DAC8', 33 | tension: 0.3 34 | } 35 | return obj; 36 | }).filter(el => el); 37 | const labels = datesInMonth.map(day => dayjs(day).format('MMM DD')); 38 | const yAxisLabels = (type === 'complex') 39 | ? null 40 | : isMobile 41 | ? habits.map(habit => habit.icon) 42 | : habits.map(habit => habit.name); 43 | return { 44 | config: chartSetup(labels, yAxisLabels, datasets, type, isMobile, includeDateMarkers), 45 | data: { 46 | labels, 47 | yAxisLabels, 48 | datasets 49 | } 50 | } 51 | } 52 | 53 | export const chartSetup = (labels, yAxisLabels, datasets, type, isMobile, includeDateMarkers) => ({ 54 | type: 'line', 55 | options: { 56 | layout: { 57 | padding: { 58 | right: isMobile ? 0 : 30, 59 | bottom: (isMobile && type === 'complex') ? 32 : 0 60 | } 61 | }, 62 | scales: { 63 | x: { 64 | ticks: { 65 | font: { 66 | family: 'Inconsolata, monospace' 67 | }, 68 | display: (type === 'complex') || includeDateMarkers, 69 | padding: 5 70 | }, 71 | grid: { 72 | drawTicks: (type === 'complex') || (isMobile && type === 'simple' && includeDateMarkers) 73 | } 74 | }, 75 | y: { 76 | type: yAxisLabels ? 'category' : 'linear', 77 | labels: yAxisLabels, 78 | ticks: { 79 | font: { 80 | family: 'Inconsolata, monospace', 81 | size: (isMobile && type === 'simple') ? 12 : 10 82 | }, 83 | color: '#000' 84 | }, 85 | afterSetDimensions: (axes) => { 86 | axes.paddingLeft = isMobile ? 40 : 100; 87 | } 88 | } 89 | }, 90 | responsive: true, 91 | maintainAspectRatio: false, 92 | hoverMode: 'index', 93 | plugins: { 94 | legend: { 95 | display: type === 'complex', 96 | position: 'bottom', 97 | labels: { 98 | font: { 99 | family: 'Work Sans, sans-serif', 100 | size: 12 101 | }, 102 | boxWidth: 7, 103 | usePointStyle: true, 104 | padding: 20 105 | } 106 | }, 107 | tooltip: { 108 | enabled: type === 'complex', 109 | cornerRadius: 0, 110 | titleColor: '#000', 111 | titleFont: { 112 | family: 'Inconsolata, monospace', 113 | weight: 'normal', 114 | size: 14 115 | }, 116 | bodyColor: '#000', 117 | bodyFont: { 118 | family: 'Work Sans, sans-serif', 119 | lineHeight: 1 120 | }, 121 | backgroundColor: '#fff', 122 | boxWidth: 7, 123 | boxHeight: 7, 124 | usePointStyle: true, 125 | padding: 8, 126 | callbacks: { 127 | afterLabel: (context) => { 128 | const { unit } = context.dataset; 129 | return unit; 130 | } 131 | } 132 | } 133 | } 134 | }, 135 | data: { 136 | labels, 137 | datasets 138 | }, 139 | }); 140 | 141 | export default graphConfig; 142 | -------------------------------------------------------------------------------- /components/MyHabits/HabitGrid.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useRef, useState } from "react"; 2 | 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 4 | import { faPen, faPlus, faTrashAlt } from "@fortawesome/free-solid-svg-icons"; 5 | 6 | import styles from "./myHabits.module.css"; 7 | import { ModalContext } from "../../contexts"; 8 | import { getUnitFromLabel } from "../../utils"; 9 | import { Grip, HabitForm, HabitIcon } from "."; 10 | 11 | export const HabitGridItem = React.forwardRef(({ user, index, habits, addingNew, habit, habitItemsStuff }, ref) => { 12 | const { id, name, icon, color, label, complex, retired } = habit ?? {}; 13 | const { habitItems, habitItemOrder, updateHabitItemOrder } = habitItemsStuff ?? {}; 14 | const [dragging, setDragging] = useState(false); 15 | const { createModal } = useContext(ModalContext); 16 | const manageHabit = () => { 17 | createModal('manageHabit', { 18 | habitForm: , 19 | habitFormProps: { 20 | title: addingNew ? 'create a new habit' : 'edit this habit', 21 | user, 22 | ...habit 23 | } 24 | }); 25 | } 26 | const deleteHabit = () => { 27 | const habit = { id, name }; 28 | createModal('deleteHabit', { habit }); 29 | } 30 | if (addingNew) return ( 31 |
32 | 36 |
37 | ); 38 | const orderIndex = { order: index }; 39 | return ( 40 |
44 | 45 | 46 | 47 | {!retired && ()} 55 |
56 | ); 57 | }); 58 | 59 | const HabitGridItemHeader = ({ name, icon }) => { 60 | return ( 61 |
62 | {icon} 63 | {name} 64 |
65 | ); 66 | } 67 | 68 | const HabitGridItemBody = ({ label, complex, manageHabit, deleteHabit }) => { 69 | const [pre, post] = [label?.split('{')[0], label?.split('}')[1]]; 70 | const unit = getUnitFromLabel(label); 71 | return ( 72 |
73 | {complex 74 | ? {pre} __ {unit} {post} 75 | : {label} 76 | } 77 |
78 | 79 | 80 |
81 |
82 | ); 83 | } 84 | 85 | const MakeDraggable = (props) => { 86 | const { habitItems, habitItemOrder } = props; 87 | const hotspotsRef = useRef([]); 88 | useEffect(() => { 89 | const lastHabitItemId = habitItemOrder[habitItemOrder.length - 1]; // lastElement depends on habitItemOrder!!!!!! 90 | const lastElement = habitItems[lastHabitItemId]; 91 | const objectToMap = { 92 | ...habitItems, 93 | last_item: lastElement // to add the hotspot after the last item 94 | } 95 | const generateHotspots = ([id, element]) => { 96 | const hotspotDetails = (element) => { 97 | if (!element) return null; 98 | let { top, left, right, bottom } = element.getBoundingClientRect(); 99 | const width = 100; 100 | const height = bottom - top; 101 | left = (id === 'last_item') ? right - (width * 0.5) : left - (width * 0.5); 102 | return { 103 | id, top, left, width, height 104 | } 105 | } 106 | return hotspotDetails(element); 107 | } 108 | hotspotsRef.current = Object.entries(objectToMap).map(generateHotspots).filter(el => el); 109 | }, [habitItems, habitItemOrder]) 110 | return ( 111 | 115 | ); 116 | } 117 | 118 | export const NewHabitGridItem = ({ user, habits }) => { 119 | return ( 120 | 125 | ); 126 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🍊 habitat 2 | 3 | A simple browser app for tracking habits! ✨ 4 | 5 | Built with: 6 | - [Next.js](https://nextjs.org/) 7 | - GraphQL using [Apollo Server](https://github.com/apollographql/apollo-server) 8 | - [Prisma](http://prisma.io/) ORM for Postgres 9 | - [Chart.js](https://www.chartjs.org/) 10 | - [next-iron-session](https://github.com/vvo/next-iron-session) for authorization 11 | 12 | habitat dashboard 13 | 14 | ## how to use 15 | 16 | Habitat is live at [https://habi.vercel.app/](https://habi.vercel.app/?demo)! Registration is currently closed as I don't want to inadvertently become responsible for a bunch of strangers' habit data, but you can use the username **demo** and password **habitat** to log in with a special account I set up for people interested in demoing the app. 17 | 18 | If you like Habitat and you actually want to use it, feel free to [contact me](mailto:contact@ngw.dev). 19 | 20 | ## overview 21 | 22 | ### general functionality 23 | 24 | - Authenticate users using `next-iron-session`, which creates a signed and encrypted cookie to store session data 25 | 26 | - Create/read/update/delete habit data 27 | * Display habit data for a given calendar period (month and year) on either a timeline, calendar, or line graph 28 | * Add, edit, or delete habit data for a given day 29 | - Create/read/update/delete habits 30 | * Display habits either as a list or on a grid 31 | * Configure habit display information: name, label, icon, and color 32 | * Configure habit type: complex/scalar ("I practiced music for this many hours today") vs. simple ("I practiced music today"/"I didn't practice music today"), active vs. inactive/retired (retired habits will no longer show up when creating/editing records, but existing data for those habits will be preserved) 33 | - Allow users logged on to the [demo account](#how-to-use) to generate a set of sample habits and at least a month's worth of randomized habit data to play around with. I have this set up so that any data (sample or otherwise) created by a demo user is bound to a unique string or `demoTokenId` stored in that user's browser session, so that multiple users can be logged on to the same demo account simultaneously without interfering with anyone else's experience of the app. 34 | - Allow users to set preferences for how the app should behave 35 | * Set a default view type for habit data (timeline, calendar, or line graph) and for habits (list or grid) 36 | * Toggle view options for the clock in the top right corner of the app: display or hide clock, 12- or 24-hour clock, show or hide clock seconds 37 | - Edit account details (name, email address, and password) 38 | 39 | ### api breakdown 40 | 41 | - Apollo Server for GraphQL (in [/pages/api/server](/pages/api/server)) 42 | * [index.js](/pages/api/server/index.js): Creates an instance of `ApolloServer` 43 | * [schemas/index.js](/pages/api/server/schemas/index.js): Defines my GraphQL schema 44 | * [resolvers/index.js](/pages/api/server/resolvers/index.js): Defines my resolvers, which tell Apollo Server how to fetch data for each type defined in the schema 45 | - Prisma ORM for Postgres 46 | * [/prisma/schema.prisma](/prisma/schema.prisma): Prisma schema file, the main configuration file for my Prisma setup 47 | * [/lib/prisma.js](/lib/prisma.js): Creates an instance of `PrismaClient`, which is used by my [resolvers](/pages/api/server/resolvers/index.js) to send database queries 48 | 49 | - All non-auth-related API routes are restricted to one file, [/pages/api/index.js](/pages/api/index.js), whose exports are objects that correspond to resource types - User, Habit, and Entry. Each of these objects comes with a set of CRUD functions that use the `handleQuery` fetch wrapper (see [/pages/api/handleRequest.js](/pages/api/handleRequest.js)) to call the GraphQL server with a pre-defined mutation/query string and optional query variables. 50 | 51 | - Auth-related API logic is located inside the [/pages/api/auth](/pages/api/auth) directory: 52 | - [index.js](/pages/api/auth/index.js): Checks if there is user data stored in session. The data returned from this function, fetched at request time via `getServerSideProps`, is used on all protected pages to redirect to the login page if no valid session is found. 53 | - [getSession.js](/pages/api/auth/getSession.js): Gets the session data. The only time this is used is when retrieving the `demoTokenId` unique to each demo account session (this is explained in more detail above, under [general functionality](#general-functionality)). 54 | - [login.js](/pages/api/auth/login.js): Uses `next-iron-session` to create a signed and encrypted cookie to store session data 55 | - [logout.js](/pages/api/auth/logout.js): Destroys the session 56 | 57 | ## notes to self 58 | 59 | - [ ] dash panel idea - 'help'/tutorial section for dashboard and habit pages, togglable in settings 60 | - [ ] character limit on habit names 61 | - [ ] figure out switching from simple -> complex habits - should maybe prompt the user for an amount to put in for entries that record that habit as completed? -------------------------------------------------------------------------------- /components/MyHabits/HabitList.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useRef, useState } from "react"; 2 | 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 4 | import { faAngleDoubleRight } from "@fortawesome/free-solid-svg-icons"; 5 | 6 | import styles from "./myHabits.module.css"; 7 | import { ModalContext } from "../../contexts"; 8 | import { useRefName } from "../../hooks"; 9 | import { Grip, HabitForm, HabitIcon } from "."; 10 | 11 | export const HabitListItem = React.forwardRef(({ addingNew, user, index, habit, habitItemsStuff }, ref) => { 12 | const { id, name, icon, color, label, complex, retired } = habit ?? {}; 13 | const { habitItems, habitItemOrder, updateHabitItemOrder } = habitItemsStuff ?? {}; 14 | const [expanded, setExpanded] = useState(false); 15 | const [dragging, setDragging] = useState(false); 16 | const orderIndex = { order: index }; 17 | return ( 18 |
22 | setExpanded(state => !state) 27 | }} /> 28 | {(!addingNew && !retired) && ()} 36 | 49 |
50 | ); 51 | }); 52 | 53 | const MakeDraggable = (props) => { 54 | const { habitItems, habitItemOrder } = props; 55 | const hotspotsRef = useRef([]); 56 | useEffect(() => { 57 | const lastHabitItemId = habitItemOrder[habitItemOrder.length - 1]; // lastElement depends on habitItemOrder!!!!!! 58 | const lastElement = habitItems[lastHabitItemId]; 59 | const objectToMap = { 60 | ...habitItems, 61 | last_item: lastElement // to add the hotspot after the last item 62 | } 63 | const generateHotspots = ([id, element]) => { 64 | const hotspotDetails = () => { 65 | if (!element) return null; 66 | let { top, bottom, left, right } = element.getBoundingClientRect(); 67 | const width = right - left; 68 | const height = 24; 69 | top = (id === 'last_item') ? bottom : top - height; 70 | return { 71 | id, top, left, width, height 72 | } 73 | } 74 | return hotspotDetails(); 75 | } 76 | hotspotsRef.current = Object.entries(objectToMap).map(generateHotspots).filter(el => el); 77 | }, [habitItems, habitItemOrder]); 78 | return ( 79 | 83 | ); 84 | } 85 | 86 | const HabitListItemHeader = ({ name, icon, retired, toggleExpanded }) => { 87 | return ( 88 |
89 | {icon} 90 |

{name}

91 |
92 | ); 93 | } 94 | 95 | const HabitListItemBody = ({ addingNew, user, id, name, icon, color, label, complex, retired, expanded, updateExpanded }) => { 96 | const habitBodyRef = useRef(null); 97 | useEffect(() => { 98 | const habitBody = useRefName(habitBodyRef); 99 | if (!habitBody) return; 100 | if (expanded) { 101 | habitBody.style.maxHeight = habitBody.scrollHeight + 'px'; 102 | } else { 103 | habitBody.style.maxHeight = '0'; 104 | } 105 | }, [expanded]); 106 | return ( 107 |
108 |
109 | updateExpanded(false), 120 | resetFormAfter: !!addingNew 121 | }} /> 122 | {addingNew || } 123 |
124 |
125 | ); 126 | } 127 | 128 | export const NewHabitListItem = ({ habits, user }) => { 129 | return ( 130 | 138 | ); 139 | } 140 | 141 | const DeleteHabit = ({ id, name }) => { 142 | const { createModal } = useContext(ModalContext); 143 | const confirmDeleteHabit = () => { 144 | const habit = { id, name }; 145 | createModal('deleteHabit', { habit }); 146 | } 147 | return ( 148 |
149 | 153 |
154 | ); 155 | } -------------------------------------------------------------------------------- /components/MyData/index.js: -------------------------------------------------------------------------------- 1 | import { useContext, useState } from "react"; 2 | 3 | import dayjs from "dayjs"; 4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 5 | import { faChartLine, faListOl, faTh } from "@fortawesome/free-solid-svg-icons"; 6 | 7 | import styles from "./myData.module.css"; 8 | import { DataContext, MobileContext, ModalContext } from "../../contexts"; 9 | import Timeline from "./Timeline"; 10 | import Calendar from "./Calendar"; 11 | import Graph from "./Graph"; 12 | import ArrowNav from "../ArrowNav"; 13 | import ViewOptions from "../ViewOptions"; 14 | import { Button, Submit } from "../Form"; 15 | import { User } from "../../pages/api"; 16 | 17 | const MyData = ({ user, habits, entries, calendarPeriod, updateCalendarPeriod, updateDashPanel }) => { 18 | const [dataView, setDataView] = useState(user.settings?.dashboard__defaultView ?? 'list'); 19 | return ( 20 |
21 | 27 | 35 |
36 | ); 37 | } 38 | 39 | const NoData = ({ user, calendarPeriod }) => { 40 | const { demoTokenId } = useContext(DataContext); 41 | return ( 42 |
43 | you haven't added any data for this period 44 | {demoTokenId && } 45 |
46 | ); 47 | } 48 | 49 | const MyDataHeader = ({ calendarPeriod, updateCalendarPeriod, dataView, updateDataView }) => { 50 | const isMobile = useContext(MobileContext); 51 | const currentPeriod = dayjs().format('YYYY-MM'); 52 | const nav = (direction) => () => { 53 | const newPeriod = direction === 'next' 54 | ? (period) => dayjs(period).add(1, 'month').format('YYYY-MM') 55 | : (period) => dayjs(period).subtract(1, 'month').format('YYYY-MM'); 56 | updateCalendarPeriod(newPeriod); 57 | } 58 | return ( 59 |
60 |
61 | 62 | {(calendarPeriod !== currentPeriod) && (!isMobile) && 63 | } 69 |
70 |
71 | {dayjs(calendarPeriod).format('MMMM YYYY')} 72 | 73 | 79 | 85 | 91 | 92 |
93 |
94 | ); 95 | } 96 | 97 | const MyDataContent = ({ user, habits, entries, calendarPeriod, updateDashPanel, dataView }) => { 98 | if (!entries.length) return ; 99 | const viewShouldInherit = { habits, entries, calendarPeriod, updateDashPanel }; 100 | return ( 101 |
102 | {(dataView === 'list') && } 103 | {(dataView === 'grid') && } 104 | {(dataView === 'graph') && } 105 |
106 | ) 107 | } 108 | 109 | const GenerateDemoData = ({ user, calendarPeriod }) => { 110 | const { demoTokenId, demoGenOption, habits, getHabits, getEntries } = useContext(DataContext); 111 | const isMobile = useContext(MobileContext); 112 | const { createModal } = useContext(ModalContext); 113 | const [successPending, setSuccessPending] = useState(false); 114 | const generateData = () => { 115 | setSuccessPending(true); 116 | return User.generateDemoData({ 117 | id: user.id, 118 | demoTokenId, 119 | calendarPeriod, 120 | alsoHabits: !habits?.length 121 | }).then(getHabits).then(getEntries); 122 | } 123 | const explainDemoData = () => { 124 | createModal('generateDemoData', { generateData }) 125 | } 126 | const buttonClassName = `mt15 ${isMobile ? 'compact' : ''}`; 127 | if (!demoGenOption) return null; 128 | if (habits.length) return ( 129 | 136 | ); 137 | return ( 138 |
139 | 142 |
143 | ); 144 | } 145 | 146 | export default MyData; -------------------------------------------------------------------------------- /hooks/useForm.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { useWarnError } from "."; 3 | 4 | export const useFormData = (initialState = {}) => { 5 | const [formData, setFormData] = useState(initialState); 6 | const updateFormData = (e) => setFormData(prevState => ({ 7 | ...prevState, 8 | [e.target.name]: e.target.value 9 | })); 10 | const updateFormDataCheckbox = (e) => setFormData(prevState => ({ 11 | ...prevState, 12 | [e.target.name]: e.target.checked 13 | })); 14 | const updateFormDataDropdown = (key, value) => setFormData(prevState => ({ 15 | ...prevState, 16 | [key]: value 17 | })); 18 | const resetForm = () => setFormData(initialState); 19 | return { 20 | formData, 21 | updateFormData, 22 | updateFormDataCheckbox, 23 | updateFormDataDropdown, 24 | setFormData, 25 | resetForm 26 | } 27 | } 28 | 29 | export const useFormError = (initialState = {}) => { 30 | const [formError, setFormError] = useState(initialState); 31 | const parseFormError = (errorReport) => { 32 | /* sample errorReport: [ 33 | { location: 'email', message: 'already in use' }, 34 | { location: 'password', message: 'must be at least 6 chars' } 35 | ] */ 36 | if (!Array.isArray(errorReport)) return console.error(errorReport); // todo better 37 | const errors = errorReport.reduce((obj, error) => { 38 | obj[error.location] = error.message; 39 | return obj; 40 | }, {}); 41 | /* errors: { 42 | email: 'already in use', 43 | password: 'must be at least 6 chars' 44 | } */ 45 | return errors; 46 | } 47 | const updateFormError = (errorReport) => { 48 | const errors = parseFormError(errorReport); 49 | // now spread error object into formError so that errorAlert on each 50 | // input field can look at formError[inputName] and see if there's an error there 51 | setFormError(errors); 52 | } 53 | const resetFormError = (e) => { 54 | if (!formError?.[e.target.name]) return; 55 | setFormError(prevState => { 56 | if (!prevState?.[e.target.name]) return prevState; 57 | const newState = {...prevState}; 58 | delete newState[e.target.name]; 59 | return newState; 60 | }); 61 | } 62 | const errorAlert = (inputName) => { 63 | if (formError?.[inputName]) return { 64 | type: 'error', 65 | message: formError[inputName] 66 | } 67 | } 68 | return { 69 | updateFormError, 70 | parseFormError, 71 | resetFormError, 72 | errorAlert 73 | } 74 | } 75 | 76 | export const useForm = (initialFormData = {}) => { 77 | const { formData, updateFormData, updateFormDataCheckbox, updateFormDataDropdown, setFormData, resetForm } = useFormData(initialFormData); 78 | const { updateFormError, parseFormError, resetFormError, errorAlert } = useFormError({}); 79 | const inputProps = { 80 | onChange: updateFormData, 81 | onInput: resetFormError, 82 | alert: errorAlert 83 | } 84 | const checkboxProps = { 85 | onChange: updateFormDataCheckbox 86 | } 87 | const dropdownProps = { 88 | onChange: updateFormDataDropdown 89 | } 90 | return { 91 | formData, 92 | setFormData, 93 | resetForm, 94 | handleFormError: updateFormError, 95 | parseFormError, 96 | inputProps, 97 | checkboxProps, 98 | dropdownProps 99 | } 100 | } 101 | 102 | export const useFormSubmit = ({ onSubmit, onSuccess, handleFormError, behavior }) => { 103 | const warnError = useWarnError(); 104 | const defaultBehavior = { 105 | showLoading: true, 106 | showSuccess: true, 107 | checkmarkStick: true 108 | } 109 | const { showLoading, showSuccess, checkmarkStick } = Object.assign(defaultBehavior, behavior); 110 | const [clickedOnceAlready, setClickedOnceAlready] = useState(false); 111 | const [successPending, setSuccessPending] = useState(false); 112 | const [successAnimation, setSuccessAnimation] = useState(null); 113 | useEffect(() => { 114 | if (successAnimation === 'fade') { 115 | setTimeout(() => { 116 | setSuccessAnimation(null); 117 | }, 500); /* length of checkmark-shrink duration */ 118 | } 119 | }, [successAnimation]); 120 | const handleResult = (result) => { 121 | const validateResult = (result) => { 122 | if (!result) throw new Error('no response from server'); 123 | if (Object.values(result)[0] == null) throw new Error('response object was null, indicates malformed query or something?') 124 | } 125 | validateResult(result); 126 | if (showSuccess) { 127 | setSuccessAnimation('check'); 128 | setSuccessPending(false); 129 | } 130 | handleSuccess(result); 131 | } 132 | const handleSuccess = (result) => { 133 | setTimeout(() => { 134 | onSuccess(result); 135 | if (showSuccess && !checkmarkStick) { 136 | setSuccessAnimation('fade'); 137 | setClickedOnceAlready(false); 138 | } 139 | }, showSuccess ? 1400 : 0); 140 | } 141 | const handleError = (err) => { 142 | setSuccessPending(false); 143 | setClickedOnceAlready(false); 144 | if (err === 'handled') return; 145 | const { __typename, errors } = err; 146 | if (__typename === 'FormErrorReport') { 147 | if (handleFormError) handleFormError(errors); 148 | else warnError('unhandledFormError', errors); 149 | return; 150 | } 151 | if (__typename === 'NiceTry') { 152 | return warnError('niceTry'); 153 | } 154 | warnError('somethingWentWrong', err); 155 | } 156 | const handleSubmit = (e) => { 157 | e.preventDefault(); 158 | if (clickedOnceAlready) return; 159 | setClickedOnceAlready(true); 160 | if (!onSubmit) return console.log('missing onSubmit handler!'); 161 | if (showLoading) setSuccessPending(true); 162 | onSubmit().then(handleResult).catch(handleError); 163 | } 164 | return { 165 | handleSubmit, 166 | successPending, 167 | successAnimation 168 | } 169 | } -------------------------------------------------------------------------------- /styles/global.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Dela+Gothic+One&family=Inconsolata:wght@100;200;300;400;500;600;700;800;900&family=Inter:wght@100;200;300;400;500;600;700;800;900&family=Work+Sans:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,200;1,300;1,400;1,500;1,600;1,700;1,800&display=swap'); 2 | 3 | @keyframes formErrorDisappear { 4 | from { 5 | opacity: 1; 6 | } 7 | to { 8 | opacity: 0; 9 | } 10 | } 11 | @keyframes check { 12 | 100% { 13 | stroke-dashoffset: 0; 14 | } 15 | } 16 | @keyframes checkmark-shrink { 17 | 100% { 18 | opacity: 0; 19 | transform: translate3d(0, 0, 0) rotate(45deg) scale(0); 20 | } 21 | } 22 | @keyframes dashPanelAnimation { 23 | from { 24 | opacity: 0; 25 | } 26 | to { 27 | opacity: 1; 28 | } 29 | } 30 | 31 | :root { 32 | --header-font: 'Dela Gothic One', sans-serif; 33 | --default-font: 'Work Sans', 'Inter', sans-serif; 34 | --mono-font: 'Inconsolata', monospace; 35 | --link-color: #000; 36 | --link-bg: #000; 37 | --caution: #df436a; 38 | --button-cancel-color: #767578; 39 | --loading-icon-color: #9FF7E7; 40 | --dashboard-nav-width: 7rem; 41 | --dashpanel-sidebar-width: 250px; 42 | } 43 | 44 | * { 45 | box-sizing: border-box; 46 | outline: none; 47 | -webkit-tap-highlight-color: transparent; 48 | } 49 | 50 | html, body, #__next { 51 | width: 100%; 52 | height: 100%; 53 | margin: 0; 54 | } 55 | 56 | #__next > div { 57 | width: 100%; 58 | height: 100%; 59 | } 60 | 61 | body { 62 | font-family: var(--default-font), 'Helvetica Neue', sans-serif; 63 | line-height: 1.4; 64 | -webkit-font-smoothing: antialiased; 65 | -moz-osx-font-smoothing: grayscale; 66 | } 67 | body.dragging { 68 | cursor: grabbing; 69 | } 70 | 71 | p, h1, h2, h3, h4, h5, h6 { 72 | margin-top: 0; 73 | } 74 | h1, h2, h3, h4, h5, h6 { 75 | line-height: 1; 76 | } 77 | h1 { 78 | font-family: var(--header-font); 79 | font-weight: normal; 80 | } 81 | h2 { 82 | font-size: 2rem; 83 | margin-bottom: 1rem; 84 | } 85 | center { 86 | display: block; 87 | text-align: center; 88 | } 89 | code { 90 | font-family: var(--mono-font); 91 | font-size: 0.8rem; 92 | line-height: 1rem; 93 | padding: 0 0.2rem; 94 | letter-spacing: -0.02rem; 95 | } 96 | 97 | @media screen and (max-width: 900px) { 98 | h2 { 99 | font-size: 1.5rem; 100 | } 101 | } 102 | 103 | a, a:visited, button.link { 104 | cursor: pointer; 105 | font-family: var(--mono-font); 106 | font-size: inherit; 107 | line-height: inherit; 108 | color: var(--link-color); 109 | text-decoration: none; 110 | position: relative; 111 | z-index: 2; 112 | display: inline-block; 113 | transition: 0.2s; 114 | } 115 | a::before, button.link::after { 116 | content: ''; 117 | position: absolute; 118 | z-index: -1; 119 | bottom: 0; 120 | left: 0; 121 | width: calc(100% + 1em); 122 | max-width: 100%; 123 | height: 100%; 124 | max-height: 0px; 125 | border: 1px solid #000; 126 | border-width: 0 0 1px; 127 | transition: 0.2s; 128 | } 129 | a:hover::before, button.link:hover::after { 130 | border-width: 1px; 131 | max-height: 100%; 132 | max-width: calc(100% + 1em); 133 | transform: skewX(-12deg) translate3d(-0.5em, 1px, 0); 134 | } 135 | 136 | button { 137 | background: 0; 138 | border: 0; 139 | padding: 0; 140 | margin: 0; 141 | cursor: pointer; 142 | } 143 | button, input { 144 | font-family: var(--mono-font); 145 | outline: none; 146 | } 147 | button.cancel::before, button.cancel::after { 148 | border-color: var(--button-cancel-color); 149 | } 150 | button.cancel::before { 151 | background: var(--button-cancel-color); 152 | } 153 | button span[data-ghost=true] { 154 | visibility: hidden; 155 | } 156 | button span[data-ghost=true] + * { 157 | display: inline-flex; 158 | align-items: center; 159 | justify-content: center; 160 | position: absolute; 161 | top: 50%; 162 | left: 50%; 163 | width: auto; 164 | height: 100%; 165 | transform: translate3d(-45%, -50%, 0) scale(0.5); 166 | } 167 | .SuccessAnimation svg { 168 | fill: none; 169 | stroke: #fff; 170 | stroke-width: 1.5px; 171 | stroke-dasharray: 2rem; 172 | stroke-dashoffset: 2rem; 173 | height: 100%; 174 | transform-origin: center; 175 | transform: translate3d(-10%, -30%, 0) rotate(45deg) scale(1); 176 | } 177 | .SuccessAnimation.check svg { 178 | animation: 0.7s check 1 forwards; 179 | } 180 | .SuccessAnimation.fade svg { 181 | stroke-dashoffset: 0; 182 | animation: 0.5s checkmark-shrink 1 forwards; 183 | } 184 | 185 | form .formGrid { 186 | display: flex; 187 | } 188 | form div + .formGrid { 189 | margin-top: 1rem; 190 | } 191 | 192 | div.sbs { 193 | display: flex; 194 | } 195 | 196 | label { 197 | font-family: var(--mono-font); 198 | font-size: 0.8rem; 199 | text-transform: uppercase; 200 | display: block; 201 | margin-bottom: 0.3rem; 202 | } 203 | input { 204 | background: transparent; 205 | border: 1px solid #000; 206 | line-height: 2.5; 207 | font-size: 1rem; 208 | padding: 0 0.7rem; 209 | transition: 0.2s; 210 | } 211 | input:focus { 212 | box-shadow: inset 3px 0 0 #000; 213 | } 214 | input:disabled, button:disabled { 215 | opacity: 0.4; 216 | cursor: not-allowed; 217 | user-select: none; 218 | } 219 | input[type=date] { 220 | cursor: pointer; 221 | user-select: none; 222 | } 223 | 224 | .bb { 225 | font-size: 0.8rem; 226 | } 227 | .mt05 { 228 | margin-top: 0.5rem !important; 229 | } 230 | .mt10 { 231 | margin-top: 1rem !important; 232 | } 233 | .mb10 { 234 | margin-bottom: 1rem !important; 235 | } 236 | .mb20 { 237 | margin-bottom: 2rem !important; 238 | } 239 | .mt15 { 240 | margin-top: 1.5rem !important; 241 | } 242 | .mt25 { 243 | margin-top: 2.5rem !important; 244 | } 245 | .tar { 246 | text-align: right; 247 | } 248 | .jcfs { 249 | justify-content: flex-start !important; 250 | } 251 | .jcc { 252 | justify-content: center; 253 | } 254 | .aic { 255 | align-items: center; 256 | } 257 | 258 | #nprogress { 259 | pointer-events: none; 260 | } 261 | #nprogress .spinner { 262 | display: block; 263 | position: fixed; 264 | z-index: 1346; 265 | bottom: 3rem; 266 | right: 3rem; 267 | } 268 | #nprogress .spinner-icon { 269 | width: 3rem; 270 | height: 3rem; 271 | border: solid 0.125rem transparent; 272 | border-top-color: #000; 273 | border-left-color: #000; 274 | border-radius: 999px; 275 | animation: nprogress-spinner 0.4s linear infinite; 276 | } 277 | .nprogress-custom-parent { 278 | overflow: hidden; 279 | position: relative; 280 | } 281 | .nprogress-custom-parent #nprogress .spinner, 282 | .nprogress-custom-parent #nprogress .bar { 283 | position: absolute; 284 | } 285 | 286 | details.errorReport { 287 | max-width: 100%; 288 | margin-bottom: 1.5rem; 289 | } 290 | details.errorReport summary { 291 | font-weight: 400; 292 | text-transform: uppercase; 293 | font-size: 0.8rem; 294 | color: #333; 295 | outline: none; 296 | } 297 | details.errorReport .errorDetails { 298 | white-space: pre-line; 299 | word-wrap: break-word; 300 | display: block; 301 | padding: 0.5rem; 302 | margin-top: 0.5rem; 303 | border: 1px solid #ddd; 304 | max-height: 6rem; 305 | overflow: auto; 306 | } 307 | details.errorReport .errorDetails code { 308 | line-height: 1rem; 309 | display: block; 310 | } 311 | 312 | /* habit clone for rearranging habits */ 313 | *[class$=-clone] { 314 | position: fixed; 315 | z-index: 9999; 316 | top: 0; 317 | left: 0; 318 | line-height: 2.5; 319 | padding: 0 1rem; 320 | display: flex; 321 | align-items: center; 322 | justify-content: center; 323 | } 324 | *[class$=-clone]::before { 325 | content: ''; 326 | position: absolute; 327 | z-index: -1; 328 | width: 100%; 329 | height: 100%; 330 | background: #fff; 331 | transform: skew(-12deg); 332 | box-shadow: 0.2rem 0.2rem 0.5rem rgba(0, 0, 0, 0.1); 333 | } 334 | 335 | @media screen and (max-width: 900px) { 336 | #nprogress .spinner { 337 | bottom: 1rem; 338 | right: unset; 339 | left: 1rem; 340 | } 341 | #nprogress .spinner-icon { 342 | width: 1.5rem; 343 | height: 1.5rem; 344 | border-width: 0.0625rem; 345 | } 346 | } 347 | 348 | @keyframes nprogress-spinner { 349 | 0% { 350 | transform: rotate(0deg); 351 | } 352 | 100% { 353 | transform: rotate(360deg); 354 | } 355 | } -------------------------------------------------------------------------------- /components/MyHabits/myHabits.module.css: -------------------------------------------------------------------------------- 1 | .MyHabitsNav { 2 | position: relative; 3 | display: inline-block; 4 | width: 100%; 5 | } 6 | .HabitViewOptions { 7 | margin-bottom: 2rem; 8 | } 9 | div button.currentHabitView { /* including div for specificity */ 10 | opacity: 1; 11 | } 12 | 13 | .retired, .dragging > * { 14 | opacity: 0.5; 15 | transition: 0.2s; 16 | } 17 | .retired:hover { 18 | opacity: 1; 19 | } 20 | 21 | .HabitsContainer h2 { 22 | grid-column: 1 / -1; 23 | } 24 | 25 | .HabitList { 26 | display: inline-flex; 27 | flex-direction: column; 28 | width: 350px; 29 | max-width: 100%; 30 | } 31 | .HabitList h2 { 32 | margin-bottom: 1.5rem; 33 | } 34 | .HabitList + .HabitList { 35 | display: flex; 36 | margin-top: 2rem; 37 | } 38 | .HabitListItem:not(:last-of-type) { 39 | position: relative; 40 | margin-bottom: 1.4rem; 41 | } 42 | .HabitListItemHeader { 43 | position: relative; 44 | cursor: pointer; 45 | display: flex; 46 | align-items: center; 47 | justify-content: flex-start; 48 | } 49 | .HabitListItemHeader h3 { 50 | font-family: var(--mono-font); 51 | font-size: 1.2rem; 52 | font-weight: 400; 53 | letter-spacing: -0.5px; 54 | margin-left: 1rem; 55 | margin-bottom: 0; 56 | } 57 | .HabitIcon { 58 | position: relative; 59 | z-index: 9; 60 | width: 2rem; 61 | height: 2rem; 62 | display: flex; 63 | align-items: center; 64 | justify-content: center; 65 | font-size: 2.5rem; 66 | } 67 | .HabitListItem .HabitIcon::after { 68 | content: '+'; 69 | color: #000; 70 | font-size: 0.8rem; 71 | line-height: 0.75rem; 72 | position: absolute; 73 | bottom: 0; 74 | left: 75%; 75 | width: 0.75rem; 76 | height: 0.75rem; 77 | border-radius: 999px; 78 | display: flex; 79 | align-items: center; 80 | justify-content: center; 81 | background: #fff; 82 | box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.5); 83 | } 84 | .HabitListItem.expanded .HabitIcon::after { 85 | content: '-' 86 | } 87 | .HabitListItemBody { 88 | font-size: 0.8rem; 89 | margin-left: 0.5rem; 90 | width: calc(100% - 0.5rem); 91 | border-left: 1px solid #000; 92 | background: #fff; 93 | max-height: 0; 94 | overflow: hidden; 95 | transform-origin: top left; 96 | opacity: 0; 97 | transform: scaleX(0); 98 | transition: max-height 0.2s, opacity 0.2s, transform 0.2s, border-radius 0.5s; 99 | margin-top: 0.5rem; 100 | border-radius: 0 0 4rem 0; 101 | } 102 | .HabitListItem.expanded .HabitListItemBody { 103 | opacity: 1; 104 | transform: scaleX(1); 105 | border-radius: 0; 106 | } 107 | .HabitListItemBody > div { 108 | padding: 1rem 1rem 1.5rem; 109 | } 110 | .HabitListItemBody label { 111 | font-size: 0.8rem; 112 | } 113 | .HabitListItemBody input { 114 | border: 0; 115 | background: #f7f7f7; 116 | font-size: 0.9rem; 117 | } 118 | .HabitListItem .grip { 119 | top: 0; 120 | right: 0; 121 | height: 2rem; 122 | align-items: center; 123 | } 124 | .HabitListItem[data-hotspot=true]::before, .HabitListItem[data-hotspot=after]::before { 125 | content: ''; 126 | position: absolute; 127 | bottom: calc(100% + 0.875rem); 128 | left: 0; 129 | margin-bottom: -1px; 130 | width: 100%; 131 | height: 2px; 132 | background: #000; 133 | } 134 | .HabitListItem[data-hotspot=after]::before { 135 | bottom: unset; 136 | top: calc(100% + 0.5rem); 137 | } 138 | 139 | .HabitGrid { 140 | display: flex; 141 | flex-flow: row wrap; 142 | margin: 1rem -1.5rem 0 0; 143 | } 144 | .HabitGrid h2 { 145 | grid-column: 1 / -1; 146 | flex: 1 0 100%; 147 | margin: 2rem 0; 148 | } 149 | .HabitGridItem { 150 | flex: 1 0 0; 151 | display: flex; 152 | flex-direction: column; 153 | position: relative; 154 | margin: 0 1.5rem 1.5rem 0; 155 | padding: 1rem; 156 | background: #fff; 157 | } 158 | .HabitGridColorIndicator { 159 | position: absolute; 160 | display: block; 161 | top: 0; 162 | left: 0; 163 | width: 100%; 164 | height: 0.5rem; 165 | margin-bottom: 0.5rem; 166 | opacity: 0.5; 167 | } 168 | .HabitGridItemHeader .HabitIcon { 169 | margin-top: -2rem; 170 | } 171 | .HabitGridItemHeader .HabitIcon + span { 172 | display: block; 173 | margin: 0.75rem 0 0.5rem; 174 | font-weight: 600; 175 | font-size: 1.2rem; 176 | padding-right: 1rem; 177 | } 178 | .HabitGridItemBody { 179 | flex: 1 0 auto; 180 | display: flex; 181 | flex-direction: column; 182 | justify-content: space-between; 183 | } 184 | .HabitGridItemBody > span { 185 | font-family: var(--mono-font); 186 | font-size: 0.8rem; 187 | margin-bottom: 0.75rem; 188 | } 189 | .HabitGridItemBody > div { 190 | display: flex; 191 | align-items: center; 192 | justify-content: flex-start; 193 | } 194 | .HabitGridItemBody > div button { 195 | opacity: 0.3; 196 | } 197 | .HabitGridItemBody > div button + button { 198 | margin-left: 0.5rem; 199 | } 200 | .NewHabitGridItem { 201 | grid-column: 1 / -1; 202 | flex: 1 0 100%; 203 | } 204 | .NewHabitGridItem > button { 205 | display: flex; 206 | align-items: center; 207 | } 208 | .NewHabitGridItem > button > div { 209 | width: 2rem; 210 | height: 2rem; 211 | background: #000; 212 | color: #fff; 213 | border-radius: 999px; 214 | font-size: 1.2rem; 215 | display: flex; 216 | align-items: center; 217 | justify-content: center; 218 | } 219 | .NewHabitGridItem > button > span { 220 | display: block; 221 | margin-left: 0.75rem; 222 | font-size: 1rem; 223 | position: relative; 224 | } 225 | .NewHabitGridItem > button > span::before { 226 | content: ''; 227 | display: block; 228 | z-index: -1; 229 | position: absolute; 230 | top: 50%; 231 | left: -0.5rem; 232 | width: calc(100% + 1rem); 233 | height: calc(100% + 0.5rem); 234 | background: #fff; 235 | transform: skew(-12deg) translate3d(0, -50%, 0); 236 | } 237 | .HabitGridItem .grip { 238 | top: 1rem; 239 | right: 0.5rem; 240 | align-items: flex-end; 241 | } 242 | .HabitGridItem[data-hotspot=true]::before, .HabitGridItem[data-hotspot=after]::before { 243 | content: ''; 244 | position: absolute; 245 | top: 0; 246 | right: calc(100% + 0.75rem); /* half of 1.5rem margin between grid items */ 247 | margin-right: -1px; 248 | width: 2px; 249 | height: 100%; 250 | background: #000; 251 | } 252 | .HabitGridItem[data-hotspot=after]::before { 253 | right: unset; 254 | left: calc(100% + 0.75rem); 255 | margin-left: -1px; 256 | } 257 | 258 | .grip { 259 | position: absolute; 260 | display: inline-flex; 261 | cursor: grab; 262 | opacity: 0.2; 263 | transition: 0.2s; 264 | touch-action: none; 265 | } 266 | .grip:hover { 267 | opacity: 0.5; 268 | } 269 | .grip:active { 270 | cursor: grabbing; 271 | } 272 | 273 | .displayOptions { 274 | margin-top: 1rem; 275 | display: flex; 276 | } 277 | .displayOptions > div:first-child { 278 | flex: 1 1 auto; 279 | } 280 | .displayOptions > div:last-child { 281 | flex: 0 1 auto; 282 | } 283 | 284 | .DeleteHabit { 285 | margin-top: 1rem; 286 | text-align: center; 287 | } 288 | .DeleteHabit button { 289 | font-size: 0.5rem; 290 | } 291 | .DeleteHabit button > span { 292 | display: inline-block; 293 | margin-left: 0.25rem; 294 | font-size: 0.75rem; 295 | } 296 | 297 | @media screen and (max-width: 900px) { 298 | .HabitIcon { 299 | width: 1.75rem; 300 | height: 1.75rem; 301 | font-size: 2rem; 302 | } 303 | .HabitList { 304 | max-width: 100%; 305 | } 306 | .HabitList + .HabitList { 307 | margin-top: 1.5rem; 308 | } 309 | .HabitListItem { 310 | margin-bottom: 1rem; 311 | } 312 | .HabitListItemHeader h3 { 313 | font-size: 1rem; 314 | } 315 | .HabitGrid { 316 | display: grid; 317 | grid-template-columns: repeat(auto-fit, 33%); 318 | margin: 0 -1rem 0 0; 319 | } 320 | .HabitGrid h2 { 321 | margin-bottom: 1.5rem; 322 | } 323 | .HabitGridItem { 324 | margin: 0 1rem 1rem 0; 325 | } 326 | .HabitGridItem[data-hotspot=true]::before { 327 | right: calc(100% + 0.5rem); /* half of 1rem margin between grid items */ 328 | } 329 | .HabitGridItemHeader .HabitIcon { 330 | margin-top: -1.5rem; 331 | } 332 | .HabitGridItemHeader .HabitIcon + span { 333 | line-height: 1; 334 | } 335 | .HabitGridItemBody > span { 336 | line-height: 1.2; 337 | } 338 | .HabitGridItemBody button { 339 | font-size: 0.9rem; 340 | } 341 | } 342 | 343 | @media screen and (max-width: 600px) { 344 | .HabitGrid { 345 | grid-template-columns: repeat(auto-fit, 50%); 346 | } 347 | .HabitGridItemBody button { 348 | font-size: 0.8rem; 349 | } 350 | } -------------------------------------------------------------------------------- /components/Modal/modalStore.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from "react"; 2 | import { useRouter } from "next/router"; 3 | 4 | import { User, Entry, Habit, Token, handleRequest } from "../../pages/api"; 5 | import { DataContext } from "../../contexts"; 6 | import { useForm } from "../../hooks"; 7 | import Form, { Submit, Button, Input } from "../Form"; 8 | import ModalForm, { ModalizedForm } from "./ModalForm"; 9 | 10 | export const modalStore = { 11 | 'demoMessage': (props) => , 12 | 'generateDemoData': (props) => , 13 | 'somethingWentWrong': (props) => , 14 | 'unhandledFormError': (props) => , 15 | 'niceTry': (props) => , 16 | 'forgotPassword': (props) => , 17 | 'warnOverwriteEntry': (props) => , 18 | 'manageHabit': (props) => , 19 | 'deleteHabit': (props) => , 20 | 'deleteEntry': (props) => , 21 | 'deleteAccount': (props) => , 22 | } 23 | 24 | const DemoMessage = ({ closeModal }) => { 25 | const router = useRouter(); 26 | const handleClick = () => { 27 | router.push('/'); 28 | closeModal(); 29 | } 30 | return ( 31 | <> 32 |

hi there!

33 |

welcome to habitat! i've set up a special account for people interesting in demoing the app. sign in with the username demo & password habitat and you'll have the option to generate sample data to play around with, which will be cleared on logout or automatically after 2 hours.

34 | 35 | 36 | ); 37 | } 38 | 39 | const GenerateDemoData = ({ generateData, closeModal }) => { 40 | const [successPending, setSuccessPending] = useState(false); 41 | const handleClick = () => { 42 | setSuccessPending(true); 43 | generateData().then(closeModal); 44 | } 45 | return ( 46 | <> 47 |

what's this?

48 |

for your convenience, this will generate a set of sample habits and a month's worth of randomized habit data for you to play around with. to wipe this account and start fresh, log out and then log back in with the same credentials (username demo & password habitat). have fun!

49 | 55 | 56 | ); 57 | } 58 | 59 | const SomethingWentWrong = ({ error, closeModal }) => { 60 | const handleClick = () => { 61 | closeModal(); 62 | window.location.reload(); 63 | } 64 | return ( 65 |
66 |

something went wrong

67 |

an unknown error occurred, please reload the page and try again

68 |
69 | View details 70 |
71 | {error?.caughtErrors ?? "nothing to see here, sorry :("} 72 |
73 |
74 | 75 |
76 | ); 77 | } 78 | 79 | const UnhandledFormError = ({ error, closeModal }) => { 80 | const { parseFormError } = useForm(); 81 | const printErrors = () => { 82 | return Object.entries(parseFormError(error)).map(([key, value]) => { 83 | return
  • {key}: {value}
  • 84 | }); 85 | } 86 | return ( 87 |
    88 |

    oopsies

    89 |

    found errors on the following fields:

    90 |
      {printErrors()}
    91 |
    92 | 93 |
    94 |
    95 | ); 96 | } 97 | 98 | const NiceTry = ({ closeModal }) => { 99 | return ( 100 | <> 101 |

    nice try

    102 |

    you think im going to let my app be defeated by some nerd who knows how to use dev tools? try again sweetie

    103 | 104 | 105 | ); 106 | } 107 | 108 | const ForgotPassword = ({ closeModal }) => { 109 | const [success, setSuccess] = useState(false); 110 | const { formData, handleFormError, inputProps } = useForm(); 111 | const handleSubmit = async () => Token.createPasswordToken(formData); 112 | const handleSuccess = () => { 113 | setTimeout(() => { 114 | setSuccess(true); 115 | }, 200); 116 | } 117 | if (success) return ( 118 | <> 119 |

    success!

    120 |

    an email containing a link to reset your password has been sent to {formData.email}. be sure to check your spam folder if you can't find the email in your regular inbox.

    121 | 122 | 123 | ); 124 | return ( 125 |
    130 | 131 |
    132 | ); 133 | } 134 | 135 | const WarnOverwriteEntry = ({ date, handleSubmit, handleSuccess }) => { 136 | return ( 137 | }> 142 |

    an entry for {date} already exists. if you continue, it will be overwritten.

    143 |
    144 | ); 145 | } 146 | 147 | const ManageHabit = ({ habitForm, habitFormProps }) => { 148 | return ( 149 | 153 | ); 154 | } 155 | 156 | const DeleteHabit = ({ habit }) => { 157 | const { getHabits, getEntries } = useContext(DataContext); 158 | const handleSubmit = async () => Habit.delete({ id: habit.id }); 159 | const handleSuccess = () => { 160 | getHabits(); 161 | getEntries(); // in case any entries were left empty by deleting this habit and all its records, since those entries will have been deleted too 162 | } 163 | return ( 164 | }> 169 |

    are you sure you want to delete the habit {habit.name} and all data associated with it? this action cannot be undone.

    170 |
    171 | ); 172 | } 173 | 174 | const DeleteEntry = ({ entry }) => { 175 | const { getEntries } = useContext(DataContext); 176 | const handleSubmit = async () => Entry.delete({ id: entry.id }); 177 | return ( 178 | }> 183 |

    are you sure you want to delete this entry? this action cannot be undone.

    184 |
    185 | ); 186 | } 187 | 188 | const DeleteAccount = ({ user, closeModal }) => { 189 | const router = useRouter(); 190 | const [success, setSuccess] = useState(false); 191 | const handleSubmit = async () => User.deleteAccount({ id: user.id }); 192 | const handleSuccess = () => setSuccess(true); 193 | const handleSuccessClick = () => { 194 | closeModal(); 195 | const logout = async () => { 196 | await handleRequest('/api/auth/logout'); 197 | router.push('/'); 198 | } 199 | setTimeout(logout, 200); 200 | } 201 | if (success) return ( 202 | <> 203 |

    success!

    204 |

    all data associated with this account has been deleted. sorry to see you go!

    205 | 206 | 207 | ); 208 | return ( 209 |
    }> 214 |

    are you absolutely sure you want to delete your account and all data associated with it? this action cannot be undone.

    215 |
    216 | ); 217 | } -------------------------------------------------------------------------------- /pages/api/index.js: -------------------------------------------------------------------------------- 1 | import { handleRequest, handleQuery } from "./handleRequest"; 2 | 3 | export const User = { 4 | get: async (params) => await handleQuery(queries.getUser, params), 5 | create: async (formData) => await handleQuery(mutations.createUser, formData), 6 | edit: async (formData) => await handleQuery(mutations.editUser, formData), 7 | editPassword: async (formData) => await handleQuery(mutations.editPassword, formData), 8 | editSettings: async (formData) => await handleQuery(mutations.editSettings, formData), 9 | login: async (params) => await handleQuery(queries.loginUser, params), 10 | deleteAccount: async (data) => await handleQuery(mutations.deleteAccount, data), 11 | generateDemoData: async (id) => await handleQuery(mutations.generateDemoData, id), 12 | clearDemoData: async ({ demoTokenId }) => await handleQuery(mutations.clearDemoData, { demoTokenId }), 13 | validateSignupToken: async ({ tokenId }) => await handleQuery(mutations.validateSignupToken, { tokenId }) 14 | } 15 | 16 | export const Habit = { 17 | get: async (params) => await handleQuery(queries.getHabits, params), 18 | create: async (formData) => await handleQuery(mutations.createHabit, formData), 19 | edit: async (formData) => await handleQuery(mutations.editHabit, formData), 20 | delete: async (formData) => await handleQuery(mutations.deleteHabit, formData), 21 | rearrange: async (data) => await handleQuery(mutations.rearrangeHabits, data) 22 | } 23 | 24 | export const Entry = { 25 | get: async (params) => await handleQuery(queries.getEntries, params), 26 | create: async (formData) => await handleQuery(mutations.createEntry, formData), 27 | edit: async (formData) => await handleQuery(mutations.editEntry, formData), 28 | delete: async (formData) => await handleQuery(mutations.deleteEntry, formData), 29 | } 30 | 31 | export const Token = { 32 | validate: async ({ tokenId }) => await handleQuery(mutations.validatePasswordToken, { tokenId }), 33 | createPasswordToken: async (formData) => await handleQuery(mutations.createPasswordToken, formData) 34 | } 35 | 36 | const queries = { 37 | getUser: ` 38 | query($id: String, $demoTokenId: String) { 39 | user(id: $id, demoTokenId: $demoTokenId) { 40 | id 41 | name 42 | email 43 | settings { 44 | dashboard__defaultView 45 | habits__defaultView 46 | habits__newHabitIcon 47 | appearance__showClock 48 | appearance__24hrClock 49 | appearance__showClockSeconds 50 | } 51 | } 52 | } 53 | `, 54 | getHabits: ` 55 | query($userId: String, $demoTokenId: String) { 56 | habits(userId: $userId, demoTokenId: $demoTokenId) { 57 | id 58 | name 59 | icon 60 | color 61 | label 62 | complex 63 | retired 64 | } 65 | } 66 | `, 67 | getEntries: ` 68 | query($userId: String, $demoTokenId: String) { 69 | entries(userId: $userId, demoTokenId: $demoTokenId) { 70 | id 71 | date 72 | records { 73 | id 74 | habitId 75 | amount 76 | check 77 | } 78 | } 79 | } 80 | `, 81 | loginUser: ` 82 | query($email: String, $password: String) { 83 | login(email: $email, password: $password) { 84 | ... on FormErrorReport { 85 | __typename 86 | errors { 87 | location 88 | message 89 | } 90 | } 91 | ... on User { 92 | id 93 | name 94 | email 95 | settings { 96 | dashboard__defaultView 97 | habits__defaultView 98 | habits__newHabitIcon 99 | appearance__showClock 100 | appearance__24hrClock 101 | appearance__showClockSeconds 102 | } 103 | demoTokenId 104 | } 105 | } 106 | } 107 | ` 108 | } 109 | 110 | const mutations = { 111 | validateSignupToken: ` 112 | mutation($tokenId: String) { 113 | validateSignupToken(tokenId: $tokenId) { 114 | ... on FormErrorReport { 115 | __typename 116 | errors { 117 | location 118 | message 119 | } 120 | } 121 | ... on Token { 122 | id 123 | } 124 | } 125 | } 126 | `, 127 | createUser: ` 128 | mutation($email: String, $password: String, $token: String) { 129 | createUser(email: $email, password: $password, token: $token) { 130 | ... on FormErrorReport { 131 | __typename 132 | errors { 133 | location 134 | message 135 | } 136 | } 137 | ... on User { 138 | id 139 | name 140 | email 141 | settings { 142 | dashboard__defaultView 143 | habits__defaultView 144 | habits__newHabitIcon 145 | appearance__showClock 146 | appearance__24hrClock 147 | appearance__showClockSeconds 148 | } 149 | } 150 | } 151 | } 152 | `, 153 | editUser: ` 154 | mutation($id: String, $name: String, $email: String) { 155 | editUser(id: $id, name: $name, email: $email) { 156 | ... on FormErrorReport { 157 | __typename 158 | errors { 159 | location 160 | message 161 | } 162 | } 163 | ... on User { 164 | id 165 | name 166 | email 167 | } 168 | } 169 | } 170 | `, 171 | editPassword: ` 172 | mutation($id: String, $password: String, $confirmPassword: String, $reset: Boolean) { 173 | editPassword(id: $id, password: $password, confirmPassword: $confirmPassword, reset: $reset) { 174 | ... on FormErrorReport { 175 | __typename 176 | errors { 177 | location 178 | message 179 | } 180 | } 181 | ... on User { 182 | id 183 | name 184 | email 185 | } 186 | } 187 | } 188 | `, 189 | deleteAccount: ` 190 | mutation($id: String) { 191 | deleteAccount(id: $id) { 192 | ... on FormErrorReport { 193 | __typename 194 | errors { 195 | location 196 | message 197 | } 198 | } 199 | ... on User { 200 | id 201 | name 202 | email 203 | } 204 | } 205 | } 206 | `, 207 | editSettings: ` 208 | mutation( 209 | $userId: String, 210 | $demoTokenId: String, 211 | $dashboard__defaultView: String, 212 | $habits__defaultView: String, 213 | $habits__newHabitIcon: String, 214 | $appearance__showClock: Boolean, 215 | $appearance__24hrClock: Boolean, 216 | $appearance__showClockSeconds: Boolean 217 | ) { 218 | editSettings( 219 | userId: $userId, 220 | demoTokenId: $demoTokenId, 221 | dashboard__defaultView: $dashboard__defaultView, 222 | habits__defaultView: $habits__defaultView, 223 | habits__newHabitIcon: $habits__newHabitIcon, 224 | appearance__showClock: $appearance__showClock, 225 | appearance__24hrClock: $appearance__24hrClock, 226 | appearance__showClockSeconds: $appearance__showClockSeconds 227 | ) { 228 | dashboard__defaultView 229 | habits__defaultView 230 | habits__newHabitIcon 231 | appearance__showClock 232 | appearance__24hrClock 233 | appearance__showClockSeconds 234 | } 235 | } 236 | `, 237 | createHabit: ` 238 | mutation( 239 | $name: String, 240 | $icon: String, 241 | $color: String, 242 | $label: String, 243 | $complex: Boolean, 244 | $retired: Boolean, 245 | $userId: String, 246 | $demoTokenId: String 247 | ) { 248 | createHabit( 249 | name: $name, 250 | icon: $icon, 251 | color: $color, 252 | label: $label, 253 | complex: $complex, 254 | retired: $retired, 255 | userId: $userId, 256 | demoTokenId: $demoTokenId 257 | ) { 258 | ... on FormErrorReport { 259 | __typename 260 | errors { 261 | location 262 | message 263 | } 264 | } 265 | ... on Habit { 266 | id 267 | name 268 | icon 269 | color 270 | label 271 | complex 272 | } 273 | } 274 | } 275 | `, 276 | editHabit: ` 277 | mutation($id: String, $name: String, $icon: String, $color: String, $label: String, $complex: Boolean, $retired: Boolean) { 278 | editHabit(id: $id, name: $name, icon: $icon, color: $color, label: $label, complex: $complex, retired: $retired) { 279 | ... on FormErrorReport { 280 | __typename 281 | errors { 282 | location 283 | message 284 | } 285 | } 286 | ... on Habit { 287 | id 288 | name 289 | icon 290 | color 291 | label 292 | complex 293 | } 294 | } 295 | } 296 | `, 297 | deleteHabit: ` 298 | mutation($id: String) { 299 | deleteHabit(id: $id) { 300 | id 301 | name 302 | icon 303 | color 304 | label 305 | complex 306 | retired 307 | } 308 | } 309 | `, 310 | rearrangeHabits: ` 311 | mutation($array: [String]) { 312 | rearrangeHabits(array: $array) { 313 | success 314 | } 315 | } 316 | `, 317 | createEntry: ` 318 | mutation($userId: String, $date: String, $records: [RecordInput], $demoTokenId: String) { 319 | createEntry(userId: $userId, date: $date, records: $records, demoTokenId: $demoTokenId) { 320 | ... on FormErrorReport { 321 | __typename 322 | errors { 323 | location 324 | message 325 | } 326 | } 327 | ... on Entry { 328 | id 329 | date 330 | records { 331 | habitId 332 | amount 333 | check 334 | } 335 | } 336 | } 337 | } 338 | `, 339 | editEntry: ` 340 | mutation($id: String, $date: String, $records: [RecordInput], $demoTokenId: String) { 341 | editEntry(id: $id, date: $date, records: $records, demoTokenId: $demoTokenId) { 342 | ... on FormErrorReport { 343 | __typename 344 | errors { 345 | location 346 | message 347 | } 348 | } 349 | ... on Entry { 350 | id 351 | date 352 | records { 353 | habitId 354 | amount 355 | check 356 | } 357 | } 358 | } 359 | } 360 | `, 361 | deleteEntry: ` 362 | mutation($id: String) { 363 | deleteEntry(id: $id) { 364 | id 365 | date 366 | } 367 | } 368 | `, 369 | generateDemoData: ` 370 | mutation($id: String, $demoTokenId: String, $calendarPeriod: String, $alsoHabits: Boolean) { 371 | generateDemoData(id: $id, demoTokenId: $demoTokenId, calendarPeriod: $calendarPeriod, alsoHabits: $alsoHabits) { 372 | success 373 | } 374 | } 375 | `, 376 | clearDemoData: ` 377 | mutation($demoTokenId: String) { 378 | clearDemoData(demoTokenId: $demoTokenId) { 379 | success 380 | } 381 | } 382 | `, 383 | createPasswordToken: ` 384 | mutation($email: String) { 385 | createPasswordToken(email: $email) { 386 | ... on FormErrorReport { 387 | __typename 388 | errors { 389 | location 390 | message 391 | } 392 | } 393 | ... on Token { 394 | id 395 | } 396 | } 397 | } 398 | `, 399 | validatePasswordToken: ` 400 | mutation($tokenId: String) { 401 | validatePasswordToken(tokenId: $tokenId) { 402 | ... on FormErrorReport { 403 | __typename 404 | errors { 405 | location 406 | message 407 | } 408 | } 409 | ... on Token { 410 | id 411 | userId 412 | } 413 | } 414 | } 415 | `, 416 | } 417 | 418 | export { handleRequest } --------------------------------------------------------------------------------