Jelajahi Sumber

implement base app structure with calorie input

mightyplow 5 tahun lalu
induk
melakukan
c0f41f872a
33 mengubah file dengan 609 tambahan dan 4 penghapusan
  1. 12 0
      src/assets/fonts/simpleCalorieTracker.svg
  2. TEMPAT SAMPAH
      src/assets/fonts/simpleCalorieTracker.ttf
  3. TEMPAT SAMPAH
      src/assets/fonts/simpleCalorieTracker.woff
  4. 5 3
      src/components/App.tsx
  5. 22 0
      src/components/button/IconButton.tsx
  6. 19 0
      src/components/button/button.css
  7. 53 0
      src/components/caloriesInput/CaloriesInput.tsx
  8. 37 0
      src/components/caloriesInput/caloriesInput.css
  9. 30 0
      src/components/caloriesList/CalorieRow.tsx
  10. 28 0
      src/components/caloriesList/CaloriesDateInput.tsx
  11. 52 0
      src/components/caloriesList/CaloriesList.tsx
  12. 18 0
      src/components/caloriesList/CaloriesSum.tsx
  13. 21 0
      src/components/caloriesList/calorieRow.css
  14. 13 0
      src/components/caloriesList/caloriesDateInput.css
  15. 9 0
      src/components/caloriesList/caloriesList.css
  16. 3 0
      src/components/caloriesList/caloriesSum.css
  17. 18 0
      src/components/dateDisplay/DateDisplay.tsx
  18. 7 0
      src/components/dateDisplay/dateDisplay.css
  19. 18 0
      src/components/icon/Icon.tsx
  20. 25 0
      src/components/icon/icon.css
  21. 1 1
      src/index.tsx
  22. 46 0
      src/layout/App.tsx
  23. 20 0
      src/layout/AppContext.ts
  24. 52 0
      src/layout/app.css
  25. 17 0
      src/layout/pageFooter/PageFooter.tsx
  26. 13 0
      src/layout/pageFooter/pageFooter.css
  27. 23 0
      src/layout/pageHeader/PageHeader.tsx
  28. 8 0
      src/layout/pageHeader/pageHeader.css
  29. 10 0
      src/styles/fonts.css
  30. 2 0
      src/styles/maps/icons.yml
  31. 4 0
      src/typings/cssModules.d.ts
  32. 9 0
      src/typings/models.d.ts
  33. 14 0
      src/utils/date.ts

File diff ditekan karena terlalu besar
+ 12 - 0
src/assets/fonts/simpleCalorieTracker.svg


TEMPAT SAMPAH
src/assets/fonts/simpleCalorieTracker.ttf


TEMPAT SAMPAH
src/assets/fonts/simpleCalorieTracker.woff


+ 5 - 3
src/components/App.tsx

@@ -1,11 +1,13 @@
 import React from 'react';
 
-function App () {
+function Button () {
   return (
-    <div>baz</div>
+    <div>
+
+    </div>
   );
 }
 
 export {
-  App
+  Button
 };

+ 22 - 0
src/components/button/IconButton.tsx

@@ -0,0 +1,22 @@
+import cx from 'classnames';
+import React, { ReactChild } from 'react';
+import { Icon } from '../icon/Icon';
+import styles from './button.css';
+
+type RawButtonProps = {
+  icon: string,
+  buttonClassName?: string,
+  iconClassName?: string
+};
+
+function IconButton ({icon, buttonClassName, iconClassName}: RawButtonProps) {
+  return (
+    <button className={cx(styles.iconButton, buttonClassName)}>
+      <Icon {...{icon}} className={cx(iconClassName)} />
+    </button>
+  );
+}
+
+export {
+  IconButton
+};

+ 19 - 0
src/components/button/button.css

@@ -0,0 +1,19 @@
+.iconButton {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    margin: 0;
+    padding: .2em;
+
+    font-size: 100%;
+    line-height: 1;
+
+    background: rgba(0, 0, 0, 0.1);
+    border: 0;
+    outline: none;
+    text-shadow: 1px 1px 1px #ddd;
+
+    &:hover {
+        background: rgba(0, 0, 0, 0.2);
+    }
+}

+ 53 - 0
src/components/caloriesInput/CaloriesInput.tsx

@@ -0,0 +1,53 @@
+import React, { FormEvent } from 'react';
+import { IconButton } from '../button/IconButton';
+import styles from './caloriesInput.css';
+
+type CaloriesInputProps = {
+  addCalories: (calories: { title: string, count: number}) => void
+};
+
+interface ICalorieInputForm extends HTMLFormElement {
+  caloriesTitle: HTMLInputElement;
+  caloriesCount: HTMLInputElement;
+}
+
+function CaloriesInput ({ addCalories }: CaloriesInputProps) {
+  function onSubmit (event: FormEvent<ICalorieInputForm>) {
+    event.preventDefault();
+
+    const form = event.currentTarget;
+    const title = form.caloriesTitle.value;
+    const count = Number(form.caloriesCount.value) || 0;
+
+    addCalories({ title, count });
+
+    form.querySelectorAll('input').forEach((formElement) => {
+      formElement.value = '';
+      formElement.blur();
+    });
+  }
+
+  return (
+    <form onSubmit={onSubmit} className={styles.caloriesInput}>
+      <div className={styles.inputWrapper}>
+        <div className={styles.formRow}>
+          <label htmlFor="caloriesTitle">Nahrungsmittel:</label>
+          <input id="caloriesTitle" name="caloriesTitle" type="text" required />
+        </div>
+
+        <div className={styles.formRow}>
+          <label htmlFor="caloriesCount">Kalorien:</label>
+          <input id="caloriesCount" name="caloriesCount" type="number" required />
+        </div>
+      </div>
+
+      <div className={styles.buttonWrapper}>
+        <IconButton icon="plus" buttonClassName={styles.addButton} />
+      </div>
+    </form>
+  );
+}
+
+export {
+  CaloriesInput
+};

+ 37 - 0
src/components/caloriesInput/caloriesInput.css

@@ -0,0 +1,37 @@
+.caloriesInput {
+    display: flex;
+    margin-top: auto;
+    padding: .5em;
+}
+
+.formRow {
+    display: flex;
+    align-items: center;
+
+    &:not(:last-child) {
+        margin-bottom: .5em;
+    }
+
+    label {
+        margin-right: 1em;
+        width: 7em;
+    }
+
+    input {
+        flex: 1;
+        width: 0;
+    }
+}
+
+.inputWrapper {
+    width: 100%;
+    margin-right: 1em;
+}
+
+.buttonWrapper {
+    display: flex;
+}
+
+.addButton {
+    padding: 1em;
+}

+ 30 - 0
src/components/caloriesList/CalorieRow.tsx

@@ -0,0 +1,30 @@
+import cx from 'classnames';
+import React, { MouseEventHandler } from 'react';
+import styles from './calorieRow.css';
+
+type CalorieRowProps = {
+  item: CalorieValue,
+  onRowClick?: (item: CalorieValue) => void,
+  isSelected?: boolean
+};
+
+function CalorieRow (props: CalorieRowProps) {
+  const {
+    item,
+    isSelected = false,
+    onRowClick = Function.prototype
+  } = props;
+
+  const onClick = () => onRowClick(item);
+
+  return (
+    <div className={cx(styles.calorieRow, {[styles.selected]: isSelected})} {...{onClick}}>
+      <div className={styles.title}>{item.title}</div>
+      <div>{item.count}</div>
+    </div>
+  );
+}
+
+export {
+  CalorieRow
+};

+ 28 - 0
src/components/caloriesList/CaloriesDateInput.tsx

@@ -0,0 +1,28 @@
+import React, { ChangeEvent, useContext } from 'react';
+import { AppContext } from '../../layout/AppContext';
+import { getDateString } from '../../utils/date';
+import styles from './caloriesDateInput.css';
+
+function CaloriesDateInput () {
+  const {selectedDate, setSelectedDate} = useContext(AppContext);
+
+  function onDateChange (event: ChangeEvent<HTMLInputElement>) {
+    const newDate = (new Date(event.target.value)).getTime();
+    setSelectedDate(newDate);
+  }
+
+  const date = new Date(selectedDate);
+  const selectedDateString = getDateString(date);
+
+  return (
+    <div>
+      <span>{date.toLocaleDateString(navigator.language, { weekday: 'short'})}, </span>
+      <input required type="date" value={selectedDateString} onChange={onDateChange}
+             className={styles.caloriesDateInput} />
+    </div>
+  );
+}
+
+export {
+  CaloriesDateInput
+};

+ 52 - 0
src/components/caloriesList/CaloriesList.tsx

@@ -0,0 +1,52 @@
+import React, { useContext, useState } from 'react';
+import { AppContext } from '../../layout/AppContext';
+import { haveSameDay } from '../../utils/date';
+import { CaloriesInput } from '../caloriesInput/CaloriesInput';
+import { CalorieRow } from './CalorieRow';
+import styles from './caloriesList.css';
+
+function CaloriesList () {
+  const {calorieItems = [], setCalorieItems, selectedDate} = useContext(AppContext);
+  const [selectedItem, setSelectedItem] = useState<CalorieValue>();
+
+  function addCalories (addedCalories: { title: string, count: number }) {
+    const updatedCalories = [
+      ...calorieItems,
+      {
+        ...addedCalories,
+        timestamp: selectedDate
+      }
+    ];
+
+    setCalorieItems(updatedCalories);
+  }
+
+  function onRowClick (item: CalorieValue) {
+    if (selectedItem === item) {
+      return setSelectedItem(undefined);
+    }
+
+    return setSelectedItem(item);
+  }
+
+  const calories = calorieItems.filter(({timestamp}) => haveSameDay(timestamp, selectedDate));
+
+  return (
+    <section className={styles.caloriesList}>
+      <section className={styles.caloriesItems}>
+        {calories.map((item: CalorieValue, index: number) => {
+          // todo: use better key
+          return (
+            <CalorieRow key={index} {...{item, onRowClick, isSelected: item === selectedItem}} />
+          );
+        })}
+      </section>
+
+      <CaloriesInput {...{addCalories}} />
+    </section>
+  );
+}
+
+export {
+  CaloriesList
+};

+ 18 - 0
src/components/caloriesList/CaloriesSum.tsx

@@ -0,0 +1,18 @@
+import React from 'react';
+import styles from './caloriesSum.css';
+
+type CaloriesSumProps = {
+  calories: CalorieValue[]
+};
+
+function CaloriesSum ({ calories }: CaloriesSumProps) {
+  return (
+    <section className={styles.caloriesSum}>
+      {calories.reduce((sum, { count }) => sum + count, 0)}
+    </section>
+  );
+}
+
+export {
+  CaloriesSum
+};

+ 21 - 0
src/components/caloriesList/calorieRow.css

@@ -0,0 +1,21 @@
+.calorieRow {
+    display: flex;
+    justify-content: space-between;
+    padding: .5em .5em;
+
+    &:nth-child(2n) {
+        background: #f5f5f5;
+    }
+
+    &.selected {
+        background: #ddd;
+    }
+}
+
+.title {
+    margin-right: 1em;
+}
+
+.count {
+
+}

+ 13 - 0
src/components/caloriesList/caloriesDateInput.css

@@ -0,0 +1,13 @@
+.caloriesDateInput {
+    width: auto;
+    background: none;
+    border: 0;
+    outline: none;
+    font-size: 100%;
+    font-family: inherit;
+}
+
+::-webkit-datetime-edit {
+    /* used to decrease space to triangle */
+    max-width: fit-content;
+}

+ 9 - 0
src/components/caloriesList/caloriesList.css

@@ -0,0 +1,9 @@
+.caloriesList {
+    display: flex;
+    flex-flow: column nowrap;
+    height: 100%;
+}
+
+.caloriesItems {
+    overflow: auto;
+}

+ 3 - 0
src/components/caloriesList/caloriesSum.css

@@ -0,0 +1,3 @@
+.caloriesSum {
+    text-align: right;
+}

+ 18 - 0
src/components/dateDisplay/DateDisplay.tsx

@@ -0,0 +1,18 @@
+import React, { useRef } from 'react';
+import styles from './dateDisplay.css';
+
+type DateDisplayProps = {
+  date: Date
+};
+
+function DateDisplay ({ date }: DateDisplayProps) {
+  return (
+    <section>
+      <input type="date" className={styles.dateInput} />
+    </section>
+  );
+}
+
+export {
+  DateDisplay
+};

+ 7 - 0
src/components/dateDisplay/dateDisplay.css

@@ -0,0 +1,7 @@
+.dateDisplay {
+
+}
+
+.dateInput {
+    display: none;
+}

+ 18 - 0
src/components/icon/Icon.tsx

@@ -0,0 +1,18 @@
+import cx from 'classnames';
+import React from 'react';
+import styles from './icon.css';
+
+type IconProps = {
+  icon: string,
+  className: string
+};
+
+function Icon ({ icon, className }: IconProps) {
+  return (
+    <div className={cx(styles.icon, styles[`icon-${icon}`], className)} />
+  );
+}
+
+export {
+  Icon
+};

+ 25 - 0
src/components/icon/icon.css

@@ -0,0 +1,25 @@
+:root {
+    --icon-settings: '\e900';
+    --icon-plus: '\e901';
+}
+
+.icon {
+    display: inline-flex;
+    align-items: center;
+
+    &:after {
+        font-family: simpleCalorieTracker;
+    }
+
+    &.icon-settings {
+        &:after {
+            content: var(--icon-settings);
+        }
+    }
+
+    &.icon-plus {
+        &:after {
+            content: var(--icon-plus);
+        }
+    }
+}

+ 1 - 1
src/index.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import { render } from 'react-dom';
-import { App } from './components/App';
+import { App } from './layout/App';
 
 if (process.env.NODE_ENV === 'development' && module.hot) {
   module.hot.accept();

+ 46 - 0
src/layout/App.tsx

@@ -0,0 +1,46 @@
+import React, { ReactElement, useEffect, useState } from 'react';
+import {
+  BrowserRouter as Router,
+  Route,
+  Switch
+} from 'react-router-dom';
+import { CaloriesList } from '../components/caloriesList/CaloriesList';
+import { AppContext } from './AppContext';
+import { PageFooter } from './pageFooter/PageFooter';
+import { PageHeader } from './pageHeader/PageHeader';
+
+import './app.css';
+
+const AppContextProvider = AppContext.Provider;
+const storedCalories = JSON.parse(localStorage.getItem('calorieItems') || '[]');
+
+function App (): ReactElement {
+  const [calorieItems, setCalorieItems] = useState<CalorieItems>(storedCalories);
+  const [selectedDate, setSelectedDate] = useState<number>(Date.now());
+
+  useEffect(() => {
+    localStorage.setItem('calorieItems', JSON.stringify(calorieItems));
+  });
+
+  return (
+    <AppContextProvider value={{calorieItems, setCalorieItems, selectedDate, setSelectedDate}}>
+      <PageHeader />
+
+      <main id="pageContent">
+        <Router>
+          <Switch>
+            <Route path="/">
+              <CaloriesList />
+            </Route>
+          </Switch>
+        </Router>
+      </main>
+
+      <PageFooter />
+    </AppContextProvider>
+  );
+}
+
+export {
+  App
+};

+ 20 - 0
src/layout/AppContext.ts

@@ -0,0 +1,20 @@
+import { createContext } from "react";
+import { getDateString } from '../utils/date';
+
+type AppContextType = {
+  selectedDate: number,
+  setSelectedDate: (selectedDate: number) => void,
+  calorieItems: CalorieItems;
+  setCalorieItems: (calorieItems: CalorieValue[]) => void;
+};
+
+const AppContext = createContext<AppContextType>({
+  calorieItems: [],
+  selectedDate: Date.now(),
+  setCalorieItems: () => [],
+  setSelectedDate: () => getDateString(new Date())
+});
+
+export {
+  AppContext
+};

+ 52 - 0
src/layout/app.css

@@ -0,0 +1,52 @@
+:global {
+    @import "../styles/fonts.css";
+
+    :root {
+        --primaryColor: #4dbfff;
+    }
+
+    ::-webkit-clear-button {
+        display: none;
+    }
+
+    html, body, #appContainer {
+        width: 100%;
+        height: 100%;
+    }
+
+    body, p, ul {
+        margin: 0;
+    }
+
+    body {
+        font-family: sans-serif;
+    }
+
+    #appContainer {
+        display: grid;
+        grid-template-columns: 100%;
+        grid-template-rows: min-content auto min-content;
+        grid-template-areas:
+            'header'
+            'main'
+            'footer';
+    }
+
+    #pageHeader {
+        @media (min-width: 640px) {
+            grid-area: footer;
+        }
+    }
+
+    #pageContent {
+        grid-area: main;
+        align-items: start;
+        overflow: hidden;
+    }
+
+    #pageFooter {
+        @media (min-width: 640px) {
+            grid-area: header;
+        }
+    }
+}

+ 17 - 0
src/layout/pageFooter/PageFooter.tsx

@@ -0,0 +1,17 @@
+import React from 'react';
+import { IconButton } from '../../components/button/IconButton';
+import styles from './pageFooter.css';
+
+function PageFooter () {
+  return (
+    <footer id="pageFooter" className={styles.pageFooter}>
+      <section className={styles.buttons}>
+        <IconButton icon="settings" />
+      </section>
+    </footer>
+  );
+}
+
+export {
+  PageFooter
+};

+ 13 - 0
src/layout/pageFooter/pageFooter.css

@@ -0,0 +1,13 @@
+.pageFooter {
+    display: flex;
+    padding: .5em;
+    background: var(--primaryColor);
+}
+
+.buttons {
+    display: flex;
+    align-items: center;
+    margin-left: auto;
+    font-size: 1.7em;
+    line-height: 1;
+}

+ 23 - 0
src/layout/pageHeader/PageHeader.tsx

@@ -0,0 +1,23 @@
+import React, { useContext } from 'react';
+import { CaloriesDateInput } from '../../components/caloriesList/CaloriesDateInput';
+import { CaloriesSum } from '../../components/caloriesList/CaloriesSum';
+import { haveSameDay } from '../../utils/date';
+import { AppContext } from '../AppContext';
+import styles from './pageHeader.css';
+
+function PageHeader () {
+  const {calorieItems, selectedDate} = useContext(AppContext);
+
+  const calories = calorieItems.filter(({timestamp}) => haveSameDay(timestamp, selectedDate));
+
+  return (
+    <header id="pageHeader" className={styles.pageHeader}>
+      <CaloriesDateInput />
+      <CaloriesSum {...{calories}} />
+    </header>
+  );
+}
+
+export {
+  PageHeader
+};

+ 8 - 0
src/layout/pageHeader/pageHeader.css

@@ -0,0 +1,8 @@
+.pageHeader {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: .5em;
+
+    background: var(--primaryColor);
+}

+ 10 - 0
src/styles/fonts.css

@@ -0,0 +1,10 @@
+@font-face {
+    font-family: 'simpleCalorieTracker';
+    src:
+            url('../assets/fonts/simpleCalorieTracker.ttf?8t3p8t') format('truetype'),
+            url('../assets/fonts/simpleCalorieTracker.woff?8t3p8t') format('woff'),
+            url('../assets/fonts/simpleCalorieTracker.svg?8t3p8t#simpleCalorieTracker') format('svg');
+    font-weight: normal;
+    font-style: normal;
+    font-display: block;
+}

+ 2 - 0
src/styles/maps/icons.yml

@@ -0,0 +1,2 @@
+settings: '\e900'
+plus: '\e901'

+ 4 - 0
src/typings/cssModules.d.ts

@@ -0,0 +1,4 @@
+declare module '*.css' {
+  const styles: { [className: string]: string };
+  export default styles;
+}

+ 9 - 0
src/typings/models.d.ts

@@ -0,0 +1,9 @@
+type CalorieItems = CalorieValue[];
+
+type CalorieValue = {
+  title: string,
+  count: number,
+  timestamp: number
+};
+
+type CalorieValueSetter = (calories: CalorieItems) => void;

+ 14 - 0
src/utils/date.ts

@@ -0,0 +1,14 @@
+export function getDateString (date: Date) {
+  const year = date.getFullYear();
+  const month = date.getMonth() + 1;
+  const day = date.getDate();
+  return [year, month, day]
+    .map(String)
+    .map((part) => part.padStart(2, '0'))
+    .join('-');
+}
+
+export function haveSameDay (timestampA: number, timestampB: number) {
+  const dayInMs = 3600 * 24 * 1000;
+  return Math.floor(timestampA / dayInMs) === Math.floor(timestampB / dayInMs);
+}