diff --git a/docs/example.gif b/docs/example.gif
index d6992ec..dfb0b00 100644
Binary files a/docs/example.gif and b/docs/example.gif differ
diff --git a/package-lock.json b/package-lock.json
index 5e10328..3cb1069 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4230,12 +4230,6 @@
"dev": true,
"optional": true
},
- "ini": {
- "version": "1.3.5",
- "bundled": true,
- "dev": true,
- "optional": true
- },
"is-fullwidth-code-point": {
"version": "1.0.0",
"bundled": true,
@@ -5084,9 +5078,9 @@
"dev": true
},
"ini": {
- "version": "1.3.5",
- "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
- "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.7.tgz",
+ "integrity": "sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ==",
"dev": true
},
"inquirer": {
diff --git a/src/css/index.css b/src/css/index.css
index 7836816..b0b392c 100644
--- a/src/css/index.css
+++ b/src/css/index.css
@@ -1,4 +1,5 @@
:root {
+ --black: #000;
--blue: #3b8be4;
--indigo: #6610f2;
--purple: #703c87;
@@ -21,7 +22,7 @@
--danger: var(--red);
--text-color: var(--gray-dark);
--text-muted: var(--gray-light);
- --font-family-sans-serif: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
+ --font-family-sans-serif: 'Nunito Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--font-family-monospace: Consolas, "Andale Mono WT", "Andale Mono", "Lucida Console", "Lucida Sans Typewriter", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", "Liberation Mono", "Nimbus Mono L", Monaco, "Courier New", Courier, monospace;
}
@@ -32,3 +33,77 @@ body {
color: var(--text-color);
margin: 200px 50px;
}
+
+div.component-photo-tree {
+ width: 830px;
+}
+
+ div.component-photo-tree ul {
+ list-style-type: none;
+ padding-left: 0;
+ }
+
+ div.component-photo-tree ul ul {
+ padding-left: 40px;
+ }
+
+ div.component-photo-tree ul li.collapsed li {
+ display: none;
+ }
+
+ div.component-photo-tree ul li div.line-data {
+ margin: 5px 0;
+ padding: 5px;
+ background-color: #EBEAED;
+ border-radius: 5px;
+ border: 1px solid #DEDEDE;
+ font-size: 13px;
+ display: flex;
+ align-items: center; /* align vertical */
+ outline: none;
+ }
+
+ div.component-photo-tree ul li div.line-data span.node-name {
+ margin-left: 10px;
+ }
+
+ div.component-photo-tree ul li div.line-data.has-children {
+ cursor: pointer;
+ }
+
+ div.component-photo-tree ul li div.line-data img {
+ border-radius: 3px;
+ width: 25px;
+ height: 25px;
+ }
+
+ div.component-photo-tree ul li span.icon-down {
+ display: inline-block;
+ }
+
+ div.component-photo-tree ul li span.icon-right {
+ display: none;
+ }
+
+ div.component-photo-tree ul li span.expanders.collapsed span.icon-down {
+ display: none;
+ }
+
+ div.component-photo-tree ul li span.expanders.collapsed span.icon-right {
+ display: inline-block;
+ }
+
+ div.component-photo-tree ul li span.icon-right,
+ div.component-photo-tree ul li span.icon-down {
+ font-size: 20px;
+ }
+
+
+body.theme-dark-mode {
+ color: var(--white);
+ background-color: var(--black);
+}
+
+body.theme-dark-mode div.component-photo-tree ul li div.line-data {
+ background-color: var(--gray-dark);
+}
\ No newline at end of file
diff --git a/src/index.html b/src/index.html
index 21c9b35..fc4e557 100644
--- a/src/index.html
+++ b/src/index.html
@@ -2,6 +2,8 @@
+
+
diff --git a/src/js/App.js b/src/js/App.js
index 0359100..065b681 100644
--- a/src/js/App.js
+++ b/src/js/App.js
@@ -1,14 +1,44 @@
-import React from 'react'
+import React, { useState, useEffect, createContext } from 'react'
+
+import AppContext from './AppContext'
+import ColorSchemeSwitcher from './ColorSchemeSwitcher'
+import PhotoTree from './PhotoTree'
-// NOTE: ESLint will enforce Táve coding standards:
-// https://github.com/tave/javascript/ Goodbye semicolons!
function App() {
+ const webserviceUrl = 'http://localhost:8000/webservice.php'
+ const [isDarkMode, setIsDarkMode] = useState(null)
+
+ // set dark mode setting.
+ function initializeColorSchemeSwitcher() {
+ fetch(`${webserviceUrl}?action=getSetting&name=dark_mode`)
+ .then(response => response.json())
+ .then(setIsDarkMode)
+ // .catch(app/component-specific handler as appropriate)
+ }
+
+ // get dark mode setting.
+ function updateChangedColorScheme(darkModeVal) {
+ const darkModeParamVal = darkModeVal === 'darkMode' ? 1 : 0
+
+ fetch(`${webserviceUrl}?action=setSetting&name=dark_mode&value=${darkModeParamVal}`)
+ setIsDarkMode(!!darkModeParamVal)
+ }
+
+ useEffect(initializeColorSchemeSwitcher, [])
+
return (
-
-
Hello World!
-
+
+
+
)
}
+
export default App
diff --git a/src/js/AppContext.js b/src/js/AppContext.js
new file mode 100644
index 0000000..4a4edb0
--- /dev/null
+++ b/src/js/AppContext.js
@@ -0,0 +1,5 @@
+import React, { createContext } from 'react'
+
+const AppContext = createContext(null)
+
+export default AppContext
diff --git a/src/js/ColorSchemeSwitcher.js b/src/js/ColorSchemeSwitcher.js
new file mode 100644
index 0000000..2b0afd1
--- /dev/null
+++ b/src/js/ColorSchemeSwitcher.js
@@ -0,0 +1,51 @@
+import React, { useContext } from 'react'
+import PropTypes from 'prop-types'
+
+import AppContext from './AppContext'
+
+
+function ColorSchemeSwitcher({ updateChangedColorScheme }) {
+ const { isDarkMode } = useContext(AppContext)
+
+ // set theme on body if non-null.
+ const body = document.getElementsByTagName('body')[0]
+ body.classList.remove('theme-light-mode', 'theme-dark-mode')
+
+ if (isDarkMode !== null) {
+ if (isDarkMode) {
+ body.classList.add('theme-dark-mode')
+ }
+ else {
+ body.classList.add('theme-light-mode')
+ }
+ }
+
+ // set visibility to hidden rather than display none. this prevents:
+ // (1) flash of incorrect setting within select box before settings loaded from server
+ // (2) page elements jumping around when setting is loaded after element set to display: none
+ return (
+
+
+
+ )
+}
+
+ColorSchemeSwitcher.propTypes = {
+ updateChangedColorScheme: PropTypes.func.isRequired,
+}
+
+
+export default ColorSchemeSwitcher
diff --git a/src/js/PhotoTree.js b/src/js/PhotoTree.js
new file mode 100644
index 0000000..6b026e1
--- /dev/null
+++ b/src/js/PhotoTree.js
@@ -0,0 +1,88 @@
+import React, { useState, useEffect, useContext } from 'react'
+
+import PhotoTreeList from './PhotoTreeList'
+import AppContext from './AppContext'
+
+
+function PhotoTree() {
+ const [nodes, setNodes] = useState([])
+ const { webserviceUrl } = useContext(AppContext)
+
+ // build out nested structure from node list for recursive rendering.
+ function getRecursiveTree(_nodes) {
+ // index by node.id
+ let tree = {}
+ for (let i = 0; i < _nodes.length; i += 1) {
+ const node = _nodes[i]
+ tree[node.id] = node
+ }
+
+ // take advantage of the fact that objects are references
+ // so they can be both iterated over and moved within their parent element.
+ for (let i = 0; i < _nodes.length; i += 1) {
+ const node = _nodes[i]
+
+ if (node.parent !== null) {
+ if (typeof tree[node.parent].children === 'undefined') {
+ tree[node.parent].children = {}
+ }
+
+ tree[node.parent].children[node.id] = node
+ }
+ }
+
+ // delete the top-level item object refs in the object that don't have a parent
+ // (since they've been moved under their correct parents at this point)
+ for (let i = 0; i < _nodes.length; i += 1) {
+ if (_nodes[i].parent !== null) {
+ delete tree[_nodes[i].id]
+ }
+ }
+
+ // convert the recursive object to an array since there is no
+ // react PropTypes.objectOf style validation.
+ function convertToArray(_tree) {
+ const treeAr = []
+ let i = 0
+
+ Object.values(_tree).forEach((value) => {
+ treeAr[i] = value
+ if (treeAr[i].children) {
+ treeAr[i].children = convertToArray(treeAr[i].children)
+ }
+ else {
+ treeAr[i].children = []
+ }
+ i += 1
+ })
+
+ return treeAr
+ }
+
+ tree = convertToArray(tree)
+
+ return tree
+ }
+
+ function initializePhotoTree() {
+ fetch(`${webserviceUrl}?action=getNodes`)
+ .then(response => response.json())
+ .then((data) => {
+ setNodes(getRecursiveTree(data))
+ })
+ // .catch(app/component-specific handler as appropriate)
+ }
+
+ useEffect(initializePhotoTree, [])
+
+
+ return (
+
+ )
+}
+
+
+export default PhotoTree
diff --git a/src/js/PhotoTreeList.js b/src/js/PhotoTreeList.js
new file mode 100644
index 0000000..3233e61
--- /dev/null
+++ b/src/js/PhotoTreeList.js
@@ -0,0 +1,123 @@
+import React, { useContext } from 'react'
+import PropTypes from 'prop-types'
+
+import AppContext from './AppContext'
+
+
+function PhotoTreeList({ nodes }) {
+ const { webserviceUrl } = useContext(AppContext)
+
+ // handle collapsed state server sync
+ function updateCollapsedState(originalElemClicked) {
+ const elems = originalElemClicked
+ // maintain component context when selecting for tree items.
+ .closest('.component-photo-tree')
+ .querySelectorAll('li')
+
+ const idToCollapsedStateMap = {}
+ for (let i = 0; i < elems.length; i += 1) {
+ const nodeId = elems[i].getAttribute('data-node-id')
+ const isCollapsed = elems[i].classList.contains('collapsed')
+ idToCollapsedStateMap[nodeId] = isCollapsed
+ }
+
+ fetch(`${webserviceUrl}?action=setNodesCollapsedState`,
+ {
+ method: 'POST',
+ body: JSON.stringify({ nodeIdToCollapsedStateMap: idToCollapsedStateMap }),
+ })
+ .then(response => response.json())
+ // .catch(app/component-specific handler as appropriate)
+ }
+
+ // handle collapsed state display updates.
+ function handleTreeItemClick(target) {
+ const elem = target.closest('li')
+
+ // nothing to do if there are no children.
+ if (!elem.classList.contains('has-children')) {
+ return
+ }
+
+ // set everything below to collapsed first.
+ // this allows the "collapsing of all sub folders effect" when items are collapsed
+ // (rather than just collapsing the folder directly below, although showing only the folder directly below).
+ const elems = elem.querySelectorAll('li, span.expanders')
+ for (let i = 0; i < elems.length; i += 1) {
+ const theElem = elems[i]
+ theElem.classList.add('collapsed')
+ }
+
+ // then update current element collapsed state as necessary.
+ const isCollapsed = elem.classList.contains('collapsed')
+ if (isCollapsed) {
+ elem.classList.remove('collapsed')
+ }
+ else {
+ elem.classList.add('collapsed')
+ }
+
+ const expanders = elem.querySelectorAll('span.expanders')
+ if (isCollapsed) {
+ expanders[0].classList.remove('collapsed')
+ }
+ else {
+ expanders[0].classList.add('collapsed')
+ }
+
+ updateCollapsedState(target)
+ }
+
+ if (nodes) {
+ return (
+
+ {nodes.map(node => (
+ -
+
handleTreeItemClick(e.target)}
+ onKeyPress={e => handleTreeItemClick(e.target)}
+ role="button"
+ tabIndex="0"
+ >
+

+
{node.name}
+ {node.children.length
+ ? (
+
+ ▸
+ ▾
+
+ )
+ : ''}
+
+ {node.children.length ? : ''}
+
+ ))}
+
+ )
+ }
+
+ return ''
+}
+
+PhotoTreeList.propTypes = {
+ nodes: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ parent: PropTypes.number,
+ })).isRequired,
+}
+
+
+export default PhotoTreeList
diff --git a/src/js/index.js b/src/js/index.js
index 8068cdc..93103d7 100644
--- a/src/js/index.js
+++ b/src/js/index.js
@@ -1,5 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom'
+
import App from './App'
// NOTE: After you run `npm i`, you can use `npm run start` to load this page with hot-reload
diff --git a/src/php/WebserviceHandler.php b/src/php/WebserviceHandler.php
new file mode 100644
index 0000000..e6e62f4
--- /dev/null
+++ b/src/php/WebserviceHandler.php
@@ -0,0 +1,124 @@
+setJsonData();
+ }
+
+ public function handleRequest()
+ {
+ $this->sendHeaders();
+
+ $actionData = $this->getActionData($_GET['action']);
+ $this->{$actionData['action']}($actionData);
+ }
+
+ private function getJsonPath()
+ {
+ return __DIR__ . '/../data/testdata.json';
+ }
+
+ private function setJsonData()
+ {
+ $this->jsonData = json_decode(file_get_contents($this->getJsonPath()), true);
+ }
+
+ private function saveJsonData()
+ {
+ file_put_contents($this->getJsonPath(), json_encode($this->jsonData, JSON_PRETTY_PRINT));
+ }
+
+ private function sendHeaders()
+ {
+ header('Content-Type: application/json');
+ header('Access-Control-Allow-Origin: *');
+ }
+
+ private function getNormalizedKey($name)
+ {
+ return trim(preg_replace('@[^a-z]+@i', '_', strtolower($name)));
+ }
+
+ private function getActionData($action)
+ {
+ $availableActionsToParamsReqiredMap = [
+ 'getSetting' => ['name'],
+ 'setSetting' => ['name', 'value'],
+ 'getNodes' => [],
+ 'setNodesCollapsedState' => [],
+ ];
+
+ if (!isset($availableActionsToParamsReqiredMap[$action])) {
+ exit('placeholder response -- invalid action');
+ }
+
+
+
+ // check params, merge action data.
+ $paramsReqired = $availableActionsToParamsReqiredMap[$action];
+ $paramsReqired[] = 'action';
+
+ $actionData = array_intersect_key($_GET, array_flip($paramsReqired));
+
+ if (count($paramsReqired) !== count($actionData)) {
+ exit('placeholder response -- invalid params');
+ }
+
+ return $actionData;
+ }
+
+ private function getNodes()
+ {
+ echo json_encode($this->jsonData['nodes'], JSON_PRETTY_PRINT);
+ }
+
+ private function getSetting($actionData)
+ {
+ $settingNameKey = $this->getNormalizedKey($actionData['name']);
+
+ $settingsByNameKey = [];
+ foreach ($this->jsonData['settings'] as $setting) {
+ $settingsByNameKey[$this->getNormalizedKey($setting['name'])] = $setting;
+ }
+
+ echo json_encode($settingsByNameKey[$settingNameKey]['value'], JSON_PRETTY_PRINT);
+ }
+
+ private function setSetting($actionData)
+ {
+ $settingNameKey = $this->getNormalizedKey($actionData['name']);
+ $settingVal = (bool) $actionData['value'];
+
+ $settingsByNameKey = [];
+ foreach ($this->jsonData['settings'] as $setting) {
+ $settingsByNameKey[$this->getNormalizedKey($setting['name'])] = $setting;
+ }
+
+ $settingsByNameKey[$settingNameKey]['value'] = $settingVal;
+ $this->jsonData['settings'] = array_values($settingsByNameKey);
+
+ $this->saveJsonData();
+ }
+
+ private function setNodesCollapsedState($actionData)
+ {
+ // because you can't access the post data from the post array using the php internal web server. ffs
+ $postBody = file_get_contents('php://input');
+ $postBody = json_decode($postBody, true);
+ $nodeIdToCollapsedStateMap = $postBody['nodeIdToCollapsedStateMap'];
+
+ foreach (array_keys($this->jsonData['nodes']) as $key) {
+ $this->jsonData['nodes'][$key]['collapsed'] = $nodeIdToCollapsedStateMap[$this->jsonData['nodes'][$key]['id']];
+ }
+
+ $this->jsonData['nodes'] = $this->jsonData['nodes'];
+
+ $this->saveJsonData();
+ }
+}
\ No newline at end of file
diff --git a/src/php/webservice.php b/src/php/webservice.php
index faea2b0..e63073c 100644
--- a/src/php/webservice.php
+++ b/src/php/webservice.php
@@ -1,17 +1,6 @@
handleRequest();