Compare commits

..

31 Commits

Author SHA1 Message Date
d9bc89b7f6 Update SNT gpx 2025-07-19 16:22:14 +02:00
760f38374f Add email trigger to manual positioning 2025-07-19 16:21:44 +02:00
b9a4bd6d2d Adapt manual message upload to vue 2025-05-12 19:48:49 +02:00
ea14a1ef3e Add manual message upload 2025-05-11 17:38:59 +02:00
457bab2c18 Convert upload page to vue 2025-05-03 20:37:15 +02:00
3571f93e41 Add track popups 2025-05-03 11:56:04 +02:00
e878b159bf Fix SNT track color 2025-05-02 21:27:39 +02:00
db70593852 Add function to rebuild GeoJSON 2025-05-02 21:26:45 +02:00
73b8e6b04f Add Build GeoJSON Catch 2025-05-02 21:19:11 +02:00
a49f73236b Update geo/snt.gpx 2025-05-02 21:18:50 +02:00
c0b7ad8000 Add SNT gpx 2025-05-02 21:18:29 +02:00
83bf47287c Libs update 2025-05-02 21:17:04 +02:00
4ce96e7192 Fix initial panel toggle 2024-02-27 23:13:10 +01:00
3169b8e83e Move geojson build to dedicated call 2024-02-27 23:03:32 +01:00
205855acd8 Split Gpx management classes 2024-02-27 20:09:58 +01:00
356d8ccd7e Update nodejs dependencies 2024-02-26 22:44:37 +01:00
25ff80ad7a Add track on project 2024-02-26 22:44:19 +01:00
0cd509a99d Fix config page buttons 2024-02-20 20:23:24 +01:00
3063f8b904 Fix feed update 2024-02-18 22:35:27 +01:00
59dea2917d Add projects to settings panel 2024-02-17 00:17:20 +01:00
6e614042d1 Replace panels movements with translateX 2024-02-13 23:33:57 +01:00
8c812f6b0a Fix lightbox comments 2024-02-11 09:43:22 +01:00
abacab8206 Fix lightbox deps 2024-02-11 09:20:08 +01:00
869b084d70 Upgrade maplibre and fix goToPost 2024-02-10 23:12:57 +01:00
cab899e544 Rebuild panel navigation 2024-02-06 21:22:36 +01:00
b6fc305111 Fix spotIcon classes on change 2024-02-04 22:56:37 +01:00
30a81b5341 Bump vue & deps 2024-01-15 20:35:39 +01:00
683670f77a Simplify spot button & input parameters 2024-01-11 22:16:17 +01:00
c2956ac373 Replace leaflet with maplibre GL 2024-01-11 21:01:21 +01:00
7853c6e285 Convert admin page to Vue 2023-12-16 09:19:40 +01:00
f674b0d934 Standardize admin page 2023-12-16 09:18:28 +01:00
54 changed files with 74766 additions and 3388 deletions

View File

@@ -4,6 +4,7 @@ var webpack = require("webpack");
const CopyWebpackPlugin = require('copy-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const SymlinkWebpackPlugin = require('symlink-webpack-plugin');
const { VueLoaderPlugin } = require('vue-loader')
module.exports = {
entry: {
@@ -16,29 +17,28 @@ module.exports = {
},
devtool: "inline-source-map",
module: {
rules: [
{
rules: [{
test: /\.vue$/,
loader: 'vue-loader'
}, {
test: /\.js$/,
exclude: /node_modules/,
exclude: file => (/node_modules/.test(file) && !/\.vue\.js/.test(file)),
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
}
},
},
},
{
}, {
test: /\.html$/i,
loader: "html-loader",
},
{
}, {
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource'
},
{
}, {
test: /\.s[ac]ss$/i,
use: [
'style-loader',
'vue-style-loader',
'css-loader',
'resolve-url-loader',
{
@@ -49,12 +49,10 @@ module.exports = {
}
}
]
},
{
}, {
test: /\.css$/i,
use: ["style-loader", "css-loader"],
},
{
use: ["vue-style-loader", "css-loader"],
}, {
test: /\.(png|svg|jpg|jpeg|gif)$/i,
use: {
loader: "url-loader",
@@ -76,23 +74,32 @@ module.exports = {
plugins: [
new webpack.ProvidePlugin({
$: require.resolve('jquery'),
jQuery: require.resolve('jquery'),
//L: require.resolve('leaflet')
jQuery: require.resolve('jquery')
}),
new CopyWebpackPlugin({
patterns: [{
patterns: [/*{
from: 'geo/',
to: path.resolve(DIST, 'geo')
}, {
from: path.resolve(LIB, 'index.php'),
to: 'index.php'
}, {
from: path.resolve(SRC, 'images/icons'),
to: 'images/icons'
}, */{
from: path.resolve(LIB, 'index.php'),
to: 'index.php'
}]
}),
new SymlinkWebpackPlugin({ origin: '../files/', symlink: 'files' }),
new CleanWebpackPlugin()
new SymlinkWebpackPlugin([
{ origin: '../files/', symlink: 'files' },
{ origin: '../geo/', symlink: 'geo' },
{ origin: '../src/images/icons/', symlink: 'images/icons' }
]),
new CleanWebpackPlugin(),
new webpack.DefinePlugin({
__VUE_OPTIONS_API__: 'true',
__VUE_PROD_DEVTOOLS__: 'false',
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false'
}),
new VueLoaderPlugin()
],
resolve: {
extensions: ['', '.js'],

21
composer.lock generated
View File

@@ -12,7 +12,7 @@
"dist": {
"type": "path",
"url": "../objects",
"reference": "a9f8601384a0078cf8531576768e2c5bad4bbf95"
"reference": "bcae723140735b1432caaf3070ef4e29ecb73a76"
},
"type": "library",
"autoload": {
@@ -27,16 +27,16 @@
},
{
"name": "phpmailer/phpmailer",
"version": "v6.8.1",
"version": "v6.10.0",
"source": {
"type": "git",
"url": "https://github.com/PHPMailer/PHPMailer.git",
"reference": "e88da8d679acc3824ff231fdc553565b802ac016"
"reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/e88da8d679acc3824ff231fdc553565b802ac016",
"reference": "e88da8d679acc3824ff231fdc553565b802ac016",
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144",
"reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144",
"shasum": ""
},
"require": {
@@ -56,6 +56,7 @@
"yoast/phpunit-polyfills": "^1.0.4"
},
"suggest": {
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
"ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
"ext-openssl": "Needed for secure SMTP sending and DKIM signing",
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
@@ -95,7 +96,7 @@
"description": "PHPMailer is a full-featured email creation and transfer class for PHP",
"support": {
"issues": "https://github.com/PHPMailer/PHPMailer/issues",
"source": "https://github.com/PHPMailer/PHPMailer/tree/v6.8.1"
"source": "https://github.com/PHPMailer/PHPMailer/tree/v6.10.0"
},
"funding": [
{
@@ -103,7 +104,7 @@
"type": "github"
}
],
"time": "2023-08-29T08:26:30+00:00"
"time": "2025-04-24T15:19:31+00:00"
}
],
"packages-dev": [],
@@ -114,7 +115,7 @@
},
"prefer-stable": false,
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
"plugin-api-version": "2.3.0"
"platform": {},
"platform-dev": {},
"plugin-api-version": "2.6.0"
}

View File

@@ -0,0 +1,5 @@
ALTER TABLE mappings ADD COLUMN default_map BOOLEAN DEFAULT 0 AFTER id_project;
ALTER TABLE mappings ADD CONSTRAINT default_on_generic_map_only CHECK (default_map = 0 OR id_project IS NULL);
UPDATE mappings SET default_map = 1 WHERE id_map = (select id_map from maps where codename = 'satellite');
UPDATE maps SET token = substring(pattern, locate('token=', pattern) + 6) WHERE codename = 'static_marker';
UPDATE maps SET pattern = replace(pattern, token, '{token}') WHERE codename = 'static_marker';

View File

@@ -8,7 +8,7 @@ class Settings
const DB_NAME = 'spot';
const DB_ENC = 'utf8mb4';
const TEXT_ENC = 'UTF-8';
const TIMEZONE = 'Europe/Paris';
const TIMEZONE = 'Europe/Zurich';
const MAIL_SERVER = '';
const MAIL_FROM = '';
const MAIL_USER = '';

69304
geo/snt.gpx Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,9 @@ admin_config = Config
admin_upload = Upload
save = Save
admin_save_success = Saved
admin_create_success= Created
admin_delete_success= Deleted
no_auth = No authorization
track_main = Main track
track_off-track = Off-track
@@ -21,6 +24,7 @@ upload_title = Picture & Video Uploads
upload_mode_archived= Project "$0" is archived. No upload allowed
upload_success = $0 uploaded successfully
upload_media_exist = Picture $0 already exists
new_position = New Position
post_message = Message
post_name = Name
@@ -64,7 +68,7 @@ id_project = Project ID
project = Project
projects = Projects
new_project = New Project
update_project = Update Project
update_project = Update project messages
hikes = Hikes
mode = Mode
mode_previz = Project in preparation
@@ -76,6 +80,7 @@ end = End
feeds = Feeds
id_feed = Feed ID
ref_feed_id = Ref. Feed ID
new_feed = New feed
id_spot = Spot ID
name = Name
status = Status

View File

@@ -11,6 +11,9 @@ admin_config = Configuración
admin_upload = Cargar
save = Guardar
admin_save_success = Guardado
admin_create_success= Creado
admin_delete_success= Eliminado
no_auth = No autorización
track_main = Camino principal
track_off-track = Variante
@@ -21,6 +24,7 @@ upload_title = Cargar fotos y videos
upload_mode_archived= El proyecto "$0" esta archivado. No se puede cargar
upload_success = $0 ha sido subido
upload_media_exist = La imagen $0 ya existe
new_position = Nueva posición
post_message = Mensaje
post_name = Nombre
@@ -64,7 +68,7 @@ id_project = Proyecto ID
project = Proyecto
projects = Proyectos
new_project = Nuevo proyecto
update_project = Actualizar el proyecto
update_project = Actualizar los mensajes del proyecto
hikes = Senderos
mode = Modo
mode_previz = Proyecto en preparación
@@ -76,6 +80,7 @@ end = Fin
feeds = Feeds
id_feed = ID Feed
ref_feed_id = ID Feed ref.
new_feed = Nuevo feed
id_spot = ID Spot
name = Descripción
status = Estado

View File

@@ -11,6 +11,9 @@ admin_config = Paramètres
admin_upload = Uploader
save = Sauvegarder
admin_save_success = Sauvegardé
admin_create_success= Créé
admin_delete_success= Supprimé
no_auth = Pas d'authorisation
track_main = Trajet principal
track_off-track = Variante
@@ -21,6 +24,7 @@ upload_title = Uploader photos & vidéos
upload_mode_archived= Le projet "$0" a été archivé. Aucun upload possible
upload_success = $0 a été uploadé
upload_media_exist = l'image $0 existe déjà
new_position = Nouvelle position
post_message = Message
post_name = Nom
@@ -64,7 +68,7 @@ id_project = ID projet
project = Projet
projects = Projets
new_project = Nouveau projet
update_project = Mettre à jour le projet
update_project = Mettre à jour les messages du projet
hikes = Randonnées
mode = Mode
mode_previz = Projet en cours de préparation
@@ -76,6 +80,7 @@ end = Arrivée
feeds = Feeds
id_feed = ID Feed
ref_feed_id = ID Feed ref.
new_feed = Nouveau feed
id_spot = ID Spot
name = Description
status = Statut

View File

@@ -2,8 +2,6 @@
namespace Franzz\Spot;
use Franzz\Objects\PhpObject;
use Franzz\Objects\ToolBox;
use \Settings;
/**
* GPX to GeoJSON Converter
@@ -35,298 +33,10 @@ class Converter extends PhpObject {
}
public static function isGeoJsonValid($sCodeName) {
$bResult = false;
$sGpxFilePath = Gpx::getFilePath($sCodeName);
$sGeoJsonFilePath = GeoJson::getFilePath($sCodeName);
//No need to generate if gpx is missing
if(!file_exists($sGpxFilePath) || file_exists($sGeoJsonFilePath) && filemtime($sGeoJsonFilePath) > filemtime(Gpx::getFilePath($sCodeName))) $bResult = true;
return $bResult;
}
}
class Geo extends PhpObject {
const GEO_FOLDER = '../geo/';
const OPT_SIMPLE = 'simplification';
protected $asTracks;
protected $sFilePath;
public function __construct($sCodeName) {
parent::__construct(get_class($this), Settings::DEBUG, PhpObject::MODE_HTML);
$this->sFilePath = self::getFilePath($sCodeName);
$this->asTracks = array();
}
public static function getFilePath($sCodeName) {
return self::GEO_FOLDER.$sCodeName.static::EXT;
}
public static function getDistFilePath($sCodeName) {
return 'geo/'.$sCodeName.static::EXT;
}
public function getLog() {
return $this->getCleanMessageStack(PhpObject::NOTICE_TAB);
}
}
class Gpx extends Geo {
const EXT = '.gpx';
public function __construct($sCodeName) {
parent::__construct($sCodeName);
$this->parseFile();
}
public function getTracks() {
return $this->asTracks;
}
private function parseFile() {
$this->addNotice('Parsing: '.$this->sFilePath);
if(!file_exists($this->sFilePath)) $this->addError($this->sFilePath.' file missing');
else {
$oXml = simplexml_load_file($this->sFilePath);
//Tracks
$this->addNotice('Converting '.count($oXml->trk).' tracks');
foreach($oXml->trk as $aoTrack) {
$asTrack = array(
'name' => (string) $aoTrack->name,
'desc' => str_replace("\n", '', ToolBox::fixEOL((strip_tags($aoTrack->desc)))),
'cmt' => ToolBox::fixEOL((strip_tags($aoTrack->cmt))),
'color' => (string) $aoTrack->extensions->children('gpxx', true)->TrackExtension->DisplayColor,
'points'=> array()
);
foreach($aoTrack->trkseg as $asSegment) {
foreach($asSegment as $asPoint) {
$asTrack['points'][] = array(
'lon' => (float) $asPoint['lon'],
'lat' => (float) $asPoint['lat'],
'ele' => (int) $asPoint->ele
);
}
}
$this->asTracks[] = $asTrack;
}
//Waypoints
$this->addNotice('Ignoring '.count($oXml->wpt).' waypoints');
}
}
}
class GeoJson extends Geo {
const EXT = '.geojson';
const MAX_FILESIZE = 2; //MB
const MAX_DEVIATION_FLAT = 0.1; //10%
const MAX_DEVIATION_ELEV = 0.1; //10%
public function __construct($sCodeName) {
parent::__construct($sCodeName);
}
public function saveFile() {
$this->addNotice('Saving '.$this->sFilePath);
file_put_contents($this->sFilePath, $this->buildGeoJson());
}
public function isSimplicationRequired() {
//Size in bytes
$iFileSize = strlen($this->buildGeoJson());
//Convert to MB
$iFileSize = round($iFileSize / pow(1024, 2), 2);
//Compare with max allowed size
$bFileTooLarge = ($iFileSize > self::MAX_FILESIZE);
if($bFileTooLarge) $this->addNotice('Output file is too large ('.$iFileSize.'MB > '.self::MAX_FILESIZE.'MB)');
return $bFileTooLarge;
}
public function buildTracks($asTracks, $bSimplify=false) {
$this->addNotice('Creating '.($bSimplify?'Simplified ':'').'GeoJson Tracks');
$iGlobalInvalidPointCount = 0;
$iGlobalPointCount = 0;
$this->asTracks = array();
foreach($asTracks as $asTrackProps) {
$asOptions = $this->parseOptions($asTrackProps['cmt']);
//Color mapping
switch($asTrackProps['color']) {
case 'DarkBlue':
$sType = 'main';
break;
case 'Magenta':
if($bSimplify && $asOptions[self::OPT_SIMPLE]!='keep') {
$this->addNotice('Ignoring Track "'.$asTrackProps['name'].' (off-track)');
continue 2; //discard tracks
}
else {
$sType = 'off-track';
break;
}
case 'Red':
$sType = 'hitchhiking';
break;
default:
$this->addNotice('Ignoring Track "'.$asTrackProps['name'].' (unknown color "'.$asTrackProps['color'].'")');
continue 2; //discard tracks
}
$asTrack = array(
'type' => 'Feature',
'properties' => array(
'name' => $asTrackProps['name'],
'type' => $sType,
'description' => $asTrackProps['desc']
),
'geometry' => array(
'type' => 'LineString',
'coordinates' => array()
)
);
//Track points
$asTrackPoints = $asTrackProps['points'];
$iPointCount = count($asTrackPoints);
$iInvalidPointCount = 0;
$asPrevPoint = array();
foreach($asTrackPoints as $iIndex=>$asPoint) {
$asNextPoint = ($iIndex < ($iPointCount - 1))?$asTrackPoints[$iIndex + 1]:array();
if($bSimplify && !empty($asPrevPoint) && !empty($asNextPoint)) {
if(!$this->isPointValid($asPrevPoint, $asPoint, $asNextPoint)) {
$iInvalidPointCount++;
continue;
}
}
$asTrack['geometry']['coordinates'][] = array_values($asPoint);
$asPrevPoint = $asPoint;
}
$this->asTracks[] = $asTrack;
$iGlobalInvalidPointCount += $iInvalidPointCount;
$iGlobalPointCount += $iPointCount;
if($iInvalidPointCount > 0) $this->addNotice('Removing '.$iInvalidPointCount.'/'.$iPointCount.' points ('.round($iInvalidPointCount / $iPointCount * 100, 1).'%) from '.$asTrackProps['name']);
}
if($bSimplify) $this->addNotice('Total: '.$iGlobalInvalidPointCount.'/'.$iGlobalPointCount.' points removed ('.round($iGlobalInvalidPointCount / $iGlobalPointCount * 100, 1).'%)');
}
public function sortOffTracks() {
$this->addNotice('Sorting off-tracks');
//Find first & last track points
$asTracksEnds = array();
$asTracks = array();
foreach($this->asTracks as $iTrackId=>$asTrack) {
$sTrackId = 't'.$iTrackId;
$asTracksEnds[$sTrackId] = array('first'=>reset($asTrack['geometry']['coordinates']), 'last'=>end($asTrack['geometry']['coordinates']));
$asTracks[$sTrackId] = $asTrack;
}
//Find variants close-by tracks
$asClonedTracks = $asTracks;
foreach($asClonedTracks as $sTrackId=>$asTrack) {
if($asTrack['properties']['type'] != 'off-track') continue;
$iMinDistance = INF;
$sConnectedTrackId = 0;
$iPosition = 0;
//Test all track ending points to find the closest
foreach($asTracksEnds as $sTrackEndId=>$asTrackEnds) {
if($sTrackEndId != $sTrackId) {
//Calculate distance between the last point of the track and every starting point of other tracks
$iDistance = self::getDistance($asTracksEnds[$sTrackId]['last'], $asTrackEnds['first']);
if($iDistance < $iMinDistance) {
$sConnectedTrackId = $sTrackEndId;
$iPosition = 0; //Track before the Connected Track
$iMinDistance = $iDistance;
}
//Calculate distance between the first point of the track and every ending point of other tracks
$iDistance = self::getDistance($asTracksEnds[$sTrackId]['first'], $asTrackEnds['last']);
if($iDistance < $iMinDistance) {
$sConnectedTrackId = $sTrackEndId;
$iPosition = +1; //Track after the Connected Track
$iMinDistance = $iDistance;
}
}
}
//Move track
unset($asTracks[$sTrackId]);
$iOffset = array_search($sConnectedTrackId, array_keys($asTracks)) + $iPosition;
$asTracks = array_slice($asTracks, 0, $iOffset) + array($sTrackId => $asTrack) + array_slice($asTracks, $iOffset);
}
$this->asTracks = array_values($asTracks);
}
private function parseOptions($sComment){
$sComment = strip_tags(html_entity_decode($sComment));
$asOptions = array(self::OPT_SIMPLE=>'');
foreach(explode("\n", $sComment) as $sLine) {
$asOptions[mb_strtolower(trim(mb_strstr($sLine, ':', true)))] = mb_strtolower(trim(mb_substr(mb_strstr($sLine, ':'), 1)));
}
return $asOptions;
}
private function isPointValid($asPointA, $asPointO, $asPointB) {
/* A----O Calculate angle AO^OB
* \ If angle is within [90% Pi ; 110% Pi], O can be discarded
* \ O is valid otherwise
* B
*/
//Path Turn Check -> -> -> ->
//Law of Cosines (vector): angle = arccos(OA.OB / ||OA||.||OB||)
$fVectorOA = array('lon'=>($asPointA['lon'] - $asPointO['lon']), 'lat'=> ($asPointA['lat'] - $asPointO['lat']));
$fVectorOB = array('lon'=>($asPointB['lon'] - $asPointO['lon']), 'lat'=> ($asPointB['lat'] - $asPointO['lat']));
$fLengthOA = sqrt(pow($asPointA['lon'] - $asPointO['lon'], 2) + pow($asPointA['lat'] - $asPointO['lat'], 2));
$fLengthOB = sqrt(pow($asPointO['lon'] - $asPointB['lon'], 2) + pow($asPointO['lat'] - $asPointB['lat'], 2));
$fVectorOAxOB = $fVectorOA['lon'] * $fVectorOB['lon'] + $fVectorOA['lat'] * $fVectorOB['lat'];
$fAngleAOB = ($fLengthOA != 0 && $fLengthOB != 0) ? acos($fVectorOAxOB/($fLengthOA * $fLengthOB)) : 0;
//Elevation Check
//Law of Cosines: angle = arccos((OB² + AO² - AB²) / (2*OB*AO))
$fLengthAB = sqrt(pow($asPointB['ele'] - $asPointA['ele'], 2) + pow($fLengthOA + $fLengthOB, 2));
$fLengthAO = sqrt(pow($asPointO['ele'] - $asPointA['ele'], 2) + pow($fLengthOA, 2));
$fLengthOB = sqrt(pow($asPointB['ele'] - $asPointO['ele'], 2) + pow($fLengthOB, 2));
$fAngleAOBElev = ($fLengthOB != 0 && $fLengthAO != 0) ? (acos((pow($fLengthOB, 2) + pow($fLengthAO, 2) - pow($fLengthAB, 2)) / (2 * $fLengthOB * $fLengthAO))) : 0;
return ($fAngleAOB <= (1 - self::MAX_DEVIATION_FLAT) * M_PI || $fAngleAOB >= (1 + self::MAX_DEVIATION_FLAT) * M_PI ||
$fAngleAOBElev <= (1 - self::MAX_DEVIATION_ELEV) * M_PI || $fAngleAOBElev >= (1 + self::MAX_DEVIATION_ELEV) * M_PI);
}
private function buildGeoJson() {
return json_encode(array('type'=>'FeatureCollection', 'features'=>$this->asTracks));
}
private static function getDistance($asPointA, $asPointB) {
$fLatFrom = $asPointA[1];
$fLonFrom = $asPointA[0];
$fLatTo = $asPointB[1];
$fLonTo = $asPointB[0];
$fRad = M_PI / 180;
//Calculate distance from latitude and longitude
$fTheta = $fLonFrom - $fLonTo;
$fDistance = sin($fLatFrom * $fRad) * sin($fLatTo * $fRad) + cos($fLatFrom * $fRad) * cos($fLatTo * $fRad) * cos($fTheta * $fRad);
return acos($fDistance) / $fRad * 60 * 1.853;
return !file_exists($sGpxFilePath) || file_exists($sGeoJsonFilePath) && filemtime($sGeoJsonFilePath) >= filemtime($sGpxFilePath);
}
}

View File

@@ -168,6 +168,31 @@ class Feed extends PhpObject {
return $bNewMsg;
}
public function addManualPosition($sLat, $sLng, $iTimestamp) {
$sTimeZone = date_default_timezone_get();
$oDateTime = new \DateTime('@'.$iTimestamp);
$oDateTime->setTimezone(new \DateTimeZone($sTimeZone));
$asWeather = $this->getWeather(array($sLat, $sLng), $iTimestamp);
$asMsg = [
'ref_msg_id' => $iTimestamp.'/man',
'id_feed' => $this->getFeedId(),
'type' => 'OK',
'latitude' => $sLat,
'longitude' => $sLng,
'iso_time' => $oDateTime->format("Y-m-d\TH:i:sO"), //Incorrect ISO 8601 format, but compliant with Spot data
'site_time' => $oDateTime->format(Db::TIMESTAMP_FORMAT),
'timezone' => $sTimeZone,
'unix_time' => $iTimestamp,
'content' => '',
'battery_state' => '',
'posted_on' => date(Db::TIMESTAMP_FORMAT),
];
$iMessageId = $this->oDb->insertRow(self::MSG_TABLE, array_merge($asMsg, $asWeather));
return $iMessageId;
}
private function updateFeed() {
$bNewMsg = false;
$asData = $this->retrieveFeed();

32
lib/Geo.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
namespace Franzz\Spot;
use Franzz\Objects\PhpObject;
use \Settings;
class Geo extends PhpObject {
const GEO_FOLDER = '../geo/';
const OPT_SIMPLE = 'simplification';
protected $asTracks;
protected $sFilePath;
public function __construct($sCodeName) {
parent::__construct(get_class($this), Settings::DEBUG, PhpObject::MODE_HTML);
$this->sFilePath = self::getFilePath($sCodeName);
$this->asTracks = array();
}
public static function getFilePath($sCodeName) {
return self::GEO_FOLDER.$sCodeName.static::EXT;
}
public static function getDistFilePath($sCodeName) {
return 'geo/'.$sCodeName.static::EXT;
}
public function getLog() {
return $this->getCleanMessageStack(PhpObject::NOTICE_TAB);
}
}

214
lib/GeoJson.php Normal file
View File

@@ -0,0 +1,214 @@
<?php
namespace Franzz\Spot;
class GeoJson extends Geo {
const EXT = '.geojson';
const MAX_FILESIZE = 2; //MB
const MAX_DEVIATION_FLAT = 0.1; //10%
const MAX_DEVIATION_ELEV = 0.1; //10%
public function __construct($sCodeName) {
parent::__construct($sCodeName);
}
public function saveFile() {
$this->addNotice('Saving '.$this->sFilePath);
file_put_contents($this->sFilePath, $this->buildGeoJson());
}
public function isSimplicationRequired() {
//Size in bytes
$iFileSize = strlen($this->buildGeoJson());
//Convert to MB
$iFileSize = round($iFileSize / pow(1024, 2), 2);
//Compare with max allowed size
$bFileTooLarge = ($iFileSize > self::MAX_FILESIZE);
if($bFileTooLarge) $this->addNotice('Output file is too large ('.$iFileSize.'MB > '.self::MAX_FILESIZE.'MB)');
return $bFileTooLarge;
}
public function buildTracks($asTracks, $bSimplify=false) {
$this->addNotice('Creating '.($bSimplify?'Simplified ':'').'GeoJson Tracks');
$iGlobalInvalidPointCount = 0;
$iGlobalPointCount = 0;
$this->asTracks = array();
foreach($asTracks as $asTrackProps) {
$asOptions = $this->parseOptions($asTrackProps['cmt']);
//Color mapping
switch($asTrackProps['color']) {
case 'DarkBlue':
$sType = 'main';
break;
case 'Magenta':
if($bSimplify && $asOptions[self::OPT_SIMPLE]!='keep') {
$this->addNotice('Ignoring Track "'.$asTrackProps['name'].' (off-track)');
continue 2; //discard tracks
}
else {
$sType = 'off-track';
break;
}
case 'Red':
$sType = 'hitchhiking';
break;
default:
$this->addNotice('Ignoring Track "'.$asTrackProps['name'].' (unknown color "'.$asTrackProps['color'].'")');
continue 2; //discard tracks
}
$asTrack = array(
'type' => 'Feature',
'properties' => array(
'name' => $asTrackProps['name'],
'type' => $sType,
'description' => $asTrackProps['desc']
),
'geometry' => array(
'type' => 'LineString',
'coordinates' => array()
)
);
//Track points
$asTrackPoints = $asTrackProps['points'];
$iPointCount = count($asTrackPoints);
$iInvalidPointCount = 0;
$asPrevPoint = array();
foreach($asTrackPoints as $iIndex=>$asPoint) {
$asNextPoint = ($iIndex < ($iPointCount - 1))?$asTrackPoints[$iIndex + 1]:array();
if($bSimplify && !empty($asPrevPoint) && !empty($asNextPoint)) {
if(!$this->isPointValid($asPrevPoint, $asPoint, $asNextPoint)) {
$iInvalidPointCount++;
continue;
}
}
$asTrack['geometry']['coordinates'][] = array_values($asPoint);
$asPrevPoint = $asPoint;
}
$this->asTracks[] = $asTrack;
$iGlobalInvalidPointCount += $iInvalidPointCount;
$iGlobalPointCount += $iPointCount;
if($iInvalidPointCount > 0) $this->addNotice('Removing '.$iInvalidPointCount.'/'.$iPointCount.' points ('.round($iInvalidPointCount / $iPointCount * 100, 1).'%) from '.$asTrackProps['name']);
}
if($bSimplify) $this->addNotice('Total: '.$iGlobalInvalidPointCount.'/'.$iGlobalPointCount.' points removed ('.round($iGlobalInvalidPointCount / $iGlobalPointCount * 100, 1).'%)');
}
public function sortOffTracks() {
$this->addNotice('Sorting off-tracks');
//Find first & last track points
$asTracksEnds = array();
$asTracks = array();
foreach($this->asTracks as $iTrackId=>$asTrack) {
$sTrackId = 't'.$iTrackId;
$asTracksEnds[$sTrackId] = array('first'=>reset($asTrack['geometry']['coordinates']), 'last'=>end($asTrack['geometry']['coordinates']));
$asTracks[$sTrackId] = $asTrack;
}
//Find variants close-by tracks
$asClonedTracks = $asTracks;
foreach($asClonedTracks as $sTrackId=>$asTrack) {
if($asTrack['properties']['type'] != 'off-track') continue;
$iMinDistance = INF;
$sConnectedTrackId = 0;
$iPosition = 0;
//Test all track ending points to find the closest
foreach($asTracksEnds as $sTrackEndId=>$asTrackEnds) {
if($sTrackEndId != $sTrackId) {
//Calculate distance between the last point of the track and every starting point of other tracks
$iDistance = self::getDistance($asTracksEnds[$sTrackId]['last'], $asTrackEnds['first']);
if($iDistance < $iMinDistance) {
$sConnectedTrackId = $sTrackEndId;
$iPosition = 0; //Track before the Connected Track
$iMinDistance = $iDistance;
}
//Calculate distance between the first point of the track and every ending point of other tracks
$iDistance = self::getDistance($asTracksEnds[$sTrackId]['first'], $asTrackEnds['last']);
if($iDistance < $iMinDistance) {
$sConnectedTrackId = $sTrackEndId;
$iPosition = +1; //Track after the Connected Track
$iMinDistance = $iDistance;
}
}
}
//Move track
unset($asTracks[$sTrackId]);
$iOffset = array_search($sConnectedTrackId, array_keys($asTracks)) + $iPosition;
$asTracks = array_slice($asTracks, 0, $iOffset) + array($sTrackId => $asTrack) + array_slice($asTracks, $iOffset);
}
$this->asTracks = array_values($asTracks);
}
private function parseOptions($sComment){
$sComment = strip_tags(html_entity_decode($sComment));
$asOptions = array(self::OPT_SIMPLE=>'');
foreach(explode("\n", $sComment) as $sLine) {
$asOptions[mb_strtolower(trim(mb_strstr($sLine, ':', true)))] = mb_strtolower(trim(mb_substr(mb_strstr($sLine, ':'), 1)));
}
return $asOptions;
}
private function isPointValid($asPointA, $asPointO, $asPointB) {
/* A----O Calculate angle AO^OB
* \ If angle is within [90% Pi ; 110% Pi], O can be discarded
* \ O is valid otherwise
* B
*/
//Path Turn Check -> -> -> ->
//Law of Cosines (vector): angle = arccos(OA.OB / ||OA||.||OB||)
$fVectorOA = array('lon'=>($asPointA['lon'] - $asPointO['lon']), 'lat'=> ($asPointA['lat'] - $asPointO['lat']));
$fVectorOB = array('lon'=>($asPointB['lon'] - $asPointO['lon']), 'lat'=> ($asPointB['lat'] - $asPointO['lat']));
$fLengthOA = sqrt(pow($asPointA['lon'] - $asPointO['lon'], 2) + pow($asPointA['lat'] - $asPointO['lat'], 2));
$fLengthOB = sqrt(pow($asPointO['lon'] - $asPointB['lon'], 2) + pow($asPointO['lat'] - $asPointB['lat'], 2));
$fVectorOAxOB = $fVectorOA['lon'] * $fVectorOB['lon'] + $fVectorOA['lat'] * $fVectorOB['lat'];
$fAngleAOB = ($fLengthOA != 0 && $fLengthOB != 0) ? acos($fVectorOAxOB/($fLengthOA * $fLengthOB)) : 0;
//Elevation Check
//Law of Cosines: angle = arccos((OB² + AO² - AB²) / (2*OB*AO))
$fLengthAB = sqrt(pow($asPointB['ele'] - $asPointA['ele'], 2) + pow($fLengthOA + $fLengthOB, 2));
$fLengthAO = sqrt(pow($asPointO['ele'] - $asPointA['ele'], 2) + pow($fLengthOA, 2));
$fLengthOB = sqrt(pow($asPointB['ele'] - $asPointO['ele'], 2) + pow($fLengthOB, 2));
$fAngleAOBElev = ($fLengthOB != 0 && $fLengthAO != 0) ? (acos((pow($fLengthOB, 2) + pow($fLengthAO, 2) - pow($fLengthAB, 2)) / (2 * $fLengthOB * $fLengthAO))) : 0;
return ($fAngleAOB <= (1 - self::MAX_DEVIATION_FLAT) * M_PI || $fAngleAOB >= (1 + self::MAX_DEVIATION_FLAT) * M_PI ||
$fAngleAOBElev <= (1 - self::MAX_DEVIATION_ELEV) * M_PI || $fAngleAOBElev >= (1 + self::MAX_DEVIATION_ELEV) * M_PI);
}
private function buildGeoJson() {
return json_encode(array('type'=>'FeatureCollection', 'features'=>$this->asTracks));
}
private static function getDistance($asPointA, $asPointB) {
$fLatFrom = $asPointA[1];
$fLonFrom = $asPointA[0];
$fLatTo = $asPointB[1];
$fLonTo = $asPointB[0];
$fRad = M_PI / 180;
//Calculate distance from latitude and longitude
$fTheta = $fLonFrom - $fLonTo;
$fDistance = sin($fLatFrom * $fRad) * sin($fLatTo * $fRad) + cos($fLatFrom * $fRad) * cos($fLatTo * $fRad) * cos($fTheta * $fRad);
return acos($fDistance) / $fRad * 60 * 1.853;
}
}

52
lib/Gpx.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
namespace Franzz\Spot;
use Franzz\Objects\ToolBox;
class Gpx extends Geo {
const EXT = '.gpx';
public function __construct($sCodeName) {
parent::__construct($sCodeName);
$this->parseFile();
}
public function getTracks() {
return $this->asTracks;
}
private function parseFile() {
$this->addNotice('Parsing: '.$this->sFilePath);
if(!file_exists($this->sFilePath)) $this->addError($this->sFilePath.' file missing');
else {
$oXml = simplexml_load_file($this->sFilePath);
//Tracks
$this->addNotice('Converting '.count($oXml->trk).' tracks');
foreach($oXml->trk as $aoTrack) {
$asTrack = array(
'name' => (string) $aoTrack->name,
'desc' => str_replace("\n", '', ToolBox::fixEOL((strip_tags($aoTrack->desc)))),
'cmt' => ToolBox::fixEOL((strip_tags($aoTrack->cmt))),
'color' => (string) $aoTrack->extensions->children('gpxx', true)->TrackExtension->DisplayColor,
'points'=> array()
);
foreach($aoTrack->trkseg as $asSegment) {
foreach($asSegment as $asPoint) {
$asTrack['points'][] = array(
'lon' => (float) $asPoint['lon'],
'lat' => (float) $asPoint['lat'],
'ele' => (int) $asPoint->ele
);
}
}
$this->asTracks[] = $asTrack;
}
//Waypoints
$this->addNotice('Ignoring '.count($oXml->wpt).' waypoints');
}
}
}

View File

@@ -11,13 +11,12 @@ class Map extends PhpObject {
const MAPPING_TABLE = 'mappings';
private Db $oDb;
private $asMaps;
public function __construct(Db &$oDb) {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
$this->setMaps();
$this->asMaps = array();
}
private function setMaps() {
@@ -25,14 +24,36 @@ class Map extends PhpObject {
foreach($asMaps as $asMap) $this->asMaps[$asMap['codename']] = $asMap;
}
private function getMaps($sCodeName='') {
if(empty($this->asMaps)) $this->setMaps();
return ($sCodeName=='')?$this->asMaps:$this->asMaps[$sCodeName];
}
public function getProjectMaps($iProjectId) {
$asMappings = $this->oDb->getArrayQuery("SELECT id_map FROM mappings WHERE id_project = ".$iProjectId." OR id_project IS NULL", true);
return array_filter($this->asMaps, function($asMap) use($asMappings) {return in_array($asMap['id_map'], $asMappings);});
$asMappings = $this->oDb->selectRows(
array(
'select' => array(Db::getId(self::MAP_TABLE), 'default_map'),
'from' => self::MAPPING_TABLE,
'constraint'=> array("IFNULL(id_project, {$iProjectId})" => $iProjectId)
),
Db::getId(self::MAP_TABLE)
);
$asProjectMaps = array();
foreach($this->getMaps() as $asMap) {
if(array_key_exists($asMap['id_map'], $asMappings)) {
$asMap['default_map'] = $asMappings[$asMap['id_map']];
$asProjectMaps[] = $asMap;
}
}
return $asProjectMaps;
}
public function getMapUrl($sCodeName, $asParams) {
$asParams['token'] = $this->asMaps[$sCodeName]['token'];
return self::populateParams($this->asMaps[$sCodeName]['pattern'], $asParams);
$asMap = $this->getMaps($sCodeName);
$asParams['token'] = $asMap['token'];
return self::populateParams($asMap['pattern'], $asParams);
}
private static function populateParams($sUrl, $asParams) {

View File

@@ -17,30 +17,21 @@ class Media extends PhpObject {
const THUMB_MAX_WIDTH = 400;
/**
* Database Handle
* @var Db
*/
private $oDb;
/**
* Media Project
* @var Project
*/
private $oProject;
private Db $oDb;
private Project $oProject;
private $asMedia;
private $asMedias;
private $sSystemType;
//private $sSystemType;
private $iMediaId;
public function __construct(Db &$oDb, &$oProject, $iMediaId=0) {
public function __construct(Db &$oDb, Project &$oProject, $iMediaId=0) {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
$this->oProject = &$oProject;
$this->asMedia = array();
$this->asMedias = array();
$this->sSystemType = (substr(php_uname(), 0, 7) == "Windows")?'win':'unix';
//$this->sSystemType = (substr(php_uname(), 0, 7) == "Windows")?'win':'unix';
$this->setMediaId($iMediaId);
}

View File

@@ -142,15 +142,19 @@ class Project extends PhpObject {
}
$asProject['editable'] = $this->isModeEditable($asProject['mode']);
if($sCodeName != '' && !Converter::isGeoJsonValid($sCodeName)) Converter::convertToGeoJson($sCodeName);
$asProject['geofilepath'] = Spot::addTimestampToFilePath(GeoJson::getDistFilePath($sCodeName));
//$asProject['geofilepath'] = Spot::addTimestampToFilePath(GeoJson::getDistFilePath($sCodeName));
$asProject['gpxfilepath'] = Spot::addTimestampToFilePath(Gpx::getDistFilePath($sCodeName));
$asProject['codename'] = $sCodeName;
}
return $bSpecificProj?$asProject:$asProjects;
}
public function getGeoJson() {
if($this->sCodeName != '' && !Converter::isGeoJsonValid($this->sCodeName)) Converter::convertToGeoJson($this->sCodeName);
return json_decode(file_get_contents(GeoJson::getDistFilePath($this->sCodeName)), true);
}
public function getProject() {
return $this->getProjects($this->getProjectId());
}
@@ -185,7 +189,7 @@ class Project extends PhpObject {
$this->sCodeName = $asProject['codename'];
$this->sMode = $asProject['mode'];
$this->asActive = array('from'=>$asProject['active_from'], 'to'=>$asProject['active_to']);
$this->asGeo = array('geofile'=>$asProject['geofilepath'], 'gpxfile'=>$asProject['gpxfilepath']);
$this->asGeo = array(/*'geofile'=>$asProject['geofilepath'], */'gpxfile'=>$asProject['gpxfilepath']);
}
else $this->addError('Error while setting project: no project ID');
}

View File

@@ -150,7 +150,8 @@ class Spot extends Main
Project::PROJ_TABLE => "UNIQUE KEY `uni_proj_name` (`codename`)",
Media::MEDIA_TABLE => "UNIQUE KEY `uni_file_name` (`filename`)",
User::USER_TABLE => "UNIQUE KEY `uni_email` (`email`)",
Map::MAP_TABLE => "UNIQUE KEY `uni_map_name` (`codename`)"
Map::MAP_TABLE => "UNIQUE KEY `uni_map_name` (`codename`)",
Map::MAPPING_TABLE => "default_on_generic_map_only CHECK (`default_map` = 0 OR `id_project` IS NULL)"
),
'cascading_delete' => array
(
@@ -173,7 +174,6 @@ class Spot extends Main
return parent::getMainPage(
array(
'vars' => array(
'chunk_size' => self::FEED_CHUNK_SIZE,
'default_project_codename' => $this->oProject->getProjectCodeName(),
'projects' => $this->oProject->getProjects(),
'user' => $this->oUser->getUserInfo()
@@ -181,7 +181,8 @@ class Spot extends Main
'consts' => array(
'modes' => Project::MODES,
'clearances' => User::CLEARANCES,
'default_timezone' => Settings::TIMEZONE
'default_timezone' => Settings::TIMEZONE,
'chunk_size' => self::FEED_CHUNK_SIZE
)
),
self::MAIN_PAGE,
@@ -204,6 +205,10 @@ class Spot extends Main
$this->oProject->setProjectId($iProjectId);
}
public function getProjectGeoJson() {
return self::getJsonResult(true, '', $this->oProject->getGeoJson());
}
public function updateProject() {
$bNewMsg = false;
$bSuccess = true;
@@ -218,6 +223,15 @@ class Spot extends Main
//Send Update Email
if($bNewMsg) {
$bSuccess = $this->sendEmail();
$sDesc = $bSuccess?'mail_sent':'mail_failure';
}
else $sDesc = 'no_new_msg';
return self::getJsonResult($bSuccess, $sDesc);
}
private function sendEmail() {
$oEmail = new Email($this->asContext['serv_name'], 'email_update');
$oEmail->setDestInfo($this->oUser->getActiveUsersInfo());
@@ -245,12 +259,7 @@ class Spot extends Main
if($iPostCount == self::MAIL_CHUNK_SIZE) break;
}
$bSuccess = $oEmail->send();
$sDesc = $bSuccess?'mail_sent':'mail_failure';
}
else $sDesc = 'no_new_msg';
return self::getJsonResult($bSuccess, $sDesc);
return $oEmail->send();
}
public function genCronFile() {
@@ -627,6 +636,19 @@ class Spot extends Main
return self::getJsonResult($asResult['result'], $asResult['desc'], $asResult['data']);
}
public function addPosition($sLat, $sLng, $iTimestamp) {
$oFeed = new Feed($this->oDb, $this->oProject->getFeedIds()[0]);
$bSuccess = ($oFeed->addManualPosition($sLat, $sLng, $iTimestamp) > 0);
if($bSuccess) {
$bSuccess = $this->sendEmail();
$sDesc = $bSuccess?'mail_sent':'mail_failure';
}
else $sDesc = 'error_commit_db';
return self::getJsonResult($bSuccess, $sDesc);
}
public function getAdminSettings($sType='') {
$oFeed = new Feed($this->oDb);
$asData = array(
@@ -709,38 +731,68 @@ class Spot extends Main
return self::getJsonResult($bSuccess, $sDesc, array($sType=>array($asResult)));
}
public function delAdminSettings($sType, $iId) {
public function createAdminSettings($sType) {
$bSuccess = false;
$sDesc = '';
$asResult = array();
switch($sType) {
case 'project':
$oProject = new Project($this->oDb, $iId);
$asResult = $oProject->delete();
$sDesc = $asResult['project'][0]['desc'];
break;
case 'feed':
$oFeed = new Feed($this->oDb, $iId);
$asResult = array('feed'=>array($oFeed->delete()));
$sDesc = $asResult['feed'][0]['desc'];
break;
}
$bSuccess = ($sDesc=='');
return self::getJsonResult($bSuccess, $sDesc, $asResult);
}
public function createProject() {
$oProject = new Project($this->oDb);
$iNewProjectId = $oProject->createProjectId();
$oFeed = new Feed($this->oDb);
$oFeed->createFeedId($iNewProjectId);
return self::getJsonResult($iNewProjectId>0, '', array(
$bSuccess = $iNewProjectId > 0;
$asResult = array(
'project' => array($oProject->getProject()),
'feed' => array($oFeed->getFeed())
));
);
break;
case 'feed':
$oFeed = new Feed($this->oDb);
$iNewFeedId = $oFeed->createFeedId($this->oProject->getProjectId());
$bSuccess = $iNewFeedId > 0;
$asResult = array(
'feed' => array($oFeed->getFeed())
);
break;
}
return self::getJsonResult($bSuccess, $sDesc, $asResult);
}
public function deleteAdminSettings($sType, $iId) {
$bSuccess = false;
$sDesc = '';
$asResult = array();
switch($sType) {
case 'project':
$oProject = new Project($this->oDb, $iId);
$asResult = $oProject->delete();
$sDesc = $asResult['project'][0]['desc'];
$bSuccess = $asResult['project'][0]['del'];
break;
case 'feed':
$oFeed = new Feed($this->oDb, $iId);
$asResult = array('feed' => array($oFeed->delete()));
$sDesc = $asResult['feed'][0]['desc'];
$bSuccess = $asResult['feed'][0]['del'];
break;
case 'user':
$asResult = array('user' => array($this->oUser->removeUser($iId)));
$sDesc = $asResult['user'][0]['desc'];
$bSuccess = $asResult['user'][0]['result'];
break;
}
return self::getJsonResult($bSuccess, $sDesc, $asResult);
}
public function buildGeoJSON($sCodeName) {
return Converter::convertToGeoJson($sCodeName);
}
public static function decToDms($dValue, $sType) {

View File

@@ -20,6 +20,7 @@ class User extends PhpObject {
//Cookie
const COOKIE_ID_USER = 'subscriber';
const COOKIE_DURATION = 60 * 60 * 24 * 365; //1 year
/**
* Database Handle
* @var Db
@@ -33,7 +34,7 @@ class User extends PhpObject {
public function __construct(Db &$oDb) {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
$this->iUserId = 0;
$this->setUserId(0);
$this->asUserInfo = array(
'id' => 0,
Db::getId(self::USER_TABLE) => 0,
@@ -47,6 +48,51 @@ class User extends PhpObject {
$this->checkUserCookie();
}
public function getUserId() {
return $this->iUserId;
}
public function setUserId($iUserId) {
$this->iUserId = 0;
if($iUserId > 0) {
$asUser = $this->getActiveUserInfo($iUserId);
if(!empty($asUser)) {
$this->iUserId = $iUserId;
$this->asUserInfo = $asUser;
}
}
}
public function getUserInfo() {
return $this->asUserInfo;
}
public function getActiveUserInfo($iUserId) {
$asUsersInfo = array();
if($iUserId > 0) $asUsersInfo = $this->getActiveUsersInfo($iUserId);
return empty($asUsersInfo)?array():array_shift($asUsersInfo);
}
public function getActiveUsersInfo($iUserId=-1) {
//Mapping between user fields and DB fields
$asSelect = array_keys($this->asUserInfo);
$asSelect[array_search('id', $asSelect)] = Db::getId(self::USER_TABLE)." AS id";
//Non-admin cannot access clearance info
if(!$this->checkUserClearance(self::CLEARANCE_ADMIN)) unset($asSelect['clearance']);
$asInfo = array(
'select' => $asSelect,
'from' => self::USER_TABLE,
'constraint'=> array('active'=>self::USER_ACTIVE)
);
if($iUserId != -1) $asInfo['constraint'][Db::getId(self::USER_TABLE)] = $iUserId;
return $this->oDb->selectRows($asInfo);
}
public function getLang() {
return $this->asUserInfo['language'];
}
@@ -95,20 +141,25 @@ class User extends PhpObject {
return Spot::getResult($bSuccess, $sDesc);
}
public function removeUser() {
public function removeUser($iUserId=0) {
$iUserId = ($iUserId > 0)?$iUserId:$this->getUserId();
$bSelf = ($iUserId == $this->getUserId());
$bSuccess = false;
$sDesc = '';
if($this->iUserId > 0) {
$iUserId = $this->oDb->updateRow(self::USER_TABLE, $this->getUserId(), array('active'=>self::USER_INACTIVE));
if($bSelf || $this->checkUserClearance(self::CLEARANCE_ADMIN)) {
if($this->getUserId() > 0) {
$iUserId = $this->oDb->updateRow(self::USER_TABLE, $iUserId, array('active' => self::USER_INACTIVE));
if($iUserId==0) $sDesc = 'lang:error_commit_db';
else {
$sDesc = 'lang:nl_unsubscribed';
$this->updateCookie(-60 * 60); //Set Cookie in the past, deleting it
if($bSelf) $this->updateCookie(-60 * 60); //Set Cookie in the past, deleting it
$bSuccess = true;
}
}
else $sDesc = 'lang:nl_unknown_email';
}
else $sDesc = 'lang:no_auth';
return Spot::getResult($bSuccess, $sDesc);
}
@@ -131,49 +182,6 @@ class User extends PhpObject {
}
}
public function getUserId() {
return $this->iUserId;
}
public function setUserId($iUserId) {
$this->iUserId = 0;
$asUser = $this->getActiveUserInfo($iUserId);
if(!empty($asUser)) {
$this->iUserId = $iUserId;
$this->asUserInfo = $asUser;
}
}
public function getUserInfo() {
return $this->asUserInfo;
}
public function getActiveUserInfo($iUserId) {
$asUsersInfo = array();
if($iUserId > 0) $asUsersInfo = $this->getActiveUsersInfo($iUserId);
return empty($asUsersInfo)?array():array_shift($asUsersInfo);
}
public function getActiveUsersInfo($iUserId=-1) {
//Mapping between user fields and DB fields
$asSelect = array_keys($this->asUserInfo);
$asSelect[array_search('id', $asSelect)] = Db::getId(self::USER_TABLE)." AS id";
//Non-admin cannot access clearance info
if(!$this->checkUserClearance(self::CLEARANCE_ADMIN)) unset($asSelect['clearance']);
$asInfo = array(
'select' => $asSelect,
'from' => self::USER_TABLE,
'constraint'=> array('active'=>self::USER_ACTIVE)
);
if($iUserId != -1) $asInfo['constraint'][Db::getId(self::USER_TABLE)] = $iUserId;
return $this->oDb->selectRows($asInfo);
}
public function checkUserClearance($iClearance)
{
return ($this->asUserInfo['clearance'] >= $iClearance);

View File

@@ -26,6 +26,9 @@ $oValue = $_REQUEST['value'] ?? '';
$iId = $_REQUEST['id'] ?? 0 ;
$sType = $_REQUEST['type'] ?? '';
$sEmail = $_REQUEST['email'] ?? '';
$sLat = $_REQUEST['latitude'] ?? '';
$sLng = $_REQUEST['longitude'] ?? '';
$iTimestamp = $_REQUEST['timestamp'] ?? 0;
//Initiate class
$oSpot = new Spot(__FILE__, $sTimezone);
@@ -39,6 +42,9 @@ if($sAction!='')
case 'markers':
$sResult = $oSpot->getMarkers();
break;
case 'geojson':
$sResult = $oSpot->getProjectGeoJson();
break;
case 'next_feed':
$sResult = $oSpot->getNextFeed($iId);
break;
@@ -71,8 +77,8 @@ if($sAction!='')
case 'add_comment':
$sResult = $oSpot->addComment($iId, $sContent);
break;
case 'admin_new':
$sResult = $oSpot->createProject();
case 'add_position':
$sResult = $oSpot->addPosition($sLat, $sLng, $iTimestamp);
break;
case 'admin_get':
$sResult = $oSpot->getAdminSettings();
@@ -80,8 +86,11 @@ if($sAction!='')
case 'admin_set':
$sResult = $oSpot->setAdminSettings($sType, $iId, $sField, $oValue);
break;
case 'admin_del':
$sResult = $oSpot->delAdminSettings($sType, $iId);
case 'admin_create':
$sResult = $oSpot->createAdminSettings($sType);
break;
case 'admin_delete':
$sResult = $oSpot->deleteAdminSettings($sType, $iId);
break;
case 'generate_cron':
$sResult = $oSpot->genCronFile();
@@ -89,6 +98,9 @@ if($sAction!='')
case 'sql':
$sResult = $oSpot->getDbBuildScript();
break;
case 'build_geojson':
$sResult = $oSpot->buildGeoJSON($sName);
break;
default:
$sResult = Main::getJsonResult(false, Main::NOT_FOUND);
}

4396
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,14 @@
{
"devDependencies": {
"@babel/core": "^7.23.2",
"@babel/preset-env": "^7.23.2",
"babel-loader": "^9.1.3",
"@babel/core": "^7.23.9",
"@babel/preset-env": "^7.23.9",
"babel-loader": "^10.0.0",
"resolve-url-loader": "^5.0.0",
"symlink-webpack-plugin": "^1.1.0",
"webpack": "^5.89.0",
"webpack-cli": "^5.1.4"
"vue-loader": "^17.4.2",
"vue-template-compiler": "^2.7.16",
"webpack": "^5.99.7",
"webpack-cli": "^6.0.1"
},
"name": "spot",
"description": "FindMeSpot & GPX integration",
@@ -23,23 +25,23 @@
"autosize": "^6.0.1",
"blueimp-file-upload": "^10.32.0",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.8.1",
"copy-webpack-plugin": "^13.0.0",
"css-loader": "^7.1.2",
"d3": "^7.8.5",
"file-loader": "^6.2.0",
"html-loader": "^4.2.0",
"html-loader": "^5.0.0",
"jquery": "^3.7.1",
"jquery-mousewheel": "^3.1.13",
"jquery.waitforimages": "^2.4.0",
"leaflet": "^1.9.4",
"leaflet-geometryutil": "^0.10.2",
"leaflet.heightgraph": "^1.4.0",
"lightbox2": "^2.11.4",
"maplibre-gl": "^5.4.0",
"resize-observer-polyfill": "^1.5.1",
"sass": "^1.69.4",
"sass-loader": "^13.3.2",
"simplebar": "^6.2.5",
"style-loader": "^3.3.3",
"url-loader": "^4.1.1"
"sass": "^1.70.0",
"sass-loader": "^16.0.5",
"simplebar-vue": "^2.3.3",
"style-loader": "^4.0.0",
"url-loader": "^4.1.1",
"vue": "^3.3.8",
"vue-style-loader": "^4.1.3"
}
}

View File

@@ -1,6 +1,8 @@
# Spot Project
[Spot](https://www.findmespot.com) & GPX integration
## Dependencies
* npm 18+
* composer
* php-mbstring
@@ -11,25 +13,31 @@
* ffprobe & ffmpeg
* STARTTLS Email Server (use Gmail if none available)
* Optional: Geo Caching Server (WMTS Caching Service)
## PHP Configuration
* max_execution_time = 300
* memory_limit = 500M
* post_max_size = 4G
* upload_max_filesize = 4G
* max_file_uploads = 50
## Getting started
1. Clone Git onto web server
2. composer install
3. npm run dev
4. Update php.ini parameters
5. Copy timezone data: mariadb_tzinfo_to_sql /usr/share/zoneinfo | mariadb -u root mysql
6. Copy settings-sample.php to settings.php and populate
7. Go to #admin and create a new project, feed & maps
8. Add a GPX file named <project_codename>.gpx to /geo/
3. npm install webpack
4. npm run dev
5. Update php.ini parameters
6. Copy timezone data: mariadb-tzinfo-to-sql /usr/share/zoneinfo | mariadb -u root mysql
7. Copy settings-sample.php to settings.php and populate
8. Go to #admin and create a new project, feed & maps
9. Add a GPX file named <project_codename>.gpx to /geo/
## To Do List
* ECMA import/export
* Add mail frequency slider
* Use WMTS servers directly when not using Geo Caching Server
* Allow HEIF picture format
* Vector tiles support (https://www.arcgis.com/home/item.html?id=7dc6cea0b1764a1f9af2e679f642f0f5) + Use of GL library. Use Mapbox GL JS / Maplibre GL JS / ESRI-Leaflet-vector?
* Fix .MOV playback on windows firefox
* Garmin InReach Integration

84
src/Spot.vue Normal file
View File

@@ -0,0 +1,84 @@
<script>
import Project from './components/project.vue';
import Admin from './components/admin.vue';
import Upload from './components/upload.vue';
const aoRoutes = {
'project': Project,
'admin': Admin,
'upload': Upload
};
export default {
data() {
return {
hash: {},
consts: this.spot.consts,
user: this.spot.vars('user')
};
},
provide() {
return {
projects: this.spot.vars('projects'),
consts: this.consts,
user: this.user
};
},
inject: ['spot'],
computed: {
page() {
this.spot.vars('page', this.hash.page);
return aoRoutes[this.hash.page];
}
},
created() {
//User
this.user.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || this.consts.default_timezone;
},
mounted() {
window.addEventListener('hashchange', () => {this.onHashChange();});
var oEvent = new Event('hashchange');
window.dispatchEvent(oEvent);
},
methods: {
_hash(hash, bReboot) {
bReboot = bReboot || false;
if(!hash) return window.location.hash.slice(1);
else window.location.hash = '#'+hash;
if(bReboot) location.reload();
},
onHashChange() {
let asHash = this.getHash();
if(asHash.hash !='' && asHash.page != '') {
if(asHash.page == this.hash.page) this.spot.onSamePageMove(asHash);
this.hash = asHash;
}
else if(!this.hash.page) this.setHash(this.spot.consts.default_page);
},
getHash() {
let sHash = this._hash();
let asHash = sHash.split(this.spot.consts.hash_sep);
let sPage = asHash.shift() || '';
return {hash:sHash, page:sPage, items:asHash};
},
setHash(sPage, asItems, bReboot) {
bReboot = bReboot || false;
sPage = sPage || '';
asItems = asItems || [];
if(typeof asItems == 'string') asItems = [asItems];
if(sPage != '') {
let sItems = (asItems.length > 0)?this.spot.consts.hash_sep+asItems.join(this.spot.consts.hash_sep):'';
this._hash(sPage+sItems, bReboot);
}
}
}
}
</script>
<template>
<div id="main">
<component :is="page" />
</div>
<div id="mobile"></div>
</template>

235
src/components/admin.vue Normal file
View File

@@ -0,0 +1,235 @@
<script>
import SpotIcon from './spotIcon.vue';
import SpotButton from './spotButton.vue';
import AdminInput from './adminInput.vue';
export default {
components: {
SpotIcon,
SpotButton,
AdminInput
},
inject: ['spot'],
data() {
return {
elems: {},
feedbacks: []
};
},
mounted() {
this.setEvents();
this.setProjects();
},
methods: {
l(id) {
return this.spot.lang(id);
},
setEvents() {
this.spot.addPage('admin', {
onFeedback: (sType, sMsg, asContext) => {
delete asContext.a;
delete asContext.t;
sMsg += ' (';
for(const [sKey, sElem] of Object.entries(asContext)) {
sMsg += sKey+'='+sElem+' / ' ;
}
sMsg = sMsg.slice(0, -3)+')';
this.feedbacks.push({type:sType, msg:sMsg});
}
});
},
async setProjects() {
let aoElemTypes = await this.spot.get2('admin_get');
for(const [sType, aoElems] of Object.entries(aoElemTypes)) {
this.elems[sType] = {};
for(const [iKey, oElem] of Object.entries(aoElems)) {
oElem.type = sType;
this.elems[sType][oElem.id] = oElem;
}
}
},
createElem(sType) {
this.spot.get2('admin_create', {type: sType})
.then((aoNewElemTypes) => {
for(const [sType, aoNewElems] of Object.entries(aoNewElemTypes)) {
for(const [iKey, oNewElem] of Object.entries(aoNewElems)) {
oNewElem.type = sType;
this.elems[sType][oNewElem.id] = oNewElem;
this.spot.onFeedback('success', this.spot.lang('admin_create_success'), {'create':sType});
}
}
})
.catch((sMsg) => {this.spot.onFeedback('error', sMsg, {'create':sType});});
},
deleteElem(oElem) {
const asInputs = {
type: oElem.type,
id: oElem.id
};
this.spot.get(
'admin_delete',
(asData) => {
delete this.elems[asInputs.type][asInputs.id];
this.spot.onFeedback('success', this.spot.lang('admin_delete_success'), asInputs);
},
asInputs,
(sError) => {
this.spot.onFeedback('error', sError, asInputs);
}
);
},
updateElem(oElem, oEvent) {
if(typeof this.spot.tmp('wait') != 'undefined') clearTimeout(this.spot.tmp('wait'));
let sOldVal = this.elems[oElem.type][oElem.id][oEvent.target.name];
let sNewVal = oEvent.target.value;
if(sOldVal != sNewVal) {
let asInputs = {
type: oElem.type,
id: oElem.id,
field: oEvent.target.name,
value: sNewVal
};
this.spot.get2('admin_set', asInputs)
.then((asData) => {
this.elems[oElem.type][oElem.id][oEvent.target.name] = sNewVal;
this.spot.onFeedback('success', this.spot.lang('admin_save_success'), asInputs);
})
.catch((sError) => {
oEvent.target.value = sOldVal;
this.spot.onFeedback('error', sError, asInputs);
});
}
},
queue(oElem, oEvent) {
if(typeof this.spot.tmp('wait') != 'undefined') clearTimeout(this.spot.tmp('wait'));
this.spot.tmp('wait', setTimeout(() => {this.updateElem(oElem, oEvent);}, 2000));
},
updateProject() {
this.spot.get2('update_project')
.then((asData, sMsg) => {this.spot.onFeedback('success', sMsg, {'update':'project'});})
.catch((sMsg) => {this.spot.onFeedback('error', sMsg, {'update':'project'});});
}
}
}
</script>
<template>
<div id="admin">
<a name="back" class="button" href="#project"><SpotIcon :icon="'back'" :text="l('nav_back')" /></a>
<h1>{{ l('projects') }}</h1>
<div id="project_section">
<table>
<thead>
<tr>
<th>{{ l('id_project') }}</th>
<th>{{ l('project') }}</th>
<th>{{ l('mode') }}</th>
<th>{{ l('code_name') }}</th>
<th>{{ l('start') }}</th>
<th>{{ l('end') }}</th>
<th>{{ l('delete') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="project in elems.project">
<td>{{ project.id }}</td>
<td><AdminInput :type="'text'" :name="'name'" :elem="project" /></td>
<td>{{ project.mode }}</td>
<td><AdminInput :type="'text'" :name="'codename'" :elem="project" /></td>
<td><AdminInput :type="'date'" :name="'active_from'" :elem="project" /></td>
<td><AdminInput :type="'date'" :name="'active_to'" :elem="project" /></td>
<td><SpotButton :icon="'close fa-lg'" @click="deleteElem(project)" /></td>
</tr>
</tbody>
</table>
<SpotButton :classes="'new'" :text="l('new_project')" :icon="'new'" @click="createElem('project')" />
</div>
<h1>{{ l('feeds') }}</h1>
<div id="feed_section">
<table>
<thead>
<tr>
<th>{{ l('id_feed') }}</th>
<th>{{ l('ref_feed_id') }}</th>
<th>{{ l('id_spot') }}</th>
<th>{{ l('id_project') }}</th>
<th>{{ l('name') }}</th>
<th>{{ l('status') }}</th>
<th>{{ l('last_update') }}</th>
<th>{{ l('delete') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="feed in elems.feed">
<td>{{ feed.id }}</td>
<td><AdminInput :type="'text'" :name="'ref_feed_id'" :elem="feed" /></td>
<td><AdminInput :type="'number'" :name="'id_spot'" :elem="feed" /></td>
<td><AdminInput :type="'number'" :name="'id_project'" :elem="feed" /></td>
<td>{{ feed.name }}</td>
<td>{{ feed.status }}</td>
<td>{{ feed.last_update }}</td>
<td><SpotButton :icon="'close fa-lg'" @click="deleteElem(feed)" /></td>
</tr>
</tbody>
</table>
<SpotButton :classes="'new'" :text="l('new_feed')" :icon="'new'" @click="createElem('feed')" />
</div>
<h1>Spots</h1>
<div id="spot_section">
<table>
<thead>
<tr>
<th>{{ l('id_spot') }}</th>
<th>{{ l('ref_spot_id') }}</th>
<th>{{ l('name') }}</th>
<th>{{ l('model') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="spot in elems.spot">
<td>{{ spot.id }}</td>
<td>{{ spot.ref_spot_id }}</td>
<td>{{ spot.name }}</td>
<td>{{ spot.model }}</td>
</tr>
</tbody>
</table>
</div>
<h1>{{ l('active_users') }}</h1>
<div id="user_section">
<table>
<thead>
<tr>
<th>{{ l('id_user') }}</th>
<th>{{ l('user_name') }}</th>
<th>{{ l('language') }}</th>
<th>{{ l('time_zone') }}</th>
<th>{{ l('clearance') }}</th>
<th>{{ l('delete') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="user in elems.user">
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.language }}</td>
<td>{{ user.timezone }}</td>
<td><AdminInput :type="'number'" :name="'clearance'" :elem="user" /></td>
<td><SpotButton :icon="'close fa-lg'" @click="deleteElem(user)" /></td>
</tr>
</tbody>
</table>
</div>
<h1>{{ l('toolbox') }}</h1>
<div id="toolbox">
<SpotButton :classes="'refresh'" :text="l('update_project')" :icon="'refresh'" @click="updateProject" />
</div>
<div id="feedback" class="feedback">
<p v-for="feedback in feedbacks" :class="feedback.type">{{ feedback.msg }}</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script>
export default {
props: {
type: String,
name: String,
elem: Object
},
computed: {
value() {
return this.elem[this.name];
}
}
}
</script>
<template>
<input :type="type" :name="name" :value="value" @change="$parent.updateElem(elem, $event)" @keyup="$parent.queue(elem, $event)" />
</template>

590
src/components/project.vue Normal file
View File

@@ -0,0 +1,590 @@
<script>
import 'maplibre-gl/dist/maplibre-gl.css';
import { Map, NavigationControl, Marker, LngLatBounds, LngLat, Popup } from 'maplibre-gl';
import { createApp, defineComponent, nextTick, ref, defineCustomElement, provide, inject } from 'vue';
import simplebar from 'simplebar-vue';
import autosize from 'autosize';
import mousewheel from 'jquery-mousewheel';
import waitforimages from 'jquery.waitforimages';
import lightbox from '../scripts/lightbox.js';
//import SimpleBar from 'simplebar';
import SpotIcon from './spotIcon.vue';
import SpotButton from './spotButton.vue';
import ProjectPost from './projectPost.vue';
import ProjectPopup from './projectPopup.vue';
export default {
components: {
SpotIcon,
SpotButton,
ProjectPost,
ProjectPopup,
simplebar
},
data() {
return {
server: this.spot.consts.server,
feed: {loading:false, updatable:true, outOfData:false, refIdFirst:0, refIdLast:0, firstChunk:true},
feedPanelOpen: false,
feedSimpleBar: null,
settingsPanelOpen: false,
markerSize: {width: 32, height: 32},
project: {},
projectCodename: null,
modeHisto: false,
posts: [],
nlFeedbacks: [],
nlLoading: false,
baseMaps: {},
baseMap: null,
messages: null,
map: null,
hikes: {
colors:{'main':'#00ff78', 'off-track':'#0000ff', 'hitchhiking':'#FF7814'},
width: 4
}
};
},
computed: {
projectClasses() {
return [
this.feedPanelOpen?'with-feed':'',
this.settingsPanelOpen?'with-settings':''
].filter(n => n).join(' ');
},
nlClasses() {
return [
this.nlAction,
this.nlLoading?'loading':''
].filter(n => n).join(' ');
},
subscribed() {
return this.user.id_user > 0;
},
nlAction() {
return this.subscribed?'unsubscribe':'subscribe';
},
mobile() {
return this.spot.isMobile();
}
},
watch: {
baseMap(sNewBaseMap, sOldBaseMap) {
if(sOldBaseMap) this.map.setLayoutProperty(sOldBaseMap, 'visibility', 'none');
if(sNewBaseMap) this.map.setLayoutProperty(sNewBaseMap, 'visibility', 'visible');
},
projectCodename(sNewCodeName, sOldCodeName) {
console.log('change in projectCodename: '+sNewCodeName);
//this.toggleSettingsPanel(false);
this.$parent.setHash(this.$parent.hash.page, [sNewCodeName]);
this.init();
}
},
provide() {
return {
project: this.project
};
},
inject: ['spot', 'projects', 'user'],
mounted() {
this.spot.addPage('project', {
onResize: () => {
//this.spot.tmp('map_offset', -1 * (this.feedPanelOpen?getOuterWidth(this.$refs.feed):0) / getOuterWidth(window));
/* TODO
if(typeof this.spot.tmp('elev') != 'undefined' && this.spot.tmp('elev')._showState) {
this.spot.tmp('elev').resize({width:this.getElevWidth()});
}
*/
}
});
this.projectCodename = (this.$parent.hash.items.length==0)?this.spot.vars('default_project_codename'):this.$parent.hash.items[0];
},
methods: {
init() {
let bFirstLoad = (typeof this.project.codename == 'undefined');
this.initProject();
if(bFirstLoad) this.initLightbox();
this.initFeed();
this.initMap();
},
initProject() {
this.project = this.projects[this.projectCodename];
this.modeHisto = (this.project.mode == this.spot.consts.modes.histo);
this.feed = {loading:false, updatable:true, outOfData:false, refIdFirst:0, refIdLast:0, firstChunk:true};
this.posts = [];
//this.baseMap = null;
this.baseMaps = {};
},
initLightbox() {
lightbox.option({
alwaysShowNavOnTouchDevices: true,
albumLabel: '<i class="fa fa-fw fa-lg fa-media push"></i> %1 / %2',
fadeDuration: 300,
imageFadeDuration: 400,
positionFromTop: 0,
resizeDuration: 400,
hasVideo: true,
onMediaChange: (oMedia) => {
this.spot.updateHash('media', oMedia.id);
if(oMedia.set == 'post-medias') this.goToPost({type: 'media', id: oMedia.id});
},
onClosing: () => {this.spot.flushHash();}
});
},
async initFeed() {
//Simplebar event
this.$refs.feedSimpleBar.scrollElement.addEventListener('scroll', (oEvent) => {this.onFeedScroll(oEvent);});
//Mobile Touchscreen Events
//TODO
//Add post Event handling
//TODO
await this.getNextFeed();
//Scroll to post
if(this.$parent.hash.items.length == 3) this.findPost({type: this.$parent.hash.items[1], id: this.$parent.hash.items[2]});
},
async initMap() {
//Get Map Info
const aoMarkers = await this.spot.get2('markers', {id_project: this.project.id});
this.baseMap = null;
this.baseMaps = aoMarkers.maps;
this.messages = aoMarkers.messages;
//Base maps (raster tiles)
let asSources = {};
let asLayers = [];
for(const asBaseMap of this.baseMaps) {
asSources[asBaseMap.codename] = {
type: 'raster',
tiles: [asBaseMap.pattern],
tileSize: asBaseMap.tile_size
};
asLayers.push({
id: asBaseMap.codename,
type: 'raster',
source: asBaseMap.codename,
'layout': {'visibility': 'none'},
minZoom: asBaseMap.min_zoom,
maxZoom: asBaseMap.max_zoom
});
}
//Map
if(this.map) this.map.remove();
this.map = new Map({
container: 'map',
style: {
version: 8,
sources: asSources,
layers: asLayers
},
attributionControl: false
});
this.map.once('load', async () => {
//Default Basemap
this.baseMap = this.baseMaps.filter((asBM) => asBM.default_map)[0].codename;
//Get track
const oTrack = await this.spot.get2('geojson', {id_project: this.project.id});
this.map.addSource('track', {
'type': 'geojson',
'data': oTrack
});
//Color mapping
let asColorMapping = ['match', ['get', 'type']];
for(const sHikeType in this.hikes.colors) {
asColorMapping.push(sHikeType);
asColorMapping.push(this.hikes.colors[sHikeType]);
}
asColorMapping.push('black'); //fallback value
//Track layer
this.map.addLayer({
'id': 'track',
'type': 'line',
'source': 'track',
'layout': {
'line-join': 'round',
'line-cap': 'round'
},
'paint': {
'line-color': asColorMapping,
'line-width': this.hikes.width
}
});
//Markers
let aoMarkerSource = {type:'geojson', data:{type: 'FeatureCollection', features: []}};
for(const oMsg of this.messages) {
aoMarkerSource.data.features.push({
'type': 'Feature',
'properties': {
...oMsg,
...{'description': ''}
},
'geometry': {
'type': 'Point',
'coordinates': [oMsg.longitude, oMsg.latitude]
}
});
//Tooltip
/*
let $Tooltip = $($('<div>', {'class':'info-window'})
.append($('<h1>')
.addIcon('fa-message fa-lg', true)
.append($('<span>').text(this.spot.lang('post_message')+' '+this.spot.lang('counter', oMsg.displayed_id)))
.append($('<span>', {'class':'message-type'}).text('('+oMsg.type+')'))
)
.append($('<div>', {'class':'separator'}))
.append($('<p>', {'class':'coordinates'})
.addIcon('fa-coords fa-fw fa-lg', true)
.append(this.getGoogleMapsLink(oMsg))
)
.append($('<p>', {'class':'time'})
.addIcon('fa-time fa-fw fa-lg', true)
.append(oMsg.formatted_time+(this.project.mode==this.spot.consts.modes.blog?' ('+oMsg.relative_time+')':''))))[0];
const vTooltip = h(SpotIcon, {icon:'project', 'classes':'fa-fw', text:'hikes'});
//let vTooltip = h(SpotIcon, {icon:'project', 'classes':'fa-fw', text:'hikes'});
oPopup.setDOMContent(vTooltip);
new Marker({
element: $('<div style="width:'+this.markerSize.width+'px;height:'+this.markerSize.height+'px;"><span class="fa-stack"><i class="fa fa-message fa-stack-2x"></i><i class="fa fa-message-in fa-rotate-270 fa-stack-1x"></i></span></div>')[0],
anchor: 'bottom'
})
.setLngLat(new LngLat(oMsg.longitude, oMsg.latitude))
.setPopup(oPopup)
.addTo(this.map)
;
*/
}
this.map.addSource('markers', aoMarkerSource);
const image = await this.map.loadImage('https://maplibre.org/maplibre-gl-js/docs/assets/custom_marker.png');
this.map.addImage('markerIcon', image.data);
this.map.addLayer({
'id': 'markers',
'type': 'symbol',
'source': 'markers',
'layout': {
//'icon-anchor': 'bottom',
'icon-image': 'markerIcon'
//'icon-overlap': 'always'
}
});
this.map.on("click", "markers", (e) => {
var oPopup = new Popup({
anchor: 'bottom',
offset: [0, this.markerSize.height * -1],
closeButton: false
})
.setHTML('<div id="popup"></div>')
.setLngLat(e.lngLat)
.addTo(this.map);
let rProp = ref(e.features[0].properties);
const vPopup = defineComponent({
extends: ProjectPopup,
setup: () => {
console.log(rProp.value);
provide('options', rProp.value);
provide('spot', this.spot);
provide('project', this.project);
return {'options': rProp.value, 'spot':this.spot, 'project':this.project};
}
});
nextTick(() => {
createApp(vPopup).mount("#popup");
});
});
//Centering map
let bOpenFeedPanel = !this.mobile;
let oBounds = new LngLatBounds();
if(
this.project.mode == this.spot.consts.modes.blog &&
this.messages.length > 0 &&
this.$parent.hash.items[2] != 'message'
) {
//Fit to last message
let oLastMsg = this.messages[this.messages.length - 1];
oBounds.extend(new LngLat(oLastMsg.longitude, oLastMsg.latitude));
}
else {
//Fit to track
for(const iFeatureId in oTrack.features) {
oBounds = oTrack.features[iFeatureId].geometry.coordinates.reduce(
(bounds, coord) => {
return bounds.extend(coord);
},
oBounds
);
}
}
const iFeedPanelPadding = bOpenFeedPanel?(getOuterWidth(this.$refs.feed)/2):0;
await this.map.fitBounds(
oBounds,
{
padding: {
top: 20,
bottom: 20,
left: (20 + iFeedPanelPadding),
right: (20 + iFeedPanelPadding)
},
animate: false,
maxZoom: 15
}
);
//Toggle only when map is ready, for the tilt effet
this.toggleFeedPanel(bOpenFeedPanel);
});
this.map.on('idle', () => {
});
//Legend
},
getGoogleMapsLink(asInfo) {
return $('<a>', {
href:'https://www.google.com/maps/place/'+asInfo.lat_dms+'+'+asInfo.lon_dms+'/@'+asInfo.latitude+','+asInfo.longitude+',10z',
title: this.spot.lang('see_on_google'),
target: '_blank',
rel: 'noreferrer noopener'
}).text(asInfo.lat_dms+' '+asInfo.lon_dms);
},
async getNextFeed() {
if(!this.feed.outOfData && !this.feed.loading) {
//Get next chunk
this.feed.loading = true;
let aoData = await this.spot.get2('next_feed', {id_project: this.project.id, id: this.feed.refIdLast});
let iPostCount = Object.keys(aoData.feed).length;
this.feed.loading = false;
this.feed.firstChunk = false;
//Update pointers
this.feed.outOfData = (iPostCount < this.spot.consts.chunk_size);
if(iPostCount > 0) {
this.feed.refIdLast = aoData.ref_id_last;
if(this.feed.firstChunk) this.feed.refIdFirst = aoData.ref_id_first;
}
//Add posts
this.posts.push(...aoData.feed);
}
return true;
},
onFeedScroll(oEvent) {
//FIXME remvove jquery dependency
var $Box = $(oEvent.currentTarget);
var $BoxContent = $Box.find('.simplebar-content');
if(($Box.scrollTop() + $(window).height()) / $BoxContent.height() >= 0.8) this.getNextFeed();
},
async manageSubs() {
var regexEmail = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
if(!regexEmail.test(this.user.email)) this.nlFeedbacks.push({type:'error', 'msg':this.spot.lang('nl_invalid_email')});
else {
this.spot.get2(this.nlAction, {'email': this.user.email, 'name': this.user.name}, this.nlLoading)
.then((asUser, sDesc) => {
this.nlFeedbacks.push('success', sDesc);
this.user = asUser;
})
.catch((sDesc) => {this.nlFeedbacks.push('error', sDesc);});
}
},
toggleFeedPanel(bShow, sMapAction) {
let bOldValue = this.feedPanelOpen;
this.feedPanelOpen = (typeof bShow === 'object' || typeof bShow === 'undefined')?(!this.feedPanelOpen):bShow;
if(bOldValue != this.feedPanelOpen && !this.mobile) {
this.spot.onResize();
sMapAction = sMapAction || 'panTo';
switch(sMapAction) {
case 'none':
break;
case 'panTo':
this.map.panBy(
[(this.feedPanelOpen?1:-1) * getOuterWidth(this.$refs.feed) / 2, 0],
{duration: 500}
);
break;
case 'panToInstant':
this.map.panBy([(this.feedPanelOpen?1:-1) * getOuterWidth(this.$refs.feed) / 2, 0]);
break;
case 'fitBounds':
/*
this.map.fitBounds(
this.spot.tmp('track').getBounds(),
{
paddingTopLeft: L.point(5, this.spot.tmp('marker_size').height + 5),
paddingBottomRight: L.point(this.spot.tmp('$Feed').outerWidth(true) + 5, 5)
}
);
break;
*/
}
}
},
toggleSettingsPanel(bShow, sMapAction) {
let bOldValue = this.settingsPanelOpen;
this.settingsPanelOpen = (typeof bShow === 'object' || typeof bShow === 'undefined')?(!this.settingsPanelOpen):bShow;
if(bOldValue != this.settingsPanelOpen && !this.mobile) {
this.spot.onResize();
sMapAction = sMapAction || 'panTo';
switch(sMapAction) {
case 'none':
break;
case 'panTo':
this.map.panBy(
[(this.settingsPanelOpen?-1:1) * getOuterWidth(this.$refs.settings) / 2, 0],
{duration: 500}
);
break;
case 'panToInstant':
this.map.panBy([(this.settingsPanelOpen?-1:1) * getOuterWidth(this.$refs.settings) /2, 0]);
break;
}
}
},
async findPost(oPost) {
if(this.goToPost(oPost)) {
//if(oPost.type=='media' || oPost.type=='message') $Post.find('a.drill').click();
}
else if(!this.feed.outOfData) {
await this.getNextFeed();
this.findPost(oPost);
}
else console.log('Missing element ID "'+oPost.id+'" of type "'+oPost.type+'"');
},
goToPost(oPost) {
//TODO remove jquery deps
let bFound = false;
let aoRefs = this.$refs.posts.filter((post)=>{return post.postId == oPost.type+'-'+oPost.id;});
if(aoRefs.length == 1) {
this.$refs.feedSimpleBar.scrollElement.scrollTop += Math.round(
$(aoRefs[0].$el).offset().top
- parseInt($(this.$refs.feedSimpleBar.$el).css('padding-top'))
);
bFound = true;
this.spot.flushHash(['post', 'message']);
}
return bFound;
}
}
}
</script>
<template>
<div id="projects" :class="projectClasses">
<div id="background"></div>
<div id="submap">
<div class="loader fa fa-fw fa-map flicker"></div>
</div>
<div id="map"></div>
<div id="settings" class="map-container map-container-left" ref="settings">
<div id="settings-panel" class="map-panel">
<div class="settings-header">
<div class="logo"><img width="289" height="72" src="images/logo_black.png" alt="Spotty" /></div>
<div id="last_update"><p><span><img src="images/spot-logo-only.svg" alt="" /></span><abbr></abbr></p></div>
</div>
<div class="settings-sections">
<simplebar id="settings-sections-scrollbox">
<div class="settings-section">
<h1><SpotIcon :icon="'project'" :classes="'fa-fw'" :text="spot.lang('hikes')" /></h1>
<div class="settings-section-body">
<div class="radio" v-for="project in projects">
<input type="radio" :id="project.id" :value="project.codename" v-model="projectCodename" />
<label :for="project.id">
<span>{{ project.name }}</span>
<a class="download" :href="project.gpxfilepath" :title="spot.lang('track_download')" @click.stop="()=>{}">
<SpotIcon :icon="'download'" :classes="'push-left'" />
</a>
</label>
</div>
</div>
</div>
<div class="settings-section">
<h1><SpotIcon :icon="'map'" :classes="'fa-fw'" :text="spot.lang('maps')" /></h1>
<div class="settings-section-body">
<div class="radio" v-for="bm in baseMaps">
<input type="radio" :id="bm.id_map" :value="bm.codename" v-model="baseMap" />
<label :for="bm.id_map">{{ this.spot.lang('map_'+bm.codename) }}</label>
</div>
</div>
</div>
<div class="settings-section newsletter">
<h1><SpotIcon :icon="'newsletter'" :classes="'fa-fw'" :text="spot.lang('newsletter')" /></h1>
<input type="email" name="email" id="email" :placeholder="spot.lang('nl_email_placeholder')" v-model="user.email" :disabled="nlLoading || subscribed" />
<SpotButton id="nl_btn" :classes="nlClasses" :title="spot.lang('nl_'+nlAction)" @click="manageSubs" />
<div id="settings-feedback" class="feedback">
<p v-for="feedback in nlFeedbacks" :class="feedback.type">
<SpotIcon :icon="feedback.type" :text="feedback.msg" />
</p>
</div>
{{ spot.lang(subscribed?'nl_subscribed_desc':'nl_unsubscribed_desc') }}
</div>
<div class="settings-section admin" v-if="spot.checkClearance(spot.consts.clearances.admin)">
<h1><SpotIcon :icon="'admin fa-fw'" :text="spot.lang('admin')" /></h1>
<a class="button" href="#admin"><SpotIcon :icon="'config'" :text="spot.lang('admin_config')" /></a>
<a class="button" href="#upload"><SpotIcon :icon="'upload'" :text="spot.lang('admin_upload')" /></a>
</div>
</simplebar>
</div>
<div class="settings-footer">
<a href="https://git.lutran.fr/franzz/spot" :title="spot.lang('credits_git')" target="_blank" rel="noopener">
<SpotIcon :icon="'credits'" :text="spot.lang('credits_project')" />
</a> {{ spot.lang('credits_license') }}</div>
</div>
<div :class="'map-control map-control-icon settings-control map-control-'+(mobile?'bottom':'top')" @click="toggleSettingsPanel">
<SpotIcon :icon="settingsPanelOpen?'prev':'menu'" />
</div>
<div v-if="!mobile" id="legend" class="map-control settings-control map-control-bottom">
<div v-for="(color, hikeType) in hikes.colors" class="track">
<span class="line" :style="'background-color:'+color+'; height:'+hikes.width+'px;'"></span>
<span class="desc">{{ spot.lang('track_'+hikeType) }}</span>
</div>
</div>
<div id="title" :class="'map-control settings-control map-control-'+(mobile?'bottom':'top')">
<span>{{ project.name }}</span>
</div>
</div>
<div id="feed" class="map-container map-container-right" ref="feed">
<simplebar id="feed-panel" class="map-panel" ref="feedSimpleBar">
<div id="feed-header">
<ProjectPost v-if="modeHisto" :options="{type: 'archived', headerless: true}" />
<ProjectPost v-else :options="{type: 'poster', relative_time: spot.lang('post_new_message')}" />
</div>
<div id="feed-posts">
<ProjectPost v-for="post in posts" :options="post" ref="posts" />
</div>
<div id="feed-footer" v-if="feed.loading">
<ProjectPost :options="{type: 'loading', headerless: true}" />
</div>
</simplebar>
<div :class="'map-control map-control-icon feed-control map-control-'+(mobile?'bottom':'top')" @click="toggleFeedPanel">
<SpotIcon :icon="feedPanelOpen?'next':'post'" />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script>
export default {
props: {
options: Object
},
inject: ['spot']
}
</script>
<template>
<a
:href="'https://www.google.com/maps/place/'+options.lat_dms+'+'+options.lon_dms+'/@'+options.latitude+','+options.longitude+',10z'"
:title="spot.lang('see_on_google')"
target="_blank"
rel="noreferrer noopener"
>{{ options.lat_dms+' '+options.lon_dms }}</a>
</template>

View File

@@ -0,0 +1,60 @@
<script>
import spotIcon from './spotIcon.vue';
export default {
components: {
spotIcon
},
props: {
options: Object,
type: String
},
data() {
return {
title:''
}
},
inject: ['spot'],
mounted() {
this.title =
(this.$refs.comment?this.$refs.comment.outerHTML:'') +
this.$refs[this.type=='marker'?'takenon':'postedon'].outerHTML +
this.$refs[this.type=='marker'?'postedon':'takenon'].outerHTML
;
}
}
</script>
<template>
<a
class="media-link drill"
:href="options.media_path"
:data-lightbox="type+'-medias'"
:data-type="options.subtype"
:data-id="options.id_media"
:data-title="title"
:data-orientation="options.rotate"
>
<img
:src="options.thumb_path"
:width="options.width"
:height="options.height"
:title="spot.lang((options.subtype == 'video')?'click_watch':'click_zoom')"
class="clickable"
/>
<span class="drill-icon"><spotIcon :icon="'drill-'+options.subtype" /></span>
<span v-if="options.comment" class="comment">{{ options.comment }}</span>
</a>
<div style="display:none">
<span ref="comment" class="lb-caption-line comment desktop" :title="options.comment">
<spotIcon :icon="'post'" :classes="'fa-lg fa-fw'" />
<span class="comment-text">{{ options.comment }}</span>
</span>
<span ref="postedon" class="lb-caption-line" :title="$parent.timeDiff?spot.lang('local_time', options.posted_on_formatted_local):''">
<spotIcon :icon="'upload'" :classes="'fa-lg fa-fw'" :text="options.posted_on_formatted" />
</span>
<span ref="takenon" class="lb-caption-line" :title="$parent.timeDiff?spot.lang('local_time', options.taken_on_formatted_local):''">
<spotIcon :icon="options.subtype+'-shot'" :classes="'fa-lg fa-fw'" :text="options.taken_on_formatted" />
</span>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script>
import { options } from 'lightbox2';
import projectMapLink from './projectMapLink.vue';
import spotIcon from './spotIcon.vue';
import projectRelTime from './projectRelTime.vue';
export default {
components: {
spotIcon,
projectMapLink,
projectRelTime
},
//props: {
// options: Object,
//},
data() {
return {
}
},
//inject: ['options', 'spot', 'project'],
mounted() {
}
}
</script>
<template>
<div class="info-window">
<h1>
<spotIcon :icon="'message'" :classes="'fa-lg'" :text="spot.lang('post_message')+' '+spot.lang('counter', options.displayed_id)" />
<span class="message-type">({{ options.type }})</span>
</h1>
<div class="separator"></div>
<p class="coordinates">
<spotIcon :icon="'coords'" :classes="'fa-fw fa-lg'" :margin="true" />
<projectMapLink :options="options" />
</p>
<p class="time">
<spotIcon :icon="'time'" :classes="'fa-fw fa-lg'" :text="options.formatted_time" />
<span v-if="project.mode==spot.consts.modes.blog"> ({{ options.relative_time }})</span>
</p>
<p class="timezone" v-if="options.day_offset != '0'">
<spotIcon :icon="'timezone'" :classes="'fa-fw fa-lg'" :margin="true" />
<projectRelTime :localTime="options.formatted_time_local" :offset="options.day_offset" />
</p>
<p class="weather" v-if="options.weather_icon && options.weather_icon!='unknown'" :title="options.weather_cond==''?'':spot.lang(options.weather_cond)">
<spotIcon :icon="options.weather_icon" :classes="'fa-fw fa-lg'" :text="options.weather_temp+'°C'" />
</p>
</div>
</template>

View File

@@ -0,0 +1,170 @@
<script>
import spotIcon from './spotIcon.vue';
import spotButton from './spotButton.vue';
import projectMediaLink from './projectMediaLink.vue';
import projectMapLink from './projectMapLink.vue';
import projectRelTime from './projectRelTime.vue';
import autosize from 'autosize';
export default {
components: {
spotIcon,
spotButton,
projectMediaLink,
projectMapLink,
projectRelTime
},
props: {
options: Object
},
data() {
return {
mouseOverHeader: false,
absTime: this.options.formatted_time,
absTimeLocal: this.options.formatted_time_local,
timeDiff: (this.options.formatted_time && this.options.formatted_time_local != this.options.formatted_time),
anchorVisible: ['message', 'media', 'post'].includes(this.options.type),
anchorTitle: this.spot.lang('copy_to_clipboard'),
anchorIcon: 'link'
};
},
computed: {
postClass() {
let sHeaderLess = this.options.headerless?' headerless':'';
return 'post-item '+this.options.type+sHeaderLess;
},
postId() {
return this.options.id?(this.options.type+'-'+this.options.id):'';
},
subType() {
return this.options.subtype || this.options.type;
},
displayedId() {
return this.options.displayed_id?(this.spot.lang('counter', this.options.displayed_id)):'';
},
hash() {
let asHash = this.spot.getHash();
return '#'+[asHash.page, asHash.items[0], this.options.type, this.options.id].join(this.spot.consts.hash_sep);
},
modeHisto() {
return (this.project.mode==this.spot.consts.modes.histo);
},
relTime() {
return this.modeHisto?(this.options.formatted_time || '').substr(0, 10):this.options.relative_time;
},
},
inject: ['spot', 'project', 'user'],
methods: {
copyAnchor() {
copyTextToClipboard(this.spot.consts.server+this.spot.hash());
this.anchorTitle = this.spot.lang('link_copied');
this.anchorIcon = 'copied';
setTimeout(()=>{ //TODO animation
this.anchorTitle = this.spot.lang('copy_to_clipboard');
this.anchorIcon = 'link';
}, 5000);
},
panMapToMessage() {
//TODO
/*
var $Parent = $(oEvent.currentTarget).parent();
var oMarker = this.spot.tmp(['markers', $Parent.data('id')]);
if(this.isMobile()) {
this.toggleFeedPanel(false, 'panToInstant');
this.spot.tmp('map').setView(oMarker.getLatLng(), 15);
}
else {
var iOffset = (this.isFeedPanelOpen()?1:-1)*this.spot.tmp('$Feed').outerWidth(true)/2 - (this.isSettingsPanelOpen()?1:-1)*this.spot.tmp('$Settings').outerWidth(true)/2;
var iRatio = -1 * iOffset / $('body').outerWidth(true);
this.spot.tmp('map').setOffsetView(iRatio, oMarker.getLatLng(), 15);
}
$Parent.data('clicked', true);
if(!oMarker.isPopupOpen()) oMarker.openPopup();
*/
},
openMarkerPopup() {
//TODO
/*
let oMarker = this.spot.tmp(['markers', $(oEvent.currentTarget).data('id')]);
if(this.spot.tmp('map') && this.spot.tmp('map').getBounds().contains(oMarker.getLatLng()) && !oMarker.isPopupOpen()) oMarker.openPopup();
*/
},
closeMarkerPopup() {
//TODO
/*
let $This = $(oEvent.currentTarget);
let oMarker = this.spot.tmp(['markers', $This.data('id')]);
if(oMarker && oMarker.isPopupOpen() && !$This.data('clicked')) oMarker.closePopup();
$This.data('clicked', false);
*/
}
},
mounted() {
//Auto-adjust text area height
if(this.options.type == 'poster') autosize(this.$refs.post);
}
}
</script>
<template>
<div :class="postClass" :id="postId">
<div class="header">
<div class="index">
<spotIcon :icon="subType" :text="displayedId" />
<a v-if="anchorVisible" class="link desktop" @click="copyAnchor" ref="anchor" :href="hash" :title="anchorTitle">
<spotIcon :icon="anchorIcon" />
</a>
</div>
<div class="time" @mouseleave="mouseOverHeader = false" @mouseover="mouseOverHeader = true" :title="timeDiff?spot.lang('local_time', absTimeLocal):''">
<Transition name="fade" mode="out-in">
<span v-if="mouseOverHeader">{{ timeDiff?spot.lang('your_time', absTime):absTime }}</span>
<span v-else>{{ relTime }}</span>
</Transition>
</div>
</div>
<div class="body">
<div v-if="options.type == 'message'" class="body-box" @mouseenter="openMarkerPopup" @mouseleave="closeMarkerPopup">
<p><spotIcon :icon="'coords'" :classes="'push'" /><projectMapLink :options="options" /></p>
<p><spotIcon :icon="'time'" :text="absTime" /></p>
<p v-if="timeDiff"><spotIcon :icon="'timezone'" :classes="'push'" /><projectRelTime :localTime="absTimeLocal" :offset="options.day_offset" /></p>
<a class="drill" @click.prevent="panMapToMessage">
<span v-if="options.weather_icon && options.weather_icon!='unknown'" class="weather clickable" :title="spot.lang(options.weather_cond)">
<spotIcon :icon="options.weather_icon" />
<span>{{ options.weather_temp+'°C' }}</span>
</span>
<img class="staticmap clickable" :title="spot.lang('click_zoom')" :src="options.static_img_url" />
<span class="drill-icon fa-stack clickable">
<spotIcon :icon="'message'" :classes="'fa-stack-2x clickable'" />
<spotIcon :icon="'message-in'" :classes="'fa-stack-1x fa-rotate-270'" />
</span>
</a>
</div>
<div v-else-if="options.type == 'media'" class="body-box">
<projectMediaLink :options="options" :type="'post'" />
</div>
<div v-else-if="options.type == 'post'">
<p class="message">{{ options.content }}</p>
<p class="signature">
<img v-if="options.gravatar" :src="'data:image/png;base64, '+options.gravatar" width="24" height="24" alt="--" />
<span v-else>-- </span>
<span>{{ options.formatted_name }}</span>
</p>
</div>
<p v-else-if="options.type == 'poster'" class="message">
<textarea ref="post" name="post" :placeholder="spot.lang('post_message')" class="autoExpand" rows="1" v-model="$parent.post"></textarea>
<input type="text" name="name" :placeholder="spot.lang('post_name')" v-model="user.name" />
<spotButton name="submit" :aria-label="spot.lang('send')" :title="spot.lang('send')" :icon="'send'" />
</p>
<div v-else-if="options.type == 'archived'">
<p><spotIcon :icon="'success'" /></p>
<p>{{ spot.lang('mode_histo') }}</p>
</div>
<div v-else-if="options.type == 'loading'">
<p class="flicker"><spotIcon :icon="'post'" /></p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script>
export default {
props: {
localTime: String,
offset: String
},
inject: ['spot']
}
</script>
<template>
<span>
<span>{{ localTime.substring(-5) }}</span>
<sup v-if="offset != '0'" :title="offset+' '+spot.lang('unit_day')+' ('+localTime.substring(0, 5)+')'">{{ ' '+offset }}</sup>
<span>&nbsp;{{ spot.lang('local_time', ' ').trim() }}</span>
</span>
</template>

View File

@@ -0,0 +1,17 @@
<script>
import SpotIcon from './spotIcon.vue';
export default {
components: {
SpotIcon
},
props: {
classes: String,
text: String,
icon: String
}
}
</script>
<template>
<button :class="classes"><SpotIcon :icon="icon" :text="text" /></button>
</template>

View File

@@ -0,0 +1,19 @@
<script>
export default {
props: {
icon: String,
text: String,
margin: Boolean,
classes: String
},
computed: {
classNames() {
return 'fa fa-'+this.icon+((this.margin || this.text && this.text!='')?' push':'')+(this.classes?' '+this.classes:'')
}
}
}
</script>
<template>
<i :class="classNames"></i>{{ text }}
</template>

100
src/components/upload.vue Normal file
View File

@@ -0,0 +1,100 @@
<script>
import SpotIcon from './spotIcon.vue';
import SpotButton from './spotButton.vue';
import "blueimp-file-upload/js/vendor/jquery.ui.widget.js";
import "blueimp-file-upload/js/jquery.iframe-transport.js";
import "blueimp-file-upload/js/jquery.fileupload.js";
export default {
name: 'upload',
components: { SpotButton, SpotIcon },
inject: ['spot', 'projects', 'consts', 'user'],
data() {
return {
project: this.projects[this.spot.vars('default_project_codename')],
files: [],
logs: [],
progress: 0
};
},
mounted() {
this.spot.addPage('upload', {});
if(this.project.editable) {
$('#fileupload')
.fileupload({
dataType: 'json',
formData: {t: this.user.timezone},
acceptFileTypes: /(\.|\/)(gif|jpe?g|png|mov)$/i,
done: (e, asData) => {
$.each(asData.result.files, (iKey, oFile) => {
let bError = ('error' in oFile);
//Feedback
this.logs.push(bError?oFile.error:(this.spot.lang('upload_success', [oFile.name])));
//Comments
oFile.content = '';
if(!bError) this.files.push(oFile);
});
},
progressall: (e, data) => {
this.progress = parseInt(data.loaded / data.total * 100, 10);
}
});
}
else this.logs = [this.spot.lang('upload_mode_archived', [this.project.name])];
},
methods: {
addComment(oFile) {
this.spot.get2('add_comment', {id: oFile.id, content: oFile.content})
.then((asData) => {this.logs.push(this.spot.lang('media_comment_update', asData.filename));})
.catch((sMsgId) => {this.logs.push(this.spot.lang(sMsgId));});
},
addPosition() {
if(navigator.geolocation) {
this.logs.push('Determining position...');
navigator.geolocation.getCurrentPosition(
(position) => {
this.logs.push('Sending position...');
this.spot.get2('add_position', {'latitude':position.coords.latitude, 'longitude':position.coords.longitude, 'timestamp':Math.round(position.timestamp / 1000)})
.then((asData) => {this.logs.push('Position sent');})
.catch((sMsgId) => {this.logs.push(self.lang(sMsgId));});
},
(error) => {
this.logs.push(error.message);
}
);
}
else this.logs.push('This browser does not support geolocation');
}
}
}
</script>
<template>
<div id="upload">
<a name="back" class="button" href="#project"><SpotIcon :icon="'back'" :text="spot.lang('nav_back')" /></a>
<h1>{{ spot.lang('upload_title') }}</h1>
<h2>{{ this.project.name }}</h2>
<div class="section" v-if="project.editable">
<input id="fileupload" type="file" name="files[]" :data-url="this.spot.getActionLink('upload')" multiple />
</div>
<div class="section progress" v-if="progress > 0">
<div class="bar" :style="{width:progress+'%'}"></div>
</div>
<div class="section comment" v-for="file in files">
<img class="thumb" :src="file.thumbnail" />
<div class="form">
<input class="content" name="content" type="text" v-model="file.content" />
<input class="id" name="id" type="hidden" :value="file.id" />
<SpotButton :classes="'save'" :icon="'save'" :text="spot.lang('save')" @click="addComment(file)" />
</div>
</div>
<div class="section location">
<SpotButton :icon="'message'" :text="spot.lang('new_position')" @click="addPosition()" />
</div>
<div class="section logs" v-if="logs.length > 0">
<p class="log" v-for="log in logs">{{ log }}.</p>
</div>
</div>
</template>

View File

@@ -1,71 +0,0 @@
<div id="admin">
<a name="back" class="button" href="[#]server[#]"><i class="fa fa-back push"></i>[#]lang:nav_back[#]</a>
<h1>[#]lang:projects[#]</h1>
<div id="project_section">
<table>
<thead>
<tr>
<th>[#]lang:id_project[#]</th>
<th>[#]lang:project[#]</th>
<th>[#]lang:mode[#]</th>
<th>[#]lang:code_name[#]</th>
<th>[#]lang:start[#]</th>
<th>[#]lang:end[#]</th>
<th>[#]lang:delete[#]</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div id="new"></div>
</div>
<h1>[#]lang:feeds[#]</h1>
<div id="feed_section">
<table>
<thead>
<tr>
<th>[#]lang:id_feed[#]</th>
<th>[#]lang:ref_feed_id[#]</th>
<th>[#]lang:id_spot[#]</th>
<th>[#]lang:id_project[#]</th>
<th>[#]lang:name[#]</th>
<th>[#]lang:status[#]</th>
<th>[#]lang:last_update[#]</th>
<th>[#]lang:delete[#]</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<h1>Spots</h1>
<div id="spot_section">
<table>
<thead>
<tr>
<th>[#]lang:id_spot[#]</th>
<th>[#]lang:ref_spot_id[#]</th>
<th>[#]lang:name[#]</th>
<th>[#]lang:model[#]</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<h1>[#]lang:active_users[#]</h1>
<div id="user_section">
<table>
<thead>
<tr>
<th>[#]lang:id_user[#]</th>
<th>[#]lang:user_name[#]</th>
<th>[#]lang:language[#]</th>
<th>[#]lang:time_zone[#]</th>
<th>[#]lang:clearance[#]</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<h1>[#]lang:toolbox[#]</h1>
<div id="toolbox"></div>
<div id="feedback" class="feedback"></div>
</div>

View File

@@ -20,12 +20,10 @@
<meta name="msapplication-config" content="images/icons/browserconfig.xml?v=GvmqYyKwbb">
<meta name="theme-color" content="#ffffff">
<script type="text/javascript">window.params = [#]GLOBAL_VARS[#];</script>
<script type="text/javascript" src="[#]filepath_js[#]"></script>
<title>Spotty</title>
</head>
<body>
<div id="container">
<div id="main"></div>
</div>
<div id="container"></div>
<script type="module" src="[#]filepath_js[#]"></script>
</body>
</html>

View File

@@ -2,35 +2,40 @@
import './jquery.helpers.js';
//Common
import { copyArray, getElem, setElem, getDragPosition, copyTextToClipboard } from './common.js';
window.copyArray = copyArray;
window.getElem = getElem;
window.setElem = setElem;
window.getDragPosition = getDragPosition;
window.copyTextToClipboard = copyTextToClipboard;
import * as common from './common.js';
window.copyArray = common.copyArray;
window.getElem = common.getElem;
window.setElem = common.setElem;
window.getDragPosition = common.getDragPosition;
window.copyTextToClipboard = common.copyTextToClipboard;
window.getOuterWidth = common.getOuterWidth;
import Css from './../styles/spot.scss';
import LogoText from '../images/logo_black.png';
import Logo from '../images/spot-logo-only.svg';
console.log(Logo);
//Masks
import Spot from './spot.js';
import Project from './page.project.js';
import Upload from './page.upload.js';
import Admin from './page.admin.js';
//import Project from './page.project.js';
//import Upload from './page.upload.js';
//import Admin from './page.admin.js';
//const Upload = () => import('@scripts/page.upload.js');
window.oSpot = new Spot(params);
let oSpot = new Spot(params);
//let oProject = new Project(oSpot);
//oSpot.addPage('project', oProject);
let oProject = new Project(oSpot);
oSpot.addPage('project', oProject);
//let oUpload = new Upload(oSpot);
//oSpot.addPage('upload', oUpload);
let oUpload = new Upload(oSpot);
oSpot.addPage('upload', oUpload);
//let oAdmin = new Admin(oSpot);
//oSpot.addPage('admin', oAdmin);
let oAdmin = new Admin(oSpot);
oSpot.addPage('admin', oAdmin);
//$(() => {oSpot.init();});
$(() => {oSpot.init();});
import { createApp } from 'vue';
import SpotVue from '../Spot.vue';
const oSpotVue = createApp(SpotVue);
oSpotVue.provide('spot', window.oSpot);
oSpotVue.mount('#container');

View File

@@ -66,3 +66,17 @@ export function copyTextToClipboard(text) {
}
);
}
export function getOuterWidth(element) {
var style = getComputedStyle(element);
var width = element.offsetWidth; // Width without padding and border
width += parseInt(style.marginLeft) + parseInt(style.marginRight); // Add margins
// Check if the box-sizing is border-box (includes padding and border in the width)
if (style.boxSizing === 'border-box') {
width += parseInt(style.paddingLeft) + parseInt(style.paddingRight); // Add padding
width += parseInt(style.borderLeftWidth) + parseInt(style.borderRightWidth); // Add border
}
return width;
}

View File

@@ -1101,7 +1101,11 @@ export default class Project {
.addIcon('fa-'+asData.subtype+'-shot fa-lg fa-fw', true)
.append(asData.taken_on_formatted);
var $Title = $('<div>').append($Comment).append(sType=='marker'?$TakenOn:$PostedOn).append(sType=='marker'?$PostedOn:$TakenOn);
var $Title = $('<div>')
.append($Comment)
.append(sType=='marker'?$TakenOn:$PostedOn)
.append(sType=='marker'?$PostedOn:$TakenOn);
var $Link =
$('<a>', {
'class': 'media-link drill',

View File

@@ -75,7 +75,7 @@ export default class Spot {
if(oData.desc.substr(0, this.consts.lang_prefix.length)==this.consts.lang_prefix) oData.desc = this.lang(oData.desc.substr(5));
if(oData.result==this.consts.error) fOnError(oData.desc);
else fOnSuccess(oData.data, oData.desc);
else if(fOnSuccess) fOnSuccess(oData.data, oData.desc);
})
.fail((jqXHR, textStatus, errorThrown) => {
fonProgress('fail');
@@ -83,14 +83,48 @@ export default class Spot {
});
}
async get2(sAction, oVars, bLoading) {
oVars = oVars || {};
oVars['a'] = sAction;
oVars['t'] = this.consts.timezone;
bLoading = true;
let oUrl = new URL(this.consts.server+this.consts.process_page);
oUrl.search = new URLSearchParams(oVars).toString();
try {
let oUrl = new URL(this.consts.server+this.consts.process_page);
oUrl.search = new URLSearchParams(oVars).toString();
const oRequest = await fetch(oUrl, {method: 'GET', /*body: JSON.stringify(oVars),*/ headers: {"Content-Type": "application/json"}});
if(!oRequest.ok) {
bLoading = false;
throw new Error('Error HTTP '+oRequest.status+': '+oRequest.statusText);
}
else {
let oResponse = await oRequest.json();
bLoading = false;
if(oResponse.desc.substr(0, this.consts.lang_prefix.length)==this.consts.lang_prefix) oResponse.desc = this.lang(oData.desc.substr(this.consts.lang_prefix.length));
if(oResponse.result == this.consts.error) return Promise.reject(oResponse.desc);
else return Promise.resolve(oResponse.data, oResponse.desc);
}
}
catch(oError) {
bLoading = false;
throw oError;
}
}
lang(sKey, asParams) {
asParams = asParams || [];
if(typeof asParams == 'string') asParams = [asParams];
if(typeof asParams != 'object') asParams = [asParams];
var sLang = '';
if(sKey in this.consts.lang) {
sLang = this.consts.lang[sKey];
for(let i in asParams) sLang = sLang.replace('$'+i, asParams[i]);
for(let i in asParams) {
sLang = sLang.replace('$'+i, asParams[i]);
}
}
else {
console.log('missing translation: '+sKey);
@@ -100,6 +134,10 @@ export default class Spot {
return sLang;
}
isMobile() {
return $('#mobile').is(':visible');
}
/* Page Switch - Trigger & Event catching */
onHashChange() {
@@ -176,6 +214,7 @@ export default class Spot {
asContext = asContext || {};
let sPage = this.vars('page');
if(this.pages[sPage].onFeedback) this.pages[sPage].onFeedback(sType, sMsg, asContext);
else console.log({type:sType, msg:sMsg, context:asContext});
}
onKeydown(oEvent) {

View File

@@ -139,3 +139,9 @@ h1 {
}
}
}
/* Mobile */
#mobile {
display: none;
}

View File

@@ -79,6 +79,9 @@ $fa-css-prefix: fa;
.#{$fa-css-prefix}-config:before { content: fa-content($fa-var-cogs); }
.#{$fa-css-prefix}-upload:before { content: fa-content($fa-var-cloud-upload); }
/* Upload */
.#{$fa-css-prefix}-save:before { content: fa-content($fa-var-floppy-disk); }
/* Feed */
.#{$fa-css-prefix}-post:before { content: fa-content($fa-var-comment); }
.#{$fa-css-prefix}-media:before { content: fa-content($fa-var-photo-video); }
@@ -95,7 +98,7 @@ $fa-css-prefix: fa;
.#{$fa-css-prefix}-video-shot:before { content: fa-content($fa-var-camcorder); }
.#{$fa-css-prefix}-image-shot:before { content: fa-content($fa-var-camera-alt); }
.#{$fa-css-prefix}-link:before { content: fa-content($fa-var-link); }
.#{$fa-css-prefix}-link.copied:before { content: fa-content($fa-var-check); }
.#{$fa-css-prefix}-copied:before { content: fa-content($fa-var-check); }
/* Feed - Poster */
.#{$fa-css-prefix}-poster:before { content: fa-content($fa-var-comment-edit); }

View File

@@ -1,114 +0,0 @@
$theme : "spot-theme";
$base-color : #CCC;
$highlight-color : #FFF;
$background : rgba($base-color, 0.2);
$drag-color : rgba($highlight-color, 0.2);
$axis-color : darken($base-color,20%);
$stroke-color : darken($base-color,40%);
$stroke-width-mouse-focus : 1;
$stroke-width-height-focus: 2;
$stroke-width-axis : 2;
@import '../../node_modules/leaflet/dist/leaflet.css';
@import '../../node_modules/leaflet.heightgraph/src/L.Control.Heightgraph.css';
/* Leaflet fixes */
.leaflet-container {
background: none;
}
.leaflet-popup {
.leaflet-popup-content-wrapper {
border-radius: 5px;
padding: 0;
.leaflet-popup-content {
margin: 0;
padding: 1rem;
box-sizing: border-box;
}
}
}
.leaflet-control.spot-control, .leaflet-control.heightgraph .heightgraph-toggle {
@extend .clickable;
width: 44px;
height: 44px;
text-align: center;
box-shadow: none;
.fa {
@extend .control-icon;
width: 100%;
}
}
/* Leaflet Heightgraph fixes */
.legend-text, .tick, .tick text, .focusbox, .height-focus.circle, .height-focus.label, .lineSelection, .horizontalLineText {
fill: #333 !important;
}
.axis path, .focusbox rect, .focusLine line, .height-focus.label rect, .height-focus.line, .horizontalLine {
stroke: #333 !important;
}
.focusbox rect, .height-focus.label rect {
stroke-width: 0;
}
.focusLine line, .focusbox rect, .height-focus.label rect {
-webkit-filter: drop-shadow(1px 0px 2px rgba(0, 0, 0, 0.6));
filter: drop-shadow(1px 0px 2px rgba(0, 0, 0, 0.6));
}
.height-focus.label rect, .focusbox rect {
fill: rgba(255,255,255,.6);
}
.heightgraph.leaflet-control {
svg.heightgraph-container {
background: none;
border-radius: 0;
.area {
@include drop-shadow(0.6);
}
}
.horizontalLine {
stroke-width: 2px;
}
.heightgraph-toggle {
background: none;
.heightgraph-toggle-icon {
@extend .control-icon;
@extend .fa-elev-chart;
height: 44px;
position: static;
background: none;
}
}
.heightgraph-close-icon {
@extend .control-icon;
@extend .fa-unsubscribe;
background: none;
font-size: 20px;
line-height: 26px;
width: 26px;
text-align: center;
display: none;
&:before {
width: 26px;
height: 26px;
}
}
}
.leaflet-default-icon-path {
background-image: none;
}

View File

@@ -1,70 +1,51 @@
@media only screen and (max-width: 800px) {
$panel-width: "100vw - #{$button-width} - 2 * #{$block-spacing}";
$panel-width-max: $panel-width;
$panel-actual-width: $panel-width;
.desktop {
display: none !important;
}
#projects {
#feed, #settings {
.map-container {
width: calc(#{$panel-width});
max-width: calc(#{$panel-width});
}
#feed {
right: calc((#{$panel-width}) * -1);
}
#settings {
left: calc((#{$panel-width}) * -1);
}
#title {
width: calc(#{$panel-width} - #{$button-width} - 4 * #{$block-spacing});
max-width: calc(#{$panel-width} - #{$button-width} - 4 * #{$block-spacing});
text-align: center;
}
.leaflet-right, .leaflet-left {
width: 100%;
}
&.with-feed, &.with-settings {
#submap {
width: 100%;
}
.leaflet-control-container .leaflet-top.leaflet-right {
display: none;
}
#title {
width: calc(#{$panel-width} - #{$button-width} - 4 * #{$block-spacing});
max-width: calc(#{$panel-width} - #{$button-width} - 4 * #{$block-spacing});
max-width: calc(100vw - #{$block-spacing} - #{$panel-actual-width} - (#{$button-width} + #{$block-spacing} * 2) * 2);
}
#submap {
transform: translateX(0);
}
}
&.with-feed {
.leaflet-right {
right: calc(#{$panel-width});
.map-container-left {
transform: translateX(-200vw);
}
.leaflet-left {
left: calc((#{$panel-width}) * -1);
.map-container-right {
transform: translateX(calc(#{$button-width} + #{$block-spacing} * 2));
}
}
&.with-settings {
.leaflet-right {
right: calc((#{$panel-width}) * -1);
}
.leaflet-left {
left: calc(#{$panel-width});
}
.map-container-left {
transform: translateX(0);
}
.leaflet-control-container .leaflet-top.leaflet-left {
display: none;
.map-container-right {
transform: translateX(200vw);
}
}
}
@@ -82,10 +63,8 @@
right: 1em;
}
}
}
@media only screen and (min-width: 801px) {
.mobile {
display: none !important;
#mobile {
display: block;
}
}

View File

@@ -0,0 +1,256 @@
#feed {
#feed-panel {
#feed-header {
.poster {
textarea[name=post] {
margin-bottom: 1em;
width: calc(100% - 2em);
}
input[name=name] {
width: calc(100% - 6em);
}
button[name=submit] {
margin-left: 1em;
margin-bottom: 0.5em;
}
}
.archived {
background: #EEE;
}
}
#feed-posts {
position: relative;
}
.body-box {
position:relative;
display: flex;
flex-direction: column;
}
.post-item {
margin-bottom: $block-spacing;
background: $post-bg;
color: $post-color;
border-radius: $block-radius;
width: calc(100% - #{$block-spacing});
box-shadow: 2px 2px 3px 0px rgba(0, 0, 0, 0.5);
a {
color: $post-color;
&:hover {
color: $post-color-hover;
}
}
.message {
margin: 0;
}
.signature {
margin: $elem-spacing 0 0 0;
text-align: right;
font-style: italic;
img {
vertical-align: baseline;
margin: 0 0.2em calc((1em - 24px)/2) 0;
position: relative;
}
}
.header {
padding: 0 $block-spacing;
position: relative;
div {
display: inline-block;
font-size: 0.8em;
padding: $elem-spacing 0px;
&.index {
width: 25%;
.link {
margin-left: $elem-spacing;
padding: 0;
line-height: 1;
}
}
&.time {
width: 75%;
text-align: right;
font-style: italic;
}
}
}
.body {
clear: both;
padding: 0em $block-spacing $block-spacing;
}
&.headerless {
.header {
display: none;
}
.body {
padding-top: $block-spacing;
text-align: center;
p {
margin: 0;
.fa {
display: inline-block;
font-size: 2em;
margin: $elem-spacing 0;
}
}
}
}
&.message {
background: $message-bg;
color: $message-color;
p {
font-size: 0.9em;
height: 1em;
margin: 0 0 $elem-spacing 0;
display: inline-block;
width: 100%;
}
a {
color: $message-color;
&:hover {
color: $message-color-hover;
}
}
a.drill {
line-height: 0;
.drill-icon {
transform: translate(-16px, -32px);
.fa-message-in {
top: -1px;
}
}
&:hover {
.fa-message {
@extend .#{$fa-css-prefix}-drill-message;
top: 13px;
left: 3px;
}
.fa-message-in {
display: none;
}
}
}
.weather {
position: absolute;
top: $block-spacing;
right: $block-spacing;
.fa {
font-size: 1.3em;
vertical-align: middle;
line-height: 1rem;
background: $message-color;
color: $message-bg;
border-radius: $block-radius 0 0 $block-radius;
padding: $elem-spacing;
}
span {
vertical-align: middle;
padding: $elem-spacing;
background: $message-bg;
color: $message-color;
border-radius: 0 $block-radius $block-radius 0;
}
}
.staticmap {
width: 100%;
border-radius: $block-radius;
}
}
&.post {
.body {
padding: 0em 1em 0.5em;
}
}
&.media {
background: $media-bg;
color: $media-color;
.body {
a {
display: inline-block;
width: 100%;
margin: 0;
color: $media-color;
position: relative;
line-height: 0;
&.drill {
&:hover {
.drill-icon .fa-drill-image, .drill-icon .fa-drill-video {
color: rgba($media-bg, 0.75);
}
.comment {
opacity: 0;
}
}
.drill-icon {
font-size: 3em;
.fa-drill-image {
color: transparent;
}
.fa-drill-video {
color: rgba(255, 255, 255, 0.5);
}
}
}
img {
width: 100%;
height: auto;
image-orientation: from-image;
outline: none;
border-radius: $block-radius;
}
.comment {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
line-height: normal;
box-sizing: border-box;
margin: 0;
padding: 0.5em;
text-align: justify;
background: rgba(255, 255, 255, 0.6);
border-radius: 0 0 $block-radius $block-radius;
transition: opacity 0.3s;
opacity: 1;
}
}
}
}
}
}
}

View File

@@ -0,0 +1,110 @@
#map {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 100%;
/* Leaflet Popup */
.maplibregl-popup-content {
h1 {
font-size: 1.4em;
margin: 0;
font-weight: bold;
}
.separator {
border-top: 1px solid #CCC;
margin: $elem-spacing 0 $block-spacing 0;
}
/* Marker Popup */
.info-window {
h1 .message-type {
color: #CCC;
font-weight: normal;
font-size: calc(1em / 1.4);
margin-left: 0.5em;
vertical-align: text-bottom;
}
p {
font-size: 1.0em;
margin: $elem-spacing 0 0 0;
a {
color: $post-color;
}
}
.medias {
line-height: 0;
a {
display: inline-block;
margin: $block-spacing $block-spacing 0 0;
&:last-child {
margin-right: 0;
}
&.drill {
font-size: 2em;
.fa-drill-image {
color: transparent;
}
.fa-drill-video {
color: rgba(255, 255, 255, 0.5);
}
&:hover {
.fa-drill-video, .fa-drill-image {
color: rgba(255, 255, 255, 0.75);
}
}
}
img {
width: auto;
height: auto;
max-width: 200px;
max-height: 100px;
border-radius: $block-radius;
image-orientation: from-image;
transition: All 0.2s;
}
}
}
}
/* Track Popup */
.track_tooltip {
p {
margin: 0;
&.description {
font-size: 1.15em;
}
}
h1, .description {
@include no-text-overflow();
}
.body {
padding-left: calc(1.25em*1.4 + #{$elem-spacing} );
.details {
margin-top: -$block-spacing;
p.detail {
margin-top: $block-spacing;
width: 50%;
display: inline-block;
}
}
}
}
}
}

View File

@@ -0,0 +1,213 @@
$panel-width: 30vw;
$panel-width-max: "400px + 3 * #{$block-spacing}";
$panel-actual-width: min(#{$panel-width}, #{$panel-width-max});
#projects {
&.with-feed, &.with-settings {
#title {
max-width: calc(100vw - #{$block-spacing} - #{$panel-actual-width} - (#{$button-width} + #{$block-spacing} * 2) * 2);
}
}
&.with-feed {
#submap {
transform: translateX(calc(#{$panel-actual-width} / -2));
}
.map-container-right {
transform: translateX(calc(100vw - #{$panel-actual-width}));
}
}
&.with-settings {
#submap {
transform: translateX(calc(#{$panel-actual-width} / 2));
}
.map-container-left {
transform: translateX(0);
.map-panel {
box-shadow: 2px 2px $block-shadow 0px rgba(0, 0, 0, .5);
}
}
}
&.with-feed.with-settings {
#submap {
transform: translateX(0);
}
#title {
max-width: calc(100vw - #{$block-spacing} - #{$panel-actual-width} * 2 - (#{$button-width} + #{$block-spacing} * 2) * 2);
}
}
.map-container { //#feed, #settings
position: absolute;
top: 0;
bottom: 0;
z-index: 1;
user-select: none;
width: #{$panel-width};
max-width: calc(#{$panel-width-max});
transition: transform 0.5s;
&.moving {
cursor: grabbing;
transition: none;
}
.map-panel { //#feed-panel, #settings-panel
position: absolute;
top: 0;
bottom: 0;
left: 0;
}
input, textarea {
background-color: $post-input-bg;
color: $post-color;
outline: none;
}
button, a.button {
background-color: $post-color;
color: $post-bg;
&:hover, &:hover a, &:hover a:visited {
background-color: $post-input-bg;
color: $post-color;
}
a, a:visited {
background-color: $post-color;
color: $post-bg;
text-decoration: none;
}
&+ button, &+ a.button {
margin-left: $elem-spacing;
}
}
}
.map-container-left { //#settings
transform: translateX(-100%);
.map-panel { //#settings-panel
width: calc(100% - #{$block-spacing});
margin: $block-spacing;
border-radius: $block-radius;
color: $post-color;
background: rgba(255, 255, 255, 0.8);
display: flex;
flex-direction: column;
flex-wrap: nowrap;
}
}
.map-container-right { //#feed
transform: translateX(100vw);
.map-panel { //#feed-panel
width: 100%;
padding-top: $block-spacing;
}
}
.map-control {
position: absolute;
background-color: $post-bg;
padding: $elem-spacing;
border-radius: 3px;
box-shadow: 2px 2px 3px 0px rgba(0, 0, 0, 0.5);
font-size: 12px;
line-height: 1.5;
&.map-control-top {
top: $block-spacing;
}
&.map-control-bottom {
bottom: $block-spacing;
}
&.map-control-icon {
cursor: pointer;
.fa {
@extend .fa-fw;
color: $post-color;
}
&:hover .fa {
color: #000000;
}
}
}
.feed-control {
right: calc(100% + $block-spacing);
}
.settings-control {
left: calc(100% + $block-spacing);
}
#legend {
.track {
white-space: nowrap;
.line {
width: 2em;
display: inline-block;
border-radius: 2px;
vertical-align: middle;
}
.desc {
font-size: 1em;
margin-left: 0.5em;
color: $legend-color;
}
}
}
#title {
left: calc(100% + #{$button-width} + 2 * #{$block-spacing});
max-width: calc(100vw - #{$block-spacing} - (#{$button-width} + 2 * #{$block-spacing}) * 2);
transition: max-width 0.5s;
@include no-text-overflow();
span {
font-size: 1.3em;
line-height: $block-spacing;
}
}
#background {
background: #666;
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
}
#submap {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
transition: transform 0.5s;
.loader {
position: absolute;
font-size: 3em;
top: calc(50% - 0.5em);
left: calc(50% - 1.25em/2);
color: #CCC;
}
}
}

View File

@@ -3,31 +3,31 @@ $elem-spacing: 0.5rem;
$block-spacing: 1rem;
$block-radius: 3px;
$block-shadow: 3px;
$panel-width: 30vw;
$panel-width-max: "400px + 3 * #{$block-spacing}";
$button-width: 44px;
$button-width: 31px;
//Feed colors
$post-input-bg: #ffffff; //#d9deff;
$post-color: #333; //#323268;
$post-input-bg: #ffffff;
$post-color: #333;
$post-color-hover: darken($post-color, 10%);
$post-bg: rgba(255,255,255,.8); //#B4BDFF;
$post-bg: rgba(255, 255, 255, .8);
$message-color: #326526;
$message-color-hover: darken($message-color, 10%);
$message-bg: #6DFF58;
$media-color: #333; //#635C28;
$media-bg: rgba(255,255,255,.8); //#F3EC9F;
$media-color: #333;
$media-bg: rgba(255, 255, 255, .8);
//Settings colors
$title-color: $post-color;
$subtitle-color: #999;
//Legend colors
$track-main-color: #00ff78;
$track-off-track-color: #0000ff;
$track-hitchhiking-color: #FF7814;
$legend-color: $post-color;
@import 'page.project.map';
@import 'page.project.panel';
@import 'page.project.feed';
@import 'page.project.settings';
#projects {
overflow: hidden;
position: absolute;
@@ -36,291 +36,6 @@ $legend-color: $post-color;
width: 100%;
height: 100%;
/* Panels movements */
&.with-feed {
#submap {
width: calc(100% - min(#{$panel-width}, #{$panel-width-max}));
}
#feed {
right: 0;
}
.leaflet-right {
right: min(#{$panel-width}, #{$panel-width-max});
}
#feed-button {
.fa {
@extend .fa-next;
}
}
#title {
max-width: calc(100vw - max(#{$panel-width}, #{$panel-width-max}) - (#{$button-width} + #{$block-spacing} * 2) * 2);
}
}
&.with-settings {
#submap {
width: calc(100% - min(#{$panel-width}, #{$panel-width-max}));
left: min(#{$panel-width}, #{$panel-width-max});
}
#settings {
left: 0;
}
.leaflet-left {
left: min(#{$panel-width}, #{$panel-width-max});
}
#settings-button {
.fa {
@extend .fa-prev;
}
}
#title {
max-width: calc(100vw - #{$block-spacing} * 2 - min(#{$panel-width}, #{$panel-width-max}) - (#{$button-width} + #{$block-spacing} * 2) * 2);
}
}
&.with-feed.with-settings {
#submap {
left: 0;
width: 100%;
}
#title {
max-width: calc(100vw - #{$block-spacing} * 2 - min(#{$panel-width}, #{$panel-width-max}) * 2 - (#{$button-width} + #{$block-spacing} * 2) * 2);
}
}
#background {
background: #666;
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
}
#submap {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 100%;
transition: width 0.5s, left 0.5s;
.loader {
position: absolute;
font-size: 3em;
top: calc(50% - 0.5em);
left: calc(50% - 1.25em/2);
color: #CCC;
}
}
#map {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 100%;
/* Leaflet Popup */
.leaflet-popup-content {
h1 {
font-size: 1.4em;
margin: 0;
font-weight: bold;
}
.separator {
border-top: 1px solid #CCC;
margin: $elem-spacing 0 $block-spacing 0;
}
/* Marker Popup */
.info-window {
h1 .message-type {
color: #CCC;
font-weight: normal;
font-size: calc(1em / 1.4);
margin-left: 0.5em;
vertical-align: text-bottom;
}
p {
font-size: 1.0em;
margin: $elem-spacing 0 0 0;
a {
color: $post-color;
}
}
.medias {
line-height: 0;
a {
display: inline-block;
margin: $block-spacing $block-spacing 0 0;
&:last-child {
margin-right: 0;
}
&.drill {
font-size: 2em;
.fa-drill-image {
color: transparent;
}
.fa-drill-video {
color: rgba(255, 255, 255, 0.5);
}
&:hover {
.fa-drill-video, .fa-drill-image {
color: rgba(255, 255, 255, 0.75);
}
}
}
img {
width: auto;
height: auto;
max-width: 200px;
max-height: 100px;
border-radius: $block-radius;
image-orientation: from-image;
transition: All 0.2s;
}
}
}
}
/* Track Popup */
.track_tooltip {
p {
margin: 0;
&.description {
font-size: 1.15em;
}
}
h1, .description {
@include no-text-overflow();
}
.body {
padding-left: calc(1.25em*1.4 + #{$elem-spacing} );
.details {
margin-top: -$block-spacing;
p.detail {
margin-top: $block-spacing;
width: 50%;
display: inline-block;
}
}
}
}
}
}
/* Leaflet patches */
.leaflet-control {
background-color: rgba(255, 255, 255, 0.6);
font-family: Roboto, Arial, sans-serif;
border-radius: $block-radius;
border: none;
margin: $block-spacing;
box-shadow: 0 1px 7px rgba(0, 0, 0, .4);
&+ .leaflet-control:not(.leaflet-control-inline) {
margin-top: 0;
}
&+ .leaflet-control.leaflet-control-inline {
margin-left: 0;
}
&.leaflet-control-scale {
padding: 0.5em;
.leaflet-control-scale-line {
background: none;
}
}
&.leaflet-control-inline {
clear: none;
}
}
/* Pull right/left controls by $panel-width */
.leaflet-right, .leaflet-left {
transition: left 0.5s, right 0.5s;
}
/* Hide default layer control */
.leaflet-top.leaflet-left .leaflet-control-layers .leaflet-control-layers-toggle {
display: none;
}
#legend {
.track {
white-space: nowrap;
.line {
width: 2em;
height: 4px;
display: inline-block;
border-radius: 2px;
vertical-align: middle;
&.main {
background-color: $track-main-color;
}
&.off-track {
background-color: $track-off-track-color;
}
&.hitchhiking {
background-color: $track-hitchhiking-color;
}
}
.desc {
font-size: 1em;
margin-left: 0.5em;
color: $legend-color;
}
}
}
#title {
@include no-text-overflow();
line-height: $button-width;
height: $button-width;
padding: 0 $block-spacing;
margin-bottom: 0;
span#project_name {
font-size: 1.3em;
}
}
#feed-button .fa {
@extend .fa-post;
}
#settings-button .fa {
@extend .fa-menu;
}
/* Drill & Map icons */
a.drill {
@@ -359,484 +74,7 @@ $legend-color: $post-color;
top: 1px;
}
.fa-track-end {
color: $track-hitchhiking-color;
color: #FF7814;
}
}
/* Feed/Settings Panel */
#feed, #settings {
position: absolute;
top: 0;
bottom: 0;
overflow: hidden;
z-index: 999;
cursor: grab;
user-select: none;
&.moving {
cursor: grabbing;
transition: none;
}
input, textarea {
background-color: $post-input-bg;
color: $post-color;
outline: none;
}
button, a.button {
background-color: $post-color;
color: $post-bg;
&:hover, &:hover a, &:hover a:visited {
background-color: $post-input-bg;
color: $post-color;
}
a, a:visited {
background-color: $post-color;
color: $post-bg;
text-decoration: none;
}
&+ button, &+ a.button {
margin-left: $elem-spacing;
}
}
#feed-panel, #settings-panel {
position: absolute;
top: 0;
bottom: 0;
left: 0;
}
}
#feed {
right: calc(min(#{$panel-width}, #{$panel-width-max}) * -1);
transition: right 0.5s;
width: #{$panel-width};
max-width: calc(#{$panel-width-max});
#feed-panel {
width: 100%;
padding-top: $block-spacing;
#posts_list {
position: relative;
}
#poster {
&.histo-mode .poster, &:not(.histo-mode) .archived {
display: none;
}
.poster {
textarea#post {
margin-bottom: 1em;
width: calc(100% - 2em);
}
input#name {
width: calc(100% - 6em);
}
button#submit {
margin-left: 1em;
margin-bottom: 0.5em;
}
}
.archived {
background: #EEE;
}
}
.body-box {
position:relative;
display: flex;
flex-direction: column;
}
.post-item {
margin-bottom: $block-spacing;
background: $post-bg;
color: $post-color;
border-radius: $block-radius;
width: calc(100% - #{$block-spacing});
box-shadow: 2px 2px 3px 0px rgba(0, 0, 0, 0.5);
a {
color: $post-color;
&:hover {
color: $post-color-hover;
}
}
.message {
margin: 0;
}
.signature {
margin: $elem-spacing 0 0 0;
text-align: right;
font-style: italic;
img {
vertical-align: baseline;
margin: 0 0.2em calc((1em - 24px)/2) 0;
position: relative;
}
}
.header {
padding: 0 $block-spacing;
position: relative;
span {
display: inline-block;
font-size: 0.8em;
padding: $elem-spacing 0px;
&.index {
width: 25%;
.link, .link:visited, .link_copied {
margin-left: $elem-spacing;
padding: 0;
line-height: 1;
}
}
&.time {
width: 75%;
text-align: right;
font-style: italic;
}
}
}
.body {
clear: both;
padding: 0em $block-spacing $block-spacing;
}
&.headerless {
.header {
display: none;
}
.body {
padding-top: $block-spacing;
text-align: center;
p {
margin: 0;
.fa {
display: inline-block;
font-size: 2em;
margin: $elem-spacing 0;
}
}
}
}
&.message {
background: $message-bg;
color: $message-color;
p {
font-size: 0.9em;
margin: 0 0 $elem-spacing 0;
display: inline-block;
width: 100%;
}
a {
color: $message-color;
&:hover {
color: $message-color-hover;
}
}
a.drill {
line-height: 0;
.drill-icon {
transform: translate(-16px, -32px);
.fa-message-in {
top: -1px;
left: -1px;
}
}
&:hover {
.fa-message {
@extend .#{$fa-css-prefix}-drill-message;
top: 13px;
left: 3px;
}
.fa-message-in {
display: none;
}
}
}
.weather {
position: absolute;
top: $block-spacing;
right: $block-spacing;
.fa {
font-size: 1.3em;
vertical-align: middle;
line-height: 1rem;
background: $message-color;
color: $message-bg;
border-radius: $block-radius 0 0 $block-radius;
padding: $elem-spacing;
}
span {
vertical-align: middle;
padding: $elem-spacing;
background: $message-bg;
color: $message-color;
border-radius: 0 $block-radius $block-radius 0;
}
}
.staticmap {
width: 100%;
border-radius: $block-radius;
}
}
&.post {
.body {
padding: 0em 1em 0.5em;
}
}
&.media {
background: $media-bg;
color: $media-color;
.body {
a {
display: inline-block;
width: 100%;
margin: 0;
color: $media-color;
position: relative;
line-height: 0;
&.drill {
&:hover {
.drill-icon .fa-drill-image, .drill-icon .fa-drill-video {
color: rgba($media-bg, 0.75);
}
.comment {
opacity: 0;
}
}
.drill-icon {
font-size: 3em;
.fa-drill-image {
color: transparent;
}
.fa-drill-video {
color: rgba(255, 255, 255, 0.5);
}
}
}
img {
width: 100%;
height: auto;
image-orientation: from-image;
outline: none;
border-radius: $block-radius;
}
.comment {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
line-height: normal;
box-sizing: border-box;
margin: 0;
padding: 0.5em;
text-align: justify;
background: rgba(255, 255, 255, 0.6);
border-radius: 0 0 $block-radius $block-radius;
transition: opacity 0.3s;
opacity: 1;
}
}
}
}
}
}
}
#settings {
left: calc(min(#{$panel-width} + #{$block-shadow}, #{$panel-width-max} + #{$block-shadow}) * -1);
transition: left 0.5s;
width: calc(#{$panel-width} + #{$block-shadow}); //Add box-shadow
max-width: calc(#{$panel-width-max} + #{$block-shadow}); //Add box-shadow
#settings-panel {
width: calc(100% - #{$block-spacing} - #{$block-shadow}); //Remove box-shadow
margin: $block-spacing;
border-radius: $block-radius;
box-shadow: 2px 2px $block-shadow 0px rgba(0, 0, 0, .5);
color: $post-color;
background: rgba(255, 255, 255, 0.8);
display: flex;
flex-direction: column;
flex-wrap: nowrap;
.settings-header {
text-align: center;
flex: 0 1 auto;
.logo {
background: rgba(255, 255, 255, .4);
padding: 2rem 1rem;
border-radius: $block-radius $block-radius 0 0;
img {
width: 100%;
height: auto;
max-width: 180px;
transform: translateX(-10%); //Center Text, not logo. logo width (40px) / image width (200px) = 20%. And centering: 20% / 2 = 10%
}
}
#last_update {
position: absolute;
margin-top: -2em;
padding: 0 1rem;
width: calc(100% - 2rem);
p {
text-align: center;
font-size: 0.8em;
margin: 0;
color: $subtitle-color;
transform: translateX(calc(-0.5 * (12px + 0.5em))); //icon width + margin right
span {
margin-right: 0.5em;
img {
width: 12px;
vertical-align: middle;
animation: spotlogo 20s infinite;
}
}
abbr {
text-decoration: none;
vertical-align: middle;
}
}
}
}
.settings-footer {
flex: 0 1 auto;
background: rgba(255, 255, 255, .4);
border-radius: 0 0 3px 3px;
font-size: 0.7em;
padding: 0.3rem;
text-align: center;
color: #888;
a {
color: #777;
text-decoration: none;
}
}
.settings-sections {
flex: 1 1 auto;
overflow: auto;
#settings-sections-scrollbox {
height: 100%;
width: 100%;
}
.settings-section {
display: inline-block;
margin: 1.5rem 1rem 0 1rem;
width: calc(100% - 2 * #{$block-spacing});
&:last-child {
margin-bottom: 1.5rem;
}
h1 {
margin: 0 0 $block-spacing;
color: $title-color;
font-size: 1.5em;
}
label {
margin-bottom: .3em;
display: block;
@extend .clickable;
& > div {
@include no-text-overflow();
}
}
&.newsletter {
input#email {
width: calc(100% - 6em);
&:disabled {
color: #999;
background: rgba(255,255,255,0.2);
}
}
button#nl_btn {
margin-left: 1em;
margin-bottom: 1em;
&.subscribe .fa {
@extend .fa-send;
}
&.unsubscribe .fa {
@extend .fa-unsubscribe;
}
&.loading {
background-color: $message-color;
color: white;
span {
@extend .flicker;
}
}
}
}
#settings-projects {
a.fa-download {
color: $legend-color;
&:hover {
color: #0078A8;
}
}
}
}
}
}
}
}
#elems {
display: none;
}

View File

@@ -0,0 +1,142 @@
#settings {
#settings-panel {
.settings-header {
text-align: center;
flex: 0 1 auto;
.logo {
background: rgba(255, 255, 255, .4);
padding: 2rem 1rem;
border-radius: $block-radius $block-radius 0 0;
img {
width: 100%;
height: auto;
max-width: 180px;
transform: translateX(-10%); //Center Text, not logo. logo width (40px) / image width (200px) = 20%. And centering: 20% / 2 = 10%
}
}
#last_update {
position: absolute;
margin-top: -2em;
padding: 0 1rem;
width: calc(100% - 2rem);
p {
text-align: center;
font-size: 0.8em;
margin: 0;
color: $subtitle-color;
transform: translateX(calc(-0.5 * (12px + 0.5em))); //icon width + margin right
span {
margin-right: 0.5em;
img {
width: 12px;
vertical-align: middle;
animation: spotlogo 20s infinite;
}
}
abbr {
text-decoration: none;
vertical-align: middle;
}
}
}
}
.settings-footer {
flex: 0 1 auto;
background: rgba(255, 255, 255, .4);
border-radius: 0 0 3px 3px;
font-size: 0.7em;
padding: 0.3rem;
text-align: center;
color: #888;
a {
color: #777;
text-decoration: none;
}
}
.settings-sections {
flex: 1 1 auto;
overflow: auto;
#settings-sections-scrollbox {
height: 100%;
width: 100%;
}
.settings-section {
display: inline-block;
margin: 1.5rem 1rem 0 1rem;
width: calc(100% - 2 * #{$block-spacing});
&:last-child {
margin-bottom: 1.5rem;
}
h1 {
margin: 0 0 $block-spacing;
color: $title-color;
font-size: 1.5em;
}
.settings-section-body {
.radio {
&:not(:first-child) {
margin-top: $elem-spacing;
}
label {
margin-left: .3rem;
@extend .clickable;
@include no-text-overflow();
}
.download {
color: $legend-color;
&:hover {
color: #0078A8;
}
}
}
}
&.newsletter {
input#email {
width: calc(100% - 6em);
&:disabled {
color: #999;
background: rgba(255,255,255,0.2);
}
}
button#nl_btn {
margin-left: 1em;
margin-bottom: 1em;
&.subscribe .fa {
@extend .fa-send;
}
&.unsubscribe .fa {
@extend .fa-unsubscribe;
}
&.loading {
background-color: $message-color;
color: white;
span {
@extend .flicker;
}
}
}
}
}
}
}
}

View File

@@ -1,36 +1,53 @@
#upload {
padding: 1em;
.section {
border-radius: 3px;
margin-top: 1rem;
padding: 0 1rem 1rem 1rem;
border-bottom: 1px solid #EEE;
}
.progress {
.bar {
height: 18px;
background: green;
}
}
.comment {
margin-top: 1em;
.thumb {
width: 30%;
max-width: 100px;
}
form {
.form {
display: inline-block;
width: calc(70% - 1em);
min-width: calc(100% - 100px - 1em);
margin-left: 1em;
width: calc(70% - 2rem);
min-width: calc(100% - 100px - 2rem);
padding: 1rem;
vertical-align: top;
box-sizing: border-box;
.content {
width: 100%;
width: calc(100% - 2rem);
box-sizing: border-box;
padding: 0.5em;
border: 1px solid #333;
background: #EEE;
}
.save {
margin-top: 1em;
margin-top: 1rem;
padding: 0.5em;
}
}
}
.logs {
padding: 1rem;
p.log {
margin: 0;
}
}
}

9
src/styles/_vue.scss Normal file
View File

@@ -0,0 +1,9 @@
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}

View File

@@ -5,8 +5,8 @@
/* Modules */
@import 'fa';
@import 'lightbox';
@import 'leaflet';
@import '../../node_modules/simplebar/dist/simplebar.css';
@import '../../node_modules/simplebar-vue/dist/simplebar.min.css';
@import 'vue';
/* Pages Specific CSS */
@import 'page.project';