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 ( +
+ Photo Tree: + +
+ ) +} + + +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 ( + + ) + } + + 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();