From 6386d0a6c716e4224273c5260b4cec66cd3eda9d Mon Sep 17 00:00:00 2001 From: Dale Davies Date: Tue, 15 Mar 2022 21:38:39 +0000 Subject: [PATCH] Added tagging, such refactor --- CHANGELOG.md | 2 +- Dockerfile | 1 + docker/nginx.conf | 36 ++-- docker/php.ini | 4 +- jumpapp/api/icon.php | 24 +++ jumpapp/api/weatherdata.php | 63 ++++++ jumpapp/assets/css/styles.css | 194 +++++++++++++++--- jumpapp/assets/images/close-dark.svg | 5 + jumpapp/assets/images/loading.svg | 15 ++ jumpapp/assets/images/map-pin-off.svg | 6 - jumpapp/assets/images/overlay.png | Bin 76 -> 0 bytes jumpapp/assets/images/tags-dark.svg | 6 + jumpapp/assets/images/tags.svg | 6 + jumpapp/assets/js/index.bundle.js | 2 +- jumpapp/assets/js/index.bundle.js.LICENSE.txt | 8 + jumpapp/assets/js/src/classes/Clock.js | 31 ++- jumpapp/assets/js/src/classes/Greeting.js | 4 +- jumpapp/assets/js/src/classes/Main.js | 131 ++++++------ jumpapp/assets/js/src/classes/Weather.js | 40 ++-- jumpapp/background-css.php | 7 +- jumpapp/classes/Background.php | 3 +- jumpapp/classes/Cache.php | 38 +++- .../Exceptions/TagNotFoundException.php | 5 + jumpapp/classes/Main.php | 70 +++---- jumpapp/classes/Pages/AbstractPage.php | 56 +++++ jumpapp/classes/Pages/ErrorPage.php | 26 +++ jumpapp/classes/Pages/HomePage.php | 45 ++++ jumpapp/classes/Pages/TagPage.php | 52 +++++ jumpapp/classes/Site.php | 52 +++-- jumpapp/classes/Sites.php | 150 +++++++++----- jumpapp/composer.json | 1 + jumpapp/composer.lock | 134 +++++++++++- jumpapp/config.php | 33 +-- jumpapp/index.php | 6 +- jumpapp/sites/sites.json | 20 +- jumpapp/templates/errorpage.mustache | 18 ++ jumpapp/templates/footer.mustache | 34 ++- jumpapp/templates/header.mustache | 10 +- jumpapp/templates/sites.mustache | 2 +- package-lock.json | 7 +- package.json | 3 + 41 files changed, 1041 insertions(+), 309 deletions(-) create mode 100644 jumpapp/api/icon.php create mode 100644 jumpapp/api/weatherdata.php create mode 100644 jumpapp/assets/images/close-dark.svg create mode 100644 jumpapp/assets/images/loading.svg delete mode 100644 jumpapp/assets/images/map-pin-off.svg delete mode 100644 jumpapp/assets/images/overlay.png create mode 100644 jumpapp/assets/images/tags-dark.svg create mode 100644 jumpapp/assets/images/tags.svg create mode 100644 jumpapp/assets/js/index.bundle.js.LICENSE.txt create mode 100644 jumpapp/classes/Exceptions/TagNotFoundException.php create mode 100644 jumpapp/classes/Pages/AbstractPage.php create mode 100644 jumpapp/classes/Pages/ErrorPage.php create mode 100644 jumpapp/classes/Pages/HomePage.php create mode 100644 jumpapp/classes/Pages/TagPage.php create mode 100644 jumpapp/templates/errorpage.mustache diff --git a/CHANGELOG.md b/CHANGELOG.md index 254baee..edafa87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New weather description and temperature display in bottom right of page. - Option to show/hide clock (SHOWCLOCK). - Option to switch between metric and imperial temperature (METRICTEMP). -- Global defaults in dites.json for nofollow and icon. +- Global defaults in sites.json for nofollow and icon. - Jump now has a favicon! ### Fixed diff --git a/Dockerfile b/Dockerfile index 9d094c0..e34288d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,7 @@ RUN apk add --no-cache \ curl \ nginx \ php8 \ + php8-curl \ php8-dom \ php8-fileinfo \ php8-fpm \ diff --git a/docker/nginx.conf b/docker/nginx.conf index d80ce86..4adeb02 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -40,40 +40,40 @@ http { root /var/www/html; index index.php index.html; + # Hide nginx server tokens and version number + server_tokens off; + location / { + # Exclude unused HTTP methods + limit_except GET HEAD POST { deny all; } # First attempt to serve request as file, then # as directory, then fall back to index.php - try_files $uri $uri/ /index.php?q=$uri&$args; + try_files $uri $uri/ index.php$is_args$args; } - # Redirect server error pages to the static page /50x.html - error_page 500 502 503 504 /50x.html; - location = /50x.html { - root /var/lib/nginx/html; - } - - # Pass the PHP scripts to PHP-FPM listening on php-fpm.sock location ~ \.php$ { - try_files $uri =404; - fastcgi_split_path_info ^(.+\.php)(/.+)$; - fastcgi_pass unix:/run/php-fpm.sock; - fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - fastcgi_param SCRIPT_NAME $fastcgi_script_name; - fastcgi_index index.php; include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.sock; + fastcgi_index $document_root/index.php; + + fastcgi_split_path_info ^((?U).+\.php)(/?.+)$; + fastcgi_param SCRIPT_FILENAME $document_root/$fastcgi_script_name; + fastcgi_param PATH_TRANSLATED $document_root/$fastcgi_path_info; + fastcgi_param PATH_INFO $fastcgi_path_info; } - location ~* \.(jpg|jpeg|gif|png|css|js|ico|xml)$ { - expires 5d; + # Tell browsers to cache static assets + location ~* \.(jpg|jpeg|gif|png|css|js|ico|xml|svg)$ { + expires 3d; } - # Deny access to . files, for security + # Deny access to dot files location ~ /\. { log_not_found off; deny all; } - # Allow fpm ping and status from localhost + # Allow fpm ping from localhost, useful for docker HEALTHCHECK. location ~ ^/(fpm-ping)$ { access_log off; allow 127.0.0.1; diff --git a/docker/php.ini b/docker/php.ini index e6b8b77..d38981a 100644 --- a/docker/php.ini +++ b/docker/php.ini @@ -1,2 +1,2 @@ -[Date] -date.timezone="UTC" \ No newline at end of file +date.timezone="UTC" +expose_php = Off \ No newline at end of file diff --git a/jumpapp/api/icon.php b/jumpapp/api/icon.php new file mode 100644 index 0000000..f7c35b2 --- /dev/null +++ b/jumpapp/api/icon.php @@ -0,0 +1,24 @@ + + * @license MIT + */ + +// Provided by composer for psr-4 style autoloading. +require __DIR__ .'/../vendor/autoload.php'; + +$config = new Jump\Config(); +$cache = new Jump\Cache($config); +$sites = new Jump\Sites($config, $cache); + +$siteurl = isset($_GET['siteurl']) ? urldecode($_GET['siteurl']) : (throw new Exception('siteurl param not provided')); + +$site = $sites->get_site_by_url($siteurl); + +$imagedata = $site->get_favicon_image_data(); + +// We made it here so output the API response as json. +header('Content-Type: '.$imagedata->mimetype); +echo $imagedata->data; diff --git a/jumpapp/api/weatherdata.php b/jumpapp/api/weatherdata.php new file mode 100644 index 0000000..d8d9a7e --- /dev/null +++ b/jumpapp/api/weatherdata.php @@ -0,0 +1,63 @@ + + * @license MIT + */ + +// Provided by composer for psr-4 style autoloading. +require __DIR__ .'/../vendor/autoload.php'; + +$config = new Jump\Config(); +$cache = new Jump\Cache($config); + +$owmapiurlbase = 'https://api.openweathermap.org/data/2.5/weather'; +$units = $config->parse_bool($config->get('metrictemp')) ? 'metric' : 'imperial'; + +// If we have either lat or lon query params then cast them to a float, if not then +// set the values to zero. +$lat = isset($_GET['lat']) ? (float) $_GET['lat'] : 0; +$lon = isset($_GET['lon']) ? (float) $_GET['lon'] : 0; + +// Use the lat and lon values provided unless they are zero, this might mean that +// either they werent provided as query params or they couldn't be cast to a float. +// If they are zero then use the default latlong from config. +$latlong = [$lat, $lon]; +if ($lat === 0 || $lon === 0) { + $latlong = explode(',', $config->get('latlong', false)); +} + +// This is the API endpoint and params we are using for the query, +$url = $owmapiurlbase + .'?units=' . $units + .'&lat=' . $latlong[0] + .'&lon=' . $latlong[1] + .'&appid=' . $config->get('owmapikey', false); + +// Use the cache to store/retrieve data, make an md5 hash of latlong so it is not possible +// to track location history form the stored cache. +$weatherdata = $cache->load(cachename: 'weatherdata', key: md5(json_encode($latlong)), callback: function() use ($url) { + // Ask the API for some data. + $ch = curl_init(); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1); + curl_setopt($ch, CURLOPT_FAILONERROR, true); + $response = curl_exec($ch); + + // Just in case something went wrong with the request we'll capture the error. + if (curl_errno($ch)) { + $curlerror = curl_error($ch); + } + curl_close($ch); + // If we had an error then return the error message and exit, otherwise return the API response. + if (isset($curlerror)) { + die($curlerror); + } + return $response; +}); + +// We made it here so output the API response as json. +header('Content-Type: application/json; charset=utf-8'); +echo $weatherdata; \ No newline at end of file diff --git a/jumpapp/assets/css/styles.css b/jumpapp/assets/css/styles.css index 99cf5aa..e727d32 100644 --- a/jumpapp/assets/css/styles.css +++ b/jumpapp/assets/css/styles.css @@ -30,8 +30,12 @@ body { opacity: 0; } +.enable { + display: block !important; +} + .background { - filter: brightness(0.8) blur(10px); + filter: brightness(0.85) blur(10px); background-repeat: no-repeat; background-size: cover; background-position: center center; @@ -39,8 +43,41 @@ body { z-index: 1; } +.header-bar { + position: absolute; + top: 0; + right: 0; + left: 0; + padding: 15px 15px 0 15px; + overflow: hidden; + text-align: right; + z-index: 100; +} + + .show-tags { + height: 55px; + width: 55px; + display: inline-block; + background-position: top 50% left 50%; + background-repeat: no-repeat; + background-image: url(../images/tags.svg); + background-size: 35px; + background-color: #ffffff15; + border-radius: 50%; + cursor: pointer; + border: 2px solid #ffffff20; + } + + .show-tags:hover { + background-color: #fff; + box-shadow: 0 1px 5px rgba(0,0,0,.3); + background-image: url(../images/tags-dark.svg); + border: 2px solid #cecece; + transition: background-color, background-image .1s; + } + .content { - z-index: 10; + z-index: 100; display: flex; flex-direction: column; justify-content:center; @@ -54,48 +91,67 @@ body { font-size: 2.3em; font-weight: 400; text-transform: capitalize; - text-shadow: 1px 1px 2px #00000070; + text-shadow: 1px 1px 2px #000000a0; + margin-top: -50px; margin-bottom: 15px; } + .greeting .tagname { + text-transform: lowercase; + } + .greeting .tagname span { + opacity: 0.5; + margin-right:5px; + } + +.widget { + display: inline-block; + padding:5px 10px; + height: 58px; + user-select: none; + z-index:1000; +} + + .widget.clickable { + border-radius: 6px; + cursor: pointer; + } + + .widget.clickable:hover { + background-color: #ffffff15; + transition: background-color .1s; + } + .time-weather { display: block; position: absolute; right: 15px; bottom: 10px; - padding:5px 10px; - height: 58px; z-index: 100; font-family: 'Quicksand', sans-serif; font-weight: 400; - color: inherit; - text-decoration: none; - text-shadow: 1px 1px 2px #00000070; - user-select: none; - border-radius: 6px; + text-shadow: 1px 1px 2px #000000a0; } - .time-weather:hover { - background-color: #ffffff15; - transition: background-color .1s; - } .time { - display: inline-block; font-size: 2.4em; - margin-right: 10px; vertical-align: middle; } .weather { + color: inherit; + text-decoration: none; + } + .weather-icon { display: inline-block; font-size: 1.9em; vertical-align: middle; height: 48px; line-height: 48px !important; } - .weather::before { + .weather-icon::before { position: relative; - top: 4px; + /* top: 4px; */ } .weather-info { @@ -107,20 +163,16 @@ body { font-weight: 600; line-height: normal; vertical-align: middle; + text-shadow: 1px 1px 1px #000000a0; } .useclientlocation { font-size: 14px; - text-shadow: 1px 1px 2px #00000070; - user-select: none; - cursor: pointer; + text-shadow: 1px 1px 1px #000000a0; display: none; position: absolute; bottom: 10px; left: 15px; - z-index: 10; - border-radius: 6px; - height: 58px; line-height:58px; padding: 0 10px 0 45px; background-size: 37px; @@ -129,20 +181,12 @@ body { background-image: url(../images/map-pin.svg); } - .useclientlocation:hover { - background-color: #ffffff15; - transition: background-color .1s; - } - - .useclientlocation.enable { - display: block; - } - .sites, .sites li { padding: 0; margin: 0; list-style-type: none; font-size: 14px; + user-select: none; } .sites li { @@ -172,10 +216,15 @@ body { box-shadow: 0 1px 5px rgba(0,0,0,.3); padding: 15px; margin-bottom: 8px; + background-image: url(../images/loading.svg); + background-repeat: no-repeat; + background-position: 50%; + background-size: 20px; } .sites .icon img { width:100%; + background: #fff; } .sites .name { @@ -184,7 +233,84 @@ body { max-height: 3.3em; overflow: hidden; word-wrap: break-word; - text-shadow: 1px 1px 2px #00000070; + text-shadow: 1px 1px 1px #000000a0; text-overflow: ellipsis; white-space: nowrap; } + +.tags { + display:none; + color: #202124; + position: fixed; + top: 15px; + right: 15px; + text-align: left; + background-color: #fff; + border-radius: 6px; + border: .2em solid #cecece; + box-shadow: 0 1px 5px rgba(0,0,0,.3); + padding: 15px 15px 15px 15px; + min-width: 250px; + font-family: 'Quicksand', sans-serif; + font-weight: 400; + z-index:100; +} + .tags:target { + display: block; + } + + .tags .header { + font-size: 20px; + height: 35px; + margin-bottom: 20px; + border-bottom: 1px solid #ddd; + line-height: 18px; + display: block; + } + + .tags .header .close { + position: absolute; + top: 0; + right: 0; + height: 48px; + width: 48px; + display: inline-block; + background-position: top 50% left 50%; + background-repeat: no-repeat; + background-image: url(../images/close-dark.svg); + background-size: 30px; + cursor: pointer; + border: 5px solid #fff; + border-radius: 50%; + } + .tags .header .close:hover { + background-color: #f3f3f3; + } + + .tags ul { + padding: 0; + margin: 0; + list-style-position: inside; + } + .tags ul li { + text-transform: lowercase; + margin-bottom: 3px; + } + + .tags ul li::marker { + color: #bbb; + content: '#'; + } + .tags ul li a { + display:inline-block; + color: inherit; + text-decoration: dotted; + padding: 3px 5px; + margin-left: 1px; + border-radius: 4px; + } + + .tags ul li a:hover { + background-color: #f3f3f3; + transition: background-color .1s; + } diff --git a/jumpapp/assets/images/close-dark.svg b/jumpapp/assets/images/close-dark.svg new file mode 100644 index 0000000..082c40e --- /dev/null +++ b/jumpapp/assets/images/close-dark.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/jumpapp/assets/images/loading.svg b/jumpapp/assets/images/loading.svg new file mode 100644 index 0000000..f752f1e --- /dev/null +++ b/jumpapp/assets/images/loading.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jumpapp/assets/images/map-pin-off.svg b/jumpapp/assets/images/map-pin-off.svg deleted file mode 100644 index 0e0e4f0..0000000 --- a/jumpapp/assets/images/map-pin-off.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/jumpapp/assets/images/overlay.png b/jumpapp/assets/images/overlay.png deleted file mode 100644 index 4c0763c779e3828ba6fef267534e6b7612b97ea1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 76 zcmeAS@N?(olHy`uVBq!ia0vp^%plCc0wn(^)|vn*VNVywkP61+goGP9dnKJ3Gz?f6 YtP)wzcV8^E07^4>y85}Sb4q9e02M(H@c;k- diff --git a/jumpapp/assets/images/tags-dark.svg b/jumpapp/assets/images/tags-dark.svg new file mode 100644 index 0000000..289b2d1 --- /dev/null +++ b/jumpapp/assets/images/tags-dark.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/jumpapp/assets/images/tags.svg b/jumpapp/assets/images/tags.svg new file mode 100644 index 0000000..d435dd1 --- /dev/null +++ b/jumpapp/assets/images/tags.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/jumpapp/assets/js/index.bundle.js b/jumpapp/assets/js/index.bundle.js index a10cb83..6612405 100644 --- a/jumpapp/assets/js/index.bundle.js +++ b/jumpapp/assets/js/index.bundle.js @@ -1,2 +1,2 @@ /*! For license information please see index.bundle.js.LICENSE.txt */ -(()=>{"use strict";class t{constructor(t=0){this.utcshift=1e3*t,this.shiftedtimestamp=(new Date).getTime()+this.utcshift,this.shifteddate=new Date(this.shiftedtimestamp)}get_formatted_time(){return String(this.shifteddate.getUTCHours()).padStart(2,"0")+":"+String(this.shifteddate.getUTCMinutes()).padStart(2,"0")}get_hour(){return this.shifteddate.getUTCHours()}}class e{constructor(t){this.hour=t.get_hour(),this.greetings={0:"morning",12:"afternoon",16:"evening",19:"night"}}get_greeting(){let t=Object.keys(this.greetings).reverse();for(let e of t)if(this.hour>=e)return this.greetings[e]}}class i{constructor(t,e,i){this.owmapiurlbase="https://api.openweathermap.org/data/2.5/weather",this.owmapikey=t,this.latlong=e,this.metrictemp=i}async fetch_owm_data(){const t=this.owmapiurlbase+"?units="+(this.metrictemp?"metric":"imperial")+"&lat="+this.latlong[0]+"&lon="+this.latlong[1]+"&appid="+this.owmapikey;return await fetch(t).then((t=>t.json())).then((t=>{401===t.cod&&alert("The OWM API key is invalid, check config.php");var e="night";return t.dt>t.sys.sunrise&&t.dt{this.timezoneshift=t.timezoneshift,this.refresh_basic_content(),this.holderelm.href="https://openweathermap.org/city/"+t.locationcode,this.weatherelm.classList.add(t.iconclass),this.clientlocationelm.innerHTML=t.locationname,this.tempelm.innerHTML=t.temp,this.weatherdescelm.innerHTML=t.description,this.clientlocationelm.addEventListener("click",(t=>{navigator.geolocation.getCurrentPosition((t=>{this.latlong=[t.coords.latitude,t.coords.longitude],this.storage.setItem("lastrequestedlocation",JSON.stringify(this.latlong)),this.init()}),null,{enableHighAccuracy:!0})}),{once:!0}),this.clientlocationelm.classList.add("enable"),this.show_content()}))}show_content(){document.querySelectorAll(".hidden").forEach((function(t){t.classList.remove("hidden")}))}update_basic_content(){let i=new t(this.timezoneshift),s=new e(i);null!=this.timeelm&&(this.timeelm.innerHTML=i.get_formatted_time()),this.greetingelm.innerHTML=s.get_greeting()}refresh_basic_content(){this.contentintervalid&&clearInterval(this.contentintervalid),this.update_basic_content(),this.contentintervalid=setInterval((()=>{this.update_basic_content()}),this.updatefrequency)}}).init()})(); \ No newline at end of file +(()=>{"use strict";var t={729:t=>{var e=Object.prototype.hasOwnProperty,n="~";function i(){}function s(t,e,n){this.fn=t,this.context=e,this.once=n||!1}function r(t,e,i,r,o){if("function"!=typeof i)throw new TypeError("The listener must be a function");var h=new s(i,r||t,o),c=n?n+e:e;return t._events[c]?t._events[c].fn?t._events[c]=[t._events[c],h]:t._events[c].push(h):(t._events[c]=h,t._eventsCount++),t}function o(t,e){0==--t._eventsCount?t._events=new i:delete t._events[e]}function h(){this._events=new i,this._eventsCount=0}Object.create&&(i.prototype=Object.create(null),(new i).__proto__||(n=!1)),h.prototype.eventNames=function(){var t,i,s=[];if(0===this._eventsCount)return s;for(i in t=this._events)e.call(t,i)&&s.push(n?i.slice(1):i);return Object.getOwnPropertySymbols?s.concat(Object.getOwnPropertySymbols(t)):s},h.prototype.listeners=function(t){var e=n?n+t:t,i=this._events[e];if(!i)return[];if(i.fn)return[i.fn];for(var s=0,r=i.length,o=new Array(r);s{var e=t&&t.__esModule?()=>t.default:()=>t;return n.d(e,{a:e}),e},n.d=(t,e)=>{for(var i in e)n.o(e,i)&&!n.o(t,i)&&Object.defineProperty(t,i,{enumerable:!0,get:e[i]})},n.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),(()=>{class t{constructor(t){this.set_utc_shift(),this.contentintervalid=null,this.eventemitter=t}set_utc_shift(t=0){this.utcshift=1e3*t,this.shiftedtimestamp=(new Date).getTime()+this.utcshift,this.shifteddate=new Date(this.shiftedtimestamp)}get_formatted_time(){return String(this.shifteddate.getUTCHours()).padStart(2,"0")+":"+String(this.shifteddate.getUTCMinutes()).padStart(2,"0")}get_hour(){return this.shifteddate.getUTCHours()}update_time(){this.set_utc_shift(this.utcshift),this.eventemitter.emit("clock-updated",{formatted_time:this.get_formatted_time(),hour:this.get_hour(),utcshift:this.utcshift})}run(t){this.contentintervalid&&clearInterval(this.contentintervalid),this.update_time(),this.contentintervalid=setInterval((()=>{this.update_time()}),t)}}var e=n(729),i=n.n(e);class s{constructor(t){this.hour=t,this.greetings={0:"morning",12:"afternoon",16:"evening",19:"night"}}get_greeting(){let t=Object.keys(this.greetings).reverse();for(let e of t)if(this.hour>=e)return this.greetings[e]}}class r{constructor(t){this.eventemitter=t}fetch_owm_data(t){let e="/api/weatherdata.php";t.length&&(e+="?lat="+t[0]+"&lon="+t[1]),fetch(e).then((t=>t.json())).then((t=>{401===t.cod&&alert("The OWM API key is invalid, check config.php");var e="night";t.dt>t.sys.sunrise&&t.dt{this.timezoneshift=t.timezoneshift,this.weatherelm.href="https://openweathermap.org/city/"+t.locationcode,this.weathericonelm.classList.add(t.iconclass),this.clientlocationelm.innerHTML=t.locationname,this.tempelm.innerHTML=t.temp,this.weatherdescelm.innerHTML=t.description,this.clientlocationelm.classList.add("enable"),this.eventemitter.emit("show-content")})),this.eventemitter.on("clock-updated",(t=>{if(null!=this.timeelm&&(this.timeelm.innerHTML=t.formatted_time),null!=this.greetingelm){let e=new s(t.hour);this.greetingelm.innerHTML=e.get_greeting()}})),this.eventemitter.on("show-content",(()=>{this.set_clock(),this.show_content()})),this.clientlocationelm.addEventListener("click",(t=>{navigator.geolocation.getCurrentPosition((t=>{this.latlong=[t.coords.latitude,t.coords.longitude],this.storage.setItem("lastrequestedlocation",JSON.stringify(this.latlong)),this.weather.fetch_owm_data(this.latlong)}),(t=>{console.error(t.message)}),{enableHighAccuracy:!0})})),this.showtagsbuttonelm&&this.showtagsbuttonelm.addEventListener("click",(t=>{this.tagselectorelm.classList.add("enable"),t.preventDefault()})),this.tagsselectorclosebuttonelm&&this.tagsselectorclosebuttonelm.addEventListener("click",(t=>{this.tagselectorelm.classList.remove("enable")}))}show_content(){document.querySelectorAll(".hidden").forEach((function(t){t.classList.remove("hidden")}))}set_clock(){this.clock.set_utc_shift(this.timezoneshift),this.clock.run(this.updatefrequency)}}).init()})()})(); \ No newline at end of file diff --git a/jumpapp/assets/js/index.bundle.js.LICENSE.txt b/jumpapp/assets/js/index.bundle.js.LICENSE.txt new file mode 100644 index 0000000..9286247 --- /dev/null +++ b/jumpapp/assets/js/index.bundle.js.LICENSE.txt @@ -0,0 +1,8 @@ +/** + * Calculate the time, local to the requested location from + * the OpenWeather API, by passing in the number of seconds + * that location has shifted from UTC based on the timezones. + * + * @author Dale Davies + * @license MIT + */ diff --git a/jumpapp/assets/js/src/classes/Clock.js b/jumpapp/assets/js/src/classes/Clock.js index 328a55c..89791f2 100644 --- a/jumpapp/assets/js/src/classes/Clock.js +++ b/jumpapp/assets/js/src/classes/Clock.js @@ -13,7 +13,13 @@ export default class Clock { * * @param number utcshift Number of seconds to shift time from UTC. */ - constructor(utcshift = 0) { + constructor(eventemitter) { + this.set_utc_shift(); + this.contentintervalid = null; + this.eventemitter = eventemitter; + } + + set_utc_shift(utcshift = 0) { this.utcshift = utcshift*1000; this.shiftedtimestamp = new Date().getTime()+this.utcshift; this.shifteddate = new Date(this.shiftedtimestamp); @@ -42,4 +48,27 @@ export default class Clock { return this.shifteddate.getUTCHours(); } + update_time() { + this.set_utc_shift(this.utcshift); + this.eventemitter.emit('clock-updated', { + formatted_time: this.get_formatted_time(), + hour: this.get_hour(), + utcshift: this.utcshift + }); + } + + run(updatefrequency) { + // Clear any previously set intervals for updating content. + if (this.contentintervalid) { + clearInterval(this.contentintervalid); + } + // Set the clock and greeting text appropriately for the requested location. + this.update_time(); + // Update the content periodically, we don't need to be too frequent as we are + // not displaying seconds on the clock. + this.contentintervalid = setInterval(() => { + this.update_time(); + }, updatefrequency); + } + } diff --git a/jumpapp/assets/js/src/classes/Greeting.js b/jumpapp/assets/js/src/classes/Greeting.js index a0fa439..af23010 100644 --- a/jumpapp/assets/js/src/classes/Greeting.js +++ b/jumpapp/assets/js/src/classes/Greeting.js @@ -2,8 +2,8 @@ import Clock from "./Clock"; export default class Greeting { - constructor(clock) { - this.hour = clock.get_hour(); + constructor(hour) { + this.hour = hour; this.greetings = { 0 : 'morning', 12 : 'afternoon', diff --git a/jumpapp/assets/js/src/classes/Main.js b/jumpapp/assets/js/src/classes/Main.js index 87b1d6f..d5875a8 100644 --- a/jumpapp/assets/js/src/classes/Main.js +++ b/jumpapp/assets/js/src/classes/Main.js @@ -1,83 +1,116 @@ import Clock from './Clock'; +import EventEmitter from 'eventemitter3'; import Greeting from './Greeting'; import Weather from './Weather'; export default class Main { constructor() { - this.owmapikey = null; this.latlong = []; this.storage = window.localStorage; this.updatefrequency = 10000; - this.contentintervalid = null; this.timezoneshift = 0; this.metrictemp = JUMP.metrictemp; - // Cache some DOM elements that we will access frequently. this.greetingelm = document.querySelector('.greeting .chosen'); this.holderelm = document.querySelector('.time-weather'); - this.tempelm = document.querySelector('.weather-info .temp'); - this.weatherdescelm = document.querySelector('.weather-info .desc'); + this.tempelm = this.holderelm.querySelector('.weather-info .temp'); + this.weatherdescelm = this.holderelm.querySelector('.weather-info .desc'); this.timeelm = this.holderelm.querySelector('.time'); this.weatherelm = this.holderelm.querySelector('.weather'); + this.weathericonelm = this.holderelm.querySelector('.weather-icon'); this.clientlocationelm = document.querySelector('.useclientlocation'); - - // See if we were provided a latlong and api key via the apps config.php. - if (JUMP.latlong && JUMP.owmapikey) { - this.owmapikey = JUMP.owmapikey; - this.latlong = JUMP.latlong.split(','); - } - + this.showtagsbuttonelm = document.querySelector('.show-tags'); + this.tagselectorelm = document.querySelector('.tags'); + this.tagsselectorclosebuttonelm = document.querySelector('.tags .close') // If the user has previously asked for geolocation we will have stored the latlong. if (this.lastrequestedlocation = this.storage.getItem('lastrequestedlocation')){ this.latlong = JSON.parse(this.lastrequestedlocation); } + // Finally create instances of the classes we'll be using. + this.eventemitter = new EventEmitter(); + this.clock = new Clock(this.eventemitter); + this.weather = new Weather(this.eventemitter); } /** * Get data from OWM and do stuff with it. */ init() { + // Start listening for events so we can do stuff when needed. + this.add_event_listeners(); // If there is no OWM API key provided then just update the greeting // and clock, otherwise we can go get the weather data and set everything // up properly. - if (!this.owmapikey) { - this.refresh_basic_content(); - this.show_content(); + if (!JUMP.owmapikey) { + this.eventemitter.emit('show-content'); return; } - // Retrieve weather and timezone data from Open Weather Map API. - new Weather(this.owmapikey, this.latlong, this.metrictemp).fetch_owm_data().then(owmdata => { + this.weather.fetch_owm_data(this.latlong); + } + /** + * Umm... adds event listeners + */ + add_event_listeners() { + this.eventemitter.on('weather-loaded', owmdata => { // Update the timezone shift from UTC to whatever it should be for the // requested location, then tell the greeting and clock to update. this.timezoneshift = owmdata.timezoneshift; - this.refresh_basic_content(); - // Display the weather icon, link to the requested location in OWM // and update location name element. - this.holderelm.href = 'https://openweathermap.org/city/' + owmdata.locationcode; - this.weatherelm.classList.add(owmdata.iconclass); + this.weatherelm.href = 'https://openweathermap.org/city/' + owmdata.locationcode; + this.weathericonelm.classList.add(owmdata.iconclass); this.clientlocationelm.innerHTML = owmdata.locationname; this.tempelm.innerHTML = owmdata.temp; this.weatherdescelm.innerHTML = owmdata.description; - - // Should someone click on the location button then request their location - // from the client and store it, then re run init() to update the page. - this.clientlocationelm.addEventListener('click', e => { - navigator.geolocation.getCurrentPosition(position => { - this.latlong = [position.coords.latitude, position.coords.longitude]; - this.storage.setItem('lastrequestedlocation', JSON.stringify(this.latlong)); - this.init(); - }, null, {enableHighAccuracy: true}); - }, {once: true}); this.clientlocationelm.classList.add('enable'); + this.eventemitter.emit('show-content'); + }); - // Finally we can make everything visible. + this.eventemitter.on('clock-updated', clockdata => { + if (this.timeelm != null) { + this.timeelm.innerHTML = clockdata.formatted_time; + } + if (this.greetingelm != null) { + let greeting = new Greeting(clockdata.hour); + this.greetingelm.innerHTML = greeting.get_greeting(); + } + }); + + this.eventemitter.on('show-content', () => { + this.set_clock(); this.show_content(); }); + // Should someone click on the location button then request their location + // from the client and store it, then refetch weather data to update the page. + this.clientlocationelm.addEventListener('click', e => { + navigator.geolocation.getCurrentPosition(position => { + this.latlong = [position.coords.latitude, position.coords.longitude]; + this.storage.setItem('lastrequestedlocation', JSON.stringify(this.latlong)); + this.weather.fetch_owm_data(this.latlong); + }, + error => { + console.error(error.message); + }, + {enableHighAccuracy: true}); + }); + + if (this.showtagsbuttonelm) { + this.showtagsbuttonelm.addEventListener('click', e => { + this.tagselectorelm.classList.add('enable'); + e.preventDefault(); + }); + } + + if (this.tagsselectorclosebuttonelm) { + this.tagsselectorclosebuttonelm.addEventListener('click', e => { + this.tagselectorelm.classList.remove('enable'); + }); + } + } /** @@ -91,37 +124,9 @@ export default class Main { }); } - /** - * Calculate the correct time for the requested location and display it, - * along with an appropriate greeting. - */ - update_basic_content() { - let clock = new Clock(this.timezoneshift); - let greeting = new Greeting(clock); - if (this.timeelm != null) { - this.timeelm.innerHTML = clock.get_formatted_time(); - } - this.greetingelm.innerHTML = greeting.get_greeting(); - } - - /** - * Update the greeting message and clock initially, then continue to update - * them at the frequency set in this.updatefrequency. - */ - refresh_basic_content() { - // Clear any previously set intervals for updating content. - if (this.contentintervalid) { - clearInterval(this.contentintervalid); - } - - // Set the clock and greeting text appropriately for the requested location. - this.update_basic_content(); - - // Update the content periodically, we don't need to be too frequent as we are - // not displaying seconds on the clock. - this.contentintervalid = setInterval(() => { - this.update_basic_content(); - }, this.updatefrequency); + set_clock() { + this.clock.set_utc_shift(this.timezoneshift); + this.clock.run(this.updatefrequency); } } diff --git a/jumpapp/assets/js/src/classes/Weather.js b/jumpapp/assets/js/src/classes/Weather.js index c68d216..7dd9021 100644 --- a/jumpapp/assets/js/src/classes/Weather.js +++ b/jumpapp/assets/js/src/classes/Weather.js @@ -1,34 +1,27 @@ export default class Weather { /** - * Reposible for retrieveing weather data from OWM and doing + * Responsible for retrieveing weather data from OWM and doing * stuff with it. * - * @param {string} owmapikey OWM API key. * @param {string} latlong Comma separated string representing a lattitude and longitude. - * @param {boolean} metrictemp Are temperature units in metric or imperial. */ - constructor(owmapikey, latlong, metrictemp) { - this.owmapiurlbase = 'https://api.openweathermap.org/data/2.5/weather'; - this.owmapikey = owmapikey; - this.latlong = latlong; - this.metrictemp = metrictemp + constructor(eventemitter) { + this.eventemitter = eventemitter; } /** - * Make an async request to the OWM API, parse and return the response. - * - * @returns {Promise} Containing parsed OWM data. + * Make an async request to the weather API, parse and return the response. */ - async fetch_owm_data() { - const url = this.owmapiurlbase - +'?units=' + (this.metrictemp ? 'metric' : 'imperial') - +'&lat=' + this.latlong[0] - +'&lon=' + this.latlong[1] - +'&appid=' + this.owmapikey; - - // Get some data from the open weather map api... - const promise = await fetch(url) + fetch_owm_data(latlong) { + // If we are provided with a latlong then the user must have cliecked on the location + // button at some point, so let's use this in the api url... + let apiurl = '/api/weatherdata.php'; + if (latlong.length) { + apiurl += ('?lat=' + latlong[0] + '&lon=' + latlong[1]); + } + // Get some data from the weather api... + fetch(apiurl) .then(response => response.json()) .then(data => { if (data.cod === 401) { @@ -39,16 +32,15 @@ export default class Weather { if (data.dt > data.sys.sunrise && data.dt < data.sys.sunset) { daynightvariant = 'day' } - return { + this.eventemitter.emit('weather-loaded', { locationcode: data.id, locationname: data.name, - temp: Math.ceil(data.main.temp) + '°' + (this.metrictemp ? 'C' : 'F'), + temp: Math.ceil(data.main.temp) + '°' + (JUMP.metrictemp ? 'C' : 'F'), description: data.weather[0].main, iconclass: 'wi-owm-' + daynightvariant + '-' + data.weather[0].id, timezoneshift: data.timezone - }; + }); }) - return promise; } } diff --git a/jumpapp/background-css.php b/jumpapp/background-css.php index 1f987a9..ebabcfb 100644 --- a/jumpapp/background-css.php +++ b/jumpapp/background-css.php @@ -10,7 +10,10 @@ require __DIR__ .'/vendor/autoload.php'; $config = new Jump\Config(); -$backgroundfile = (new Jump\Background($config))->get_random_background_file(); +$backgroundimgfile = (new Jump\Background($config))->get_random_background_file(); + +$blur = floor((int)$config->get('bgblur', false) / 100 * 15); +$brightness = (int)$config->get('bgbright', false) ? (int)$config->get('bgbright', false) / 100 : 1; header('Content-Type: text/css'); -echo '.background {background-image: url("'.$backgroundfile.'");}'; \ No newline at end of file +echo '.background {background-image: url("'.$backgroundimgfile.'");filter: brightness('.$brightness.') blur('.$blur.'px);}'; \ No newline at end of file diff --git a/jumpapp/classes/Background.php b/jumpapp/classes/Background.php index 2726ffc..59dfa9d 100644 --- a/jumpapp/classes/Background.php +++ b/jumpapp/classes/Background.php @@ -14,8 +14,7 @@ class Background { private string $backgroundsdirectory; private array $backgroundfiles; - public function __construct(Config $config) { - $this->config = $config; + public function __construct(private Config $config) { $this->backgroundsdirectory = $config->get('backgroundsdir'); $this->webaccessibledir = str_replace($config->get('wwwroot'), '', $config->get('backgroundsdir')); $this->enumerate_files(); diff --git a/jumpapp/classes/Cache.php b/jumpapp/classes/Cache.php index 18f7118..2d5318e 100644 --- a/jumpapp/classes/Cache.php +++ b/jumpapp/classes/Cache.php @@ -21,14 +21,12 @@ class Cache { * @var array Multidimensional array */ private array $caches; - private Config $config; /** * Creates file storage for cache and initialises cache objects for each * name/type specified in $caches definition. */ - public function __construct(Config $config) { - $this->config = $config; + public function __construct(private Config $config) { // Define the various caches used throughout the app. $this->caches = [ 'sites' => [ @@ -36,6 +34,11 @@ class Cache { 'expirationtype' => Caching\Cache::FILES, 'expirationparams' => $config->get('sitesfile') ], + 'tags' => [ + 'cache' => null, + 'expirationtype' => Caching\Cache::FILES, + 'expirationparams' => $config->get('sitesfile') + ], 'templates/sites' => [ 'cache' => null, 'expirationtype' => Caching\Cache::FILES, @@ -44,25 +47,34 @@ class Cache { $config->get('sitesfile'), $config->get('templatedir').'/sites.mustache' ] - ] + ], + 'templates/errorpage' => [ + 'cache' => null, + 'expirationtype' => Caching\Cache::FILES, + 'expirationparams' => [ + $config->get('templatedir').'/errorpage.mustache' + ] + ], + 'weatherdata' => [ + 'cache' => null, + 'expirationtype' => Caching\Cache::EXPIRE, + 'expirationparams' => '5 minutes' + ], ]; // Inititalise file storage for cache using cachedir path from config. $this->storage = new Caching\Storages\FileStorage($this->config->get('cachedir').'/application'); - // Initialise a cache object for each cache name/type specified in caches array. - array_walk($this->caches, function(&$cachedef, $cachename) { - $cachedef['cache'] = new Caching\Cache($this->storage, $cachename); - }); } /** * Read the specified item from the cache or generate it, mostly a wrapper * around Nette\Caching\Cache::load(). * - * @param string $cachename The name of a cache type, must match a key in $caches definition. + * @param string $cachename The name of a cache, must match a key in $caches definition. + * @param string $key A key used to represent an object within a cache, * @param callable $callback The code from which the result should be stored in cache. * @return mixed The result of callback function retreieved from cache. */ - public function load(string $cachename, callable $callback): mixed { + public function load(string $cachename, ?string $key = 'default', callable $callback): mixed { // If cachebypass has been set in config.php then just execute the callback. if ($this->config->parse_bool($this->config->get('cachebypass'))) { return $callback(); @@ -71,9 +83,13 @@ class Cache { if (!array_key_exists($cachename, $this->caches)) { throw new \Exception('Cache name not found ('.$cachename.')'); } + // If a cache key has not been used then intialise a cache object for it. + if (!isset($this->caches[$cachename]['cache']) || !array_key_exists($key, $this->caches[$cachename]['cache'])) { + $this->caches[$cachename]['cache'][$key] = new Caching\Cache($this->storage, $cachename.'/'.$key); + } // Retrieve the initialised cache object from $caches, defines the caches expiry // and executes the callback. - return $this->caches[$cachename]['cache']->load($cachename, + return $this->caches[$cachename]['cache'][$key]->load($cachename.'/'.$key, function (&$dependencies) use ($callback, $cachename) { $dependencies[$this->caches[$cachename]['expirationtype']] = $this->caches[$cachename]['expirationparams']; return $callback(); diff --git a/jumpapp/classes/Exceptions/TagNotFoundException.php b/jumpapp/classes/Exceptions/TagNotFoundException.php new file mode 100644 index 0000000..44795c6 --- /dev/null +++ b/jumpapp/classes/Exceptions/TagNotFoundException.php @@ -0,0 +1,5 @@ +config = new Config(); - $this->mustache = new \Mustache_Engine([ - 'loader' => new \Mustache_Loader_FilesystemLoader($this->config->get('templatedir')) - ]); $this->cache = new Cache($this->config); - $this->sites = new Sites($this->config, $this->cache); + $this->router = new RouteList; + + // Set up the routes that Jump expects. + $this->router->addRoute('/tag/', [ + 'class' => 'Jump\Pages\TagPage' + ]); } - private function render_header(): string { - $template = $this->mustache->loadTemplate('header'); - return $template->render([ - 'noindex' => $this->config->parse_bool($this->config->get('noindex')), - 'sitename' => $this->config->get('sitename'), - 'latlong' => $this->config->get('latlong', false), - 'owmapikey' => $this->config->get('owmapikey', false), - 'metrictemp' => $this->config->parse_bool($this->config->get('metrictemp')), - ]); - } + function init() { + // Try to match the correct route based on the HTTP request. + $matchedroute = $this->router->match( + (new \Nette\Http\RequestFactory)->fromGlobals() + ); - private function render_sites(): string { - return $this->cache->load('templates/sites', function() { - $template = $this->mustache->loadTemplate('sites'); - return $template->render([ - 'hassites' => !empty($this->sites->get_sites()), - 'sites' => $this->sites->get_sites() - ]); - }); - } + // If we do not have a matched route then just serve up the home page. + $pageclass = $matchedroute['class'] ?? 'Jump\Pages\HomePage'; + $param = $matchedroute['param'] ?? null; - private function render_footer(): string { - $template = $this->mustache->loadTemplate('footer'); - return $template->render([ - 'showclock' => $this->config->parse_bool($this->config->get('showclock')) - ]); - } - - public function build_index_page(): void { - $this->outputarray = [ - $this->render_header(), - $this->render_sites(), - $this->render_footer(), - ]; - } - - public function get_output(): string { - return implode('', $this->outputarray); + // Instantiate the correct class to build the requested page, get the + // content and return it. + $page = new $pageclass($this->config, $this->cache, $param ?? null); + return $page->get_output(); } } diff --git a/jumpapp/classes/Pages/AbstractPage.php b/jumpapp/classes/Pages/AbstractPage.php new file mode 100644 index 0000000..528d428 --- /dev/null +++ b/jumpapp/classes/Pages/AbstractPage.php @@ -0,0 +1,56 @@ +hastags = false; + $this->mustache = new \Mustache_Engine([ + 'loader' => new \Mustache_Loader_FilesystemLoader($this->config->get('templatedir')) + ]); + } + + abstract protected function render_content(): string; + + protected function render_header(): string { + $template = $this->mustache->loadTemplate('header'); + return $template->render([ + 'noindex' => $this->config->parse_bool($this->config->get('noindex')), + 'title' => $this->config->get('sitename'), + 'owmapikey' => !!$this->config->get('owmapikey', false), + 'metrictemp' => $this->config->parse_bool($this->config->get('metrictemp')) + ]); + } + + protected function render_footer(): string { + $template = $this->mustache->loadTemplate('footer'); + return $template->render([ + 'showclock' => $this->config->parse_bool($this->config->get('showclock')) + ]); + } + + protected function render_page(): void { + $this->outputarray = [ + $this->render_header(), + $this->render_content(), + $this->render_footer(), + ]; + } + + public function get_output(): string { + $this->render_page(); + return implode('', $this->outputarray); + } + +} diff --git a/jumpapp/classes/Pages/ErrorPage.php b/jumpapp/classes/Pages/ErrorPage.php new file mode 100644 index 0000000..3a84e82 --- /dev/null +++ b/jumpapp/classes/Pages/ErrorPage.php @@ -0,0 +1,26 @@ +mustache = new \Mustache_Engine([ + 'loader' => new \Mustache_Loader_FilesystemLoader($this->config->get('templatedir')) + ]); + $this->content = $cache->load(cachename: 'templates/errorpage', key: $httpcode.md5($message), callback: function() use ($httpcode, $message) { + $template = $this->mustache->loadTemplate('errorpage'); + return $template->render([ + 'code' => $httpcode, + 'message' => $message + ]); + }); + } + + public function init() { + http_response_code($this->httpcode); + die($this->content); + } +} \ No newline at end of file diff --git a/jumpapp/classes/Pages/HomePage.php b/jumpapp/classes/Pages/HomePage.php new file mode 100644 index 0000000..82ad02e --- /dev/null +++ b/jumpapp/classes/Pages/HomePage.php @@ -0,0 +1,45 @@ +mustache->loadTemplate('header'); + $greeting = null; + if (!$this->config->parse_bool($this->config->get('showgreeting'))) { + $greeting = 'home'; + } + return $template->render([ + 'greeting' => $greeting, + 'noindex' => $this->config->parse_bool($this->config->get('noindex')), + 'title' => $this->config->get('sitename'), + 'owmapikey' => !!$this->config->get('owmapikey', false), + ]); + } + + protected function render_content(): string { + return $this->cache->load(cachename: 'templates/sites', callback: function() { + $sites = new \Jump\Sites($this->config, $this->cache); + $template = $this->mustache->loadTemplate('sites'); + return $template->render([ + 'hassites' => !empty($sites->get_sites()), + 'sites' => $sites->get_sites_by_tag('home'), + ]); + }); + } + + protected function render_footer(): string { + return $this->cache->load(cachename: 'templates/sites', key: 'footer', callback: function() { + $sites = new \Jump\Sites(config: $this->config, cache: $this->cache); + $tags = $sites->get_tags_for_template(); + $template = $this->mustache->loadTemplate('footer'); + return $template->render([ + 'hastags' => !empty($tags), + 'tags' => $tags, + 'showclock' => $this->config->parse_bool($this->config->get('showclock')) + ]); + }); + } + +} diff --git a/jumpapp/classes/Pages/TagPage.php b/jumpapp/classes/Pages/TagPage.php new file mode 100644 index 0000000..6df3be4 --- /dev/null +++ b/jumpapp/classes/Pages/TagPage.php @@ -0,0 +1,52 @@ +mustache->loadTemplate('header'); + $greeting = $this->param; + $title = 'Tag: '.$this->param; + return $template->render([ + 'greeting' => $greeting, + 'noindex' => $this->config->parse_bool($this->config->get('noindex')), + 'title' => $title, + 'owmapikey' => !!$this->config->get('owmapikey', false), + ]); + } + + protected function render_content(): string { + $cachekey = isset($this->param) ? 'tag:'.$this->param : null; + return $this->cache->load(cachename: 'templates/sites', key: $cachekey, callback: function() { + $sites = new \Jump\Sites(config: $this->config, cache: $this->cache); + try { + $taggedsites = $sites->get_sites_by_tag($this->param); + } + catch (TagNotFoundException) { + (new ErrorPage($this->cache, $this->config, 404, 'There are no sites with this tag.'))->init(); + } + $template = $this->mustache->loadTemplate('sites'); + return $template->render([ + 'hassites' => !empty($taggedsites), + 'sites' => $taggedsites, + ]); + }); + } + + protected function render_footer(): string { + return $this->cache->load(cachename: 'templates/sites', key: 'footer', callback: function() { + $sites = new \Jump\Sites(config: $this->config, cache: $this->cache); + $tags = $sites->get_tags_for_template(); + $template = $this->mustache->loadTemplate('footer'); + return $template->render([ + 'hastags' => !empty($tags), + 'tags' => $tags, + 'showclock' => $this->config->parse_bool($this->config->get('showclock')) + ]); + }); + } + +} diff --git a/jumpapp/classes/Site.php b/jumpapp/classes/Site.php index 692b1d1..111fbef 100644 --- a/jumpapp/classes/Site.php +++ b/jumpapp/classes/Site.php @@ -2,6 +2,8 @@ namespace Jump; +use stdClass; + /** * Parse the data required to represent a site and provide method for generating * and/or retrieving the site's icon. @@ -11,43 +13,49 @@ namespace Jump; */ class Site { - private Config $config; public string $name; public bool $nofollow; - public string $icon; + public ?string $iconname; public string $url; + public array $tags = ['home']; - public function __construct(Config $config, array $sitearray, array $default) { - $this->config = $config; - $this->defaults = $default; + /** + * Parse the data required to represent a site and provide method for generating + * and/or retrieving the site's icon. + * + * @param Config $config A Jump Config() object. + * @param array $sitearray Array of options for this site from sites.json. + * @param array $defaults Array of default values for this site to use, defined in sites.json. + */ + public function __construct(private Config $config, array $sitearray, private array $defaults) { if (!isset($sitearray['name'], $sitearray['url'])) { throw new \Exception('The array passed to Site() must contain the keys "name" and "url"!'); } $this->name = $sitearray['name']; $this->url = $sitearray['url']; $this->nofollow = isset($sitearray['nofollow']) ? $sitearray['nofollow'] : (isset($this->defaults['nofollow']) ? $this->defaults['nofollow'] : false); - $this->icon = isset($sitearray['icon']) ? $this->get_favicon_datauri($sitearray['icon']) : $this->get_favicon_datauri(); + $this->iconname = $sitearray['icon'] ?? null; + $this->tags = $sitearray['tags'] ?? $this->tags; } /** - * Return a data uri for a given icon, or a site's favicon if an icon - * is not provided. + * Return an object containing mimetype and raw image data, or a site's + * favicon if an icon is not provided in sites.json. * - * @param string|null $icon File name of a given icon to retrieve. - * @return string Base 64 encoded datauri for the icon image. + * @return object Containing mimetype and raw image data. */ - public function get_favicon_datauri(?string $icon = null): string { + public function get_favicon_image_data(): object { // Use the applications own default icon unless one is supplied via the sites.json file. $defaulticon = $this->config->get('defaulticonpath'); if (isset($this->defaults['icon'])) { $defaulticon = $this->config->get('sitesdir').'/icons/'.$this->defaults['icon']; } // Did we have a supplied icon or are we going to try retrieving the favicon? - if ($icon === null) { + if ($this->iconname === null) { // Go get the favicon, if there isnt one then use the default icon. $favicon = new \Favicon\Favicon(); $favicon->cache([ - 'dir' => $this->config->get('cachedir').'/icons/', + 'dir' => $this->config->get('cachedir').'/icons', 'timeout' => 86400 ]); $rawimage = $favicon->get($this->url, \Favicon\FaviconDLType::RAW_IMAGE); @@ -55,10 +63,22 @@ class Site { $rawimage = file_get_contents($defaulticon); } } else { - $rawimage = file_get_contents($this->config->get('sitesdir').'/icons/'.$icon); + $rawimage = file_get_contents($this->config->get('sitesdir').'/icons/'.$this->iconname); } - $mimetype = (new \finfo(FILEINFO_MIME_TYPE))->buffer($rawimage); - return 'data:'.$mimetype.';base64,'.base64_encode($rawimage); + $imagedata = new stdClass(); + $imagedata->mimetype = (new \finfo(FILEINFO_MIME_TYPE))->buffer($rawimage); + $imagedata->data = $rawimage; + return $imagedata; + } + + /** + * Return a data uri or a site's favicon if an icon is not provided. + * + * @return string Base 64 encoded datauri for the icon image. + */ + public function get_favicon_datauri(): string { + $imagedata = $this->get_favicon_image_data(); + return 'data:'.$imagedata->mimetype.';base64,'.base64_encode($imagedata->data); } } diff --git a/jumpapp/classes/Sites.php b/jumpapp/classes/Sites.php index c0ac904..654cf2d 100644 --- a/jumpapp/classes/Sites.php +++ b/jumpapp/classes/Sites.php @@ -2,19 +2,20 @@ namespace Jump; -use Exception; +use \Exception; +use \Jump\Exceptions\TagNotFoundException; /** * Loads, validates and caches the site data defined in sites.json * into an array of Site objects. * + * TO DO: Implement search() method. + * * @author Dale Davies * @license MIT */ class Sites { - private Cache $cache; - private Config $config; private array $default; private string $sitesfilelocation; private array $loadedsites; @@ -22,7 +23,7 @@ class Sites { /** * Automatically load sites.json on instantiation. */ - public function __construct(Config $config, Cache $cache) { + public function __construct(private Config $config, private Cache $cache) { $this->config = $config; $this->loadedsites = []; $this->sitesfilelocation = $this->config->get('sitesfile'); @@ -31,69 +32,126 @@ class Sites { 'icon' => null, 'nofollow' => false ]; - $this->load_sites_from_json(); - } + $this->tags = []; - /** - * Try to load the list of sites from site.json. - * - * Throws an exception if the file cannot be loaded, is empty, or cannot - * be decoded to an array, - * - * @return void - * @throws Exception if sites.json cannot be found - */ - private function load_sites_from_json(): void { - $this->loadedsites = $this->cache->load('sites', function() { - $sites = []; - $rawjson = file_get_contents($this->sitesfilelocation); - if ($rawjson === false) { - throw new Exception('There was a problem loading the sites.json file'); + // Retrieve sites from cache. Load all sites from json file if not cached or + // the cache has expired. + $this->loadedsites = $this->cache->load(cachename: 'sites', callback: function() { + return $this->load_sites_from_json(); + }); + + // Enumerate a list of unique tags from loaded sites. Again will retrieve from + // cache if available. + $this->tags = $this->cache->load(cachename: 'tags', callback: function() { + $uniquetags = []; + foreach (array_column($this->get_sites(), 'tags') as $tags) { + foreach ($tags as $tag) { + $uniquetags[] = $tag; + } } - if ($rawjson === '') { - throw new Exception('The sites.json file is empty'); - } - // Do some checks to see if the JSON decodes into something - // like what we expect to see... - $decodedjson = json_decode($rawjson); - if (is_array($decodedjson)) { - $sites = $decodedjson; - } - if (isset($decodedjson->sites) && is_array($decodedjson->sites)) { - $sites = $decodedjson->sites; - $this->default = (array) $decodedjson->default; - } - // Walk over the sites array and instantiate an actual Site() object - // for each element. - array_walk($sites, function(&$item, $key, $default) { - $item = new Site($this->config, (array) $item, $default); - }, $this->default); - // Return the array of Site() objects, note we are in a callback - // so the return is not from the outer function. - return $sites; + return array_values(array_unique($uniquetags)); }); } /** - * Return the loaded sites. + * Try to load the list of sites from sites.json. * - * @return array of sites loaded from sites.json + * Throws an exception if the file cannot be loaded, is empty, or cannot + * be decoded to an array, + * + * @return array Array of Site objects sites loaded from sites.json + * @throws Exception If sites.json cannot be found. + */ + private function load_sites_from_json(): array { + $allsites = []; + $rawjson = file_get_contents($this->sitesfilelocation); + if ($rawjson === false) { + throw new Exception('There was a problem loading the sites.json file'); + } + if ($rawjson === '') { + throw new Exception('The sites.json file is empty'); + } + // Do some checks to see if the JSON decodes into something + // like what we expect to see... + $decodedjson = json_decode($rawjson); + // First we'll assume maybe the old format for sites.json. + if (is_array($decodedjson)) { + $allsites = $decodedjson; + } + // Now check for the newer format. + if (isset($decodedjson->sites) && is_array($decodedjson->sites)) { + $allsites = $decodedjson->sites; + $this->default = (array) $decodedjson->default; + } + + // Instantiate an actual Site() object for each element. + foreach ($allsites as $key => $item) { + $allsites[$key] = new Site($this->config, (array) $item, $this->default); + } + + // Return the array of Site() objects, note we are in a callback + // so the return is not from the outer function. + return $allsites; + } + + /** + * Returns an array of all loaded Site objects. + * + * @return array Array of all loaded Site objects. */ public function get_sites(): array { return $this->loadedsites; } + /** + * Return array of tags sorted alphabetically, minus the home tag. + * + * @return array Array of tag names. + */ + public function get_tags_for_template(): array { + $template_tags = []; + foreach ($this->tags as $tag) { + if ($tag === 'home') { + continue; + } + $template_tags[] = $tag; + } + sort($template_tags); + return $template_tags; + } + /** * Given a URL, does that site exist in our list of sites? * * @param string $url The URL to search for. - * @return Site + * @return Site A matching Site object if found. + * @throws Exception If a site with given URL does not exist. */ public function get_site_by_url(string $url): Site { $found = array_search($url, array_column($this->get_sites(), 'url')); - if (!$found) { + if ($found === false) { throw new Exception('The site could not be found ('.$url.')'); } return $this->loadedsites[$found]; } + + /** + * Returns an array of Site objects with a given tag. + * + * @param string $tagname The tag to look look up sites. + * @return array Array of Site objects with the given tag. + * @throws Exception If there are no sites tagged with $tagname. + */ + public function get_sites_by_tag(string $tagname): array { + if (!in_array($tagname, $this->tags)) { + throw new TagNotFoundException('No sites have been tagged with "'.$tagname.'"'); + } + $found = []; + foreach ($this->get_sites() as $site) { + if (in_array($tagname, $site->tags)) { + $found[] = $site; + } + } + return $found; + } } \ No newline at end of file diff --git a/jumpapp/composer.json b/jumpapp/composer.json index 50ff1f9..81f381c 100644 --- a/jumpapp/composer.json +++ b/jumpapp/composer.json @@ -8,6 +8,7 @@ "mustache/mustache": "~2.5", "arthurhoaro/favicon": "~1.0", "nette/caching": "^3.1", + "nette/routing": "^3.0.2", "phlak/config": "^7.0" } } diff --git a/jumpapp/composer.lock b/jumpapp/composer.lock index a59460f..77ff91b 100644 --- a/jumpapp/composer.lock +++ b/jumpapp/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "972ae27bc31bfd2e428f6cba56a0dc37", + "content-hash": "097843a2f00f12e9786893c07a3ae8e3", "packages": [ { "name": "arthurhoaro/favicon", @@ -235,6 +235,138 @@ ], "time": "2021-12-12T17:43:24+00:00" }, + { + "name": "nette/http", + "version": "v3.1.5", + "source": { + "type": "git", + "url": "https://github.com/nette/http.git", + "reference": "8146c2f2a262691a7139f9c56007961dcc5c1f42" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/http/zipball/8146c2f2a262691a7139f9c56007961dcc5c1f42", + "reference": "8146c2f2a262691a7139f9c56007961dcc5c1f42", + "shasum": "" + }, + "require": { + "nette/utils": "^3.1", + "php": ">=7.2 <8.2" + }, + "conflict": { + "nette/di": "<3.0.3", + "nette/schema": "<1.2" + }, + "require-dev": { + "nette/di": "^3.0", + "nette/security": "^3.0", + "nette/tester": "^2.0", + "phpstan/phpstan": "^0.12", + "tracy/tracy": "^2.4" + }, + "suggest": { + "ext-fileinfo": "to detect type of uploaded files" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🌐 Nette Http: abstraction for HTTP request, response and session. Provides careful data sanitization and utility for URL and cookies manipulation.", + "homepage": "https://nette.org", + "keywords": [ + "cookies", + "http", + "nette", + "proxy", + "request", + "response", + "security", + "session", + "url" + ], + "time": "2021-11-29T18:56:42+00:00" + }, + { + "name": "nette/routing", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/nette/routing.git", + "reference": "5532e7e3612e13def357f089c1a5c25793a16843" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/routing/zipball/5532e7e3612e13def357f089c1a5c25793a16843", + "reference": "5532e7e3612e13def357f089c1a5c25793a16843", + "shasum": "" + }, + "require": { + "nette/http": "^3.0", + "nette/utils": "^3.0", + "php": ">=7.1" + }, + "require-dev": { + "nette/tester": "^2.0", + "phpstan/phpstan": "^0.12", + "tracy/tracy": "^2.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "Nette Routing: two-ways URL conversion", + "homepage": "https://nette.org", + "keywords": [ + "nette" + ], + "time": "2021-02-06T04:08:30+00:00" + }, { "name": "nette/utils", "version": "v3.2.7", diff --git a/jumpapp/config.php b/jumpapp/config.php index 8273d4d..02316f1 100644 --- a/jumpapp/config.php +++ b/jumpapp/config.php @@ -8,21 +8,30 @@ return [ // The site name is displayed in the browser tab. - 'sitename' => getenv('SITENAME') ?: 'Jump', - // Should the clock be displayed? - 'showclock' => getenv('SHOWCLOCK') ?: true, - // Temperature unit: True = metric / False = imperial. - 'metrictemp' => getenv('METRICTEMP') ?: true, + 'sitename' => getenv('SITENAME') ?: 'Jump', // Where on the this code is located. - 'wwwroot' => getenv('WWWROOT') ?: '/var/www/html', + 'wwwroot' => getenv('WWWROOT') ?: '/var/www/html', + // Stop retrieving items from the cache, useful for testing. - 'cachebypass' => getenv('CACHEBYPASS') ?: false, + 'cachebypass' => getenv('CACHEBYPASS') ?: false, // Where is the cache storage directory, should not be public. - 'cachedir' => getenv('CACHEDIR') ?: '/var/www/cache', + 'cachedir' => getenv('CACHEDIR') ?: '/var/www/cache', + // Include the robots noindex meta tag in site header. - 'noindex' => getenv('NOINDEX') ?: true, - // Coordinates for weather location. E.g. 51.509865,-0.118092 - 'latlong' => getenv('LATLONG') ?: '', + 'noindex' => getenv('NOINDEX') ?: true, + // Should the clock be displayed? + 'showclock' => getenv('SHOWCLOCK') ?: true, + // Show a friendly greeting message rather than "#home". + 'showgreeting' => getenv('SHOWGREETING') ?: true, + // Background blur percentage. + 'bgblur' => getenv('BGBLUR') ?: '70', + // Background brightness percentage. + 'bgbright' => getenv('BGBRIGHT') ?: '85', + // Open Weather Map API key. - 'owmapikey' => getenv('OWMAPIKEY') ?: '', + 'owmapikey' => getenv('OWMAPIKEY') ?: '', + // Coordinates for weather location. E.g. 51.509865,-0.118092 + 'latlong' => getenv('LATLONG') ?: '', + // Temperature unit: True = metric / False = imperial. + 'metrictemp' => getenv('METRICTEMP') ?: true, ]; \ No newline at end of file diff --git a/jumpapp/index.php b/jumpapp/index.php index 94f3fd1..73c38c0 100644 --- a/jumpapp/index.php +++ b/jumpapp/index.php @@ -1,6 +1,6 @@ * @license MIT @@ -9,7 +9,5 @@ // Provided by composer for psr-4 style autoloading. require __DIR__ .'/vendor/autoload.php'; -// Initialise the application, then render and output its index page. $jumpapp = new Jump\Main(); -$jumpapp->build_index_page(); -echo $jumpapp->get_output(); +echo $jumpapp->init(); diff --git a/jumpapp/sites/sites.json b/jumpapp/sites/sites.json index 2a66a88..12cbfa2 100644 --- a/jumpapp/sites/sites.json +++ b/jumpapp/sites/sites.json @@ -4,25 +4,37 @@ "icon": "my-default-icon.png" }, "sites": [ + { + "name": "Github", + "url" : "https://github.com/daledavies/jump" + }, + { + "name": "Docker Hub", + "url" : "https://hub.docker.com/r/daledavies/jump" + }, { "name": "Bitwarden", "url" : "https://bitwarden.example.com", - "icon": "bitwarden.png" + "icon": "bitwarden.png", + "tags": ["stuff"] }, { "name": "Gitea", "url" : "https://git.example.com", - "icon": "gitea.png" + "icon": "gitea.png", + "tags": ["stuff"] }, { "name": "Nextcloud", "url" : "https://cloud.example.com", - "icon": "nextcloud.png" + "icon": "nextcloud.png", + "tags": ["home", "stuff", "things"] }, { "name": "Paperless", "url" : "https://paperless.example.com", - "icon": "paperless.jpg" + "icon": "paperless.jpg", + "tags": ["things", "home"] }, { "name": "Google", diff --git a/jumpapp/templates/errorpage.mustache b/jumpapp/templates/errorpage.mustache new file mode 100644 index 0000000..78147cd --- /dev/null +++ b/jumpapp/templates/errorpage.mustache @@ -0,0 +1,18 @@ + + + + + + + + + {{message}} + + +
+
{{code}}
+ {{message}} +
+
+ + \ No newline at end of file diff --git a/jumpapp/templates/footer.mustache b/jumpapp/templates/footer.mustache index d1f6bb0..0d39413 100644 --- a/jumpapp/templates/footer.mustache +++ b/jumpapp/templates/footer.mustache @@ -1,14 +1,28 @@ - - + + +
+ {{# hastags }}{{/ hastags }} +
+ {{# hastags}} +
+ Tags + +
+ {{/ hastags}}
- + \ No newline at end of file diff --git a/jumpapp/templates/header.mustache b/jumpapp/templates/header.mustache index 9af8505..3f285af 100644 --- a/jumpapp/templates/header.mustache +++ b/jumpapp/templates/header.mustache @@ -8,13 +8,12 @@ - + - {{sitename}} + {{title}}