first commit
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
/build
|
||||||
|
/*.log
|
||||||
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/plantes.iml" filepath="$PROJECT_DIR$/.idea/plantes.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/plantes.iml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
22
README.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# plantes
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
# install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# serve with hot reload at localhost:8080
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# build for production with minification
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# test the production build locally
|
||||||
|
npm run serve
|
||||||
|
|
||||||
|
# run tests with jest and enzyme
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
For detailed explanation on how things work, checkout the [CLI Readme](https://github.com/developit/preact-cli/blob/master/README.md).
|
||||||
38841
package-lock.json
generated
Normal file
43
package.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"name": "plantes",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"build": "preact build",
|
||||||
|
"serve": "sirv build --port 8080 --cors --single",
|
||||||
|
"dev": "preact watch",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "preact",
|
||||||
|
"ignorePatterns": [
|
||||||
|
"build/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"enzyme": "^3.10.0",
|
||||||
|
"enzyme-adapter-preact-pure": "^2.0.0",
|
||||||
|
"eslint": "^6.0.1",
|
||||||
|
"eslint-config-preact": "^1.1.0",
|
||||||
|
"jest": "^24.9.0",
|
||||||
|
"jest-preset-preact": "^1.0.0",
|
||||||
|
"preact-cli": "^3.0.0",
|
||||||
|
"preact-cli-tailwind": "^3.0.0",
|
||||||
|
"sirv-cli": "1.0.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "^10.3.2",
|
||||||
|
"preact-render-to-string": "^5.1.4",
|
||||||
|
"preact-router": "^3.2.1",
|
||||||
|
"tailwindcss": "^2.2.19"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"preset": "jest-preset-preact",
|
||||||
|
"setupFiles": [
|
||||||
|
"<rootDir>/tests/__mocks__/browserMocks.js",
|
||||||
|
"<rootDir>/tests/__mocks__/setupTests.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
5
preact.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const tailwind = require('preact-cli-tailwind');
|
||||||
|
module.exports = (config, env, helpers) => {
|
||||||
|
config = tailwind(config, env, helpers);
|
||||||
|
return config;
|
||||||
|
};
|
||||||
26
src/Contexts.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { createContext } from "preact";
|
||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import usePlants from "./hooks/PlantsHook";
|
||||||
|
import { useLocalStorage } from "./hooks/LocalStorageHook"
|
||||||
|
import useUser from "./hooks/UserHook";
|
||||||
|
|
||||||
|
export const UserContext = createContext()
|
||||||
|
export const PlantsContext = createContext()
|
||||||
|
|
||||||
|
export default function ContextsProviders({children}) {
|
||||||
|
|
||||||
|
const [data, setData] = useLocalStorage('data', {})
|
||||||
|
const [user, setUser] = useUser(data, setData)
|
||||||
|
const {plants, addPlant, editPlant, removePlant, addAction, doneTask, history} = usePlants(data, setData)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
|
||||||
|
console.log('first', user);
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
return <UserContext.Provider value={[user, setUser]}>
|
||||||
|
<PlantsContext.Provider value={{plants, addPlant, editPlant, removePlant, addAction, doneTask, history}}>
|
||||||
|
{children}
|
||||||
|
</PlantsContext.Provider>
|
||||||
|
</UserContext.Provider>
|
||||||
|
}
|
||||||
BIN
src/assets/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/icons/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/icons/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
src/assets/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src/assets/icons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 626 B |
BIN
src/assets/icons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src/assets/icons/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
38
src/components/App.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { Router } from 'preact-router';
|
||||||
|
import ContextsProviders from '../Contexts';
|
||||||
|
import Header from './Header';
|
||||||
|
|
||||||
|
|
||||||
|
// Code-splitting is automated for `routes` directory
|
||||||
|
import Home from '../routes/Home';
|
||||||
|
import Plant from '../routes/Plant';
|
||||||
|
import Profile from '../routes/Profile';
|
||||||
|
import {useEffect} from "preact/hooks";
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Notification) {
|
||||||
|
alert('Le navigateur ne supporte pas les notifications.');
|
||||||
|
} else if (Notification.permission !== 'granted') {
|
||||||
|
Notification.requestPermission();
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="app" class="h-screen overflow-auto flex flex-col">
|
||||||
|
<ContextsProviders>
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 dark:bg-gray-800 dark:text-white">
|
||||||
|
<Router>
|
||||||
|
<Home path="/" />
|
||||||
|
<Plant path="plant/:id" />
|
||||||
|
<Profile path="/profile" />
|
||||||
|
</Router>
|
||||||
|
</main>
|
||||||
|
</ContextsProviders>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
export default App;
|
||||||
10
src/components/Button.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { classNames } from "../utilities/classNames"
|
||||||
|
|
||||||
|
export const Button = ({ children, className = "", type = "text", ...props }) => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type={type} className={classNames("border px-2 py-1 shadow rounded", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
src/components/Form.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import {classNames} from "../utilities/classNames";
|
||||||
|
|
||||||
|
|
||||||
|
export const InputField = ({children, name, type = "text", ...props}) => {
|
||||||
|
|
||||||
|
const id = props.id ?? name
|
||||||
|
const classStyle = props.className ?? ''
|
||||||
|
|
||||||
|
if (props.className) {
|
||||||
|
delete props.className
|
||||||
|
}
|
||||||
|
|
||||||
|
return <fieldset className={classNames(classStyle)}>
|
||||||
|
{children && <label htmlFor={id} className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{ children }
|
||||||
|
</label>}
|
||||||
|
<input id={id}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
className="focus:ring-indigo-500 focus:border-indigo-500 block w-full px-2 py-1 mt-1 sm:text-sm border border-gray-300 rounded-md dark:bg-gray-500"
|
||||||
|
{...props}/>
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextAreaField = ({children, name, ...props}) => {
|
||||||
|
|
||||||
|
const id = props.id ?? name
|
||||||
|
const classStyle = props.className ?? ''
|
||||||
|
|
||||||
|
if (props.className) {
|
||||||
|
delete props.className
|
||||||
|
}
|
||||||
|
|
||||||
|
return <fieldset className={classNames(classStyle)}>
|
||||||
|
{children && <label htmlFor={id} className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{ children }
|
||||||
|
</label>}
|
||||||
|
<textarea id={id}
|
||||||
|
name={name}
|
||||||
|
className="focus:ring-indigo-500 focus:border-indigo-500 block w-full px-2 py-1 mt-1 sm:text-sm border border-gray-300 dark:bg-gray-500 rounded-md"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectField = ({children, name, options, className = '', ...props}) => {
|
||||||
|
|
||||||
|
const id = props.id ?? name
|
||||||
|
|
||||||
|
return <fieldset className={className}>
|
||||||
|
{children && <label htmlFor={id} className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{ children }
|
||||||
|
</label>}
|
||||||
|
<select id={id} name={name} className="focus:ring-indigo-500 focus:border-indigo-500 block w-full px-2 py-1 mt-1 sm:text-sm border border-gray-300 dark:bg-gray-500 rounded-md" {...props}>
|
||||||
|
{options.map((option, index) => <option key={index} value={option}
|
||||||
|
className="capitalize">{option}</option>)}
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
29
src/components/Header.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { h } from "preact"
|
||||||
|
import { Link } from "preact-router/match"
|
||||||
|
|
||||||
|
const Header = () => {
|
||||||
|
return (
|
||||||
|
<header class="bg-green-700 text-white flex justify-between items-center p-2 text-lg">
|
||||||
|
<Link href="/" class="font-bold text-xl">
|
||||||
|
Plantes
|
||||||
|
</Link>
|
||||||
|
<nav class="flex">
|
||||||
|
<NavLink path="/profile">Me</NavLink>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header
|
||||||
|
|
||||||
|
const NavLink = ({ path, children }) => {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={path}
|
||||||
|
activeClassName="font-bold bg-green-800"
|
||||||
|
class="py-1 px-2 rounded"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
src/components/Modals.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { classNames } from "../utilities/classNames"
|
||||||
|
|
||||||
|
export const Modal = ({children, isOpen, customClose = false, ...props}) => {
|
||||||
|
|
||||||
|
const handleClose = e => props.onChange(e)
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{isOpen &&
|
||||||
|
<div className="overlay fixed block top-0 bottom-0 left-0 right-0 z-10 bg-gray-800 bg-opacity-80" onClick={handleClose}>
|
||||||
|
<div className="h-full flex justify-center items-center">
|
||||||
|
<div className="flex flex-col bg-white dark:bg-gray-700 dark:text-white rounded p-2 min-h-48 min-w-48" {...props}>
|
||||||
|
<div className="flex-1">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{!customClose &&<div className="flex justify-end">
|
||||||
|
<button type="button" className="bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 hover:dark:bg-gray-800 px-2 py-1 rounded shadow close-button" onClick={handleClose}>Close</button>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModalTitle = ({children, ...props}) => {
|
||||||
|
return <div className="bg-green-700 text-white text-2xl font-bold text-center -mx-2 -mt-2 p-2 rounded-tl rounded-tr" {...props}>{children}</div>
|
||||||
|
}
|
||||||
9
src/components/PageLayout.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { classNames } from "../utilities/classNames";
|
||||||
|
|
||||||
|
|
||||||
|
export const PageLayout = ({children, ...props}) => {
|
||||||
|
|
||||||
|
return <div class={classNames("p-2", props.class ?? "")}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
17
src/components/Plants.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Link } from "preact-router/match"
|
||||||
|
import {getPicture} from "../utilities/pictures";
|
||||||
|
|
||||||
|
export const PlantThumb = ({ plant, children }) => {
|
||||||
|
|
||||||
|
return <Link href={`/plant/${plant.id}`} class="block h-48">
|
||||||
|
<div className="bg-green-400 relative rounded shadow-lg flex flex-col">
|
||||||
|
<img src={getPicture(plant.id)} alt="" className="object-cover h-48 w-full rounded" />
|
||||||
|
<div className="bg-green-700 text-white p-2 text-center absolute bottom-0 w-full rounded-bl rounded-br">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<div title="Actions" className="absolute right-2 top-2 rounded-full flex justify-center items-center bg-green-700 w-6 h-6">
|
||||||
|
{plant.actions.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
27
src/components/Tasks.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import {Button} from "./Button"
|
||||||
|
import {useContext} from "preact/hooks"
|
||||||
|
import {PlantsContext} from "../Contexts"
|
||||||
|
|
||||||
|
export const Tasks = () => {
|
||||||
|
|
||||||
|
const { plants, doneTask, history } = useContext(PlantsContext)
|
||||||
|
|
||||||
|
const taskIsRequired = (action) => {
|
||||||
|
if (history()[action.id]) {
|
||||||
|
let lastTask = new Date(history()[action.id])
|
||||||
|
return lastTask.addDays(Number(action.frequency)) < (new Date())
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="mb-5">
|
||||||
|
<h1>Tasks</h1>
|
||||||
|
<div>
|
||||||
|
{plants.map(plant => plant.actions.filter(action => taskIsRequired(action)).map(action => <div className="flex items-center gap-2">
|
||||||
|
<span className="capitalize"><b>{plant.name}</b> {action.action_type}</span>
|
||||||
|
<span> every {action.frequency} days {action.id}</span>
|
||||||
|
<span><Button onClick={() => doneTask(action.id)}>Done</Button></span>
|
||||||
|
</div>))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
26
src/hooks/LocalStorageHook.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useState } from "preact/hooks"
|
||||||
|
|
||||||
|
export const useLocalStorage = (key, initialValue) => {
|
||||||
|
const [storedValue, setStoredValue] = useState(() => {
|
||||||
|
try {
|
||||||
|
const item = window.localStorage.getItem(key)
|
||||||
|
return item ? JSON.parse(item) : initialValue
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('localstorageHook', error)
|
||||||
|
return initialValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const setValue = (value) => {
|
||||||
|
try {
|
||||||
|
const valueToStore =
|
||||||
|
value instanceof Function ? value(storedValue) : value
|
||||||
|
setStoredValue(valueToStore)
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(valueToStore))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('localstorageHook', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [storedValue, setValue]
|
||||||
|
}
|
||||||
76
src/hooks/PlantsHook.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useEffect, useState } from "preact/hooks"
|
||||||
|
import {route} from "preact-router";
|
||||||
|
|
||||||
|
const usePlants = (data, setData) => {
|
||||||
|
const [plants, setPlants] = useState([])
|
||||||
|
const [tasks, setTasks] = useState([])
|
||||||
|
|
||||||
|
useEffect(() =>{
|
||||||
|
setPlants(data.plants ?? [])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addPlant = (plantForm) => {
|
||||||
|
let plant = plantForm
|
||||||
|
let maxId = Math.max.apply(Math, plants.map(function(elem) { return elem.id; }))
|
||||||
|
plant.id = maxId === -Infinity ? 1 : maxId + 1
|
||||||
|
plant.actions = []
|
||||||
|
setPlants([...plants, plant])
|
||||||
|
setData({...data, plants: [...plants, plant]})
|
||||||
|
}
|
||||||
|
|
||||||
|
const editPlant = (plant) => {
|
||||||
|
let plantIndex = plants.findIndex(item => item.id === plant.id)
|
||||||
|
plants[plantIndex] = plant
|
||||||
|
savePlants(plants)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removePlant = (plant) => {
|
||||||
|
plants.splice(plants.findIndex(item => item.id === plant.id), 1)
|
||||||
|
savePlants(plants)
|
||||||
|
route('/', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addAction = (plant, action) => {
|
||||||
|
action.id = action.action_type + '-' + plant.id
|
||||||
|
let plantIndex = plants.findIndex(item => item.id === plant.id)
|
||||||
|
let actionIndex = plant.actions.findIndex(item => item.action_type === action.action_type)
|
||||||
|
console.log(actionIndex, plantIndex, plant, plants, action)
|
||||||
|
actionIndex >= 0 ? plant.actions[actionIndex] = action
|
||||||
|
: plant.actions.push(action)
|
||||||
|
editPlant(plant)
|
||||||
|
}
|
||||||
|
|
||||||
|
const savePlants = (plants) => {
|
||||||
|
setPlants(plants)
|
||||||
|
setData({...data, plants: plants})
|
||||||
|
}
|
||||||
|
|
||||||
|
const doneTask = (actionId) => {
|
||||||
|
let history = data.history ?? {}
|
||||||
|
history[actionId] = new Date()
|
||||||
|
setData({...data, history: history})
|
||||||
|
notifyMe()
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = () => data.history ?? {}
|
||||||
|
|
||||||
|
const notifyMe = () => {
|
||||||
|
if (!('Notification' in window)) {
|
||||||
|
alert('Ce navigateur ne prend pas en charge la notification de bureau')
|
||||||
|
} else if (Notification.permission === 'granted') {
|
||||||
|
// Si tout va bien, créons une notification
|
||||||
|
const notification = new Notification('Salut toi!')
|
||||||
|
} else if (Notification.permission !== 'denied') {
|
||||||
|
Notification.requestPermission().then((permission) => {
|
||||||
|
// Si l'utilisateur accepte, créons une notification
|
||||||
|
if (permission === 'granted') {
|
||||||
|
const notification = new Notification('Salut toi!')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {plants, addPlant, editPlant, removePlant, addAction, doneTask, history}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default usePlants
|
||||||
17
src/hooks/UserHook.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect, useState } from "preact/hooks"
|
||||||
|
|
||||||
|
|
||||||
|
const useUser = (data, setData) => {
|
||||||
|
const [user, setUser] = useState(data.user ?? {name: "me", dark_mode: false})
|
||||||
|
|
||||||
|
useEffect(() =>{
|
||||||
|
setData({...data, user: user})
|
||||||
|
console.log('in', user);
|
||||||
|
document.querySelector('html').classList.toggle('dark', user.dark_mode)
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
|
||||||
|
return [user, setUser]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useUser
|
||||||
15
src/index.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import App from './components/App';
|
||||||
|
import './style';
|
||||||
|
|
||||||
|
Date.prototype.addDays = function (days) {
|
||||||
|
let date = new Date(this.valueOf());
|
||||||
|
date.setDate(date.getDate() + days);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
Date.prototype.toFrDate = function() {
|
||||||
|
let month = ((this.getMonth() + 1 < 10) ? '0' : '') + (this.getMonth() + 1)
|
||||||
|
return `${this.getDate()}/${month}/${this.getFullYear()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
24
src/manifest.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "plantes",
|
||||||
|
"short_name": "plantes",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#fff",
|
||||||
|
"theme_color": "#673ab8",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/assets/icons/android-chrome-192x192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/icons/android-chrome-512x512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"_version": "1.1.0",
|
||||||
|
"sap.app": {},
|
||||||
|
"sap.ui": {}
|
||||||
|
}
|
||||||
71
src/routes/Home.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { createPortal } from "preact/compat"
|
||||||
|
import { useContext, useState } from "preact/hooks"
|
||||||
|
import { Button } from "../components/Button"
|
||||||
|
import {Modal, ModalTitle} from "../components/Modals"
|
||||||
|
import { PageLayout } from "../components/PageLayout"
|
||||||
|
import { PlantThumb } from "../components/Plants"
|
||||||
|
import { PlantsContext } from "../Contexts"
|
||||||
|
import {InputField, TextAreaField} from "../components/Form"
|
||||||
|
import {Tasks} from "../components/Tasks"
|
||||||
|
|
||||||
|
export const Home = () => {
|
||||||
|
const [addModal, setAddModal] = useState(false)
|
||||||
|
const [plantForm, setPlantForm] = useState({})
|
||||||
|
const app = document.getElementById("app")
|
||||||
|
const { plants, addPlant } = useContext(PlantsContext)
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
console.log(plantForm)
|
||||||
|
addPlant(plantForm)
|
||||||
|
setAddModal(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseAddModal = (e) => {
|
||||||
|
if (e.target.classList.contains("overlay") ||
|
||||||
|
e.target.classList.contains("close-button")) {
|
||||||
|
setAddModal(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout class="relative">
|
||||||
|
|
||||||
|
<Tasks />
|
||||||
|
|
||||||
|
<h1>Plants</h1>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||||
|
{plants.map((plant) => (
|
||||||
|
<PlantThumb plant={plant}>{plant.name}</PlantThumb>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="w-full sticky bottom-5 flex justify-end">
|
||||||
|
<div onClick={() => setAddModal(true)}
|
||||||
|
className="rounded-full w-16 h-16 flex items-center justify-center cursor-pointer bg-green-800 hover-bg-green-900 text-white">
|
||||||
|
Add +
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createPortal(
|
||||||
|
<Modal isOpen={addModal} onChange={handleCloseAddModal}>
|
||||||
|
<ModalTitle>
|
||||||
|
Add Plant
|
||||||
|
</ModalTitle>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
|
||||||
|
<InputField name="name" className="mb-2 mt-5" onChange={(e) => setPlantForm({ ...plantForm, name: e.target.value }) }>Name</InputField>
|
||||||
|
<TextAreaField name="description" className="mb-5" onChange={(e) => setPlantForm({ ...plantForm, description: e.target.value })}>Description</TextAreaField>
|
||||||
|
|
||||||
|
<Button type="submit" className="block w-full mt-5 mb-2 bg-green-800 hover:bg-green-900 text-white mx-auto px-2 py-1 shadow">
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Modal>,
|
||||||
|
app
|
||||||
|
)}
|
||||||
|
</PageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Home
|
||||||
100
src/routes/Plant.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import {createPortal, useRef} from "preact/compat";
|
||||||
|
import { useContext, useEffect, useState } from "preact/hooks";
|
||||||
|
import { Button } from "../components/Button";
|
||||||
|
import {Modal, ModalTitle} from "../components/Modals";
|
||||||
|
import { PageLayout } from "../components/PageLayout"
|
||||||
|
import { PlantsContext } from "../Contexts";
|
||||||
|
import {InputField, SelectField} from "../components/Form";
|
||||||
|
import {getPicture, storePicture} from "../utilities/pictures";
|
||||||
|
|
||||||
|
const Plant = ({id}) => {
|
||||||
|
|
||||||
|
const [addModal, setAddModal] = useState(false)
|
||||||
|
const {plants, removePlant, addAction, doneTask, history} = useContext(PlantsContext)
|
||||||
|
const [plant, setPlant] = useState({})
|
||||||
|
const [actionForm, setActionForm] = useState({})
|
||||||
|
const [image, setImage] = useState(localStorage.getItem("image" + id) ?? '')
|
||||||
|
const pictureName = 'picture-' + id
|
||||||
|
const picture = useRef(null)
|
||||||
|
|
||||||
|
const action_types = ['watering', 'spraying', 'bathing']
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const plantFind = plants.find(plant => plant.id === Number(id))
|
||||||
|
setPlant(plantFind)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImage(localStorage.getItem(pictureName))
|
||||||
|
}, [picture])
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!actionForm.action_type) {
|
||||||
|
actionForm.action_type = action_types[0]
|
||||||
|
}
|
||||||
|
console.log("my event", e, actionForm)
|
||||||
|
addAction(plant, actionForm)
|
||||||
|
setAddModal(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseAddModal = (e) => {
|
||||||
|
if (e.target.classList.contains("overlay") || e.target.classList.contains("close-button")) {
|
||||||
|
setAddModal(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addPicture = e => storePicture(document.getElementById("input-file"), id, setImage)
|
||||||
|
|
||||||
|
return <PageLayout>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<h1>{ plant.name }</h1>
|
||||||
|
<Button onClick={() => removePlant(plant)}>Delete</Button>
|
||||||
|
</div>
|
||||||
|
<p>{ plant.description }</p>
|
||||||
|
<div>
|
||||||
|
<img id="picture" ref={picture} src={getPicture(id)} alt=""/>
|
||||||
|
<img id="output" src="" alt=""/>
|
||||||
|
<input id="input-file" type="file" name="picture" onChange={addPicture}/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Actions</h2>
|
||||||
|
{plant.actions && plant.actions.map(action => {
|
||||||
|
let isDone = false
|
||||||
|
let lastTask = false
|
||||||
|
if (history()[action.id]) {
|
||||||
|
lastTask = new Date(history()[action.id])
|
||||||
|
isDone = lastTask.addDays(Number(action.frequency)) < (new Date())
|
||||||
|
}
|
||||||
|
console.log(lastTask, isDone)
|
||||||
|
return <div key={action.action_type} className="flex items-center gap-2">
|
||||||
|
<span className="capitalize">{action.action_type}</span>
|
||||||
|
<span>evey {action.frequency} days</span>
|
||||||
|
<span><Button onClick={() => doneTask(action.id)}>Done</Button></span>
|
||||||
|
<span>last task {lastTask ? lastTask.toFrDate() : 'never'}</span>
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
<Button className="bg-red-500" onClick={() => setAddModal(true)}>Add/Edit</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createPortal(
|
||||||
|
<Modal isOpen={addModal} onChange={handleCloseAddModal}>
|
||||||
|
<ModalTitle>Add Action</ModalTitle>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
|
||||||
|
<SelectField name="action_type" className="mb-2 mt-5" options={action_types} onChange={(e) => setActionForm({ ...actionForm, action_type: e.target.value })}>Name</SelectField>
|
||||||
|
<InputField name="frequency" className="mb-5" onChange={(e) => setActionForm({ ...actionForm, frequency: e.target.value })}>Frequency</InputField>
|
||||||
|
|
||||||
|
<Button type="submit" className="block w-full mt-5 mb-2 bg-green-800 hover:bg-green-900 text-white mx-auto px-2 py-1 shadow">
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Modal>,
|
||||||
|
app
|
||||||
|
)}
|
||||||
|
</PageLayout>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Plant
|
||||||
24
src/routes/Profile.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useContext, useEffect, useState } from "preact/hooks";
|
||||||
|
import { PageLayout } from "../components/PageLayout";
|
||||||
|
import { UserContext } from "../Contexts";
|
||||||
|
|
||||||
|
export default function Profile() {
|
||||||
|
const [user, setUser] = useContext(UserContext)
|
||||||
|
const [darkMode, setDarkMode] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDarkMode(user.dark_mode)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUser({...user, dark_mode: darkMode})
|
||||||
|
}, [darkMode])
|
||||||
|
|
||||||
|
return <PageLayout>
|
||||||
|
<h1 className="dark:text-red-600">Profile</h1>
|
||||||
|
<label htmlFor="dark_mode">
|
||||||
|
Dark Mode
|
||||||
|
<input type="checkbox" id="dark_mode" name="dark_mode" value={darkMode} onChange={() => setDarkMode(!darkMode)} />
|
||||||
|
</label>
|
||||||
|
</PageLayout>
|
||||||
|
}
|
||||||
13
src/style/index.css
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
h1 {
|
||||||
|
@apply text-2xl font-bold mt-5 mb-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
@apply text-xl font-bold mt-5 mb-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/sw.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { getFiles, setupPrecaching, setupRouting } from 'preact-cli/sw/';
|
||||||
|
|
||||||
|
setupRouting();
|
||||||
|
setupPrecaching(getFiles());
|
||||||
|
|
||||||
|
function wait(ms) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = location.protocol + '//' + location.host
|
||||||
|
const PREFIX = "V1"
|
||||||
|
const CACHED_FILES = [
|
||||||
|
`${BASE}/sw.js`,
|
||||||
|
`${BASE}/js/app.js`,
|
||||||
|
`${BASE}/css/app.css`,
|
||||||
|
`${BASE}/offline.html`,
|
||||||
|
]
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
self.skipWaiting()
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(PREFIX)
|
||||||
|
await cache.addAll(CACHED_FILES)
|
||||||
|
})()
|
||||||
|
)
|
||||||
|
console.log(`${PREFIX} Install`)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
clients.claim()
|
||||||
|
event.waitUntil((async() => {
|
||||||
|
const keys = await caches.keys()
|
||||||
|
await Promise.all(
|
||||||
|
keys.map(key => {
|
||||||
|
if (!key.includes(PREFIX)) {
|
||||||
|
return caches.delete(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})())
|
||||||
|
console.log(`${PREFIX} Activate`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const delay = 1000 * 60 * 60 * 24
|
||||||
|
|
||||||
|
console.log(localStorage.getItem('data'))
|
||||||
|
|
||||||
|
wait(delay)
|
||||||
|
.then(() => {
|
||||||
|
// do thing
|
||||||
|
|
||||||
|
}).catch(err => console.log(err))
|
||||||
15
src/template.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title><% preact.title %></title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<link rel="apple-touch-icon" href="/assets/icons/apple-touch-icon.png">
|
||||||
|
<% preact.headEnd %>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<% preact.bodyEnd %>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4
src/utilities/classNames.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export function classNames() {
|
||||||
|
// console.log(Object.values(arguments), Object.values(arguments).join(' '));
|
||||||
|
return Object.values(arguments).join(' ')
|
||||||
|
}
|
||||||
108
src/utilities/pictures.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
|
||||||
|
const pictureName = id => {
|
||||||
|
return 'picture-' + id
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPicture = id => {
|
||||||
|
return localStorage.getItem('picture-' + id ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const storePicture = (file, id, setter) => {
|
||||||
|
var filesToUploads = document.getElementById("input-file").files;
|
||||||
|
file = filesToUploads[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader()
|
||||||
|
|
||||||
|
reader.onload = function (e) {
|
||||||
|
console.log(e)
|
||||||
|
var img = document.createElement("img");
|
||||||
|
img.src = e.target.result;
|
||||||
|
|
||||||
|
// Create your canvas
|
||||||
|
var canvas = document.createElement("canvas");
|
||||||
|
var ctx = canvas.getContext("2d");
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
var MAX_WIDTH = 500;
|
||||||
|
var MAX_HEIGHT = 500;
|
||||||
|
var width = img.width;
|
||||||
|
var height = img.height;
|
||||||
|
|
||||||
|
// Add the resizing logic
|
||||||
|
if (width > height) {
|
||||||
|
if (width > MAX_WIDTH) {
|
||||||
|
height *= MAX_WIDTH / width;
|
||||||
|
width = MAX_WIDTH;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (height > MAX_HEIGHT) {
|
||||||
|
width *= MAX_HEIGHT / height;
|
||||||
|
height = MAX_HEIGHT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//Specify the resizing result
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
let ctx2 = canvas.getContext("2d");
|
||||||
|
ctx2.drawImage(img, 0, 0, width, height);
|
||||||
|
console.log(img, ctx2)
|
||||||
|
let dataurl = canvas.toDataURL(file.type);
|
||||||
|
|
||||||
|
console.log(dataurl)
|
||||||
|
localStorage.setItem(pictureName(id), dataurl)
|
||||||
|
setter(localStorage.getItem(pictureName(id)))
|
||||||
|
document.getElementById("output").src = dataurl;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
reader.addEventListener("load", function () {
|
||||||
|
|
||||||
|
let img = document.createElement("img");
|
||||||
|
img.src = reader.result
|
||||||
|
|
||||||
|
var canvas = document.createElement("canvas");
|
||||||
|
var ctx = canvas.getContext("2d");
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
var MAX_WIDTH = 500;
|
||||||
|
var MAX_HEIGHT = 500;
|
||||||
|
var width = img.width;
|
||||||
|
var height = img.height;
|
||||||
|
|
||||||
|
// Add the resizing logic
|
||||||
|
if (width > height) {
|
||||||
|
if (width > MAX_WIDTH) {
|
||||||
|
height *= MAX_WIDTH / width;
|
||||||
|
width = MAX_WIDTH;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (height > MAX_HEIGHT) {
|
||||||
|
width *= MAX_HEIGHT / height;
|
||||||
|
height = MAX_HEIGHT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//Specify the resizing result
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
var ctx = canvas.getContext("2d");
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
let dataurl = canvas.toDataURL(file.type);
|
||||||
|
console.log(dataurl)
|
||||||
|
// document.getElementById('picture').src = dataurl;
|
||||||
|
|
||||||
|
|
||||||
|
localStorage.setItem(pictureName(id), dataurl)
|
||||||
|
// localStorage.setItem(pictureName(id), reader.result)
|
||||||
|
setter(localStorage.getItem(pictureName(id)))
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
if (imgPath) {
|
||||||
|
reader.readAsDataURL(imgPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
}
|
||||||
29
tailwind.config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
module.exports = {
|
||||||
|
mode: 'jit',
|
||||||
|
purge: ['./src/**/*.{js,jsx,ts,tsx}', './public/index.html'],
|
||||||
|
darkMode: 'class', // or 'media' or 'class'
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
minHeight: {
|
||||||
|
'10': '2.5rem',
|
||||||
|
'12': '3rem',
|
||||||
|
'24': '6rem',
|
||||||
|
'32': '9rem',
|
||||||
|
'48': '12rem',
|
||||||
|
'80': '20rem',
|
||||||
|
},
|
||||||
|
minWidth: {
|
||||||
|
'10': '2.5rem',
|
||||||
|
'12': '3rem',
|
||||||
|
'24': '6rem',
|
||||||
|
'32': '9rem',
|
||||||
|
'48': '12rem',
|
||||||
|
'80': '20rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
21
tests/__mocks__/browserMocks.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// Mock Browser API's which are not supported by JSDOM, e.g. ServiceWorker, LocalStorage
|
||||||
|
/**
|
||||||
|
* An example how to mock localStorage is given below 👇
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Mocks localStorage
|
||||||
|
const localStorageMock = (function() {
|
||||||
|
let store = {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getItem: (key) => store[key] || null,
|
||||||
|
setItem: (key, value) => store[key] = value.toString(),
|
||||||
|
clear: () => store = {}
|
||||||
|
};
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'localStorage', {
|
||||||
|
value: localStorageMock
|
||||||
|
}); */
|
||||||
3
tests/__mocks__/fileMocks.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// This fixed an error related to the CSS and loading gif breaking my Jest test
|
||||||
|
// See https://facebook.github.io/jest/docs/en/webpack.html#handling-static-assets
|
||||||
|
module.exports = 'test-file-stub';
|
||||||
6
tests/__mocks__/setupTests.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { configure } from 'enzyme';
|
||||||
|
import Adapter from 'enzyme-adapter-preact-pure';
|
||||||
|
|
||||||
|
configure({
|
||||||
|
adapter: new Adapter()
|
||||||
|
});
|
||||||
12
tests/header.test.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import Header from '../src/components/header';
|
||||||
|
// See: https://github.com/preactjs/enzyme-adapter-preact-pure
|
||||||
|
import { shallow } from 'enzyme';
|
||||||
|
|
||||||
|
describe('Initial Test of the Header', () => {
|
||||||
|
test('Header renders 3 nav items', () => {
|
||||||
|
const context = shallow(<Header />);
|
||||||
|
expect(context.find('h1').text()).toBe('Preact App');
|
||||||
|
expect(context.find('Link').length).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||