Compare commits

...

41 Commits

Author SHA1 Message Date
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
d767e335f9 Bump font awesome to 6.5.0 2023-12-07 20:33:52 +01:00
3611f2206f Swapping to objects vue branch 2023-11-19 18:25:52 +01:00
828d32b0ef Revert changes on MainAppPage: splitting getParams 2023-11-19 18:10:00 +01:00
f5d193e42b Split dependencies into modules 2023-11-19 01:03:21 +01:00
c45a19e6bf Move masks 2023-11-11 17:32:47 +01:00
f86dadfc7d Convert project to webpack 2023-11-11 17:23:33 +01:00
9d676c339b Admin: reject ID value 0 2023-11-11 17:18:35 +01:00
2f3a3f9561 Move php classes 2023-11-11 17:12:41 +01:00
55e40f76a1 Remove librairies css 2023-11-11 17:07:54 +01:00
4e9fb52318 Removing local librairies 2 2023-11-11 15:43:47 +01:00
850d2e7235 Removing local librairies 2023-11-11 15:43:24 +01:00
97645b3476 Move files (again) 2023-11-07 19:41:28 +01:00
ab914a391f Restore language folder 2023-10-20 20:56:11 +02:00
842e02f4bb Move files to follow webpack structure 2023-10-20 20:47:26 +02:00
202 changed files with 53455 additions and 4821 deletions

19
.gitignore vendored
View File

@@ -1,14 +1,7 @@
/settings.php
/style/.sass-cache/
/files/**/*.jpg
/files/**/*.JPG
/files/**/*.jpeg
/files/**/*.JPEG
/files/**/*.png
/files/**/*.PNG
/files/**/*.mov
/files/**/*.MOV
/geo/*.geojson
/spot_cron.sh
/vendor/*
/vendor/
/config/settings.php
/files/
/geo/
/node_modules/
/log.html
/dist/

12
build/paths.js Normal file
View File

@@ -0,0 +1,12 @@
const path = require('path')
module.exports = {
SRC: path.resolve(__dirname, '..', 'src'),
DIST: path.resolve(__dirname, '..', 'dist'),
ASSETS: path.resolve(__dirname, '..', 'assets'),
IMAGES: path.resolve(__dirname, '..', 'src/images'),
STYLES: path.resolve(__dirname, '..', 'src/styles'),
LIB: path.resolve(__dirname, '..', 'lib'),
MASKS: path.resolve(__dirname, '..', 'src/masks'),
ROOT: path.resolve(__dirname, '..')
}

115
build/webpack.common.js Normal file
View File

@@ -0,0 +1,115 @@
const path = require('path');
const { SRC, DIST, ASSETS, LIB } = require('./paths');
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: {
app: path.resolve(SRC, 'scripts', 'app.js')
},
output: {
path: DIST,
filename: '[name].js',
publicPath: './' //meaning dist/
},
devtool: "inline-source-map",
module: {
rules: [{
test: /\.vue$/,
loader: 'vue-loader'
}, {
test: /\.js$/,
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: [
'vue-style-loader',
'css-loader',
'resolve-url-loader',
{
loader: 'sass-loader',
options: {
implementation: require.resolve('sass'),
sourceMap: true
}
}
]
}, {
test: /\.css$/i,
use: ["vue-style-loader", "css-loader"],
}, {
test: /\.(png|svg|jpg|jpeg|gif)$/i,
use: {
loader: "url-loader",
options: {
limit: 1 * 1024,
//name: "images/[name].[hash:7].[ext]"
name: "images/[name].[ext]"
}
}
/*type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 8*1024
}
}*/
}
]
},
plugins: [
new webpack.ProvidePlugin({
$: require.resolve('jquery'),
jQuery: require.resolve('jquery')
}),
new CopyWebpackPlugin({
patterns: [/*{
from: 'geo/',
to: path.resolve(DIST, 'geo')
}, {
from: path.resolve(SRC, 'images/icons'),
to: 'images/icons'
}, */{
from: path.resolve(LIB, 'index.php'),
to: 'index.php'
}]
}),
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'],
alias: {
"@scripts": path.resolve(SRC, "scripts"),
'load-image': 'blueimp-load-image/js/load-image.js',
'load-image-meta': 'blueimp-load-image/js/load-image-meta.js',
'load-image-exif': 'blueimp-load-image/js/load-image-exif.js',
'canvas-to-blob': 'blueimp-canvas-to-blob/js/canvas-to-blob.js',
'jquery-ui/ui/widget': 'blueimp-file-upload/js/vendor/jquery.ui.widget.js'
}
}
};

6
build/webpack.dev.js Normal file
View File

@@ -0,0 +1,6 @@
const { merge } = require('webpack-merge')
module.exports = merge(require('./webpack.common.js'), {
mode: 'development',
watch: true
})

5
build/webpack.prod.js Normal file
View File

@@ -0,0 +1,5 @@
const { merge } = require('webpack-merge')
module.exports = merge(require('./webpack.common.js'), {
mode: 'production'
})

4
cli/cron.sh Normal file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
wget -qO- https://spot.lutran.fr/index.php?a=update_project > /dev/null
#Crontab job: 0 * * * * . /var/www/spot/spot_cron.sh > /dev/null

View File

@@ -9,15 +9,15 @@
}
],
"require": {
"franzz/objects": "dev-composer",
"franzz/objects": "dev-vue",
"phpmailer/phpmailer": "^6.5"
},
"autoload": {
"psr-4": {
"Franzz\\Spot\\": "inc/"
"Franzz\\Spot\\": "lib/"
},
"files": [
"settings.php"
"config/settings.php"
]
}
}

22
composer.lock generated
View File

@@ -4,15 +4,15 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "164c903fea5bdcfb36cf6ea31ec0c307",
"content-hash": "12bb836a394b645df50c14652a2ae5bf",
"packages": [
{
"name": "franzz/objects",
"version": "dev-composer",
"version": "dev-vue",
"dist": {
"type": "path",
"url": "../objects",
"reference": "e1cf78b992a6f52742d6834f7508c0ef373ac860"
"reference": "a9f8601384a0078cf8531576768e2c5bad4bbf95"
},
"type": "library",
"autoload": {
@@ -27,16 +27,16 @@
},
{
"name": "phpmailer/phpmailer",
"version": "v6.8.0",
"version": "v6.8.1",
"source": {
"type": "git",
"url": "https://github.com/PHPMailer/PHPMailer.git",
"reference": "df16b615e371d81fb79e506277faea67a1be18f1"
"reference": "e88da8d679acc3824ff231fdc553565b802ac016"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/df16b615e371d81fb79e506277faea67a1be18f1",
"reference": "df16b615e371d81fb79e506277faea67a1be18f1",
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/e88da8d679acc3824ff231fdc553565b802ac016",
"reference": "e88da8d679acc3824ff231fdc553565b802ac016",
"shasum": ""
},
"require": {
@@ -46,13 +46,13 @@
"php": ">=5.5.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^0.7.2",
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"doctrine/annotations": "^1.2.6 || ^1.13.3",
"php-parallel-lint/php-console-highlighter": "^1.0.0",
"php-parallel-lint/php-parallel-lint": "^1.3.2",
"phpcompatibility/php-compatibility": "^9.3.5",
"roave/security-advisories": "dev-latest",
"squizlabs/php_codesniffer": "^3.7.1",
"squizlabs/php_codesniffer": "^3.7.2",
"yoast/phpunit-polyfills": "^1.0.4"
},
"suggest": {
@@ -95,7 +95,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.0"
"source": "https://github.com/PHPMailer/PHPMailer/tree/v6.8.1"
},
"funding": [
{
@@ -103,7 +103,7 @@
"type": "github"
}
],
"time": "2023-03-06T14:43:22+00:00"
"time": "2023-08-29T08:26:30+00:00"
}
],
"packages-dev": [],

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 = '';

41781
geo/snt.gpx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,45 +0,0 @@
<?php
namespace Franzz\Spot;
use Franzz\Objects\PhpObject;
use Franzz\Objects\Db;
use \Settings;
class Map extends PhpObject {
const MAP_TABLE = 'maps';
const MAPPING_TABLE = 'mappings';
private Db $oDb;
private $asMaps;
public function __construct(Db &$oDb) {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
$this->setMaps();
}
private function setMaps() {
$asMaps = $this->oDb->selectRows(array('from'=>self::MAP_TABLE));
foreach($asMaps as $asMap) $this->asMaps[$asMap['codename']] = $asMap;
}
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);});
}
public function getMapUrl($sCodeName, $asParams) {
$asParams['token'] = $this->asMaps[$sCodeName]['token'];
return self::populateParams($this->asMaps[$sCodeName]['pattern'], $asParams);
}
private static function populateParams($sUrl, $asParams) {
foreach($asParams as $sParam=>$sValue) {
$sUrl = str_replace('{'.$sParam.'}', $sValue, $sUrl);
}
return $sUrl;
}
}

View File

@@ -2,6 +2,7 @@ locale = en_NZ
page_og_desc = Keep contact with François when he is off hiking
error_commit_db = Issue committing to DB
unknown_field = Field "$0" is unknown
impossible_value = Value "$0" is not possible for field "$1"
nav_back = Back
@@ -10,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
@@ -63,7 +67,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
@@ -75,6 +79,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

@@ -2,6 +2,7 @@ locale = es_ES
page_og_desc = Mantente en contacto con François durante sus aventuras a la montaña
error_commit_db = Error SQL
unknown_field = Campo "$0" desconocido
impossible_value = Valor "$0" no es posible para campo "$1"
nav_back = Atrás
@@ -10,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
@@ -63,7 +67,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
@@ -75,6 +79,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

@@ -2,6 +2,7 @@ locale = fr_CH
page_og_desc = Gardez le contact avec François lorsqu'il part sur les chemins
error_commit_db = Error lors de la requête SQL
unknown_field = Champ "$0" inconnu
impossible_value = La valeur "$0" n'est pas possible pour le champ "$1"
nav_back = Retour
@@ -10,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
@@ -63,7 +67,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
@@ -75,6 +79,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

44
lib/Converter.php Normal file
View File

@@ -0,0 +1,44 @@
<?php
namespace Franzz\Spot;
use Franzz\Objects\PhpObject;
/**
* GPX to GeoJSON Converter
*
* To convert a gpx file:
* 1. Add file <file_name>.gpx to geo/ folder
* 2. Assign file to project: UPDATE projects SET codename = '<file_name>' WHERE id_project = <id_project>;
* 3. Load any page
*
* To force gpx rebuild:
* ?a=build_geojson&name=<file_name>
*/
class Converter extends PhpObject {
public function __construct() {
parent::__construct(__CLASS__);
}
public static function convertToGeoJson($sCodeName) {
$oGpx = new Gpx($sCodeName);
$oGeoJson = new GeoJson($sCodeName);
$oGeoJson->buildTracks($oGpx->getTracks());
if($oGeoJson->isSimplicationRequired()) $oGeoJson->buildTracks($oGpx->getTracks(), true);
$oGeoJson->sortOffTracks();
$oGeoJson->saveFile();
return $oGpx->getLog().'<br />'.$oGeoJson->getLog();
}
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;
}
}

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);
}
}

View File

@@ -1,120 +1,6 @@
<?php
namespace Franzz\Spot;
use Franzz\Objects\PhpObject;
use Franzz\Objects\ToolBox;
use \Settings;
/**
* GPX to GeoJSON Converter
*
* To convert a gpx file:
* 1. Add file <file_name>.gpx to geo/ folder
* 2. Assign file to project: UPDATE projects SET codename = '<file_name>' WHERE id_project = <id_project>;
* 3. Load any page
*
* To force gpx rebuild:
* ?a=build_geojson&name=<file_name>
*/
class Converter extends PhpObject {
public function __construct() {
parent::__construct(__CLASS__);
}
public static function convertToGeoJson($sCodeName) {
$oGpx = new Gpx($sCodeName);
$oGeoJson = new GeoJson($sCodeName);
$oGeoJson->buildTracks($oGpx->getTracks());
if($oGeoJson->isSimplicationRequired()) $oGeoJson->buildTracks($oGpx->getTracks(), true);
$oGeoJson->sortOffTracks();
$oGeoJson->saveFile();
return $oGpx->getLog().'<br />'.$oGeoJson->getLog();
}
public static function isGeoJsonValid($sCodeName) {
$bResult = false;
$sGpxFilePath = Geo::getFilePath($sCodeName, Gpx::EXT);
$sGeoJsonFilePath = Geo::getFilePath($sCodeName, GeoJson::EXT);
//No need to generate if gpx is missing
if(!file_exists($sGpxFilePath) || file_exists($sGeoJsonFilePath) && filemtime($sGeoJsonFilePath) > filemtime(Geo::getFilePath($sCodeName, Gpx::EXT))) $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, static::EXT);
$this->asTracks = array();
}
public static function getFilePath($sCodeName, $sExt) {
return self::GEO_FOLDER.$sCodeName.$sExt;
}
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 {

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');
}
}
}

66
lib/Map.php Normal file
View File

@@ -0,0 +1,66 @@
<?php
namespace Franzz\Spot;
use Franzz\Objects\PhpObject;
use Franzz\Objects\Db;
use \Settings;
class Map extends PhpObject {
const MAP_TABLE = 'maps';
const MAPPING_TABLE = 'mappings';
private Db $oDb;
private $asMaps;
public function __construct(Db &$oDb) {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
$this->asMaps = array();
}
private function setMaps() {
$asMaps = $this->oDb->selectRows(array('from'=>self::MAP_TABLE));
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->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) {
$asMap = $this->getMaps($sCodeName);
$asParams['token'] = $asMap['token'];
return self::populateParams($asMap['pattern'], $asParams);
}
private static function populateParams($sUrl, $asParams) {
foreach($asParams as $sParam=>$sValue) {
$sUrl = str_replace('{'.$sParam.'}', $sValue, $sUrl);
}
return $sUrl;
}
}

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(Geo::getFilePath($sCodeName, GeoJson::EXT));
$asProject['gpxfilepath'] = Spot::addTimestampToFilePath(Geo::getFilePath($sCodeName, Gpx::EXT));
//$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

@@ -28,6 +28,8 @@ use \Settings;
* - Posts (table `posts`):
* - site_time: timestamp in Site Time
* - timezone: Local Timezone
* - Users (table `users`):
* - timezone: Site Timezone (stored user's timezone for emails)
*/
class Spot extends Main
@@ -40,6 +42,8 @@ class Spot extends Main
const DEFAULT_LANG = 'en';
const MAIN_PAGE = 'index';
private Project $oProject;
private Media $oMedia;
private User $oUser;
@@ -146,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
(
@@ -158,8 +163,8 @@ class Spot extends Main
);
}
public function getAppMainPage()
{
public function getAppMainPage() {
//Cache Page List
$asPages = array_diff($this->asMasks, array('email_update', 'email_conf'));
if(!$this->oUser->checkUserClearance(User::CLEARANCE_ADMIN)) {
@@ -169,29 +174,22 @@ 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()
),
'consts' => array(
'server' => $this->asContext['serv_name'],
'modes' => Project::MODES,
'clearances' => User::CLEARANCES,
'default_timezone' => Settings::TIMEZONE
'default_timezone' => Settings::TIMEZONE,
'chunk_size' => self::FEED_CHUNK_SIZE
)
),
'index',
self::MAIN_PAGE,
array(
'language' => $this->oLang->getLanguage(),
'host_url' => $this->asContext['serv_name'],
'filepath_css' => self::addTimestampToFilePath('style/spot.css'),
'filepath_js_d3' => self::addTimestampToFilePath('script/d3.min.js'),
'filepath_js_leaflet' => self::addTimestampToFilePath('script/leaflet.min.js'),
'filepath_js_jquery' => self::addTimestampToFilePath('script/jquery.min.js'),
'filepath_js_jquery_mods' => self::addTimestampToFilePath('script/jquery.mods.js'),
'filepath_js_spot' => self::addTimestampToFilePath('script/spot.js'),
'filepath_js_lightbox' => self::addTimestampToFilePath('script/lightbox.js')
'filepath_css' => self::addTimestampToFilePath('spot.css'),
'filepath_js' => self::addTimestampToFilePath('../dist/app.js'),
),
$asPages
);
@@ -207,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;
@@ -652,6 +654,8 @@ class Spot extends Main
$sDesc = '';
$asResult = array();
if($this->oDb->isId($sField) && $sValue <= 0) return self::getJsonResult(false, $this->oLang->getTranslation('impossible_value', [$sValue, $sField]));
switch($sType) {
case 'project':
$oProject = new Project($this->oDb, $iId);
@@ -710,38 +714,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

@@ -25,7 +25,12 @@ class Uploader extends UploadHandler
$this->oMedia = &$oMedia;
$this->oLang = &$oLang;
$this->sBody = '';
parent::__construct(array('image_versions'=>array(), 'accept_file_types'=>'/\.(gif|jpe?g|png|mov|mp4)$/i'));
parent::__construct(array(
'upload_dir' => Media::MEDIA_FOLDER,
'image_versions' => array(),
'accept_file_types' => '/\.(gif|jpe?g|png|mov|mp4)$/i'
));
}
protected function validate($uploaded_file, $file, $error, $index, $content_range) {

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

@@ -5,7 +5,8 @@
//Start buffering
ob_start();
$oLoader = require __DIR__.'/vendor/autoload.php';
//Run from /dist/
$oLoader = require __DIR__.'/../vendor/autoload.php';
use Franzz\Objects\ToolBox;
use Franzz\Objects\Main;
@@ -38,6 +39,9 @@ if($sAction!='')
case 'markers':
$sResult = $oSpot->getMarkers();
break;
case 'geojson':
$sResult = $oSpot->getProjectGeoJson();
break;
case 'next_feed':
$sResult = $oSpot->getNextFeed($iId);
break;
@@ -70,17 +74,17 @@ if($sAction!='')
case 'add_comment':
$sResult = $oSpot->addComment($iId, $sContent);
break;
case 'admin_new':
$sResult = $oSpot->createProject();
break;
case 'admin_get':
$sResult = $oSpot->getAdminSettings();
break;
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();
@@ -88,6 +92,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);
}

View File

@@ -1,212 +0,0 @@
<div id="admin">
<a name="back" class="button" href="[#]host_url[#]"><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>
<script type="text/javascript">
oSpot.pageInit = function(asHash) {
self.get('admin_get', setProjects);
$('#new').addButton('new', self.lang('new_project'), 'new', createProject);
$('#toolbox').addButton('refresh', self.lang('update_project'), 'refresh', updateProject);
};
oSpot.onFeedback = function(sType, sMsg, asContext) {
delete asContext.a;
delete asContext.t;
sMsg += ' (';
$.each(asContext, function(sKey, sElem) {
sMsg += sKey+'='+sElem+' / ' ;
});
sMsg = sMsg.slice(0, -3)+')';
$('#feedback').append($('<p>', {'class': sType}).text(sMsg));
};
function setProjects(asElemTypes) {
var aoEvents = [{on:'change', callback:commit}, {on:'keyup', callback:waitAndCommit}];
var aoChangeEvent = [aoEvents[0]];
$.each(asElemTypes, function(sElemType, aoElems) {
$.each(aoElems, function(iKey, oElem) {
var sElemId = sElemType+'_'+oElem.id;
var bNew = ($('#'+sElemId).length == 0);
var $Elem = (bNew?$('<tr>', {'id': sElemId}):$('#'+sElemId))
.data('type', sElemType)
.data('id', oElem.id);
if(oElem.del) $Elem.remove();
else if(!bNew) {
$Elem.find('input').each(function(iKey, oInput){
var $Input = $(oInput);
if($Input.attr('name') in oElem && $Input.attr('type')!='date') $Input.val(oElem[$Input.attr('name')]);
});
}
else {
$Elem.append($('<td>').text(oElem.id || ''));
switch(sElemType) {
case 'project':
$Elem
.append($('<td>').addInput('text', 'name', oElem.name, aoEvents))
.append($('<td>', {'class': 'mode'}).text(oElem.mode))
.append($('<td>').addInput('text', 'codename', oElem.codename, aoEvents))
.append($('<td>').addInput('date', 'active_from', oElem.active_from, aoChangeEvent))
.append($('<td>').addInput('date', 'active_to', oElem.active_to, aoChangeEvent))
.append($('<td>').addButton('close fa-lg', '', 'del_proj', del));
break;
case 'feed':
$Elem
.append($('<td>').addInput('text', 'ref_feed_id', oElem.ref_feed_id, aoEvents))
.append($('<td>').addInput('number', 'id_spot', oElem.id_spot, aoEvents))
.append($('<td>').addInput('number', 'id_project', oElem.id_project, aoEvents))
.append($('<td>').text(oElem.name))
.append($('<td>').text(oElem.status))
.append($('<td>').text(oElem.last_update))
.append($('<td>').addButton('close fa-lg', '', 'del_feed', del));
break;
case 'spot':
$Elem
.append($('<td>').text(oElem.ref_spot_id))
.append($('<td>').text(oElem.name))
.append($('<td>').text(oElem.model))
break;
case 'user':
$Elem
.append($('<td>').text(oElem.name))
.append($('<td>').text(oElem.language))
.append($('<td>').text(oElem.timezone))
.append($('<td>').addInput('number', 'clearance', oElem.clearance, aoEvents))
break;
}
$Elem.appendTo($('#'+sElemType+'_section').find('table tbody'));
}
});
});
}
function createProject() {
self.get('admin_new', setProjects);
}
function updateProject() {
self.get(
'update_project',
function(asData, sMsg){oSpot.onFeedback('success', sMsg, {'update':'project'});},
{},
function(sMsg){oSpot.onFeedback('error', sMsg, {'update':'project'});}
);
}
function commit(event, $This) {
$This = $This || $(this);
if(typeof self.tmp('wait') != 'undefined') clearTimeout(self.tmp('wait'));
var sOldVal = $This.data('old_value');
var sNewVal = $This.val();
if(sOldVal!=sNewVal) {
$This.data('old_value', sNewVal);
var $Record = $This.closest('tr');
var asInputs = {type: $Record.data('type'), id: $Record.data('id'), field: $This.attr('name'), value: sNewVal};
self.get(
'admin_set',
function(asData){
oSpot.onFeedback('success', self.lang('admin_save_success'), asInputs);
setProjects(asData);
},
asInputs,
function(sError){
$This.data('old_value', sOldVal);
oSpot.onFeedback('error', sError, asInputs);
}
);
}
}
function waitAndCommit(event) {
if(typeof self.tmp('wait') != 'undefined') clearTimeout(self.tmp('wait'));
self.tmp('wait', setTimeout(()=>{commit(event,$(this));}, 2000));
}
function del() {
var $Record = $(this).closest('tr');
var asInputs = {type: $Record.data('type'), id: $Record.data('id')};
self.get(
'admin_del',
function(asData){
oSpot.onFeedback('success', self.lang('admin_save_success'), asInputs);
setProjects(asData);
},
asInputs,
function(sError){
oSpot.onFeedback('error', sError, asInputs);
}
);
}
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -1,70 +0,0 @@
<div id="upload">
<a name="back" class="button" href="[#]host_url[#]"><i class="fa fa-back push"></i>[#]lang:nav_back[#]</a>
<h1>[#]lang:upload_title[#]</h1>
<input id="fileupload" type="file" name="files[]" multiple>
<div id="progress">
<div class="bar" style="width: 0%;"></div>
</div>
<div id="comments"></div>
<div id="status"></div>
</div>
<script type="text/javascript">
oSpot.pageInit = function(asHash) {
var asProject = self.vars(['projects', self.vars('default_project_codename')]);
self.tmp('status-box', $('#status'));
if(asProject.editable) {
$('#fileupload')
.attr('data-url', self.getActionLink('upload'))
.fileupload({
dataType: 'json',
formData: {t: self.consts.timezone},
acceptFileTypes: /(\.|\/)(gif|jpe?g|png|mov)$/i,
done: function (e, asData) {
$.each(asData.result.files, function(iKey, oFile) {
var bError = ('error' in oFile);
//Feedback
addStatus(bError?oFile.error:(self.lang('upload_success', [oFile.name])));
//Comments
if(!bError) addCommentBox(oFile.id, oFile.thumbnail);
});
},
progressall: function (e, data) {
var progress = parseInt(data.loaded / data.total * 100, 10);
$('#progress .bar').css('width', progress+'%');
}
});
}
else addStatus(self.lang('upload_mode_archived', [asProject.name]), true);
};
function addCommentBox(iMediaId, sThumbnailPath) {
$('#comments').append($('<div>', {'class':'comment'})
.append($('<img>', {'class':'thumb', 'src':sThumbnailPath}))
.append($('<form>')
.append($('<input>', {'class':'content', 'name':'content', 'type':'text'}))
.append($('<input>', {'class':'id', 'name':'id', 'type':'hidden', 'value':iMediaId}))
.append($('<button>', {'class':'save', 'type':'button'})
.click(function(){
var $Form = $(this).parent();
oSpot.get(
'add_comment',
function(asData){addStatus(self.lang('media_comment_update', asData.filename));},
{id:$Form.find('.id').val(), content:$Form.find('.content').val()},
function(sMsgId){addStatus(self.lang(sMsgId));},
);
})
.text(self.lang('save'))
)
)
);
}
function addStatus(sMsg, bClear) {
bClear = bClear || false;
if(bClear) self.tmp('status-box').empty();
self.tmp('status-box').append($('<p>').text(sMsg));
}
</script>

6177
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"devDependencies": {
"@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",
"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",
"version": "2.0.0",
"main": "index.js",
"private": true,
"scripts": {
"dev": "webpack --config build/webpack.dev.js",
"prod": "webpack --config build/webpack.prod.js"
},
"keywords": [],
"author": "Franzz",
"dependencies": {
"autosize": "^6.0.1",
"blueimp-file-upload": "^10.32.0",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^13.0.0",
"css-loader": "^7.1.2",
"d3": "^7.8.5",
"file-loader": "^6.2.0",
"html-loader": "^5.0.0",
"jquery": "^3.7.1",
"jquery-mousewheel": "^3.1.13",
"jquery.waitforimages": "^2.4.0",
"lightbox2": "^2.11.4",
"maplibre-gl": "^5.4.0",
"resize-observer-polyfill": "^1.5.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,10 @@
# Spot Project
[Spot](https://www.findmespot.com) & GPX integration
## Dependencies
* npm 18+
* composer
* php-mbstring
* php-imagick
* php-gd
@@ -9,23 +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. Install dependencies & update php.ini parameters
3. Copy timezone data: mariadb_tzinfo_to_sql /usr/share/zoneinfo | mariadb -u root mysql
4. Copy settings-sample.php to settings.php and populate
5. Go to #admin and create a new project, feed & maps
6. Add a GPX file named <project_codename>.gpx to /geo/
2. composer install
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

2
script/d3.min.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

23
script/leaflet.min.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -1,468 +0,0 @@
function Spot(asGlobals)
{
self = this;
this.consts = asGlobals.consts;
this.consts.hash_sep = '-';
this.consts.title = 'Spotty';
this.consts.default_page = 'project';
this.consts.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || this.consts.default_timezone;
/* Initialization */
this.init = function()
{
//Variables & constants from php
self.vars('tmp', 'object');
self.vars('page', 'string');
self.updateVars(asGlobals.vars);
//page elem
self.elem = {};
self.elem.container = $('#container');
self.elem.main = $('#main');
self.resetTmpFunctions();
//On Key down
$('html').on('keydown', function(oEvent){self.onKeydown(oEvent);});
//on window resize
$(window).on('resize', function(){self.onResize();});
//Setup menu
//self.initMenu();
//Hash management
$(window)
.bind('hashchange', self.onHashChange)
.trigger('hashchange');
};
this.updateVars = function(asVars)
{
$.each(asVars, function(sKey, oValue){self.vars(sKey, oValue)});
};
/* Variable Management */
this.vars = function(oVarName, oValue)
{
var asVarName = (typeof oVarName == 'object')?oVarName:[oVarName];
//Set, name & type / default value (init)
if(typeof oValue !== 'undefined') setElem(self.vars, copyArray(asVarName), oValue);
//Get, only name parameter
return getElem(self.vars, asVarName);
};
this.tmp = function(sVarName, oValue)
{
var asVarName = (typeof sVarName == 'object')?sVarName:[sVarName];
asVarName.unshift('tmp');
return self.vars(asVarName, oValue);
};
/* Interface with server */
this.get = function(sAction, fOnSuccess, oVars, fOnError, fonProgress)
{
if(!oVars) oVars = {};
fOnError = fOnError || function(sError) {console.log(sError);};
fonProgress = fonProgress || function(sState){};
fonProgress('start');
oVars['a'] = sAction;
oVars['t'] = self.consts.timezone;
return $.ajax(
{
url: self.consts.process_page,
data: oVars,
dataType: 'json'
})
.done(function(oData)
{
fonProgress('done');
if(oData.desc.substr(0, self.consts.lang_prefix.length)==self.consts.lang_prefix) oData.desc = self.lang(oData.desc.substr(5));
if(oData.result==self.consts.error) fOnError(oData.desc);
else fOnSuccess(oData.data, oData.desc);
})
.fail(function(jqXHR, textStatus, errorThrown)
{
fonProgress('fail');
fOnError(textStatus+' '+errorThrown);
});
};
this.lang = function(sKey, asParams) {
var sParamType = $.type(asParams);
if(sParamType == 'undefined') asParams = [];
else if($.type(asParams) != 'array') asParams = [asParams];
var sLang = '';
if(sKey in self.consts.lang) {
sLang = self.consts.lang[sKey];
for(i in asParams) sLang = sLang.replace('$'+i, asParams[i]);
}
else {
console.log('missing translation: '+sKey);
sLang = sKey;
}
return sLang;
};
/* Page Switch - Trigger & Event catching */
this.onHashChange = function()
{
var asHash = self.getHash();
if(asHash.hash !='' && asHash.page != '') self.switchPage(asHash); //page switching
else if(self.vars('page')=='') self.setHash(self.consts.default_page); //first page
};
this.getHash = function()
{
var sHash = self.hash();
var asHash = sHash.split(self.consts.hash_sep);
var sPage = asHash.shift() || '';
return {hash:sHash, page:sPage, items:asHash};
};
this.setHash = function(sPage, asItems, bReboot)
{
bReboot = bReboot || false;
sPage = sPage || '';
asItems = asItems || [];
if(typeof asItems == 'string') asItems = [asItems];
if(sPage != '')
{
var sItems = (asItems.length > 0)?self.consts.hash_sep+asItems.join(self.consts.hash_sep):'';
self.hash(sPage+sItems, bReboot);
}
};
this.hash = function(hash, bReboot)
{
bReboot = bReboot || false;
if(!hash) return window.location.hash.slice(1);
else window.location.hash = '#'+hash;
if(bReboot) location.reload();
};
this.updateHash = function(sType, iId) {
sType = sType || '';
iId = iId || 0;
var asHash = self.getHash();
if(iId) self.setHash(asHash.page, [asHash.items[0], sType, iId]);
};
this.flushHash = function(asTypes) {
asTypes = asTypes || [];
var asHash = self.getHash();
if(asHash.items.length > 1 && (asTypes.length == 0 || asTypes.indexOf(asHash.items[1]) != -1)) self.setHash(asHash.page, [asHash.items[0]]);
};
/* Page Switch - DOM Replacement */
this.getActionLink = function(sAction, oVars)
{
if(!oVars) oVars = {};
sVars = '';
for(i in oVars)
{
sVars += '&'+i+'='+oVars[i];
}
return self.consts.process_page+'?a='+sAction+sVars;
};
this.resetTmpFunctions = function()
{
self.pageInit = function(asHash){console.log('no init for the page: '+asHash.page)};
self.onSamePageMove = function(asHash){return false};
self.onQuitPage = function(){return true};
self.onResize = function(){};
self.onFeedback = function(sType, sMsg){};
self.onKeydown = function(oEvent){};
};
this.switchPage = function(asHash)
{
var sPageName = asHash.page;
var bSamePage = (self.vars('page') == sPageName);
var bFirstPage = (self.vars('page') == '');
if(!self.consts.pages[sPageName]) { //Page does not exist
if(bFirstPage) self.setHash(self.consts.default_page);
else self.setHash(self.vars('page'), self.vars(['hash', 'items']));
}
else if(self.onQuitPage(bSamePage) && !bSamePage || self.onSamePageMove(asHash))
{
//Delete tmp variables
self.vars('tmp', {});
//disable tmp functions
self.resetTmpFunctions();
//Officially a new page
self.vars('page', sPageName);
self.vars('hash', asHash);
//Update Page Title
this.setPageTitle(sPageName+' '+(asHash.items[0] || ''));
//Replacing DOM
var $Dom = $(self.consts.pages[sPageName]);
if(bFirstPage)
{
self.splash($Dom, asHash, bFirstPage); //first page
}
else
{
self.elem.main.stop().fadeTo('fast', 0, function(){self.splash($Dom, asHash, bFirstPage);}); //Switching page
}
}
else if(bSamePage) self.vars('hash', asHash);
};
this.setPageTitle = function(sTitle) {
document.title = self.consts.title+' - '+sTitle;
};
this.splash = function($Dom, asHash, bFirstPage)
{
//Switch main content
self.elem.main.empty().html($Dom);
//Page Bootstrap
self.pageInit(asHash, bFirstPage);
//Show main
var $FadeInElem = bFirstPage?self.elem.container:self.elem.main;
$FadeInElem.hide().fadeTo('slow', 1);
};
this.getNaturalDuration = function(iHours) {
var iTimeMinutes = 0, iTimeHours = 0, iTimeDays = Math.floor(iHours/8); //8 hours a day
if(iTimeDays > 1) iTimeDays = Math.round(iTimeDays * 2) / 2; //Round down to the closest half day
else {
iTimeDays = 0;
iTimeHours = Math.floor(iHours);
iHours -= iTimeHours;
iTimeMinutes = Math.floor(iHours * 4) * 15; //Round down to the closest 15 minutes
}
return '~ '
+(iTimeDays>0?(iTimeDays+(iTimeDays%2==0?'':'½')+' '+self.lang(iTimeDays>1?'unit_days':'unit_day')):'') //Days
+((iTimeHours>0 || iTimeDays==0)?iTimeHours+self.lang('unit_hour'):'') //Hours
+((iTimeDays>0 || iTimeMinutes==0)?'':iTimeMinutes) //Minutes
};
this.checkClearance = function(sClearance) {
return (self.vars(['user', 'clearance']) >= sClearance);
};
}
/* Common Functions */
function copyArray(asArray)
{
return asArray.slice(0); //trick to copy array
}
function getElem(aoAnchor, asPath)
{
return (typeof asPath == 'object' && asPath.length > 1)?getElem(aoAnchor[asPath.shift()], asPath):aoAnchor[(typeof asPath == 'object')?asPath.shift():asPath];
}
function setElem(aoAnchor, asPath, oValue)
{
var asTypes = {boolean:false, string:'', integer:0, int:0, array:[], object:{}};
if(typeof asPath == 'object' && asPath.length > 1)
{
var nextlevel = asPath.shift();
if(!(nextlevel in aoAnchor)) aoAnchor[nextlevel] = {}; //Creating a new level
if(typeof aoAnchor[nextlevel] !== 'object') debug('Error - setElem() : Already existing path at level "'+nextlevel+'". Cancelling setElem() action');
return setElem(aoAnchor[nextlevel], asPath, oValue);
}
else
{
var sKey = (typeof asPath == 'object')?asPath.shift():asPath;
return aoAnchor[sKey] = (!(sKey in aoAnchor) && (oValue in asTypes))?asTypes[oValue]:oValue;
}
}
$.prototype.addInput = function(sType, sName, sValue, aoEvents)
{
aoEvents = aoEvents || [];
var $Input = $('<input>', {type: sType, name: sName, value: sValue}).data('old_value', sValue);
$.each(aoEvents, function(iIndex, aoEvent) {
$Input.on(aoEvent.on, aoEvent.callback);
});
return $(this).append($Input);
};
$.prototype.addButton = function(sIcon, sText, sName, fOnClick, sClass)
{
sText = sText || '';
sClass = sClass || '';
var $Btn = $('<button>', {name: sName, 'class':sClass})
.addIcon('fa-'+sIcon, (sText != ''))
.append(sText)
.click(fOnClick);
return $(this).append($Btn);
};
$.prototype.addIcon = function(sIcon, bMargin, sStyle)
{
bMargin = bMargin || false;
sStyle = sStyle || '';
return $(this).append($('<i>', {'class':'fa'+sStyle+' '+sIcon+(bMargin?' push':'')}));
};
$.prototype.defaultVal = function(sDefaultValue)
{
$(this)
.data('default_value', sDefaultValue)
.val(sDefaultValue)
.addClass('defaultText')
.focus(function()
{
var $This = $(this);
if($This.val() == $This.data('default_value')) $This.val('').removeClass('defaultText');
})
.blur(function()
{
var $This = $(this);
if($This.val() == '') $This.val($This.data('default_value')).addClass('defaultText');
});
};
$.prototype.checkForm = function(sSelector)
{
sSelector = sSelector || 'input[type="text"], textarea';
var $This = $(this);
var bOk = true;
$This.find(sSelector).each(function()
{
$This = $(this);
bOk = bOk && $This.val()!='' && $This.val()!=$This.data('default_value');
});
return bOk;
};
$.prototype.cascadingDown = function(sDuration)
{
return $(this).slideDown(sDuration, function(){$(this).next().cascadingDown(sDuration);});
};
$.prototype.hoverSwap = function(sDefault, sHover)
{
return $(this)
.data('default', sDefault)
.data('hover', sHover)
.hover(function(){
var $This = $(this),
sHover = $This.data('hover');
sDefault = $This.data('default');
if(sDefault!='' && sHover != '') {
$This.fadeOut('fast', function() {
var $This = $(this);
$This.text((sDefault==$This.text())?sHover:sDefault).fadeIn('fast');
});
}
})
.text(sDefault);
};
$.prototype.onSwipe = function(fOnStart, fOnMove, fOnEnd){
return $(this)
.on('dragstart', (e) => {
e.preventDefault();
})
.on('mousedown touchstart', (e) => {
var $This = $(this);
var oPos = getDragPosition(e);
$This.data('x-start', oPos.x);
$This.data('y-start', oPos.y);
$This.data('x-move', oPos.x);
$This.data('y-move', oPos.y);
$This.data('moving', true).addClass('moving');
fOnStart({
xStart: $This.data('x-start'),
yStart: $This.data('y-start')
});
})
.on('touchmove mousemove', (e) => {
var $This = $(this);
if($This.data('moving')) {
var oPos = getDragPosition(e);
$This.data('x-move', oPos.x);
$This.data('y-move', oPos.y);
fOnMove({
xStart: $This.data('x-start'),
yStart: $This.data('y-start'),
xMove: $This.data('x-move'),
yMove: $This.data('y-move')
});
}
})
.on('mouseup mouseleave touchend', (e) => {
var $This = $(this);
if($This.data('moving')) {
$This.data('moving', false).removeClass('moving');
fOnEnd({
xStart: $This.data('x-start'),
yStart: $This.data('y-start'),
xEnd: $This.data('x-move'),
yEnd: $This.data('y-move')
});
}
});
};
function getDragPosition(oEvent) {
let bMouse = oEvent.type.includes('mouse');
return {
x: bMouse?oEvent.pageX:oEvent.touches[0].clientX,
y: bMouse?oEvent.pageY:oEvent.touches[0].clientY
};
}
function copyTextToClipboard(text) {
if(!navigator.clipboard) {
var textArea = document.createElement('textarea');
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = '0';
textArea.style.left = '0';
textArea.style.position = 'fixed';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
var successful = document.execCommand('copy');
if(!successful) console.error('Fallback: Oops, unable to copy', text);
} catch (err) {
console.error('Fallback: Oops, unable to copy', err);
}
document.body.removeChild(textArea);
return;
}
navigator.clipboard.writeText(text).then(
function() {},
function(err) {
console.error('Async: Could not copy text: ', err);
}
);
}

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>

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

@@ -0,0 +1,233 @@
<script>
import SpotButton from './spotButton.vue';
import AdminInput from './adminInput.vue';
export default {
components: {
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"><i class="fa fa-back push"></i>{{ 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>

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

@@ -0,0 +1,77 @@
<script>
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 },
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));});
}
}
}
</script>
<template>
<div id="upload">
<a name="back" class="button" href="#project"><i class="fa fa-back push"></i>{{ spot.lang('nav_back') }}</a>
<h1>{{ spot.lang('upload_title') }}</h1>
<h2>{{ this.project.name }}</h2>
<input v-if="project.editable" id="fileupload" type="file" name="files[]" :data-url="this.spot.getActionLink('upload')" multiple>
<div class="progress" v-if="progress > 0">
<div class="bar" :style="{width:progress+'%'}"></div>
</div>
<div class="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="logs" v-if="logs.length > 0">
<p class="log" v-for="log in logs">{{ log }}.</p>
</div>
</div>
</template>

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

Before

Width:  |  Height:  |  Size: 821 B

After

Width:  |  Height:  |  Size: 821 B

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 261 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -7,7 +7,7 @@
<meta property="og:title" content="Spotty" />
<meta property="og:description" content="[#]lang:page_og_desc[#]" />
<meta property="og:type" content="website" />
<meta property="og:url" content="[#]host_url[#]" />
<meta property="og:url" content="[#]server[#]" />
<meta property="og:image" content="images/ogp.png" />
<meta property="og:locale" content="[#]lang:locale[#]" />
<link rel="apple-touch-icon" sizes="180x180" href="images/icons/apple-touch-icon.png?v=GvmqYyKwbb">
@@ -19,22 +19,11 @@
<meta name="msapplication-TileColor" content="#00a300">
<meta name="msapplication-config" content="images/icons/browserconfig.xml?v=GvmqYyKwbb">
<meta name="theme-color" content="#ffffff">
<link type="text/css" href="[#]filepath_css[#]" rel="stylesheet" media="all" />
<script type="text/javascript" src="[#]filepath_js_d3[#]"></script>
<script type="text/javascript" src="[#]filepath_js_leaflet[#]"></script>
<script type="text/javascript" src="[#]filepath_js_jquery[#]"></script>
<script type="text/javascript" src="[#]filepath_js_jquery_mods[#]"></script>
<script type="text/javascript" src="[#]filepath_js_spot[#]"></script>
<script type="text/javascript" src="[#]filepath_js_lightbox[#]"></script>
<script type="text/javascript">
var oSpot = new Spot([#]GLOBAL_VARS[#]);
$(document).ready(oSpot.init);
</script>
<title></title>
<script type="text/javascript">window.params = [#]GLOBAL_VARS[#];</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>

57
src/masks/project.html Normal file
View File

@@ -0,0 +1,57 @@
<div id="projects">
<div id="background"></div>
<div id="submap">
<div class="loader fa fa-fw fa-map flicker" id="map_loading"></div>
</div>
<div id="map"></div>
<div id="settings">
<div id="settings-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">
<div id="settings-sections-scrollbox">
<div class="settings-section">
<h1><i class="fa fa-fw push fa-project"></i>[#]lang:hikes[#]</h1>
<div id="settings-projects"></div>
</div>
<div class="settings-section">
<h1><i class="fa fa-fw push fa-map"></i>[#]lang:maps[#]</h1>
<div id="layers"></div>
</div>
<div class="settings-section newsletter">
<h1><i class="fa fa-fw push fa-newsletter"></i>[#]lang:newsletter[#]</h1>
<input type="email" name="email" id="email" placeholder="[#]lang:nl_email_placeholder[#]" /><button id="nl_btn"><span><i class="fa"></i></span></button>
<div id="settings-feedback" class="feedback"></div>
<div id="nl_desc"></div>
</div>
<div class="settings-section admin" id="admin_link">
<h1><i class="fa fa-fw push fa-admin"></i>[#]lang:admin[#]</h1>
<a class="button" id="admin_config" name="admin_config" href="#admin"><i class="fa fa-config push"></i>[#]lang:admin_config[#]</a>
<a class="button" id="admin_upload" name="admin_upload" href="#upload"><i class="fa fa-upload push"></i>[#]lang:admin_upload[#]</a>
</div>
</div>
</div>
<div class="settings-footer"><a href="https://git.lutran.fr/franzz/spot" title="[#]lang:credits_git[#]" target="_blank" rel="noopener"><i class="fa fa-credits push"></i>[#]lang:credits_project[#]</a> [#]lang:credits_license[#]</div>
</div>
</div>
<div id="feed">
<div id="feed-panel">
<div id="poster"></div>
<div id="posts_list"></div>
<div id="loading"></div>
</div>
</div>
<div id="elems">
<div id="settings-button" class="spot-control"><i class="fa fa-menu"></i></div>
<div id="feed-button" class="spot-control"><i class="fa"></i></div>
<div id="legend" class="leaflet-control-layers leaflet-control leaflet-control-layers-expanded">
<div class="track"><span class="line main"></span><span class="desc">[#]lang:track_main[#]</span></div>
<div class="track"><span class="line off-track"></span><span class="desc">[#]lang:track_off-track[#]</span></div>
<div class="track"><span class="line hitchhiking"></span><span class="desc">[#]lang:track_hitchhiking[#]</span></div>
</div>
<div id="title" class="leaflet-control-layers leaflet-control leaflet-control-layers-expanded leaflet-control-inline"><span id="project_name" class=""></span></div>
</div>
</div>
<div id="mobile" class="mobile"></div>

10
src/masks/upload.html Normal file
View File

@@ -0,0 +1,10 @@
<div id="upload">
<a name="back" class="button" href="[#]server[#]"><i class="fa fa-back push"></i>[#]lang:nav_back[#]</a>
<h1>[#]lang:upload_title[#]</h1>
<input id="fileupload" type="file" name="files[]" multiple>
<div id="progress">
<div class="bar" style="width: 0%;"></div>
</div>
<div id="comments"></div>
<div id="status"></div>
</div>

41
src/scripts/app.js Normal file
View File

@@ -0,0 +1,41 @@
//jQuery
import './jquery.helpers.js';
//Common
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';
//Masks
import Spot from './spot.js';
//import Project from './page.project.js';
//import Upload from './page.upload.js';
//import Admin from './page.admin.js';
window.oSpot = new Spot(params);
//let oProject = new Project(oSpot);
//oSpot.addPage('project', oProject);
//let oUpload = new Upload(oSpot);
//oSpot.addPage('upload', oUpload);
//let oAdmin = new Admin(oSpot);
//oSpot.addPage('admin', oAdmin);
//$(() => {oSpot.init();});
import { createApp } from 'vue';
import SpotVue from '../Spot.vue';
const oSpotVue = createApp(SpotVue);
oSpotVue.provide('spot', window.oSpot);
oSpotVue.mount('#container');

82
src/scripts/common.js Normal file
View File

@@ -0,0 +1,82 @@
/* Common Functions */
export function copyArray(asArray)
{
return asArray.slice(0); //trick to copy array
}
export function getElem(aoAnchor, asPath)
{
return (typeof asPath == 'object' && asPath.length > 1)?getElem(aoAnchor[asPath.shift()], asPath):aoAnchor[(typeof asPath == 'object')?asPath.shift():asPath];
}
export function setElem(aoAnchor, asPath, oValue)
{
var asTypes = {boolean:false, string:'', integer:0, int:0, array:[], object:{}};
if(typeof asPath == 'object' && asPath.length > 1)
{
var nextlevel = asPath.shift();
if(!(nextlevel in aoAnchor)) aoAnchor[nextlevel] = {}; //Creating a new level
if(typeof aoAnchor[nextlevel] !== 'object') debug('Error - setElem() : Already existing path at level "'+nextlevel+'". Cancelling setElem() action');
return setElem(aoAnchor[nextlevel], asPath, oValue);
}
else
{
var sKey = (typeof asPath == 'object')?asPath.shift():asPath;
return aoAnchor[sKey] = (!(sKey in aoAnchor) && (oValue in asTypes))?asTypes[oValue]:oValue;
}
}
export function getDragPosition(oEvent) {
let bMouse = oEvent.type.includes('mouse');
return {
x: bMouse?oEvent.pageX:oEvent.touches[0].clientX,
y: bMouse?oEvent.pageY:oEvent.touches[0].clientY
};
}
export function copyTextToClipboard(text) {
if(!navigator.clipboard) {
var textArea = document.createElement('textarea');
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = '0';
textArea.style.left = '0';
textArea.style.position = 'fixed';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
var successful = document.execCommand('copy');
if(!successful) console.error('Fallback: Oops, unable to copy', text);
} catch (err) {
console.error('Fallback: Oops, unable to copy', err);
}
document.body.removeChild(textArea);
return;
}
navigator.clipboard.writeText(text).then(
function() {},
function(err) {
console.error('Async: Could not copy text: ', err);
}
);
}
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;
}

Some files were not shown because too many files have changed in this diff Show More