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 4c0763c..0000000
Binary files a/jumpapp/assets/images/overlay.png and /dev/null differ
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 @@
-
- {{# showclock}}{{/ showclock}}
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ {{# showclock}}{{/ showclock}}
+
+
+
+ {{# hastags}}
+
+ {{/ hastags}}
-
+