Merge branch 'vue'
22
.gitignore
vendored
@@ -1,14 +1,8 @@
|
||||
/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/*
|
||||
/log.html
|
||||
/vendor/
|
||||
/config/settings.php
|
||||
/files/
|
||||
/geo/
|
||||
/node_modules/
|
||||
/log.html
|
||||
/dist/
|
||||
.codex
|
||||
104
build/webpack.config.js
Normal file
@@ -0,0 +1,104 @@
|
||||
const path = require('path');
|
||||
const 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');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SRC = path.resolve(ROOT, 'src');
|
||||
const DIST = path.resolve(ROOT, 'dist');
|
||||
const LIB = path.resolve(ROOT, 'lib');
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const mode = argv.mode || 'production';
|
||||
const isDev = (mode === 'development');
|
||||
|
||||
return {
|
||||
mode,
|
||||
devtool: isDev ? 'inline-source-map' : false,
|
||||
watch: isDev,
|
||||
entry: {
|
||||
app: path.resolve(SRC, 'app.js')
|
||||
},
|
||||
output: {
|
||||
path: DIST,
|
||||
filename: '[name].js',
|
||||
publicPath: '../dist/'
|
||||
},
|
||||
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: /\.s[ac]ss$/i,
|
||||
use: [
|
||||
'vue-style-loader',
|
||||
'css-loader',
|
||||
{
|
||||
loader: 'sass-loader',
|
||||
options: {
|
||||
implementation: require.resolve('sass'),
|
||||
sourceMap: isDev
|
||||
}
|
||||
}
|
||||
]
|
||||
}, {
|
||||
test: /\.css$/i,
|
||||
use: ['vue-style-loader', 'css-loader']
|
||||
}, {
|
||||
test: /\.(png|svg|jpg|jpeg|gif)$/i,
|
||||
type: 'asset',
|
||||
parser: {
|
||||
dataUrlCondition: {
|
||||
maxSize: 1 * 1024
|
||||
}
|
||||
},
|
||||
generator: {
|
||||
filename: 'images/[name][ext]'
|
||||
}
|
||||
}]
|
||||
},
|
||||
plugins: [
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{ from: path.resolve(LIB, 'index.php'), to: 'index.php' },
|
||||
{ from: path.resolve(SRC, 'images', 'logo_black.png'), to: 'images' },
|
||||
{ from: path.resolve(SRC, 'images', 'spot-logo-only.svg'), to: 'images' }
|
||||
]
|
||||
}),
|
||||
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: ['.vue', '.scss', '...'],
|
||||
alias: {
|
||||
'@components': path.resolve(SRC, 'components'),
|
||||
'@scripts': path.resolve(SRC, 'scripts'),
|
||||
'@styles': path.resolve(SRC, 'styles')
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
4
cli/cron.sh
Normal 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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
29
composer.lock
generated
@@ -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": "bcae723140735b1432caaf3070ef4e29ecb73a76"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
@@ -27,16 +27,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpmailer/phpmailer",
|
||||
"version": "v6.8.0",
|
||||
"version": "v6.12.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHPMailer/PHPMailer.git",
|
||||
"reference": "df16b615e371d81fb79e506277faea67a1be18f1"
|
||||
"reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/df16b615e371d81fb79e506277faea67a1be18f1",
|
||||
"reference": "df16b615e371d81fb79e506277faea67a1be18f1",
|
||||
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/d1ac35d784bf9f5e61b424901d5a014967f15b12",
|
||||
"reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -46,16 +46,17 @@
|
||||
"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": {
|
||||
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
|
||||
"ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
|
||||
"ext-openssl": "Needed for secure SMTP sending and DKIM signing",
|
||||
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
|
||||
@@ -95,7 +96,7 @@
|
||||
"description": "PHPMailer is a full-featured email creation and transfer class for PHP",
|
||||
"support": {
|
||||
"issues": "https://github.com/PHPMailer/PHPMailer/issues",
|
||||
"source": "https://github.com/PHPMailer/PHPMailer/tree/v6.8.0"
|
||||
"source": "https://github.com/PHPMailer/PHPMailer/tree/v6.12.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -103,7 +104,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2023-03-06T14:43:22+00:00"
|
||||
"time": "2025-10-15T16:49:08+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
@@ -114,7 +115,7 @@
|
||||
},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": [],
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.3.0"
|
||||
"platform": {},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
|
||||
5
config/db/update_v21_to_v22.sql
Normal 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';
|
||||
23
config/db/update_v22_to_v23.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
ALTER TABLE medias MODIFY latitude DECIMAL(8,6);
|
||||
ALTER TABLE medias MODIFY longitude DECIMAL(9,6);
|
||||
|
||||
UPDATE medias SET comment = 'Source chaude en plein milieu d''une forêt !' WHERE id_media = 16;
|
||||
UPDATE medias SET comment = 'Stephan veut absolument arriver à Arrowtown avant le Super Bowl :D' WHERE id_media = 48;
|
||||
|
||||
UPDATE medias SET latitude = 41.011880, longitude = -121.652212, altitude = 855 WHERE id_media = 286;
|
||||
UPDATE medias SET latitude = -41.787646,longitude = 172.886950 WHERE id_media = 62;
|
||||
UPDATE medias SET latitude = -43.575937,longitude = 170.945159, comment = 'Edoras' WHERE id_media = 17;
|
||||
UPDATE medias SET latitude = -44.176388,longitude = 170.196363, comment = 'Juste la bonne ouverture !' WHERE id_media = 29;
|
||||
UPDATE medias SET latitude = -43.695997,longitude = 170.168364, comment = 'Tasman Glacier' WHERE id_media = 31;
|
||||
UPDATE medias SET latitude = -44.802931,longitude = 168.157397 WHERE id_media = 52;
|
||||
UPDATE medias SET latitude = 42.475460, longitude = 3.040459 WHERE id_media = 70;
|
||||
UPDATE medias SET latitude = 42.701617, longitude = 0.526217 WHERE id_media = 104;
|
||||
UPDATE medias SET latitude = 42.715667, longitude = 0.028215 WHERE id_media = 107;
|
||||
UPDATE medias SET latitude = 42.691005, longitude = -0.033730 WHERE id_media = 114;
|
||||
UPDATE medias SET latitude = 42.789225, longitude = -0.155126 WHERE id_media = 122;
|
||||
UPDATE medias SET latitude = 43.085268, longitude = -1.389657 WHERE id_media = 127;
|
||||
UPDATE medias SET latitude = 43.307165, longitude = -1.630111 WHERE id_media = 129;
|
||||
UPDATE medias SET latitude = 57.278450, longitude = -5.289557 WHERE id_media = 396;
|
||||
UPDATE medias SET latitude = 58.208948, longitude = -4.927144, comment = 'Eas a'' Chual Aluinn (nom de la chute d’eau, c’est du gaélique, faut pas chercher). Les plus hautes des UK.' WHERE id_media = 409;
|
||||
UPDATE medias SET latitude = 58.243837, longitude = -4.964172 WHERE id_media = 410;
|
||||
UPDATE medias SET latitude = 58.542221, longitude = -5.048463 WHERE id_media = 417;
|
||||
@@ -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 = '';
|
||||
1104
geo/hrp.gpx
1643
geo/te_araroa.gpx
204
i18n/en.json
Normal file
@@ -0,0 +1,204 @@
|
||||
{
|
||||
"action": {
|
||||
"back": "Back",
|
||||
"delete": "Delete",
|
||||
"save": "Save",
|
||||
"send": "Send"
|
||||
},
|
||||
"admin": {
|
||||
"config": "Config",
|
||||
"create_success": "Created",
|
||||
"delete_success": "Deleted",
|
||||
"save_success": "Saved",
|
||||
"title": "Admin Panel",
|
||||
"toolbox": "Toolbox",
|
||||
"upload": "Upload"
|
||||
},
|
||||
"credits": {
|
||||
"git": "Git Repository",
|
||||
"license": "under GPLv3 license",
|
||||
"project": "Spotty Project"
|
||||
},
|
||||
"email": {
|
||||
"confirmation": {
|
||||
"body_1": "Thank you for checking in on my wanderings :). I'll make sure to keep you posted on my progress along the trail.",
|
||||
"body_2": "I usually check-in once a day, plus sometimes on special events, like successful peak ascents. I am using a GPS-based device (PLB) which does not require phone reception to work. Thus the messages should be pretty frequent, but, being awestruck by the beauty of nature, I could also just forget to send a signal once in a while. So do not worry if you don't receive anything for a couple of days.",
|
||||
"body_3": "If I've posted some pictures recently, you should also get them in the same email.",
|
||||
"conclusion": "See you down the road!",
|
||||
"preheader": "Thanks for keeping in touch!",
|
||||
"signature": "--François",
|
||||
"subject": "Successful Registration",
|
||||
"thanks_subject": "You're all set!"
|
||||
},
|
||||
"unsubscribe": "PS: Changed your mind?",
|
||||
"unsubscribe_button": "Unsubscribe",
|
||||
"update": {
|
||||
"latest_news": "Latest news:",
|
||||
"preheader": "New position received",
|
||||
"subject": "Spotted!",
|
||||
"title": "Message"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"commit_db": "Issue committing to DB",
|
||||
"impossible_value": "Value \"$0\" is not possible for field \"$1\"",
|
||||
"no_auth": "No authorization",
|
||||
"unknown_field": "Field \"$0\" is unknown"
|
||||
},
|
||||
"feed": {
|
||||
"counter": "#$0",
|
||||
"id": "Feed ID",
|
||||
"last_update": "Last Spot Check",
|
||||
"name": "Name",
|
||||
"new": "New feed",
|
||||
"plural": "Feeds",
|
||||
"ref_id": "Ref. Feed ID",
|
||||
"status": "Status"
|
||||
},
|
||||
"map": {
|
||||
"ign_france": "IGN (France)",
|
||||
"ign_spain": "IGN (Spain)",
|
||||
"linz": "LINZ",
|
||||
"natgeo": "National Geographic",
|
||||
"otm": "Open Topo Map",
|
||||
"outdoors": "Mapbox Outdoors",
|
||||
"satellite": "Satellite",
|
||||
"see_on_google": "See on Google Maps",
|
||||
"title": "Base Maps",
|
||||
"usgs": "USGS"
|
||||
},
|
||||
"media": {
|
||||
"add_on": "added on $0",
|
||||
"click_watch": "Click to watch video",
|
||||
"click_zoom": "Click to zoom",
|
||||
"comment_update": "Comment of media \"$0\" updated",
|
||||
"count": "Media $0 / $1",
|
||||
"image": "Picture",
|
||||
"image_taken": "taken on $0",
|
||||
"images": "Pictures",
|
||||
"nearby": "Nearby pictures",
|
||||
"no_id": "Missing Media ID in request",
|
||||
"video": "Video",
|
||||
"video_taken": "shot on $0"
|
||||
},
|
||||
"meta": {
|
||||
"locale": "en_NZ",
|
||||
"page_og_desc": "Keep contact with François when he is off hiking"
|
||||
},
|
||||
"newsletter": {
|
||||
"email_exists": "This email is already subscribed. You can unsubscribe by clicking on the button above.",
|
||||
"email_placeholder": "my@email.com",
|
||||
"invalid_email": "It doesn't look like an email",
|
||||
"subscribe": "Subscribe",
|
||||
"subscribed": "Thanks! You'll receive a confirmation email shortly",
|
||||
"subscribed_desc": "You're all set. We'll send you updates as soon as we get them.",
|
||||
"title": "Keep in touch!",
|
||||
"unknown_email": "Unknown email address",
|
||||
"unsubscribe": "Unsubscribe",
|
||||
"unsubscribed": "Done. No more junk mail from us",
|
||||
"unsubscribed_desc": "Write down your email address and we'll send you François' position as soon as we get it :)"
|
||||
},
|
||||
"post": {
|
||||
"copy_to_clipboard": "Copy direct link to clipboard",
|
||||
"link_copied": "Link copied!",
|
||||
"message": "Message",
|
||||
"name": "Name",
|
||||
"new_message": "New message"
|
||||
},
|
||||
"project": {
|
||||
"code_name": "Code name",
|
||||
"end": "End",
|
||||
"hikes": "Hikes",
|
||||
"id": "Project ID",
|
||||
"mode": "Mode",
|
||||
"modes": {
|
||||
"blog": "Active Project",
|
||||
"histo": "Archived Project",
|
||||
"previz": "Project in preparation"
|
||||
},
|
||||
"new": "New Project",
|
||||
"plural": "Projects",
|
||||
"single": "Project",
|
||||
"start": "Start",
|
||||
"update_messages": "Update project messages"
|
||||
},
|
||||
"spot": {
|
||||
"id": "Spot ID",
|
||||
"model": "Model",
|
||||
"name": "Spot Name",
|
||||
"plural": "Spots",
|
||||
"ref_id": "Ref. Spot ID"
|
||||
},
|
||||
"stats": {
|
||||
"duration": "Duration",
|
||||
"distance": "Distance",
|
||||
"elevation": "Elevation",
|
||||
"elevation_gain": "Elevation gain",
|
||||
"elevation_loss": "Elevation loss",
|
||||
"legend": "Legend",
|
||||
"segment_length": "Segment length",
|
||||
"type": "Track Type"
|
||||
},
|
||||
"time": {
|
||||
"city": "$0 Time",
|
||||
"date_time": "$0 at $1",
|
||||
"local": "$0 Local Time",
|
||||
"user": "$0 Your Time",
|
||||
"zone": "Time Zone"
|
||||
},
|
||||
"track": {
|
||||
"download": "Download GPX Track",
|
||||
"hitchhiking": "Hitchhiking",
|
||||
"main": "Main track",
|
||||
"off-track": "Off-track"
|
||||
},
|
||||
"unit": {
|
||||
"day": "day",
|
||||
"day_short": "D",
|
||||
"days": "days",
|
||||
"hour": "h"
|
||||
},
|
||||
"upload": {
|
||||
"media": {
|
||||
"exists": "Picture $0 already exists",
|
||||
"title": "Picture & Video Uploads"
|
||||
},
|
||||
"mode_archived": "Project \"$0\" is archived. No upload allowed",
|
||||
"position": {
|
||||
"new": "New Position",
|
||||
"title": "Additional Position"
|
||||
},
|
||||
"success": "$0 uploaded successfully"
|
||||
},
|
||||
"user": {
|
||||
"active": "Active Users",
|
||||
"clearance": "Clearance",
|
||||
"id": "User ID",
|
||||
"language": "Language",
|
||||
"name": "User Name"
|
||||
},
|
||||
"weather": {
|
||||
"clear-day": "Cloud cover is less than 20% during daytime",
|
||||
"clear-night": "Cloud cover is less than 20% during nighttime",
|
||||
"cloudy": "Cloud cover is greater than 90%",
|
||||
"fog": "Visibility is low (less than one kilometer or mile)",
|
||||
"hail": "Hail showers",
|
||||
"partly-cloudy-day": "Cloud cover is greater than 20% during daytime",
|
||||
"partly-cloudy-night": "Cloud cover is greater than 20% during nighttime",
|
||||
"rain": "Amount of rainfall is greater than zero",
|
||||
"rain-snow": "Snow and rain showers",
|
||||
"rain-snow-showers-day": "Possible rain/snow throughout the day",
|
||||
"rain-snow-showers-night": "Possible rain/snow throughout the night",
|
||||
"showers-day": "Rain showers during the day",
|
||||
"showers-night": "Rain showers during the night",
|
||||
"sleet": "Sleet",
|
||||
"snow": "Amount of snow is greater than zero",
|
||||
"snow-showers-day": "Periods of snow during the day",
|
||||
"snow-showers-night": "Periods of snow during the night",
|
||||
"thunder": "Thunderstorms",
|
||||
"thunder-rain": "Thunderstorms throughout the day or night",
|
||||
"thunder-showers-day": "Possible thunderstorms throughout the day",
|
||||
"thunder-showers-night": "Possible thunderstorms throughout the night",
|
||||
"wind": "Wind speed is high (greater than 30 kph or mph)"
|
||||
}
|
||||
}
|
||||
204
i18n/es.json
Normal file
@@ -0,0 +1,204 @@
|
||||
{
|
||||
"action": {
|
||||
"back": "Atrás",
|
||||
"delete": "Borrar",
|
||||
"save": "Guardar",
|
||||
"send": "Enviar"
|
||||
},
|
||||
"admin": {
|
||||
"config": "Configuración",
|
||||
"create_success": "Creado",
|
||||
"delete_success": "Eliminado",
|
||||
"save_success": "Guardado",
|
||||
"title": "Administración",
|
||||
"toolbox": "Herramientas",
|
||||
"upload": "Cargar"
|
||||
},
|
||||
"credits": {
|
||||
"git": "Repositorio de Git",
|
||||
"license": "bajo licencia GPLv3",
|
||||
"project": "Proyecto Spotty"
|
||||
},
|
||||
"email": {
|
||||
"confirmation": {
|
||||
"body_1": "Os agradezco mucho que sigais mi proyecto, y os intereseis de la evolucion. Os prometo que os mantendré informados sobre mi progreso.",
|
||||
"body_2": "Normalmente envío un mensaje una vez al día. Cuando voy a lugares guays, envío uno extra (cimas, ese tipo de cosas). Estoy usando una dispositivo GPS para enviar la señal, por lo que no necesito una red telefónica para que funcione. Sin embargo, puede haber ocasiones en las que presione el botón. Por lo tanto, no se preocupe si no recibe mensajes durante uno o dos días.",
|
||||
"body_3": "Cuando añada fotos en la página, también deberás encontrarlas en este correo electrónico.",
|
||||
"conclusion": "¡Nos vemos en el camino!",
|
||||
"preheader": "¡Gracias por mantenerte en contacto!",
|
||||
"signature": "--François",
|
||||
"subject": "Confirmación",
|
||||
"thanks_subject": "¡Hecho!"
|
||||
},
|
||||
"unsubscribe": "PD: ¿Demasiados correos electrónicos?",
|
||||
"unsubscribe_button": "Desinscribirse",
|
||||
"update": {
|
||||
"latest_news": "Últimas noticias:",
|
||||
"preheader": "¡Nueva posición!",
|
||||
"subject": "Nueva posición recibida",
|
||||
"title": "Mensaje"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"commit_db": "Error SQL",
|
||||
"impossible_value": "Valor \"$0\" no es posible para campo \"$1\"",
|
||||
"no_auth": "No autorización",
|
||||
"unknown_field": "Campo \"$0\" desconocido"
|
||||
},
|
||||
"feed": {
|
||||
"counter": "No. $0",
|
||||
"id": "ID Feed",
|
||||
"last_update": "Última actualización de Spot",
|
||||
"name": "Descripción",
|
||||
"new": "Nuevo feed",
|
||||
"plural": "Feeds",
|
||||
"ref_id": "ID Feed ref.",
|
||||
"status": "Estado"
|
||||
},
|
||||
"map": {
|
||||
"ign_france": "IGN (Francia)",
|
||||
"ign_spain": "IGN (España)",
|
||||
"linz": "LINZ",
|
||||
"natgeo": "National Geographic",
|
||||
"otm": "Open Topo Map",
|
||||
"outdoors": "Mapbox Topo",
|
||||
"satellite": "Satélite",
|
||||
"see_on_google": "Ver la posición en Google Maps",
|
||||
"title": "Mapas de base",
|
||||
"usgs": "USGS"
|
||||
},
|
||||
"media": {
|
||||
"add_on": "Agregado el $0",
|
||||
"click_watch": "Haz clic para ver el video",
|
||||
"click_zoom": "Haz clic para ampliar",
|
||||
"comment_update": "Comentario \"$0\" actualizado",
|
||||
"count": "Media $0 de $1",
|
||||
"image": "Foto",
|
||||
"image_taken": "Foto tomada el $0",
|
||||
"images": "Fotos",
|
||||
"nearby": "Fotos cercanas",
|
||||
"no_id": "Falta el ID del sujeto",
|
||||
"video": "Video",
|
||||
"video_taken": "Video filmado el $0"
|
||||
},
|
||||
"meta": {
|
||||
"locale": "es_ES",
|
||||
"page_og_desc": "Mantente en contacto con François durante sus aventuras a la montaña"
|
||||
},
|
||||
"newsletter": {
|
||||
"email_exists": "Esta dirección de correo electrónico ya está registrada. Puedes darte de baja haciendo clic en el botón de arriba.",
|
||||
"email_placeholder": "nombre@email.com",
|
||||
"invalid_email": "Esto no parece una dirección de correo electrónico",
|
||||
"subscribe": "Suscribir",
|
||||
"subscribed": "¡Gracias! Recibirás un correo electrónico de confirmación",
|
||||
"subscribed_desc": "Todo esta listo. Te enviaremos noticias frescas en cuanto las recibamos. Prometido...",
|
||||
"title": "Mantenerse en contacto",
|
||||
"unknown_email": "Dirección de email desconocida",
|
||||
"unsubscribe": "Desinscribirse",
|
||||
"unsubscribed": "Está hecho. ¡No más spam!",
|
||||
"unsubscribed_desc": "Anade tu dirección de correo electrónico y te enviaremos la posicion actualizada de François tan pronto como la recibamos :)"
|
||||
},
|
||||
"post": {
|
||||
"copy_to_clipboard": "Copiar el enlace",
|
||||
"link_copied": "¡Enlace copiado!",
|
||||
"message": "Mensaje",
|
||||
"name": "Nombre",
|
||||
"new_message": "Mensaje nuevo"
|
||||
},
|
||||
"project": {
|
||||
"code_name": "Nombre clave",
|
||||
"end": "Fin",
|
||||
"hikes": "Senderos",
|
||||
"id": "Proyecto ID",
|
||||
"mode": "Modo",
|
||||
"modes": {
|
||||
"blog": "Proyecto activo",
|
||||
"histo": "Proyecto archivado",
|
||||
"previz": "Proyecto en preparación"
|
||||
},
|
||||
"new": "Nuevo proyecto",
|
||||
"plural": "Proyectos",
|
||||
"single": "Proyecto",
|
||||
"start": "Inicio",
|
||||
"update_messages": "Actualizar los mensajes del proyecto"
|
||||
},
|
||||
"spot": {
|
||||
"id": "ID Spot",
|
||||
"model": "Modelo",
|
||||
"name": "Spot",
|
||||
"plural": "Spots",
|
||||
"ref_id": "ID Spot ref."
|
||||
},
|
||||
"stats": {
|
||||
"duration": "Duración",
|
||||
"distance": "Distancia",
|
||||
"elevation": "Elevación",
|
||||
"elevation_gain": "Ascenso acumulado",
|
||||
"elevation_loss": "Descenso acumulado",
|
||||
"legend": "Leyenda",
|
||||
"segment_length": "Tamaño del segmento",
|
||||
"type": "Tipo de sendero"
|
||||
},
|
||||
"time": {
|
||||
"city": "Hora de $0",
|
||||
"date_time": "$0 a la $1",
|
||||
"local": "$0 hora local",
|
||||
"user": "$0 en tu zona horaria",
|
||||
"zone": "Huso horario"
|
||||
},
|
||||
"track": {
|
||||
"download": "Descarga la ruta GPX",
|
||||
"hitchhiking": "Autostop",
|
||||
"main": "Camino principal",
|
||||
"off-track": "Variante"
|
||||
},
|
||||
"unit": {
|
||||
"day": "Día",
|
||||
"day_short": "D",
|
||||
"days": "Días",
|
||||
"hour": "h"
|
||||
},
|
||||
"upload": {
|
||||
"media": {
|
||||
"exists": "La imagen $0 ya existe",
|
||||
"title": "Cargar fotos y videos"
|
||||
},
|
||||
"mode_archived": "El proyecto \"$0\" esta archivado. No se puede cargar",
|
||||
"position": {
|
||||
"new": "Nueva posición",
|
||||
"title": "Subir posición"
|
||||
},
|
||||
"success": "$0 ha sido subido"
|
||||
},
|
||||
"user": {
|
||||
"active": "Usuarios activos",
|
||||
"clearance": "Nivel de autorización",
|
||||
"id": "ID del usuario",
|
||||
"language": "Idioma",
|
||||
"name": "Nombre"
|
||||
},
|
||||
"weather": {
|
||||
"clear-day": "La nubosidad es inferior al 20 % durante el día",
|
||||
"clear-night": "La nubosidad es inferior al 20 % durante la noche",
|
||||
"cloudy": "La nubosidad es superior al 90 %",
|
||||
"fog": "La visibilidad es baja (menos de un kilómetro o una milla)",
|
||||
"hail": "Chubascos de granizo",
|
||||
"partly-cloudy-day": "La nubosidad es superior al 20 % durante el día",
|
||||
"partly-cloudy-night": "La nubosidad es superior al 20 % durante la noche",
|
||||
"rain": "La cantidad de lluvia es superior a cero",
|
||||
"rain-snow": "Chubascos de nieve y lluvia",
|
||||
"rain-snow-showers-day": "Posible lluvia/nieve durante todo el día",
|
||||
"rain-snow-showers-night": "Posible lluvia/nieve durante toda la noche",
|
||||
"showers-day": "Chubascos de lluvia durante el día",
|
||||
"showers-night": "Chubascos de lluvia durante la noche",
|
||||
"sleet": "Aguanieve",
|
||||
"snow": "La cantidad de nieve es superior a cero",
|
||||
"snow-showers-day": "Periodos de nieve durante el día",
|
||||
"snow-showers-night": "Periodos de nieve durante la noche",
|
||||
"thunder": "Tormentas",
|
||||
"thunder-rain": "Tormentas durante el día o la noche",
|
||||
"thunder-showers-day": "Posibles tormentas durante todo el día",
|
||||
"thunder-showers-night": "Posibles tormentas durante toda la noche",
|
||||
"wind": "La velocidad del viento es alta (más de 30 km/h o mph)"
|
||||
}
|
||||
}
|
||||
204
i18n/fr.json
Normal file
@@ -0,0 +1,204 @@
|
||||
{
|
||||
"action": {
|
||||
"back": "Retour",
|
||||
"delete": "Supprimer",
|
||||
"save": "Sauvegarder",
|
||||
"send": "Envoyer"
|
||||
},
|
||||
"admin": {
|
||||
"config": "Paramètres",
|
||||
"create_success": "Créé",
|
||||
"delete_success": "Supprimé",
|
||||
"save_success": "Sauvegardé",
|
||||
"title": "Administration",
|
||||
"toolbox": "Boite à outils",
|
||||
"upload": "Uploader"
|
||||
},
|
||||
"credits": {
|
||||
"git": "Dépôt Git",
|
||||
"license": "sous licence GPLv3",
|
||||
"project": "Projet Spotty"
|
||||
},
|
||||
"email": {
|
||||
"confirmation": {
|
||||
"body_1": "C'est gentil de venir voir où j'en suis. Promis, je vous tiendrais au courant de mon avancée.",
|
||||
"body_2": "En général, j'envoie un message une fois par jour. Lorsque je passe à des endroits sympas, j'en envoie un supplémentaire (ascension de sommets, ce genre de choses). J'utilise une balise GPS pour envoyer le signal, je n'ai donc pas besoin de réseau téléphonique pour que cela fonctionne. Cependant, il peut m'arriver d'appuyer sur le bouton. Donc pas de raison de s'inquiéter si vous ne recevez pas de messages pendant une journée ou deux.",
|
||||
"body_3": "Si j'ai ajouté des photos sur le site récemment, vous devriez aussi les retrouver dans cet email.",
|
||||
"conclusion": "A bientôt sur les chemins !",
|
||||
"preheader": "Merci de rester en contact !",
|
||||
"signature": "--François",
|
||||
"subject": "Confirmation",
|
||||
"thanks_subject": "C'est tout bon !"
|
||||
},
|
||||
"unsubscribe": "PS: Trop d'emails ?",
|
||||
"unsubscribe_button": "Se désinscrire",
|
||||
"update": {
|
||||
"latest_news": "Dernières nouvelles :",
|
||||
"preheader": "Nouvelle position !",
|
||||
"subject": "Nouvelle position reçue",
|
||||
"title": "Message"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"commit_db": "Error lors de la requête SQL",
|
||||
"impossible_value": "La valeur \"$0\" n'est pas possible pour le champ \"$1\"",
|
||||
"no_auth": "Pas d'authorisation",
|
||||
"unknown_field": "Champ \"$0\" inconnu"
|
||||
},
|
||||
"feed": {
|
||||
"counter": "N°$0",
|
||||
"id": "ID Feed",
|
||||
"last_update": "Dernière vérification Spot",
|
||||
"name": "Description",
|
||||
"new": "Nouveau feed",
|
||||
"plural": "Feeds",
|
||||
"ref_id": "ID Feed ref.",
|
||||
"status": "Statut"
|
||||
},
|
||||
"map": {
|
||||
"ign_france": "IGN (France)",
|
||||
"ign_spain": "IGN (Espagne)",
|
||||
"linz": "LINZ",
|
||||
"natgeo": "National Geographic",
|
||||
"otm": "Open Topo Map",
|
||||
"outdoors": "Mapbox Topo",
|
||||
"satellite": "Satellite",
|
||||
"see_on_google": "Voir la position sur Google Maps",
|
||||
"title": "Fonds de carte",
|
||||
"usgs": "USGS"
|
||||
},
|
||||
"media": {
|
||||
"add_on": "ajoutée le $0",
|
||||
"click_watch": "Click pour voir la vidéo",
|
||||
"click_zoom": "Click pour zoomer",
|
||||
"comment_update": "Commentaire du media \"$0\" mis-à-jour",
|
||||
"count": "Média $0 sur $1",
|
||||
"image": "Photo",
|
||||
"image_taken": "prise le $0",
|
||||
"images": "Photos",
|
||||
"nearby": "Photos prises dans le coin",
|
||||
"no_id": "ID du média manquant",
|
||||
"video": "Vidéo",
|
||||
"video_taken": "filmée le $0"
|
||||
},
|
||||
"meta": {
|
||||
"locale": "fr_CH",
|
||||
"page_og_desc": "Gardez le contact avec François lorsqu'il part sur les chemins"
|
||||
},
|
||||
"newsletter": {
|
||||
"email_exists": "Cette adresse email est déjà enregistrée. Vous pouvez vous désinscrire en cliquant sur le bouton ci-dessus.",
|
||||
"email_placeholder": "mon@email.com",
|
||||
"invalid_email": "Ceci ne ressemble pas à une adresse email",
|
||||
"subscribe": "S'abonner",
|
||||
"subscribed": "Merci ! Tu vas recevoir un email de confirmation très bientôt",
|
||||
"subscribed_desc": "C'est tout bon. On t'envoie des nouvelles fraiches dès qu'on les reçoit. Parole de scout.",
|
||||
"title": "Rester en contact",
|
||||
"unknown_email": "Adresse email inconnue",
|
||||
"unsubscribe": "Se désinscrire",
|
||||
"unsubscribed": "C'est fait. Fini le spam!",
|
||||
"unsubscribed_desc": "Ajoute ton adresse email et on t'enverra la nouvelle position de François dès qu'on la reçoit :)"
|
||||
},
|
||||
"post": {
|
||||
"copy_to_clipboard": "Copie le lien dans le presse-papier",
|
||||
"link_copied": "Lien copié !",
|
||||
"message": "Message",
|
||||
"name": "Nom",
|
||||
"new_message": "Nouveau message"
|
||||
},
|
||||
"project": {
|
||||
"code_name": "Nom de code",
|
||||
"end": "Arrivée",
|
||||
"hikes": "Randonnées",
|
||||
"id": "ID projet",
|
||||
"mode": "Mode",
|
||||
"modes": {
|
||||
"blog": "Projet actif",
|
||||
"histo": "Projet archivé",
|
||||
"previz": "Projet en cours de préparation"
|
||||
},
|
||||
"new": "Nouveau projet",
|
||||
"plural": "Projets",
|
||||
"single": "Projet",
|
||||
"start": "Départ",
|
||||
"update_messages": "Mettre à jour les messages du projet"
|
||||
},
|
||||
"spot": {
|
||||
"id": "ID Spot",
|
||||
"model": "Modèle",
|
||||
"name": "Spot",
|
||||
"plural": "Spots",
|
||||
"ref_id": "ID Spot ref."
|
||||
},
|
||||
"stats": {
|
||||
"duration": "Durée",
|
||||
"distance": "Distance",
|
||||
"elevation": "Dénivelé",
|
||||
"elevation_gain": "Dénivelé positif",
|
||||
"elevation_loss": "Dénivelé négatif",
|
||||
"legend": "Légende",
|
||||
"segment_length": "Taille du segment",
|
||||
"type": "Type de rando"
|
||||
},
|
||||
"time": {
|
||||
"city": "heure de $0",
|
||||
"date_time": "$0 à $1",
|
||||
"local": "$0 heure locale",
|
||||
"user": "$0 dans votre fuseau horaire",
|
||||
"zone": "Fuseau horaire"
|
||||
},
|
||||
"track": {
|
||||
"download": "Télécharger la trace GPX",
|
||||
"hitchhiking": "Hors rando",
|
||||
"main": "Trajet principal",
|
||||
"off-track": "Variante"
|
||||
},
|
||||
"unit": {
|
||||
"day": "jour",
|
||||
"day_short": "J",
|
||||
"days": "jours",
|
||||
"hour": "h"
|
||||
},
|
||||
"upload": {
|
||||
"media": {
|
||||
"exists": "l'image $0 existe déjà",
|
||||
"title": "Uploader photos & vidéos"
|
||||
},
|
||||
"mode_archived": "Le projet \"$0\" a été archivé. Aucun upload possible",
|
||||
"position": {
|
||||
"new": "Nouvelle position",
|
||||
"title": "Position supplémentaire"
|
||||
},
|
||||
"success": "$0 a été uploadé"
|
||||
},
|
||||
"user": {
|
||||
"active": "Utilisateurs actifs",
|
||||
"clearance": "Niveau d'autorisation",
|
||||
"id": "ID Utilisateur",
|
||||
"language": "Langue",
|
||||
"name": "Nom"
|
||||
},
|
||||
"weather": {
|
||||
"clear-day": "La couverture nuageuse est inférieure à 20 % pendant la journée",
|
||||
"clear-night": "La couverture nuageuse est inférieure à 20 % pendant la nuit",
|
||||
"cloudy": "La couverture nuageuse est supérieure à 90 %",
|
||||
"fog": "La visibilité est faible (moins d’un kilomètre ou d’un mile)",
|
||||
"hail": "Averses de grêle",
|
||||
"partly-cloudy-day": "La couverture nuageuse est supérieure à 20 % pendant la journée",
|
||||
"partly-cloudy-night": "La couverture nuageuse est supérieure à 20 % pendant la nuit",
|
||||
"rain": "La quantité de pluie est supérieure à zéro",
|
||||
"rain-snow": "Averses de neige et de pluie",
|
||||
"rain-snow-showers-day": "Pluie/neige possible tout au long de la journée",
|
||||
"rain-snow-showers-night": "Pluie/neige possible tout au long de la nuit",
|
||||
"showers-day": "Averses de pluie pendant la journée",
|
||||
"showers-night": "Averses de pluie pendant la nuit",
|
||||
"sleet": "Grésil",
|
||||
"snow": "La quantité de neige est supérieure à zéro",
|
||||
"snow-showers-day": "Périodes de neige pendant la journée",
|
||||
"snow-showers-night": "Périodes de neige pendant la nuit",
|
||||
"thunder": "Orages",
|
||||
"thunder-rain": "Orages tout au long de la journée ou de la nuit",
|
||||
"thunder-showers-day": "Orages possibles tout au long de la journée",
|
||||
"thunder-showers-night": "Orages possibles tout au long de la nuit",
|
||||
"wind": "La vitesse du vent est élevée (plus de 30 km/h ou mph)"
|
||||
}
|
||||
}
|
||||
45
inc/Map.php
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
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
|
||||
|
||||
nav_back = Back
|
||||
|
||||
admin = Admin Panel
|
||||
admin_config = Config
|
||||
admin_upload = Upload
|
||||
save = Save
|
||||
admin_save_success = Saved
|
||||
|
||||
track_main = Main track
|
||||
track_off-track = Off-track
|
||||
track_hitchhiking = Hitchhiking
|
||||
track_download = Download GPX Track
|
||||
|
||||
upload_title = Picture & Video Uploads
|
||||
upload_mode_archived= Project "$0" is archived. No upload allowed
|
||||
upload_success = $0 uploaded successfully
|
||||
upload_media_exist = Picture $0 already exists
|
||||
|
||||
post_message = Message
|
||||
post_name = Name
|
||||
post_new_message = New message
|
||||
and = and
|
||||
counter = #$0
|
||||
send = Send
|
||||
|
||||
maps = Base Maps
|
||||
map_satellite = Satellite
|
||||
map_otm = Open Topo Map
|
||||
map_ign_france = IGN (France)
|
||||
map_ign_spain = IGN (Spain)
|
||||
map_linz = LINZ
|
||||
map_usgs = USGS
|
||||
map_natgeo = National Geographic
|
||||
map_outdoors = Mapbox Outdoors
|
||||
|
||||
image = Picture
|
||||
images = Pictures
|
||||
image_taken = taken on $0
|
||||
video = Video
|
||||
video_taken = shot on $0
|
||||
add_on = added on $0
|
||||
click_watch = Click to watch video
|
||||
click_zoom = Click to zoom
|
||||
media_count = Media $0 / $1
|
||||
media_no_id = Missing Media ID in request
|
||||
media_comment_update= Comment of media "$0" updated
|
||||
see_on_google = See on Google Maps
|
||||
copy_to_clipboard = Copy direct link to clipboard
|
||||
link_copied = Link copied!
|
||||
|
||||
city_time = $0 Time
|
||||
local_time = $0 Local Time
|
||||
your_time = $0 Your Time
|
||||
date_time = $0 at $1
|
||||
time_zone = Time Zone
|
||||
|
||||
id_project = Project ID
|
||||
project = Project
|
||||
projects = Projects
|
||||
new_project = New Project
|
||||
update_project = Update Project
|
||||
hikes = Hikes
|
||||
mode = Mode
|
||||
mode_previz = Project in preparation
|
||||
mode_blog = Active Project
|
||||
mode_histo = Archived Project
|
||||
code_name = Code name
|
||||
start = Start
|
||||
end = End
|
||||
feeds = Feeds
|
||||
id_feed = Feed ID
|
||||
ref_feed_id = Ref. Feed ID
|
||||
id_spot = Spot ID
|
||||
name = Name
|
||||
status = Status
|
||||
last_update = Last Spot Check
|
||||
ref_spot_id = Ref. Spot ID
|
||||
model = Model
|
||||
delete = Delete
|
||||
id_user = User ID
|
||||
user_name = User Name
|
||||
active_users = Active Users
|
||||
language = Language
|
||||
clearance = Clearance
|
||||
toolbox = Toolbox
|
||||
|
||||
unit_day = day
|
||||
unit_days = days
|
||||
unit_hour = h
|
||||
|
||||
newsletter = Keep in touch!
|
||||
nl_email_placeholder= my@email.com
|
||||
nl_invalid_email = It doesn't look like an email
|
||||
nl_subscribed_desc = You're all set. We'll send you updates as soon as we get them
|
||||
nl_unsubscribed_desc= Write down your email address and we'll send you François' position as soon as we get it :)
|
||||
nl_email_exists = This email is already subscribed. You can unsubscribe by clicking on the button above.
|
||||
nl_subscribe = Subscribe
|
||||
nl_subscribed = Thanks! You'll receive a confirmation email shortly
|
||||
nl_unsubscribe = Unsubscribe
|
||||
nl_unsubscribed = Done. No more junk mail from us
|
||||
nl_unknown_email = Unknown email address
|
||||
|
||||
email_unsubscribe = PS: Changed your mind?
|
||||
email_unsub_btn = Unsubscribe
|
||||
|
||||
email_conf_subject = Successful Registration
|
||||
conf_preheader = Thanks for keeping in touch!
|
||||
conf_thanks_sub = You're all set!
|
||||
conf_body_para_1 = Thank you for checking in on my wanderings :). I'll make sure to keep you posted on my progress along the trail.
|
||||
conf_body_para_2 = I usually check-in once a day, plus sometimes on special events, like successful peak ascents. I am using a GPS-based device (PLB) which does not require phone reception to work. Thus the messages should be pretty frequent, but, being awestruck by the beauty of nature, I could also just forget to send a signal once in a while. So do not worry if you don't receive anything for a couple of days.
|
||||
conf_body_para_3 = If I've posted some pictures recently, you should also get them in the same email.
|
||||
conf_body_conclusion= See you down the road!
|
||||
conf_signature = --François
|
||||
|
||||
email_update_subject= Spotted!
|
||||
update_preheader = New position received
|
||||
update_title = Message
|
||||
update_latest_news = Latest news:
|
||||
|
||||
distance = Distance
|
||||
elevation = Elevation
|
||||
segment_length = Segment length
|
||||
type = Track Type
|
||||
legend = Legend
|
||||
|
||||
credits_project = Spotty Project
|
||||
credits_git = Git Repository
|
||||
credits_license = under GPLv3 license
|
||||
|
||||
weather_type_1 = Blowing Or Drifting Snow
|
||||
weather_type_2 = Drizzle
|
||||
weather_type_3 = Heavy Drizzle
|
||||
weather_type_4 = Light Drizzle
|
||||
weather_type_5 = Heavy Drizzle/Rain
|
||||
weather_type_6 = Light Drizzle/Rain
|
||||
weather_type_7 = Duststorm
|
||||
weather_type_8 = Fog
|
||||
weather_type_9 = Freezing Drizzle/Freezing Rain
|
||||
weather_type_10 = Heavy Freezing Drizzle/Freezing Rain
|
||||
weather_type_11 = Light Freezing Drizzle/Freezing Rain
|
||||
weather_type_12 = Freezing Fog
|
||||
weather_type_13 = Heavy Freezing Rain
|
||||
weather_type_14 = Light Freezing Rain
|
||||
weather_type_15 = Funnel Cloud/Tornado
|
||||
weather_type_16 = Hail Showers
|
||||
weather_type_17 = Ice
|
||||
weather_type_18 = Lightning Without Thunder
|
||||
weather_type_19 = Mist
|
||||
weather_type_20 = Precipitation In Vicinity
|
||||
weather_type_21 = Rain
|
||||
weather_type_22 = Heavy Rain And Snow
|
||||
weather_type_23 = Light Rain And Snow
|
||||
weather_type_24 = Rain Showers
|
||||
weather_type_25 = Heavy Rain
|
||||
weather_type_26 = Light Rain
|
||||
weather_type_27 = Sky Coverage Decreasing
|
||||
weather_type_28 = Sky Coverage Increasing
|
||||
weather_type_29 = Sky Unchanged
|
||||
weather_type_30 = Smoke Or Haze
|
||||
weather_type_31 = Snow
|
||||
weather_type_32 = Snow And Rain Showers
|
||||
weather_type_33 = Snow Showers
|
||||
weather_type_34 = Heavy Snow
|
||||
weather_type_35 = Light Snow
|
||||
weather_type_36 = Squalls
|
||||
weather_type_37 = Thunderstorm
|
||||
weather_type_38 = Thunderstorm Without Precipitation
|
||||
weather_type_39 = Diamond Dust
|
||||
weather_type_40 = Hail
|
||||
weather_type_41 = Overcast
|
||||
weather_type_42 = Partially cloudy
|
||||
weather_type_43 = Clear
|
||||
@@ -1,177 +0,0 @@
|
||||
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
|
||||
|
||||
nav_back = Atrás
|
||||
|
||||
admin = Administración
|
||||
admin_config = Configuración
|
||||
admin_upload = Cargar
|
||||
save = Guardar
|
||||
admin_save_success = Guardado
|
||||
|
||||
track_main = Camino principal
|
||||
track_off-track = Variante
|
||||
track_hitchhiking = Autostop
|
||||
track_download = Descarga la ruta GPX
|
||||
|
||||
upload_title = Cargar fotos y videos
|
||||
upload_mode_archived= El proyecto "$0" esta archivado. No se puede cargar
|
||||
upload_success = $0 ha sido subido
|
||||
upload_media_exist = La imagen $0 ya existe
|
||||
|
||||
post_message = Mensaje
|
||||
post_name = Nombre
|
||||
post_new_message = Mensaje nuevo
|
||||
and = y
|
||||
counter = No. $0
|
||||
send = Enviar
|
||||
|
||||
maps = Mapas de base
|
||||
map_satellite = Satélite
|
||||
map_otm = Open Topo Map
|
||||
map_ign_france = IGN (Francia)
|
||||
map_ign_spain = IGN (España)
|
||||
map_linz = LINZ
|
||||
map_usgs = USGS
|
||||
map_natgeo = National Geographic
|
||||
map_outdoors = Mapbox Topo
|
||||
|
||||
image = Foto
|
||||
images = Fotos
|
||||
image_taken = Foto tomada el $0
|
||||
video = Video
|
||||
video_taken = Video filmado el $0
|
||||
add_on = Agregado el $0
|
||||
click_watch = Haz clic para ver el video
|
||||
click_zoom = Haz clic para ampliar
|
||||
media_count = Media $0 de $1
|
||||
media_no_id = Falta el ID del sujeto
|
||||
media_comment_update= Comentario "$0" actualizado
|
||||
see_on_google = Ver la posición en Google Maps
|
||||
copy_to_clipboard = Copiar el enlace
|
||||
link_copied = ¡Enlace copiado!
|
||||
|
||||
city_time = Hora de $0
|
||||
local_time = $0 hora local
|
||||
your_time = $0 en tu zona horaria
|
||||
date_time = $0 a la $1
|
||||
time_zone = Huso horario
|
||||
|
||||
id_project = Proyecto ID
|
||||
project = Proyecto
|
||||
projects = Proyectos
|
||||
new_project = Nuevo proyecto
|
||||
update_project = Actualizar el proyecto
|
||||
hikes = Senderos
|
||||
mode = Modo
|
||||
mode_previz = Proyecto en preparación
|
||||
mode_blog = Proyecto activo
|
||||
mode_histo = Proyecto archivado
|
||||
code_name = Nombre clave
|
||||
start = Inicio
|
||||
end = Fin
|
||||
feeds = Feeds
|
||||
id_feed = ID Feed
|
||||
ref_feed_id = ID Feed ref.
|
||||
id_spot = ID Spot
|
||||
name = Descripción
|
||||
status = Estado
|
||||
last_update = Última actualización de Spot
|
||||
ref_spot_id = ID Spot ref.
|
||||
model = Modelo
|
||||
delete = Borrar
|
||||
id_user = ID del usuario
|
||||
user_name = Nombre
|
||||
active_users = Usuarios activos
|
||||
language = Idioma
|
||||
clearance = Nivel de autorización
|
||||
toolbox = Herramientas
|
||||
|
||||
unit_day = Día
|
||||
unit_days = Días
|
||||
unit_hour = h
|
||||
|
||||
newsletter = Mantenerse en contacto
|
||||
nl_email_placeholder= nombre@email.com
|
||||
nl_invalid_email = Esto no parece una dirección de correo electrónico
|
||||
nl_subscribed_desc = Todo esta listo. Te enviaremos noticias frescas en cuanto las recibamos. Prometido...
|
||||
nl_unsubscribed_desc= Anade tu dirección de correo electrónico y te enviaremos la posicion actualizada de François tan pronto como la recibamos :)
|
||||
nl_email_exists = Esta dirección de correo electrónico ya está registrada. Puedes darte de baja haciendo clic en el botón de arriba.
|
||||
nl_subscribe = Suscribir
|
||||
nl_subscribed = ¡Gracias! Recibirás un correo electrónico de confirmación
|
||||
nl_unsubscribe = Desinscribirse
|
||||
nl_unsubscribed = Está hecho. ¡No más spam!
|
||||
nl_unknown_email = Dirección de email desconocida
|
||||
|
||||
email_unsubscribe = PD: ¿Demasiados correos electrónicos?
|
||||
email_unsub_btn = Desinscribirse
|
||||
|
||||
email_conf_subject = Confirmación
|
||||
conf_preheader = ¡Gracias por mantenerte en contacto!
|
||||
conf_thanks_sub = ¡Hecho!
|
||||
conf_body_para_1 = Os agradezco mucho que sigais mi proyecto, y os intereseis de la evolucion. Os prometo que os mantendré informados sobre mi progreso.
|
||||
conf_body_para_2 = Normalmente envío un mensaje una vez al día. Cuando voy a lugares guays, envío uno extra (cimas, ese tipo de cosas). Estoy usando una dispositivo GPS para enviar la señal, por lo que no necesito una red telefónica para que funcione. Sin embargo, puede haber ocasiones en las que presione el botón. Por lo tanto, no se preocupe si no recibe mensajes durante uno o dos días.
|
||||
conf_body_para_3 = Cuando añada fotos en la página, también deberás encontrarlas en este correo electrónico.
|
||||
conf_body_conclusion= ¡Nos vemos en el camino!
|
||||
conf_signature = --François
|
||||
|
||||
email_update_subject= Nueva posición recibida
|
||||
update_preheader = ¡Nueva posición!
|
||||
update_title = Mensaje
|
||||
update_latest_news = Últimas noticias:
|
||||
|
||||
distance = Distancia
|
||||
elevation = Elevación
|
||||
segment_length = Tamaño del segmento
|
||||
type = Tipo de sendero
|
||||
legend = Leyenda
|
||||
|
||||
credits_project = Proyecto Spotty
|
||||
credits_git = Repositorio de Git
|
||||
credits_license = bajo licencia GPLv3
|
||||
|
||||
weather_type_1 Nieve que sopla o a la deriva
|
||||
weather_type_2 Llovizna
|
||||
weather_type_3 Llovizna fuerte
|
||||
weather_type_4 Llovizna ligera
|
||||
weather_type_5 Fuerte llovizna / lluvia
|
||||
weather_type_6 Llovizna ligera / Lluvia
|
||||
weather_type_7 Tormenta de arena
|
||||
weather_type_8 Niebla
|
||||
weather_type_9 Llovizna helada / Lluvia helada
|
||||
weather_type_10 Fuerte llovizna helada / lluvia helada
|
||||
weather_type_11 Llovizna helada ligera / lluvia helada
|
||||
weather_type_12 Niebla helada
|
||||
weather_type_13 Lluvia helada intensa
|
||||
weather_type_14 Lluvia helada ligera
|
||||
weather_type_15 Nube de embudo / Tornado
|
||||
weather_type_16 Lluvias de granizo
|
||||
weather_type_17 Hielo
|
||||
weather_type_18 Rayo sin trueno
|
||||
weather_type_19 Niebla
|
||||
weather_type_20 Precipitación en las proximidades
|
||||
weather_type_21 Lluvia
|
||||
weather_type_22 Fuertes lluvias y nieve
|
||||
weather_type_23 Lluvia ligera y nieve
|
||||
weather_type_24 Lluvias
|
||||
weather_type_25 Lluvia Pesada
|
||||
weather_type_26 Lluvia ligera
|
||||
weather_type_27 Disminución de la cobertura del cielo
|
||||
weather_type_28 Aumento de la cobertura del cielo
|
||||
weather_type_29 Cielo sin cambios
|
||||
weather_type_30 Humo o neblina
|
||||
weather_type_31 Nieve
|
||||
weather_type_32 Lluvias y nieve
|
||||
weather_type_33 Duchas de nieve
|
||||
weather_type_34 Fuertes nevadas
|
||||
weather_type_35 Nieve ligera
|
||||
weather_type_36 Chubascos
|
||||
weather_type_37 Tormenta
|
||||
weather_type_38 Tormenta sin precipitaciones
|
||||
weather_type_39 Polvo de diamante
|
||||
weather_type_40 Granizo
|
||||
weather_type_41 Nublado
|
||||
weather_type_42 Parcialmente nublado
|
||||
weather_type_43 Claro
|
||||
@@ -1,177 +0,0 @@
|
||||
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
|
||||
|
||||
nav_back = Retour
|
||||
|
||||
admin = Administration
|
||||
admin_config = Paramètres
|
||||
admin_upload = Uploader
|
||||
save = Sauvegarder
|
||||
admin_save_success = Sauvegardé
|
||||
|
||||
track_main = Trajet principal
|
||||
track_off-track = Variante
|
||||
track_hitchhiking = Hors rando
|
||||
track_download = Télécharger la trace GPX
|
||||
|
||||
upload_title = Uploader photos & vidéos
|
||||
upload_mode_archived= Le projet "$0" a été archivé. Aucun upload possible
|
||||
upload_success = $0 a été uploadé
|
||||
upload_media_exist = l'image $0 existe déjà
|
||||
|
||||
post_message = Message
|
||||
post_name = Nom
|
||||
post_new_message = Nouveau message
|
||||
and = et
|
||||
counter = N°$0
|
||||
send = Envoyer
|
||||
|
||||
maps = Fonds de carte
|
||||
map_satellite = Satellite
|
||||
map_otm = Open Topo Map
|
||||
map_ign_france = IGN (France)
|
||||
map_ign_spain = IGN (Espagne)
|
||||
map_linz = LINZ
|
||||
map_usgs = USGS
|
||||
map_natgeo = National Geographic
|
||||
map_outdoors = Mapbox Topo
|
||||
|
||||
image = Photo
|
||||
images = Photos
|
||||
image_taken = prise le $0
|
||||
video = Vidéo
|
||||
video_taken = filmée le $0
|
||||
add_on = ajoutée le $0
|
||||
click_watch = Click pour voir la vidéo
|
||||
click_zoom = Click pour zoomer
|
||||
media_count = Média $0 sur $1
|
||||
media_no_id = ID du média manquant
|
||||
media_comment_update= Commentaire du media "$0" mis-à-jour
|
||||
see_on_google = Voir la position sur Google Maps
|
||||
copy_to_clipboard = Copie le lien dans le presse-papier
|
||||
link_copied = Lien copié !
|
||||
|
||||
city_time = heure de $0
|
||||
local_time = $0 heure locale
|
||||
your_time = $0 dans votre fuseau horaire
|
||||
date_time = $0 à $1
|
||||
time_zone = Fuseau horaire
|
||||
|
||||
id_project = ID projet
|
||||
project = Projet
|
||||
projects = Projets
|
||||
new_project = Nouveau projet
|
||||
update_project = Mettre à jour le projet
|
||||
hikes = Randonnées
|
||||
mode = Mode
|
||||
mode_previz = Projet en cours de préparation
|
||||
mode_blog = Projet actif
|
||||
mode_histo = Projet archivé
|
||||
code_name = Nom de code
|
||||
start = Départ
|
||||
end = Arrivée
|
||||
feeds = Feeds
|
||||
id_feed = ID Feed
|
||||
ref_feed_id = ID Feed ref.
|
||||
id_spot = ID Spot
|
||||
name = Description
|
||||
status = Statut
|
||||
last_update = Dernière vérification Spot
|
||||
ref_spot_id = ID Spot ref.
|
||||
model = Modèle
|
||||
delete = Supprimer
|
||||
id_user = ID Utilisateur
|
||||
user_name = Nom
|
||||
active_users = Utilisateurs actifs
|
||||
language = Langue
|
||||
clearance = Niveau d'autorisation
|
||||
toolbox = Boite à outils
|
||||
|
||||
unit_day = jour
|
||||
unit_days = jours
|
||||
unit_hour = h
|
||||
|
||||
newsletter = Rester en contact
|
||||
nl_email_placeholder= mon@email.com
|
||||
nl_invalid_email = Ceci ne ressemble pas à une adresse email
|
||||
nl_subscribed_desc = C'est tout bon. On t'envoie des nouvelles fraiches dès qu'on les reçoit. Parole de scout.
|
||||
nl_unsubscribed_desc= Ajoute ton adresse email et on t'enverra la nouvelle position de François dès qu'on la reçoit :)
|
||||
nl_email_exists = Cette adresse email est déjà enregistrée. Vous pouvez vous désinscrire en cliquant sur le bouton ci-dessus.
|
||||
nl_subscribe = S'abonner
|
||||
nl_subscribed = Merci ! Tu vas recevoir un email de confirmation très bientôt
|
||||
nl_unsubscribe = Se désinscrire
|
||||
nl_unsubscribed = C'est fait. Fini le spam!
|
||||
nl_unknown_email = Adresse email inconnue
|
||||
|
||||
email_unsubscribe = PS: Trop d'emails ?
|
||||
email_unsub_btn = Se désinscrire
|
||||
|
||||
email_conf_subject = Confirmation
|
||||
conf_preheader = Merci de rester en contact !
|
||||
conf_thanks_sub = C'est tout bon !
|
||||
conf_body_para_1 = C'est gentil de venir voir où j'en suis. Promis, je vous tiendrais au courant de mon avancée.
|
||||
conf_body_para_2 = En général, j'envoie un message une fois par jour. Lorsque je passe à des endroits sympas, j'en envoie un supplémentaire (ascension de sommets, ce genre de choses). J'utilise une balise GPS pour envoyer le signal, je n'ai donc pas besoin de réseau téléphonique pour que cela fonctionne. Cependant, il peut m'arriver d'appuyer sur le bouton. Donc pas de raison de s'inquiéter si vous ne recevez pas de messages pendant une journée ou deux.
|
||||
conf_body_para_3 = Si j'ai ajouté des photos sur le site récemment, vous devriez aussi les retrouver dans cet email.
|
||||
conf_body_conclusion= A bientôt sur les chemins !
|
||||
conf_signature = --François
|
||||
|
||||
email_update_subject= Nouvelle position reçue
|
||||
update_preheader = Nouvelle position !
|
||||
update_title = Message
|
||||
update_latest_news = Dernières nouvelles :
|
||||
|
||||
distance = Distance
|
||||
elevation = Dénivelé
|
||||
segment_length = Taille du segment
|
||||
type = Type de rando
|
||||
legend = Légende
|
||||
|
||||
credits_project = Projet Spotty
|
||||
credits_git = Dépôt Git
|
||||
credits_license = sous licence GPLv3
|
||||
|
||||
weather_type_1 = Poudrerie ou neige à la dérive
|
||||
weather_type_2 = Bruine
|
||||
weather_type_3 = Bruine lourde
|
||||
weather_type_4 = Bruine légère
|
||||
weather_type_5 = Forte bruine / pluie
|
||||
weather_type_6 = Légère bruine / pluie
|
||||
weather_type_7 = Tempête de poussière
|
||||
weather_type_8 = Brouillard
|
||||
weather_type_9 = Bruine verglaçante / Pluie verglaçante
|
||||
weather_type_10 = Forte bruine verglaçante / pluie verglaçante
|
||||
weather_type_11 = Légère bruine verglaçante / pluie verglaçante
|
||||
weather_type_12 = Brouillard verglaçant
|
||||
weather_type_13 = Forte pluie verglaçante
|
||||
weather_type_14 = Légère pluie verglaçante
|
||||
weather_type_15 = Nuage d'entonnoir / Tornade
|
||||
weather_type_16 = Douches de grêle
|
||||
weather_type_17 = La glace
|
||||
weather_type_18 = Foudre sans tonnerre
|
||||
weather_type_19 = Brouillard
|
||||
weather_type_20 = Précipitations à proximité
|
||||
weather_type_21 = Pluie
|
||||
weather_type_22 = Forte pluie et neige
|
||||
weather_type_23 = Légère pluie et neige
|
||||
weather_type_24 = Averses de pluie
|
||||
weather_type_25 = Forte pluie
|
||||
weather_type_26 = Pluie légère
|
||||
weather_type_27 = Couverture du ciel en baisse
|
||||
weather_type_28 = Augmentation de la couverture du ciel
|
||||
weather_type_29 = Ciel inchangé
|
||||
weather_type_30 = Fumée ou brume
|
||||
weather_type_31 = Neige
|
||||
weather_type_32 = Averses de neige et de pluie
|
||||
weather_type_33 = Douches de neige
|
||||
weather_type_34 = Beaucoup de neige
|
||||
weather_type_35 = Neige légère
|
||||
weather_type_36 = Grains
|
||||
weather_type_37 = Orage
|
||||
weather_type_38 = Orage sans précipitations
|
||||
weather_type_39 = La poussière de diamant
|
||||
weather_type_40 = Saluer
|
||||
weather_type_41 = Couvert
|
||||
weather_type_42 = Partiellement nuageux
|
||||
weather_type_43 = Clair
|
||||
42
lib/Converter.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?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) {
|
||||
$sGpxFilePath = Gpx::getFilePath($sCodeName);
|
||||
$sGeoJsonFilePath = GeoJson::getFilePath($sCodeName);
|
||||
|
||||
//No need to generate if gpx is missing
|
||||
return !file_exists($sGpxFilePath) || file_exists($sGeoJsonFilePath) && filemtime($sGeoJsonFilePath) >= filemtime($sGpxFilePath);
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ class Email extends PhpObject {
|
||||
$oPHPMailer->setFrom(Settings::MAIL_FROM, 'Spotty');
|
||||
$oPHPMailer->addReplyTo(Settings::MAIL_FROM, 'Spotty');
|
||||
|
||||
$bSuccess = true;
|
||||
foreach($this->asDests as $asDest) {
|
||||
//Message
|
||||
$this->oTemplate->setLanguage($asDest['language'], Spot::DEFAULT_LANG);
|
||||
@@ -72,7 +73,7 @@ class Email extends PhpObject {
|
||||
$oPHPMailer->addCustomHeader('List-Unsubscribe-Post','List-Unsubscribe=One-Click');
|
||||
|
||||
//Email Content
|
||||
$this->oTemplate->setTag('timezone', 'lang:city_time', self::getTimeZoneCity($asDest['timezone']));
|
||||
$this->oTemplate->setTag('timezone', 'lang:time.city', self::getTimeZoneCity($asDest['timezone']));
|
||||
$sHtmlMessage = $this->oTemplate->getMask();
|
||||
$sPlainMessage = strip_tags(str_replace('<br />', "\n", $sHtmlMessage));
|
||||
|
||||
@@ -86,11 +87,10 @@ class Email extends PhpObject {
|
||||
|
||||
//Content
|
||||
$oPHPMailer->isHTML(true);
|
||||
$oPHPMailer->Subject = $this->oTemplate->getTranslator()->getTranslation($this->sTemplateName.'_subject');
|
||||
$oPHPMailer->Subject = $this->oTemplate->getTranslator()->getTranslation($this->sTemplateName.'.subject');
|
||||
$oPHPMailer->Body = $sHtmlMessage;
|
||||
$oPHPMailer->AltBody = $sPlainMessage;
|
||||
|
||||
$bSuccess = true;
|
||||
try {
|
||||
$bSuccess = $bSuccess && $oPHPMailer->send();
|
||||
}
|
||||
32
lib/Geo.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -280,10 +166,10 @@ class GeoJson extends Geo {
|
||||
|
||||
private function isPointValid($asPointA, $asPointO, $asPointB) {
|
||||
/* A----O Calculate angle AO^OB
|
||||
* \ If angle is within [90% Pi ; 110% Pi], O can be discarded
|
||||
* \ O is valid otherwise
|
||||
* B
|
||||
*/
|
||||
* \ If angle is within [90% Pi ; 110% Pi], O can be discarded
|
||||
* \ O is valid otherwise
|
||||
* B
|
||||
*/
|
||||
|
||||
//Path Turn Check -> -> -> ->
|
||||
//Law of Cosines (vector): angle = arccos(OA.OB / ||OA||.||OB||)
|
||||
52
lib/Gpx.php
Normal 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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -61,10 +52,10 @@ class Media extends PhpObject {
|
||||
$asData = array();
|
||||
if($this->iMediaId > 0) {
|
||||
$bResult = $this->oDb->updateRow(self::MEDIA_TABLE, $this->iMediaId, array('comment'=>$sComment));
|
||||
if(!$bResult) $sError = 'error_commit_db';
|
||||
if(!$bResult) $sError = 'error.commit_db';
|
||||
else $asData = $this->getInfo();
|
||||
}
|
||||
else $sError = 'media_no_id';
|
||||
else $sError = 'media.no_id';
|
||||
|
||||
return Spot::getResult(($sError==''), $sError, $asData);
|
||||
}
|
||||
@@ -110,11 +101,11 @@ class Media extends PhpObject {
|
||||
$sError = '';
|
||||
$asParams = array();
|
||||
if(!$this->isProjectEditable() && $sMethod!='sync') {
|
||||
$sError = 'upload_mode_archived';
|
||||
$sError = 'upload.mode_archived';
|
||||
$asParams[] = $this->oProject->getProjectCodeName();
|
||||
}
|
||||
elseif($this->oDb->pingValue(self::MEDIA_TABLE, array('filename'=>$sMediaName)) && $sMethod!='sync') {
|
||||
$sError = 'upload_media_exist';
|
||||
$sError = 'upload.media.exists';
|
||||
$asParams[] = $sMediaName;
|
||||
}
|
||||
else {
|
||||
@@ -140,7 +131,7 @@ class Media extends PhpObject {
|
||||
if($sMethod=='sync') $iMediaId = $this->oDb->insertUpdateRow(self::MEDIA_TABLE, $asDbInfo, array('filename'));
|
||||
else $iMediaId = $this->oDb->insertRow(self::MEDIA_TABLE, $asDbInfo);
|
||||
|
||||
if(!$iMediaId) $sError = 'error_commit_db';
|
||||
if(!$iMediaId) $sError = 'error.commit_db';
|
||||
else {
|
||||
$this->setMediaId($iMediaId);
|
||||
$asParams = $this->getInfo(); //Creates thumbnail
|
||||
@@ -120,6 +120,7 @@ class Project extends PhpObject {
|
||||
|
||||
public function getProjects($iProjectId=0) {
|
||||
$bSpecificProj = ($iProjectId > 0);
|
||||
$sDefaultProjectCodeName = $this->getProjectCodeName();
|
||||
$asInfo = array(
|
||||
'select'=> array(
|
||||
Db::getId(self::PROJ_TABLE)." AS id",
|
||||
@@ -142,15 +143,20 @@ 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;
|
||||
$asProject['default'] = ($sCodeName == $sDefaultProjectCodeName);
|
||||
}
|
||||
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 +191,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');
|
||||
}
|
||||
@@ -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;
|
||||
@@ -108,8 +112,8 @@ class Spot extends Main
|
||||
'iso_time' => "VARCHAR(24)",
|
||||
'language' => "VARCHAR(2)",
|
||||
'last_update' => "TIMESTAMP DEFAULT 0",
|
||||
'latitude' => "DECIMAL(7,5)",
|
||||
'longitude' => "DECIMAL(8,5)",
|
||||
'latitude' => "DECIMAL(8,6)",
|
||||
'longitude' => "DECIMAL(9,6)",
|
||||
'altitude' => "SMALLINT",
|
||||
'model' => "VARCHAR(20)",
|
||||
'name' => "VARCHAR(100)",
|
||||
@@ -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,40 +163,32 @@ class Spot extends Main
|
||||
);
|
||||
}
|
||||
|
||||
public function getAppMainPage()
|
||||
{
|
||||
public function getAppMainPage() {
|
||||
|
||||
//Cache Page List
|
||||
$asPages = array_diff($this->asMasks, array('email_update', 'email_conf'));
|
||||
$asPages = array_diff($this->asMasks, array('email.update', 'email.confirmation'));
|
||||
if(!$this->oUser->checkUserClearance(User::CLEARANCE_ADMIN)) {
|
||||
$asPages = array_diff($asPages, array('admin', 'upload'));
|
||||
}
|
||||
|
||||
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()
|
||||
),
|
||||
'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,
|
||||
'hash_sep' => '-',
|
||||
'title' => 'Spotty',
|
||||
'default_page' => 'project'
|
||||
)
|
||||
),
|
||||
'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')
|
||||
'language' => $this->oLang->getLanguage(),
|
||||
'filepath_js' => self::addTimestampToFilePath('../dist/app.js'),
|
||||
),
|
||||
$asPages
|
||||
);
|
||||
@@ -207,6 +204,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;
|
||||
@@ -221,34 +222,7 @@ class Spot extends Main
|
||||
|
||||
//Send Update Email
|
||||
if($bNewMsg) {
|
||||
$oEmail = new Email($this->asContext['serv_name'], 'email_update');
|
||||
$oEmail->setDestInfo($this->oUser->getActiveUsersInfo());
|
||||
|
||||
//Add Position
|
||||
$asLastMessage = array_shift($this->getSpotMessages(array($this->oProject->getLastMessageId($this->getFeedConstraints(Feed::MSG_TABLE)))));
|
||||
$oEmail->oTemplate->setTags($asLastMessage);
|
||||
$oEmail->oTemplate->setTag('date_time', 'time:'.$asLastMessage['unix_time'], 'd/m/Y, H:i');
|
||||
|
||||
//Add latest news feed
|
||||
$asNews = $this->getNextFeed(0, true);
|
||||
$iPostCount = 0;
|
||||
foreach($asNews as $asPost) {
|
||||
if($asPost['type'] != 'message') {
|
||||
$oEmail->oTemplate->newInstance('news');
|
||||
$oEmail->oTemplate->setInstanceTags('news', array(
|
||||
'local_server' => $this->asContext['serv_name'],
|
||||
'project' => $this->oProject->getProjectCodeName(),
|
||||
'type' => $asPost['type'],
|
||||
'id' => $asPost['id_'.$asPost['type']])
|
||||
);
|
||||
$oEmail->oTemplate->addInstance($asPost['type'], $asPost);
|
||||
$oEmail->oTemplate->setInstanceTag($asPost['type'], 'local_server', $this->asContext['serv_name']);
|
||||
$iPostCount++;
|
||||
}
|
||||
if($iPostCount == self::MAIL_CHUNK_SIZE) break;
|
||||
}
|
||||
|
||||
$bSuccess = $oEmail->send();
|
||||
$bSuccess = $this->sendEmail();
|
||||
$sDesc = $bSuccess?'mail_sent':'mail_failure';
|
||||
}
|
||||
else $sDesc = 'no_new_msg';
|
||||
@@ -256,6 +230,38 @@ class Spot extends Main
|
||||
return self::getJsonResult($bSuccess, $sDesc);
|
||||
}
|
||||
|
||||
private function sendEmail() {
|
||||
$oEmail = new Email($this->asContext['serv_name'], 'email.update');
|
||||
$oEmail->setDestInfo($this->oUser->getActiveUsersInfo());
|
||||
|
||||
//Add Position
|
||||
$asSpotMessages = $this->getSpotMessages(array($this->oProject->getLastMessageId($this->getFeedConstraints(Feed::MSG_TABLE))));
|
||||
$asLastMessage = array_shift($asSpotMessages);
|
||||
$oEmail->oTemplate->setTags($asLastMessage);
|
||||
$oEmail->oTemplate->setTag('date_time', 'time:'.$asLastMessage['unix_time'], 'd/m/Y, H:i');
|
||||
|
||||
//Add latest news feed
|
||||
$asNews = $this->getNextFeed(0, true);
|
||||
$iPostCount = 0;
|
||||
foreach($asNews as $asPost) {
|
||||
if($asPost['type'] != 'message') {
|
||||
$oEmail->oTemplate->newInstance('news');
|
||||
$oEmail->oTemplate->setInstanceTags('news', array(
|
||||
'local_server' => $this->asContext['serv_name'],
|
||||
'project' => $this->oProject->getProjectCodeName(),
|
||||
'type' => $asPost['type'],
|
||||
'id' => $asPost['id_'.$asPost['type']])
|
||||
);
|
||||
$oEmail->oTemplate->addInstance($asPost['type'], $asPost);
|
||||
$oEmail->oTemplate->setInstanceTag($asPost['type'], 'local_server', $this->asContext['serv_name']);
|
||||
$iPostCount++;
|
||||
}
|
||||
if($iPostCount == self::MAIL_CHUNK_SIZE) break;
|
||||
}
|
||||
|
||||
return $oEmail->send();
|
||||
}
|
||||
|
||||
public function genCronFile() {
|
||||
//$bSuccess = (file_put_contents('spot_cron.sh', '#!/bin/bash'."\n".'cd '.dirname($_SERVER['SCRIPT_FILENAME'])."\n".'php -f index.php a=update_feed')!==false);
|
||||
$sFileName = 'spot_cron.sh';
|
||||
@@ -269,21 +275,38 @@ class Spot extends Main
|
||||
|
||||
public function getMarkers($asMessageIds=array(), $asMediaIds=array(), $bInternal=false)
|
||||
{
|
||||
//Get messages
|
||||
$asMessages = $this->getSpotMessages($asMessageIds);
|
||||
$asGeoMedias = array();
|
||||
usort($asMessages, function($a, $b){return $a['unix_time'] > $b['unix_time'];});
|
||||
$bHasMsg = !empty($asMessages);
|
||||
foreach($asMessages as &$asMessage) {
|
||||
$asMessage['id'] = $asMessage[Db::getId(Feed::MSG_TABLE)];
|
||||
$asMessage['type'] = 'message';
|
||||
$asMessage['subtype'] = 'message';
|
||||
}
|
||||
|
||||
//Add medias
|
||||
//Get Geo-positioned Medias
|
||||
$asMedias = $this->getMedias('taken_on', $asMediaIds);
|
||||
usort($asMedias, function($a, $b){return $a['unix_time'] > $b['unix_time'];});
|
||||
$asGeoMedias = $this->getMedias('posted_on', $asMediaIds, true);
|
||||
foreach($asGeoMedias as &$asGeoMedia) {
|
||||
$iId = $asGeoMedia[Db::getId(Media::MEDIA_TABLE)];
|
||||
unset($asGeoMedia[Db::getId(Media::MEDIA_TABLE)]);
|
||||
|
||||
$asGeoMedia['id'] = $iId;
|
||||
$asGeoMedia['type'] = 'media';
|
||||
$asGeoMedia['lat_dms'] = self::decToDms($asGeoMedia['latitude'], 'lat');
|
||||
$asGeoMedia['lon_dms'] = self::decToDms($asGeoMedia['longitude'], 'lon');
|
||||
$asGeoMedia['medias'] = array_values(array_filter($asMedias, function($asMedia) use ($iId) {
|
||||
return $asMedia['id_media'] == $iId;
|
||||
}));
|
||||
}
|
||||
|
||||
//Assign medias to closest message
|
||||
$iIndex = 0;
|
||||
$iMaxIndex = count($asMessages) - 1;
|
||||
foreach($asMedias as $asMedia) {
|
||||
if($asMedia['latitude']!='' && $asMedia['longitude']!='') $asGeoMedias[] = $asMedia;
|
||||
elseif($bHasMsg) {
|
||||
if(!empty($asMessages)) {
|
||||
usort($asMessages, function($a, $b){return $a['unix_time'] > $b['unix_time'];});
|
||||
usort($asMedias, function($a, $b){return $a['unix_time'] > $b['unix_time'];});
|
||||
|
||||
$iIndex = 0;
|
||||
$iMaxIndex = count($asMessages) - 1;
|
||||
foreach($asMedias as $asMedia) {
|
||||
while($iIndex <= $iMaxIndex && $asMedia['unix_time'] > $asMessages[$iIndex]['unix_time']) $iIndex++;
|
||||
|
||||
//All medias before first message or after last message are assigned to first/last message respectively
|
||||
@@ -298,13 +321,16 @@ class Spot extends Main
|
||||
}
|
||||
}
|
||||
|
||||
//Combine markers
|
||||
$asMarkers = [...$asMessages, ...$asGeoMedias];
|
||||
usort($asMarkers, function($a, $b){return $a['unix_time'] > $b['unix_time'];});
|
||||
|
||||
//Spot Last Update
|
||||
$asLastUpdate = array();
|
||||
$this->addTimeStamp($asLastUpdate, $this->oProject->getLastUpdate());
|
||||
|
||||
$asResult = array(
|
||||
'messages' => $asMessages,
|
||||
'medias' => $asGeoMedias,
|
||||
'markers' => $asMarkers,
|
||||
'maps' => $this->oMap->getProjectMaps($this->oProject->getProjectId()),
|
||||
'last_update' => $asLastUpdate
|
||||
);
|
||||
@@ -317,8 +343,8 @@ class Spot extends Main
|
||||
$asUserInfo = $this->oUser->getUserInfo();
|
||||
|
||||
//Send Confirmation Email
|
||||
if($asResult['result'] && $asResult['desc']=='lang:nl_subscribed') {
|
||||
$oConfEmail = new Email($this->asContext['serv_name'], 'email_conf');
|
||||
if($asResult['result'] && $asResult['desc']=='lang:newsletter.subscribed' && !Settings::DEBUG) {
|
||||
$oConfEmail = new Email($this->asContext['serv_name'], 'email.confirmation');
|
||||
$oConfEmail->setDestInfo($asUserInfo);
|
||||
$oConfEmail->send();
|
||||
}
|
||||
@@ -328,7 +354,7 @@ class Spot extends Main
|
||||
|
||||
public function unsubscribe() {
|
||||
$asResult = $this->oUser->removeUser();
|
||||
return self::getJsonResult($asResult['result'], $asResult['desc'], $asResult['data']);
|
||||
return self::getJsonResult($asResult['result'], $asResult['desc'], User::DEFAULT_USER);
|
||||
}
|
||||
|
||||
public function unsubscribeFromEmail($iUserId) {
|
||||
@@ -380,26 +406,30 @@ class Spot extends Main
|
||||
* @param String $sTimeRefField Field to calculate relative times: 'taken_on' or 'posted_on'
|
||||
* @return Array Medias info
|
||||
*/
|
||||
private function getMedias($sTimeRefField, $asMediaIds=array())
|
||||
private function getMedias($sTimeRefField, $asMediaIds=array(), $bOnlyGeoMedia=false)
|
||||
{
|
||||
//Constraints
|
||||
$asConstraints = $this->getFeedConstraints(Media::MEDIA_TABLE, $sTimeRefField);
|
||||
if(!empty($asMediaIds)) {
|
||||
$asConstraints['constraint'][Db::getId(Media::MEDIA_TABLE)] = $asMediaIds;
|
||||
$asConstraints['constOpe'][Db::getId(Media::MEDIA_TABLE)] = 'IN';
|
||||
$asConstraints['constraint'][Db::getId(Media::MEDIA_TABLE)] = $asMediaIds;
|
||||
}
|
||||
if($bOnlyGeoMedia) {
|
||||
$asConstraints['constOpe']['latitude'] = ' IS NOT ';
|
||||
$asConstraints['constraint']['latitude'] = 'NULL';
|
||||
$asConstraints['constOpe']['longitude'] = ' IS NOT ';
|
||||
$asConstraints['constraint']['longitude'] = 'NULL';
|
||||
}
|
||||
|
||||
$asMedias = $this->oMedia->getMediasInfo($asConstraints);
|
||||
foreach($asMedias as &$asMedia) {
|
||||
$iTimeStampTakenOn = strtotime($asMedia['taken_on']);
|
||||
$iTimeStampPostedOn = strtotime($asMedia['posted_on']);
|
||||
$asMedia['taken_on_formatted'] = $this->getTimeFormat($iTimeStampTakenOn);
|
||||
$asMedia['taken_on_formatted_local'] = $this->getTimeFormat($iTimeStampTakenOn, $asMedia['timezone']);
|
||||
$asMedia['posted_on_formatted'] = $this->getTimeFormat($iTimeStampPostedOn);
|
||||
$asMedia['posted_on_formatted_local'] = $this->getTimeFormat($iTimeStampPostedOn, $asMedia['timezone']);
|
||||
$asMedia['displayed_id'] = $asMedia[Db::getId(Media::MEDIA_TABLE)];
|
||||
|
||||
$this->addTimeStamp($asMedia, strtotime($asMedia[$sTimeRefField]), $asMedia['timezone']);
|
||||
$this->addTimeStamp($asMedia, strtotime($asMedia['taken_on']), $asMedia['timezone'], 'taken_on');
|
||||
$this->addTimeStamp($asMedia, strtotime($asMedia['posted_on']), $asMedia['timezone'], 'posted_on');
|
||||
unset($asMedia['taken_on']);
|
||||
unset($asMedia['posted_on']);
|
||||
}
|
||||
|
||||
return $asMedias;
|
||||
@@ -431,14 +461,16 @@ class Spot extends Main
|
||||
return $asPosts;
|
||||
}
|
||||
|
||||
private function addTimeStamp(&$asData, $iTime, $sTimeZone='') {
|
||||
$asData['unix_time'] = (int) $iTime;
|
||||
$asData['relative_time'] = Toolbox::getDateTimeDesc($iTime, $this->oLang->getLanguage());
|
||||
$asData['formatted_time'] = $this->getTimeFormat($iTime);
|
||||
private function addTimeStamp(&$asData, $iTime, $sTimeZone='', $sPrefix='') {
|
||||
if($sPrefix != '') $sPrefix = $sPrefix.'_';
|
||||
|
||||
$asData[$sPrefix.'unix_time'] = (int) $iTime;
|
||||
$asData[$sPrefix.'relative_time'] = Toolbox::getDateTimeDesc($iTime, $this->oLang->getLanguage());
|
||||
$asData[$sPrefix.'formatted_time'] = $this->getTimeFormat($iTime);
|
||||
|
||||
if($sTimeZone != '') {
|
||||
$asData['formatted_time_local'] = $this->getTimeFormat($iTime, $sTimeZone);
|
||||
$asData['day_offset'] = self::getTimeZoneDayOffset($iTime, $sTimeZone);
|
||||
$asData[$sPrefix.'formatted_time_local'] = $this->getTimeFormat($iTime, $sTimeZone);
|
||||
$asData[$sPrefix.'day_offset'] = self::getTimeZoneDayOffset($iTime, $sTimeZone);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,7 +539,7 @@ class Spot extends Main
|
||||
|
||||
$asResult = array_merge($asResult, $asMarkers);
|
||||
}
|
||||
else $sDesc = 'mode_histo';
|
||||
else $sDesc = 'project.modes.histo';
|
||||
|
||||
return self::getJsonResult(true, $sDesc, $asResult);
|
||||
}
|
||||
@@ -530,7 +562,6 @@ class Spot extends Main
|
||||
$this->oDb->cleanSql($sDirection);
|
||||
$this->oDb->cleanSql($sSort);
|
||||
|
||||
$sMediaRefField = 'posted_on';
|
||||
$sProjectIdField = Db::getId(Project::PROJ_TABLE);
|
||||
$sMsgIdField = Db::getId(Feed::MSG_TABLE);
|
||||
$sMediaIdField = Db::getId(Media::MEDIA_TABLE);
|
||||
@@ -544,9 +575,9 @@ class Spot extends Main
|
||||
"INNER JOIN ".Feed::FEED_TABLE." USING({$sFeedIdField})",
|
||||
$this->getFeedConstraints(Feed::MSG_TABLE, 'site_time', 'sql'),
|
||||
"UNION",
|
||||
"SELECT {$sProjectIdField}, {$sMediaIdField} AS id, 'media' AS type, CONCAT(UNIX_TIMESTAMP({$sMediaRefField}), '.1', {$sMediaIdField}) AS ref",
|
||||
"SELECT {$sProjectIdField}, {$sMediaIdField} AS id, 'media' AS type, CONCAT(UNIX_TIMESTAMP(posted_on), '.1', {$sMediaIdField}) AS ref",
|
||||
"FROM ".Media::MEDIA_TABLE,
|
||||
$this->getFeedConstraints(Media::MEDIA_TABLE, $sMediaRefField, 'sql'),
|
||||
$this->getFeedConstraints(Media::MEDIA_TABLE, 'posted_on', 'sql'),
|
||||
"UNION",
|
||||
"SELECT {$sProjectIdField}, {$sPostIdField} AS id, 'post' AS type, CONCAT(UNIX_TIMESTAMP(site_time), '.2', {$sPostIdField}) AS ref",
|
||||
"FROM ".self::POST_TABLE,
|
||||
@@ -568,17 +599,18 @@ class Spot extends Main
|
||||
}
|
||||
|
||||
//Sort Table IDs by type & Get attributes
|
||||
$asFeedIds = array('message'=>array(), 'media'=>array(), 'message'=>array());
|
||||
$asFeedIds = array('message'=>array(), 'media'=>array(), 'post'=>array());
|
||||
foreach($asItems as $asItem) {
|
||||
$asFeedIds[$asItem['type']][$asItem['id']] = $asItem;
|
||||
}
|
||||
$asFeedAttrs = array(
|
||||
'message' => empty($asFeedIds['message'])?array():$this->getSpotMessages(array_keys($asFeedIds['message'])),
|
||||
'media' => empty($asFeedIds['media'])?array():$this->getMedias($sMediaRefField, array_keys($asFeedIds['media'])),
|
||||
'media' => empty($asFeedIds['media'])?array():$this->getMedias('posted_on', array_keys($asFeedIds['media'])),
|
||||
'post' => empty($asFeedIds['post'])?array():$this->getPosts(array_keys($asFeedIds['post']))
|
||||
);
|
||||
|
||||
//Replace Array Key with Item ID
|
||||
$asFeeds = array();
|
||||
foreach($asFeedAttrs as $sType=>$asFeedAttr) {
|
||||
foreach($asFeedAttr as $asFeed) {
|
||||
$asFeeds[$sType][$asFeed['id_'.$sType]] = $asFeed;
|
||||
@@ -612,7 +644,7 @@ class Spot extends Main
|
||||
|
||||
$this->oUser->updateNickname($sName);
|
||||
}
|
||||
else $sDesc = 'mode_histo';
|
||||
else $sDesc = 'project.modes.histo';
|
||||
|
||||
return self::getJsonResult(($iPostId > 0), $sDesc);
|
||||
}
|
||||
@@ -632,9 +664,21 @@ class Spot extends Main
|
||||
|
||||
public function addPosition($sLat, $sLng, $iTimestamp) {
|
||||
$oFeed = new Feed($this->oDb, $this->oProject->getFeedIds()[0]);
|
||||
<<<<<<< HEAD:inc/Spot.php
|
||||
$bResult = ($oFeed->addManualPosition($sLat, $sLng, $iTimestamp) > 0);
|
||||
|
||||
return self::getJsonResult($bResult, $bResult?'':$this->oDb->getLastError());
|
||||
=======
|
||||
$bSuccess = ($oFeed->addManualPosition($sLat, $sLng, $iTimestamp) > 0);
|
||||
|
||||
if($bSuccess) {
|
||||
$bSuccess = $this->sendEmail();
|
||||
$sDesc = $bSuccess?'mail_sent':'mail_failure';
|
||||
}
|
||||
else $sDesc = 'error.commit_db';
|
||||
|
||||
return self::getJsonResult($bSuccess, $sDesc);
|
||||
>>>>>>> vue:lib/Spot.php
|
||||
}
|
||||
|
||||
public function getAdminSettings($sType='') {
|
||||
@@ -659,6 +703,8 @@ class Spot extends Main
|
||||
$sDesc = '';
|
||||
$asResult = array();
|
||||
|
||||
if($this->oDb->isId($sField) && $sValue <= 0) return self::getJsonResult(false, $this->oLang->getTranslation('error.impossible_value', [$sValue, $sField]));
|
||||
|
||||
switch($sType) {
|
||||
case 'project':
|
||||
$oProject = new Project($this->oDb, $iId);
|
||||
@@ -676,7 +722,7 @@ class Spot extends Main
|
||||
$bSuccess = $oProject->setActivePeriod($sValue.' 23:59:59', 'to');
|
||||
break;
|
||||
default:
|
||||
$sDesc = $this->oLang->getTranslation('unknown_field', $sField);
|
||||
$sDesc = $this->oLang->getTranslation('error.unknown_field', $sField);
|
||||
}
|
||||
$asResult = $oProject->getProject();
|
||||
$asResult['active_from'] = substr($asResult['active_from'], 0, 10);
|
||||
@@ -695,7 +741,7 @@ class Spot extends Main
|
||||
$bSuccess = $oFeed->setProjectId($sValue);
|
||||
break;
|
||||
default:
|
||||
$sDesc = $this->oLang->getTranslation('unknown_field', $sField);
|
||||
$sDesc = $this->oLang->getTranslation('error.unknown_field', $sField);
|
||||
}
|
||||
$asResult = $oFeed->getFeed();
|
||||
break;
|
||||
@@ -707,48 +753,78 @@ class Spot extends Main
|
||||
$sDesc = $asReturnCode['desc'];
|
||||
break;
|
||||
default:
|
||||
$sDesc = $this->oLang->getTranslation('unknown_field', $sField);
|
||||
$sDesc = $this->oLang->getTranslation('error.unknown_field', $sField);
|
||||
}
|
||||
$asResult = $this->oUser->getActiveUserInfo($iId);
|
||||
break;
|
||||
}
|
||||
if(!$bSuccess && $sDesc=='') $sDesc = Mask::LANG_PREFIX.'error_commit_db';
|
||||
if(!$bSuccess && $sDesc=='') $sDesc = Mask::LANG_PREFIX.'error.commit_db';
|
||||
|
||||
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);
|
||||
$iNewProjectId = $oProject->createProjectId();
|
||||
|
||||
$oFeed = new Feed($this->oDb);
|
||||
$oFeed->createFeedId($iNewProjectId);
|
||||
|
||||
$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()));
|
||||
$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;
|
||||
}
|
||||
$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(
|
||||
'project' => array($oProject->getProject()),
|
||||
'feed' => array($oFeed->getFeed())
|
||||
));
|
||||
public function buildGeoJSON($sCodeName) {
|
||||
return Converter::convertToGeoJson($sCodeName);
|
||||
}
|
||||
|
||||
public function buildGeoJSON($sCodeName) {
|
||||
@@ -794,7 +870,7 @@ class Spot extends Main
|
||||
|
||||
$sDate = $oDate->format('d/m/Y');
|
||||
$sTime = $oDate->format('H:i');
|
||||
return $this->oLang->getTranslation('date_time', array($sDate, $sTime));
|
||||
return $this->oLang->getTranslation('time.date_time', array($sDate, $sTime));
|
||||
}
|
||||
|
||||
public static function getTimeZoneDayOffset($iTime, $sLocalTimeZone) {
|
||||
@@ -802,7 +878,7 @@ class Spot extends Main
|
||||
$iLocalDate = (int) (new \DateTime('@'.$iTime))->setTimezone(new \DateTimeZone($sLocalTimeZone))->format('Ymd');
|
||||
$iSiteDate = (int) (new \DateTime('@'.$iTime))->setTimezone(new \DateTimeZone($sSiteTimeZone ))->format('Ymd');
|
||||
|
||||
return ($iLocalDate == $iSiteDate)?'0':(($iLocalDate > $iSiteDate)?'+1':'-1');
|
||||
return ($iLocalDate == $iSiteDate)?'0':(($iLocalDate < $iSiteDate)?'+1':'-1');
|
||||
}
|
||||
|
||||
public static function getTimeZoneFromDate($sDate) {
|
||||
@@ -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) {
|
||||
@@ -33,7 +38,7 @@ class Uploader extends UploadHandler
|
||||
|
||||
//Check project mode
|
||||
if(!$this->oMedia->isProjectEditable()) {
|
||||
$file->error = $this->get_error_message('upload_mode_archived', array($this->oMedia->getProjectCodeName()));
|
||||
$file->error = $this->get_error_message('upload.mode_archived', array($this->oMedia->getProjectCodeName()));
|
||||
$bResult = false;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,18 @@ class User extends PhpObject {
|
||||
//Cookie
|
||||
const COOKIE_ID_USER = 'subscriber';
|
||||
const COOKIE_DURATION = 60 * 60 * 24 * 365; //1 year
|
||||
|
||||
const DEFAULT_USER = array(
|
||||
'id' => 0,
|
||||
'id_user' => 0,
|
||||
'name' => '',
|
||||
'email' => '',
|
||||
'language' => '',
|
||||
'timezone' => '',
|
||||
'active' => self::USER_INACTIVE,
|
||||
'clearance' => self::CLEARANCE_USER
|
||||
);
|
||||
|
||||
/**
|
||||
* Database Handle
|
||||
* @var Db
|
||||
@@ -33,104 +45,11 @@ class User extends PhpObject {
|
||||
public function __construct(Db &$oDb) {
|
||||
parent::__construct(__CLASS__);
|
||||
$this->oDb = &$oDb;
|
||||
$this->iUserId = 0;
|
||||
$this->asUserInfo = array(
|
||||
'id' => 0,
|
||||
Db::getId(self::USER_TABLE) => 0,
|
||||
'name' => '',
|
||||
'email' => '',
|
||||
'language' => '',
|
||||
'timezone' => '',
|
||||
'active' => self::USER_INACTIVE,
|
||||
'clearance' => self::CLEARANCE_USER
|
||||
);
|
||||
$this->setUserId(0);
|
||||
$this->asUserInfo = self::DEFAULT_USER;
|
||||
$this->checkUserCookie();
|
||||
}
|
||||
|
||||
public function getLang() {
|
||||
return $this->asUserInfo['language'];
|
||||
}
|
||||
|
||||
public function addUser($sEmail, $sLang, $sTimezone, $sNickName='') {
|
||||
$bSuccess = false;
|
||||
$sDesc = '';
|
||||
$sEmail = trim($sEmail);
|
||||
|
||||
//Check Email availability
|
||||
$iUserId = $this->oDb->selectValue(self::USER_TABLE, Db::getId(self::USER_TABLE), array('email'=>$sEmail, 'active'=>self::USER_ACTIVE));
|
||||
|
||||
if($iUserId > 0) {
|
||||
//Just log user in
|
||||
$sDesc = 'lang:nl_email_exists';
|
||||
$bSuccess = true;
|
||||
}
|
||||
else {
|
||||
//Add/Reactivate user
|
||||
$iUserId = $this->oDb->insertUpdateRow(
|
||||
self::USER_TABLE,
|
||||
array('email'=>$sEmail, 'language'=>$sLang, 'timezone'=>$sTimezone, 'active'=>self::USER_ACTIVE),
|
||||
array('email')
|
||||
);
|
||||
|
||||
if($iUserId==0) $sDesc = 'lang:error_commit_db';
|
||||
else {
|
||||
$sDesc = 'lang:nl_subscribed';
|
||||
$bSuccess = true;
|
||||
}
|
||||
}
|
||||
|
||||
if($bSuccess) {
|
||||
$this->setUserId($iUserId);
|
||||
|
||||
//Set Cookie (valid 1 year)
|
||||
$this->updateCookie(self::COOKIE_DURATION);
|
||||
|
||||
//Update Nickname if user has already posted
|
||||
$this->updateNickname($sNickName);
|
||||
|
||||
//Retrieve Gravatar image
|
||||
$this->updateGravatar($iUserId, $sEmail);
|
||||
}
|
||||
|
||||
return Spot::getResult($bSuccess, $sDesc);
|
||||
}
|
||||
|
||||
public function removeUser() {
|
||||
$bSuccess = false;
|
||||
$sDesc = '';
|
||||
|
||||
if($this->iUserId > 0) {
|
||||
$iUserId = $this->oDb->updateRow(self::USER_TABLE, $this->getUserId(), 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
|
||||
$bSuccess = true;
|
||||
}
|
||||
}
|
||||
else $sDesc = 'lang:nl_unknown_email';
|
||||
|
||||
return Spot::getResult($bSuccess, $sDesc);
|
||||
}
|
||||
|
||||
public function updateNickname($sNickname) {
|
||||
if($this->getUserId() > 0 && $sNickname!='') $this->oDb->updateRow(self::USER_TABLE, $this->getUserId(), array('name'=>$sNickname));
|
||||
}
|
||||
|
||||
private function updateGravatar($iUserId, $sEmail) {
|
||||
$sImage = ($sEmail != '')?@file_get_contents('https://www.gravatar.com/avatar/'.md5($sEmail).'.png?d=404&s=24'):'';
|
||||
$this->oDb->updateRow(self::USER_TABLE, $iUserId, array('gravatar' => base64_encode($sImage)));
|
||||
}
|
||||
|
||||
private function checkUserCookie() {
|
||||
if(isset($_COOKIE[self::COOKIE_ID_USER])){
|
||||
$this->setUserId($_COOKIE[self::COOKIE_ID_USER]);
|
||||
|
||||
//Extend cookie life
|
||||
if($this->getUserId() > 0) $this->updateCookie(self::COOKIE_DURATION);
|
||||
}
|
||||
}
|
||||
|
||||
public function getUserId() {
|
||||
return $this->iUserId;
|
||||
}
|
||||
@@ -138,10 +57,12 @@ class User extends PhpObject {
|
||||
public function setUserId($iUserId) {
|
||||
$this->iUserId = 0;
|
||||
|
||||
$asUser = $this->getActiveUserInfo($iUserId);
|
||||
if(!empty($asUser)) {
|
||||
$this->iUserId = $iUserId;
|
||||
$this->asUserInfo = $asUser;
|
||||
if($iUserId > 0) {
|
||||
$asUser = $this->getActiveUserInfo($iUserId);
|
||||
if(!empty($asUser)) {
|
||||
$this->iUserId = $iUserId;
|
||||
$this->asUserInfo = $asUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +95,95 @@ class User extends PhpObject {
|
||||
return $this->oDb->selectRows($asInfo);
|
||||
}
|
||||
|
||||
public function getLang() {
|
||||
return $this->asUserInfo['language'];
|
||||
}
|
||||
|
||||
public function addUser($sEmail, $sLang, $sTimezone, $sNickName='') {
|
||||
$bSuccess = false;
|
||||
$sDesc = '';
|
||||
$sEmail = trim($sEmail);
|
||||
|
||||
//Check Email availability
|
||||
$iUserId = $this->oDb->selectValue(self::USER_TABLE, Db::getId(self::USER_TABLE), array('email'=>$sEmail, 'active'=>self::USER_ACTIVE));
|
||||
|
||||
if($iUserId > 0) {
|
||||
//Just log user in
|
||||
$sDesc = 'lang:newsletter.email_exists';
|
||||
$bSuccess = true;
|
||||
}
|
||||
else {
|
||||
//Add/Reactivate user
|
||||
$iUserId = $this->oDb->insertUpdateRow(
|
||||
self::USER_TABLE,
|
||||
array('email'=>$sEmail, 'language'=>$sLang, 'timezone'=>$sTimezone, 'active'=>self::USER_ACTIVE),
|
||||
array('email')
|
||||
);
|
||||
|
||||
if($iUserId==0) $sDesc = 'lang:error.commit_db';
|
||||
else {
|
||||
$sDesc = 'lang:newsletter.subscribed';
|
||||
$bSuccess = true;
|
||||
}
|
||||
}
|
||||
|
||||
if($bSuccess) {
|
||||
$this->setUserId($iUserId);
|
||||
|
||||
//Set Cookie (valid 1 year)
|
||||
$this->updateCookie(self::COOKIE_DURATION);
|
||||
|
||||
//Update Nickname if user has already posted
|
||||
$this->updateNickname($sNickName);
|
||||
|
||||
//Retrieve Gravatar image
|
||||
$this->updateGravatar($iUserId, $sEmail);
|
||||
}
|
||||
|
||||
return Spot::getResult($bSuccess, $sDesc);
|
||||
}
|
||||
|
||||
public function removeUser($iUserId=0) {
|
||||
$iUserId = ($iUserId > 0)?$iUserId:$this->getUserId();
|
||||
$bSelf = ($iUserId == $this->getUserId());
|
||||
$bSuccess = false;
|
||||
$sDesc = '';
|
||||
|
||||
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:newsletter.unsubscribed';
|
||||
if($bSelf) $this->updateCookie(-60 * 60); //Set Cookie in the past, deleting it
|
||||
$bSuccess = true;
|
||||
}
|
||||
}
|
||||
else $sDesc = 'lang:newsletter.unknown_email';
|
||||
}
|
||||
else $sDesc = 'lang:error.no_auth';
|
||||
|
||||
return Spot::getResult($bSuccess, $sDesc);
|
||||
}
|
||||
|
||||
public function updateNickname($sNickname) {
|
||||
if($this->getUserId() > 0 && $sNickname!='') $this->oDb->updateRow(self::USER_TABLE, $this->getUserId(), array('name'=>$sNickname));
|
||||
}
|
||||
|
||||
private function updateGravatar($iUserId, $sEmail) {
|
||||
$sImage = ($sEmail != '')?@file_get_contents('https://www.gravatar.com/avatar/'.md5($sEmail).'.png?d=404&s=24'):'';
|
||||
$this->oDb->updateRow(self::USER_TABLE, $iUserId, array('gravatar' => base64_encode($sImage)));
|
||||
}
|
||||
|
||||
private function checkUserCookie() {
|
||||
if(isset($_COOKIE[self::COOKIE_ID_USER])){
|
||||
$this->setUserId($_COOKIE[self::COOKIE_ID_USER]);
|
||||
|
||||
//Extend cookie life
|
||||
if($this->getUserId() > 0) $this->updateCookie(self::COOKIE_DURATION);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkUserClearance($iClearance)
|
||||
{
|
||||
return ($this->asUserInfo['clearance'] >= $iClearance);
|
||||
@@ -188,7 +198,7 @@ class User extends PhpObject {
|
||||
if(!in_array($iClearance, self::CLEARANCES)) $sDesc = 'Setting wrong clearance "'.$iClearance.'" to user ID "'.$iUserId.'"';
|
||||
else {
|
||||
$iUserId = $this->oDb->updateRow(self::USER_TABLE, $iUserId, array('clearance'=>$iClearance));
|
||||
if(!$iUserId) $sDesc = 'lang:error_commit_db';
|
||||
if(!$iUserId) $sDesc = 'lang:error.commit_db';
|
||||
else $bSuccess = true;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -41,6 +42,9 @@ if($sAction!='')
|
||||
case 'markers':
|
||||
$sResult = $oSpot->getMarkers();
|
||||
break;
|
||||
case 'geojson':
|
||||
$sResult = $oSpot->getProjectGeoJson();
|
||||
break;
|
||||
case 'next_feed':
|
||||
$sResult = $oSpot->getNextFeed($iId);
|
||||
break;
|
||||
@@ -75,9 +79,12 @@ if($sAction!='')
|
||||
break;
|
||||
case 'add_position':
|
||||
$sResult = $oSpot->addPosition($sLat, $sLng, $iTimestamp);
|
||||
<<<<<<< HEAD:index.php
|
||||
break;
|
||||
case 'admin_new':
|
||||
$sResult = $oSpot->createProject();
|
||||
=======
|
||||
>>>>>>> vue:lib/index.php
|
||||
break;
|
||||
case 'admin_get':
|
||||
$sResult = $oSpot->getAdminSettings();
|
||||
@@ -85,8 +92,11 @@ if($sAction!='')
|
||||
case 'admin_set':
|
||||
$sResult = $oSpot->setAdminSettings($sType, $iId, $sField, $oValue);
|
||||
break;
|
||||
case 'admin_del':
|
||||
$sResult = $oSpot->delAdminSettings($sType, $iId);
|
||||
case 'admin_create':
|
||||
$sResult = $oSpot->createAdminSettings($sType);
|
||||
break;
|
||||
case 'admin_delete':
|
||||
$sResult = $oSpot->deleteAdminSettings($sType, $iId);
|
||||
break;
|
||||
case 'generate_cron':
|
||||
$sResult = $oSpot->genCronFile();
|
||||
@@ -2,30 +2,30 @@
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
|
||||
<title>[#]lang:email_conf_subject[#]</title>
|
||||
<title>[#]lang:email.confirmation.subject[#]</title>
|
||||
</head>
|
||||
<body>
|
||||
<span style="color: transparent; display: none !important; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">[#]lang:conf_preheader[#]</span>
|
||||
<span style="color: transparent; display: none !important; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">[#]lang:email.confirmation.preheader[#]</span>
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="width:100%;max-width:600px;">
|
||||
<tr>
|
||||
<td width="20%"><img src="[#]local_server[#]images/icons/mstile-144x144.png" width="90%" border="0" alt="logo" /></td>
|
||||
<td><h1>[#]lang:conf_thanks_sub[#]</h1></td>
|
||||
<td><h1>[#]lang:email.confirmation.thanks_subject[#]</h1></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<p align="justify">[#]lang:conf_body_para_1[#]</p>
|
||||
<p align="justify">[#]lang:conf_body_para_2[#]</p>
|
||||
<p align="justify">[#]lang:conf_body_para_3[#]</p>
|
||||
<p align="justify">[#]lang:email.confirmation.body_1[#]</p>
|
||||
<p align="justify">[#]lang:email.confirmation.body_2[#]</p>
|
||||
<p align="justify">[#]lang:email.confirmation.body_3[#]</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<p>[#]lang:conf_body_conclusion[#]<br />[#]lang:conf_signature[#]</p>
|
||||
<p>[#]lang:email.confirmation.conclusion[#]<br />[#]lang:email.confirmation.signature[#]</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<p>[#]lang:email_unsubscribe[#] <a href="[#]unsubscribe_link[#]" target="_blank" rel="noopener">[#]lang:email_unsub_btn[#]</a></p>
|
||||
<p>[#]lang:email.unsubscribe[#] <a href="[#]unsubscribe_link[#]" target="_blank" rel="noopener">[#]lang:email.unsubscribe_button[#]</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -2,14 +2,14 @@
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
|
||||
<title>[#]lang:email_update_subject[#]</title>
|
||||
<title>[#]lang:email.update.subject[#]</title>
|
||||
</head>
|
||||
<body>
|
||||
<span style="color: transparent; display: none !important; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">[#]lang:update_preheader[#]</span>
|
||||
<span style="color: transparent; display: none !important; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">[#]lang:email.update.preheader[#]</span>
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="width:100%;max-width:600px;">
|
||||
<tr>
|
||||
<td width="20%"><img src="[#]local_server[#]images/icons/mstile-144x144.png" width="90%" border="0" alt="logo" /></td>
|
||||
<td><h1>[#]lang:update_title[#] [#]type[#] #[#]displayed_id[#]</h1></td>
|
||||
<td><h1>[#]lang:email.update.title[#] [#]type[#] #[#]displayed_id[#]</h1></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
@@ -22,7 +22,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<h2>[#]lang:update_latest_news[#]</h2>
|
||||
<h2>[#]lang:email.update.latest_news[#]</h2>
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<!-- [PART] news [START] -->
|
||||
<tr>
|
||||
@@ -39,7 +39,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<p>[#]lang:email_unsubscribe[#] <a href="[#]unsubscribe_link[#]" target="_blank" rel="noopener">[#]lang:email_unsub_btn[#]</a></p>
|
||||
<p>[#]lang:email.unsubscribe[#] <a href="[#]unsubscribe_link[#]" target="_blank" rel="noopener">[#]lang:email.unsubscribe_button[#]</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
212
masks/admin.html
@@ -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>
|
||||
1205
masks/project.html
@@ -1,94 +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="location">
|
||||
<button id="add_loc"><i class="fa fa-message push"></i>New Position</button>
|
||||
</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+'%');
|
||||
}
|
||||
});
|
||||
|
||||
$('#add_loc').click(() => {
|
||||
if(navigator.geolocation) {
|
||||
addStatus('Determining position...');
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
addStatus('Sending position...');
|
||||
oSpot.get(
|
||||
'add_position',
|
||||
function(asData){addStatus('Position sent');},
|
||||
{'latitude':position.coords.latitude, 'longitude':position.coords.longitude, 'timestamp':Math.round(position.timestamp / 1000)},
|
||||
function(sMsgId){addStatus(self.lang(sMsgId));},
|
||||
);
|
||||
},
|
||||
(error) => {
|
||||
addStatus(error.message);
|
||||
}
|
||||
);
|
||||
}
|
||||
else addStatus('This browser does not support geolocation');
|
||||
});
|
||||
}
|
||||
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>
|
||||
5373
package-lock.json
generated
Normal file
40
package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.23.9",
|
||||
"@babel/preset-env": "^7.23.9",
|
||||
"babel-loader": "^10.0.0",
|
||||
"symlink-webpack-plugin": "^1.1.0",
|
||||
"vue-loader": "^17.4.2",
|
||||
"webpack": "^5.99.7",
|
||||
"webpack-cli": "^7.0.2"
|
||||
},
|
||||
"name": "spot",
|
||||
"description": "FindMeSpot & GPX integration",
|
||||
"version": "2.0.0",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "webpack --config build/webpack.config.js --mode development",
|
||||
"prod": "webpack --config build/webpack.config.js --mode production"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Franzz",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||
"@fortawesome/vue-fontawesome": "^3.2.0",
|
||||
"@uppy/core": "^5.2.0",
|
||||
"@uppy/xhr-upload": "^5.2.0",
|
||||
"autosize": "^6.0.1",
|
||||
"clean-webpack-plugin": "^4.0.0",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"css-loader": "^7.1.2",
|
||||
"html-loader": "^5.0.0",
|
||||
"maplibre-gl": "^5.4.0",
|
||||
"sass": "^1.97.2",
|
||||
"sass-loader": "^16.0.5",
|
||||
"simplebar-vue": "^2.3.3",
|
||||
"vue": "^3.3.8",
|
||||
"vue-style-loader": "^4.1.3"
|
||||
}
|
||||
}
|
||||
26
readme.md
@@ -1,6 +1,10 @@
|
||||
# Spot Project
|
||||
[Spot](https://www.findmespot.com) & GPX integration
|
||||
|
||||
## Dependencies
|
||||
|
||||
* npm 18+
|
||||
* composer
|
||||
* php-mbstring
|
||||
* php-imagick
|
||||
* php-gd
|
||||
@@ -9,24 +13,40 @@
|
||||
* 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
|
||||
<<<<<<< HEAD
|
||||
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/
|
||||
7. Run composer install
|
||||
=======
|
||||
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/
|
||||
|
||||
>>>>>>> vue
|
||||
## 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
|
||||
* Fix .MOV playback on windows firefox
|
||||
* Garmin InReach Integration
|
||||
|
||||
2
script/d3.min.js
vendored
2
script/jquery.min.js
vendored
23
script/leaflet.min.js
vendored
@@ -1,738 +0,0 @@
|
||||
/*!
|
||||
* Lightbox v2.11.4
|
||||
* by Lokesh Dhakar
|
||||
*
|
||||
* More info:
|
||||
* http://lokeshdhakar.com/projects/lightbox2/
|
||||
*
|
||||
* Copyright Lokesh Dhakar
|
||||
* Released under the MIT license
|
||||
* https://github.com/lokesh/lightbox2/blob/master/LICENSE
|
||||
*
|
||||
* @preserve
|
||||
*/
|
||||
|
||||
// Uses Node, AMD or browser globals to create a module.
|
||||
(function (root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define(['jquery'], factory);
|
||||
} else if (typeof exports === 'object') {
|
||||
// Node. Does not work with strict CommonJS, but
|
||||
// only CommonJS-like environments that support module.exports,
|
||||
// like Node.
|
||||
module.exports = factory(require('jquery'));
|
||||
} else {
|
||||
// Browser globals (root is window)
|
||||
root.lightbox = factory(root.jQuery);
|
||||
}
|
||||
}(this, function ($) {
|
||||
|
||||
function Lightbox(options) {
|
||||
this.album = [];
|
||||
this.currentImageIndex = void 0;
|
||||
this.init();
|
||||
|
||||
// options
|
||||
this.options = $.extend({}, this.constructor.defaults);
|
||||
this.option(options);
|
||||
}
|
||||
|
||||
// Descriptions of all options available on the demo site:
|
||||
// http://lokeshdhakar.com/projects/lightbox2/index.html#options
|
||||
Lightbox.defaults = {
|
||||
albumLabel: 'Image %1 of %2',
|
||||
alwaysShowNavOnTouchDevices: false,
|
||||
fadeDuration: 600,
|
||||
fitImagesInViewport: true,
|
||||
imageFadeDuration: 600,
|
||||
positionFromTop: 50,
|
||||
resizeDuration: 700,
|
||||
showImageNumberLabel: true,
|
||||
wrapAround: false,
|
||||
disableScrolling: false,
|
||||
/*
|
||||
Sanitize Title
|
||||
If the caption data is trusted, for example you are hardcoding it in, then leave this to false.
|
||||
This will free you to add html tags, such as links, in the caption.
|
||||
|
||||
If the caption data is user submitted or from some other untrusted source, then set this to true
|
||||
to prevent xss and other injection attacks.
|
||||
*/
|
||||
sanitizeTitle: false
|
||||
, hasVideo: true
|
||||
, onMediaChange: (oMedia) => {}
|
||||
};
|
||||
|
||||
Lightbox.prototype.option = function(options) {
|
||||
$.extend(this.options, options);
|
||||
};
|
||||
|
||||
Lightbox.prototype.imageCountLabel = function(currentImageNum, totalImages) {
|
||||
return this.options.albumLabel.replace(/%1/g, currentImageNum).replace(/%2/g, totalImages);
|
||||
};
|
||||
|
||||
Lightbox.prototype.init = function() {
|
||||
var self = this;
|
||||
// Both enable and build methods require the body tag to be in the DOM.
|
||||
$(document).ready(function() {
|
||||
self.enable();
|
||||
self.build();
|
||||
});
|
||||
};
|
||||
|
||||
// Loop through anchors and areamaps looking for either data-lightbox attributes or rel attributes
|
||||
// that contain 'lightbox'. When these are clicked, start lightbox.
|
||||
Lightbox.prototype.enable = function() {
|
||||
var self = this;
|
||||
$('body').on('click', 'a[rel^=lightbox], area[rel^=lightbox], a[data-lightbox], area[data-lightbox]', function(event) {
|
||||
self.start($(event.currentTarget));
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
// Build html for the lightbox and the overlay.
|
||||
// Attach event handlers to the new DOM elements. click click click
|
||||
Lightbox.prototype.build = function() {
|
||||
if ($('#lightbox').length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
var self = this;
|
||||
|
||||
// The two root notes generated, #lightboxOverlay and #lightbox are given
|
||||
// tabindex attrs so they are focusable. We attach our keyboard event
|
||||
// listeners to these two elements, and not the document. Clicking anywhere
|
||||
// while Lightbox is opened will keep the focus on or inside one of these
|
||||
// two elements.
|
||||
//
|
||||
// We do this so we can prevent propogation of the Esc keypress when
|
||||
// Lightbox is open. This prevents it from intefering with other components
|
||||
// on the page below.
|
||||
//
|
||||
// Github issue: https://github.com/lokesh/lightbox2/issues/663
|
||||
$('\
|
||||
<div id="lightboxOverlay" tabindex="-1" class="lightboxOverlay"></div>\
|
||||
<div id="lightbox" tabindex="-1" class="lightbox">\
|
||||
<div class="lb-outerContainer">\
|
||||
<div class="lb-container">\
|
||||
<img class="lb-image" src="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==" alt="" />\
|
||||
<div class="lb-nav">\
|
||||
<div class="lb-prev-area">\
|
||||
<a class="lb-prev" aria-label="Previous image" href="" role="button"></a>\
|
||||
</div>\
|
||||
<div class="lb-next-area">\
|
||||
<a class="lb-next" aria-label="Next image" href="" role="button"></a>\
|
||||
</div>\
|
||||
</div>\
|
||||
<div class="lb-loader">\
|
||||
<a class="lb-cancel" href="#"></a>\
|
||||
</div>\
|
||||
</div>\
|
||||
</div>\
|
||||
<div class="lb-dataContainer desktop">\
|
||||
<div class="lb-data">\
|
||||
<div class="lb-details">\
|
||||
<span class="lb-caption"></span>\
|
||||
<span class="lb-number"></span>\
|
||||
</div>\
|
||||
<div class="lb-closeContainer">\
|
||||
<a class="lb-close" role="button"></a>\
|
||||
</div>\
|
||||
</div>\
|
||||
</div>\
|
||||
').appendTo($('body'));
|
||||
|
||||
// Cache jQuery objects
|
||||
this.$lightbox = $('#lightbox');
|
||||
this.$overlay = $('#lightboxOverlay');
|
||||
this.$outerContainer = this.$lightbox.find('.lb-outerContainer');
|
||||
this.$container = this.$lightbox.find('.lb-container');
|
||||
this.$image = this.$lightbox.find('.lb-image');
|
||||
this.$nav = this.$lightbox.find('.lb-nav');
|
||||
|
||||
if(self.options.hasVideo) {
|
||||
this.$video = $('<video class="lb-video" controls autoplay></video>');
|
||||
this.$image.after(this.$video);
|
||||
this.videoBorderWidth = {
|
||||
top: parseInt(this.$video.css('border-top-width'), 10),
|
||||
right: parseInt(this.$video.css('border-right-width'), 10),
|
||||
bottom: parseInt(this.$video.css('border-bottom-width'), 10),
|
||||
left: parseInt(this.$video.css('border-left-width'), 10)
|
||||
};
|
||||
}
|
||||
|
||||
// Store css values for future lookup
|
||||
this.containerPadding = {
|
||||
top: parseInt(this.$container.css('padding-top'), 10),
|
||||
right: parseInt(this.$container.css('padding-right'), 10),
|
||||
bottom: parseInt(this.$container.css('padding-bottom'), 10),
|
||||
left: parseInt(this.$container.css('padding-left'), 10)
|
||||
};
|
||||
|
||||
this.imageBorderWidth = {
|
||||
top: parseInt(this.$image.css('border-top-width'), 10),
|
||||
right: parseInt(this.$image.css('border-right-width'), 10),
|
||||
bottom: parseInt(this.$image.css('border-bottom-width'), 10),
|
||||
left: parseInt(this.$image.css('border-left-width'), 10)
|
||||
};
|
||||
|
||||
// Attach event handlers to the newly minted DOM elements
|
||||
this.$overlay.hide().add(this.$lightbox.find('.lb-dataContainer')).on('click', function() {
|
||||
self.end();
|
||||
return false;
|
||||
});
|
||||
|
||||
this.$lightbox.hide().on('click', function(event) {
|
||||
if ($(event.target).attr('id') === 'lightbox') {
|
||||
self.end();
|
||||
}
|
||||
});
|
||||
|
||||
this.$outerContainer.on('click', function(event) {
|
||||
if ($(event.target).attr('id') === 'lightbox') {
|
||||
self.end();
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
this.$lightbox.find('.lb-prev').on('click', function() {
|
||||
if (self.currentImageIndex === 0) {
|
||||
self.changeImage(self.album.length - 1);
|
||||
} else {
|
||||
self.changeImage(self.currentImageIndex - 1);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
this.$lightbox.find('.lb-next').on('click', function() {
|
||||
if (self.currentImageIndex === self.album.length - 1) {
|
||||
self.changeImage(0);
|
||||
} else {
|
||||
self.changeImage(self.currentImageIndex + 1);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
/*
|
||||
Show context menu for image on right-click
|
||||
|
||||
There is a div containing the navigation that spans the entire image and lives above of it. If
|
||||
you right-click, you are right clicking this div and not the image. This prevents users from
|
||||
saving the image or using other context menu actions with the image.
|
||||
|
||||
To fix this, when we detect the right mouse button is pressed down, but not yet clicked, we
|
||||
set pointer-events to none on the nav div. This is so that the upcoming right-click event on
|
||||
the next mouseup will bubble down to the image. Once the right-click/contextmenu event occurs
|
||||
we set the pointer events back to auto for the nav div so it can capture hover and left-click
|
||||
events as usual.
|
||||
*/
|
||||
this.$nav.on('mousedown', function(event) {
|
||||
if (event.which === 3) {
|
||||
self.$nav.css('pointer-events', 'none');
|
||||
|
||||
self.$lightbox.one('contextmenu', function() {
|
||||
setTimeout(function() {
|
||||
this.$nav.css('pointer-events', 'auto');
|
||||
}.bind(self), 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
this.$lightbox.find('.lb-loader, .lb-close').on('click keyup', function(e) {
|
||||
// If mouse click OR 'enter' or 'space' keypress, close LB
|
||||
if (
|
||||
e.type === 'click' || (e.type === 'keyup' && (e.which === 13 || e.which === 32))) {
|
||||
self.end();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Show overlay and lightbox. If the image is part of a set, add siblings to album array.
|
||||
Lightbox.prototype.start = function($link) {
|
||||
var self = this;
|
||||
var $window = $(window);
|
||||
|
||||
$window.on('resize', $.proxy(this.sizeOverlay, this));
|
||||
|
||||
this.sizeOverlay();
|
||||
|
||||
//Manage Zoom Event
|
||||
this.$nav.mousewheel((e) => {
|
||||
var asImg = self.album[this.currentImageIndex];
|
||||
if(!asImg.type != 'video') {
|
||||
asTransform = this.$image.css('transform').replace(/[^0-9\-.,]/g, '').split(',');
|
||||
var fOldZoom = parseFloat(asTransform[0] || 1);
|
||||
var fOldTranslateX = parseFloat(asTransform[4] || 0);
|
||||
var fOldTranslateY = parseFloat(asTransform[5] || 0);
|
||||
var fOldZoom = parseFloat(asTransform[0] || 1);
|
||||
var fNewZoom = Math.min(Math.max(fOldZoom + e.deltaY / 10, 1), Math.max(asImg.width/this.$image.width(), asImg.height/this.$image.height()));
|
||||
|
||||
var fTransX = fOldTranslateX + (fNewZoom - fOldZoom) * (this.$image.width()/2 - e.offsetX);
|
||||
var fTransY = fOldTranslateY + (fNewZoom - fOldZoom) * (this.$image.height()/2 - e.offsetY);
|
||||
var fTransMaxX = (fNewZoom - 1) * this.$image.width() / 2;
|
||||
var fTransMaxY = (fNewZoom - 1) * this.$image.height() / 2;
|
||||
|
||||
fTransX = Math.max(Math.min(fTransX, fTransMaxX), fTransMaxX * -1);
|
||||
fTransY = Math.max(Math.min(fTransY, fTransMaxY), fTransMaxY * -1);
|
||||
|
||||
this.$image.css('--scale', fNewZoom);
|
||||
this.$container.toggleClass('moveable', (fNewZoom > 1));
|
||||
this.$image.css('--translate-x', fTransX+'px');
|
||||
this.$image.css('--translate-y', fTransY+'px');
|
||||
}
|
||||
});
|
||||
|
||||
//Manage Repositioning Event
|
||||
this.$nav.on('mousedown', (e) => {
|
||||
if(this.$image.css('--scale') > 1) {
|
||||
//The following block gets the X/Y offset (the difference between where it starts and where it was clicked)
|
||||
this.gMouseDownOffsetX = e.clientX - parseFloat(this.$image.css('--translate-x') || 0);
|
||||
this.gMouseDownOffsetY = e.clientY - parseFloat(this.$image.css('--translate-y') || 0);
|
||||
|
||||
//Change cursor
|
||||
this.$container.addClass('moving');
|
||||
|
||||
$window.on('mousemove', divMove);
|
||||
}
|
||||
});
|
||||
|
||||
$window.on('mouseup', () => {
|
||||
$window.off('mousemove', divMove);
|
||||
this.$container.removeClass('moving');
|
||||
});
|
||||
|
||||
function divMove(e){
|
||||
let iZoom = self.$image.css('--scale');
|
||||
let fTransX = e.clientX - self.gMouseDownOffsetX;
|
||||
let fTransY = e.clientY - self.gMouseDownOffsetY;
|
||||
let fTransMaxX = (iZoom - 1) * self.$image.width() / 2;
|
||||
let fTransMaxY = (iZoom - 1) * self.$image.height() / 2;
|
||||
|
||||
fTransX = Math.max(Math.min(fTransX, fTransMaxX), fTransMaxX * -1);
|
||||
fTransY = Math.max(Math.min(fTransY, fTransMaxY), fTransMaxY * -1);
|
||||
|
||||
self.$image.css('--translate-x', fTransX + 'px');
|
||||
self.$image.css('--translate-y', fTransY + 'px');
|
||||
}
|
||||
|
||||
this.album = [];
|
||||
var imageNumber = 0;
|
||||
|
||||
// Support both data-lightbox attribute and rel attribute implementations
|
||||
var dataLightboxValue = $link.attr('data-lightbox');
|
||||
var $links;
|
||||
|
||||
if (dataLightboxValue) {
|
||||
$links = $($link.prop('tagName') + '[data-lightbox="' + dataLightboxValue + '"]');
|
||||
for (var i = 0; i < $links.length; i = ++i) {
|
||||
this.addToAlbum($($links[i]));
|
||||
if ($links[i] === $link[0]) {
|
||||
imageNumber = i;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ($link.attr('rel') === 'lightbox') {
|
||||
// If image is not part of a set
|
||||
this.addToAlbum($link);
|
||||
} else {
|
||||
// If image is part of a set
|
||||
$links = $($link.prop('tagName') + '[rel="' + $link.attr('rel') + '"]');
|
||||
for (var j = 0; j < $links.length; j = ++j) {
|
||||
this.addToAlbum($($links[j]));
|
||||
if ($links[j] === $link[0]) {
|
||||
imageNumber = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Position Lightbox
|
||||
this.$lightbox.fadeIn(this.options.fadeDuration);
|
||||
|
||||
// Disable scrolling of the page while open
|
||||
if (this.options.disableScrolling) {
|
||||
$('body').addClass('lb-disable-scrolling');
|
||||
}
|
||||
|
||||
this.changeImage(imageNumber);
|
||||
};
|
||||
|
||||
Lightbox.prototype.addToAlbum = function($link) {
|
||||
this.album.push({
|
||||
alt: $link.attr('data-alt'),
|
||||
link: $link.attr('href'),
|
||||
title: $link.attr('data-title') || $link.attr('title'),
|
||||
|
||||
orientation: $link.attr('data-orientation'),
|
||||
type: $link.attr('data-type'),
|
||||
id: $link.attr('data-id'),
|
||||
$Media: $link.attr('data-type')=='video'?this.$video:this.$image,
|
||||
width: $link.find('img').attr('width'),
|
||||
height: $link.find('img').attr('height'),
|
||||
set: $link.attr('data-lightbox') || $link.attr('rel')
|
||||
});
|
||||
}
|
||||
|
||||
Lightbox.prototype.getMaxSizes = function(iMediaWidth, iMediaHeight, sMediaType) {
|
||||
var iWindowWidth = $(window).width();
|
||||
var iWindowHeight = $(window).height();
|
||||
var oBorder = (sMediaType=='image')?this.imageBorderWidth:this.videoBorderWidth;
|
||||
var iMaxMediaWidth = iWindowWidth - this.containerPadding.left - this.containerPadding.right - oBorder.left - oBorder.right;
|
||||
var iMaxMediaHeight = iWindowHeight - this.containerPadding.top - this.containerPadding.bottom - oBorder.top - oBorder.bottom - this.options.positionFromTop;
|
||||
|
||||
var iDataMaxWidth = this.$lightbox.find('.lb-dataContainer').width(), iDataMaxHeight = this.$lightbox.find('.lb-dataContainer').height();
|
||||
var iImageRatio = iMediaWidth / iMediaHeight;
|
||||
|
||||
//Case horizontal
|
||||
var iHeightH = Math.min(iMaxMediaHeight, iMediaHeight);
|
||||
var iWidthH = Math.min(iHeightH * iImageRatio, iMaxMediaWidth - iDataMaxWidth);
|
||||
var iSurfaceH = Math.min(iHeightH, iWidthH / iImageRatio) * iWidthH;
|
||||
|
||||
//Case vertical
|
||||
var iWidthV = Math.min(iMaxMediaWidth, iMediaWidth);
|
||||
var iHeightV = Math.min(iWidthV / iImageRatio, iMaxMediaHeight - iDataMaxHeight);
|
||||
var iSurfaceV = Math.min(iWidthV, iHeightV * iImageRatio) * iHeightV;
|
||||
|
||||
var sDirection = (iSurfaceV > iSurfaceH)?'vertical':'horizontal';
|
||||
|
||||
if(sDirection == 'vertical') iMaxMediaHeight -= iDataMaxHeight;
|
||||
else iMaxMediaWidth -= iDataMaxWidth;
|
||||
|
||||
return {maxWidth: iMaxMediaWidth, maxHeight: iMaxMediaHeight, direction: sDirection};
|
||||
};
|
||||
|
||||
Lightbox.prototype.updateSize = function(iMediaNumber) {
|
||||
var oMedia = this.album[iMediaNumber];
|
||||
var sFileType = oMedia.link.split('.').slice(-1)[0];
|
||||
var oMaxSizes = this.getMaxSizes(oMedia.width, oMedia.height, oMedia.type);
|
||||
var iMaxMediaWidth = oMaxSizes.maxWidth;
|
||||
var iMaxMediaHeight = oMaxSizes.maxHeight;
|
||||
this.$lightbox.removeClass('vertical horizontal').addClass(oMaxSizes.direction);
|
||||
|
||||
/*
|
||||
Since many SVGs have small intrinsic dimensions, but they support scaling
|
||||
up without quality loss because of their vector format, max out their
|
||||
size.
|
||||
*/
|
||||
if(sFileType === 'svg') {
|
||||
oMedia.$Media.width(iMaxMediaWidth);
|
||||
oMedia.$Media.height(iMaxMediaHeight);
|
||||
}
|
||||
|
||||
if(this.options.fitImagesInViewport) {
|
||||
//Check if image size is larger than maxWidth|maxHeight in settings
|
||||
if(this.options.maxWidth && this.options.maxWidth < iMaxMediaWidth) iMaxMediaWidth = this.options.maxWidth;
|
||||
if(this.options.maxHeight && this.options.maxHeight < iMaxMediaHeight) iMaxMediaHeight = this.options.maxHeight;
|
||||
}
|
||||
else {
|
||||
iMaxMediaWidth = this.options.maxWidth || oMedia.width || iMaxMediaWidth;
|
||||
iMaxMediaHeight = this.options.maxHeight || oMedia.height || iMaxMediaHeight;
|
||||
}
|
||||
|
||||
//Is the current image's width or height is greater than the maxImageWidth or maxImageHeight
|
||||
//option than we need to size down while maintaining the aspect ratio.
|
||||
var iMediaFinalWidth, iMediaFinalHeight;
|
||||
if((oMedia.width > iMaxMediaWidth) || (oMedia.height > iMaxMediaHeight)) {
|
||||
if ((oMedia.width / iMaxMediaWidth) > (oMedia.height / iMaxMediaHeight)) {
|
||||
iMediaFinalWidth = iMaxMediaWidth;
|
||||
iMediaFinalHeight = Math.round(oMedia.height / (oMedia.width / iMaxMediaWidth));
|
||||
} else {
|
||||
iMediaFinalWidth = Math.round(oMedia.width / (oMedia.height / iMaxMediaHeight));
|
||||
iMediaFinalHeight = iMaxMediaHeight;
|
||||
}
|
||||
}
|
||||
else {
|
||||
iMediaFinalWidth = oMedia.width;
|
||||
iMediaFinalHeight = oMedia.height;
|
||||
}
|
||||
|
||||
oMedia.$Media.width(iMediaFinalWidth);
|
||||
oMedia.$Media.height(iMediaFinalHeight);
|
||||
this.sizeContainer(iMediaFinalWidth, iMediaFinalHeight, oMedia.type);
|
||||
};
|
||||
|
||||
// Hide most UI elements in preparation for the animated resizing of the lightbox.
|
||||
Lightbox.prototype.changeImage = function(imageNumber) {
|
||||
var self = this;
|
||||
var filename = this.album[imageNumber].link;
|
||||
|
||||
// Disable keyboard nav during transitions
|
||||
this.disableKeyboardNav();
|
||||
|
||||
// Show loading state
|
||||
this.$overlay.fadeIn(this.options.fadeDuration);
|
||||
$('.lb-loader').fadeIn('slow');
|
||||
|
||||
this.$lightbox.find('.lb-image, .lb-video, .lb-nav, .lb-prev, .lb-next, .lb-number, .lb-caption, .lb-close').hide();
|
||||
this.$image.css({'--scale': '1', '--translate-x': '0', '--translate-y': '0'});
|
||||
self.$lightbox.find('.lb-dataContainer').css({width:'200px', height:'30px'});
|
||||
this.$outerContainer.addClass('animating');
|
||||
this.$container.removeClass('moveable moving');
|
||||
|
||||
this.options.onMediaChange(self.album[imageNumber]);
|
||||
|
||||
var $hasVideoNav = this.$container.hasClass('lb-video-nav');
|
||||
switch(self.album[imageNumber].type) {
|
||||
case 'video':
|
||||
this.$video.on('loadedmetadata', function(){
|
||||
self.album[imageNumber].width = this.videoWidth;
|
||||
self.album[imageNumber].height = this.videoHeight;
|
||||
self.updateSize(imageNumber);
|
||||
$(this).off('loadedmetadata');
|
||||
});
|
||||
|
||||
this.$video.attr('src', filename);
|
||||
|
||||
if(!$hasVideoNav) this.$container.addClass('lb-video-nav');
|
||||
break;
|
||||
case 'image':
|
||||
this.$video.attr('src', '');
|
||||
if($hasVideoNav) this.$container.removeClass('lb-video-nav');
|
||||
|
||||
// When image to show is preloaded, we send the width and height to sizeContainer()
|
||||
var preloader = new Image();
|
||||
preloader.onload = function(){
|
||||
self.$image.attr({
|
||||
'alt': self.album[imageNumber].alt,
|
||||
'src': filename
|
||||
});
|
||||
|
||||
//Orientation management
|
||||
if(Math.abs(self.album[imageNumber].orientation) == 90 && preloader.width > preloader.height) {
|
||||
var sWidth = preloader.width;
|
||||
preloader.width = preloader.height;
|
||||
preloader.height = sWidth;
|
||||
}
|
||||
self.album[imageNumber].width = preloader.width;
|
||||
self.album[imageNumber].height = preloader.height;
|
||||
|
||||
self.updateSize(imageNumber);
|
||||
};
|
||||
|
||||
// Preload image before showing
|
||||
preloader.src = this.album[imageNumber].link;
|
||||
break;
|
||||
}
|
||||
|
||||
this.currentImageIndex = imageNumber;
|
||||
};
|
||||
|
||||
// Stretch overlay to fit the viewport
|
||||
Lightbox.prototype.sizeOverlay = function(e) {
|
||||
/*
|
||||
We use a setTimeout 0 to pause JS execution and let the rendering catch-up.
|
||||
Why do this? If the `disableScrolling` option is set to true, a class is added to the body
|
||||
tag that disables scrolling and hides the scrollbar. We want to make sure the scrollbar is
|
||||
hidden before we measure the document width, as the presence of the scrollbar will affect the
|
||||
number.
|
||||
*/
|
||||
if(e) {
|
||||
if(typeof oResizeTimer != 'undefined') clearTimeout(oResizeTimer);
|
||||
oResizeTimer = setTimeout(
|
||||
() => {
|
||||
switch(this.album[this.currentImageIndex].type) {
|
||||
case 'image':
|
||||
this.changeImage(this.currentImageIndex);
|
||||
break;
|
||||
case 'video':
|
||||
this.updateSize(this.currentImageIndex);
|
||||
break;
|
||||
}
|
||||
},
|
||||
200
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Animate the size of the lightbox to fit the image we are showing
|
||||
// This method also shows the the image.
|
||||
//ADDED-START
|
||||
//Lightbox.prototype.sizeContainer = function(imageWidth, imageHeight) {
|
||||
Lightbox.prototype.sizeContainer = function(imageWidth, imageHeight, media) {
|
||||
media = media || 'image';
|
||||
//ADDED-END
|
||||
var self = this;
|
||||
|
||||
var oldWidth = this.$outerContainer.outerWidth();
|
||||
var oldHeight = this.$outerContainer.outerHeight();
|
||||
//ADDED-START
|
||||
//var newWidth = imageWidth + this.containerPadding.left + this.containerPadding.right + this.imageBorderWidth.left + this.imageBorderWidth.right;
|
||||
//var newHeight = imageHeight + this.containerPadding.top + this.containerPadding.bottom + this.imageBorderWidth.top + this.imageBorderWidth.bottom;
|
||||
var mediaBorderWidth = (media=='image')?this.imageBorderWidth:this.videoBorderWidth;
|
||||
var newWidth = imageWidth + this.containerPadding.left + this.containerPadding.right + mediaBorderWidth.left + mediaBorderWidth.right;
|
||||
var newHeight = imageHeight + this.containerPadding.top + this.containerPadding.bottom + mediaBorderWidth.top + mediaBorderWidth.bottom;
|
||||
//ADDED-END
|
||||
|
||||
function postResize() {
|
||||
if(self.$lightbox.hasClass('vertical')) self.$lightbox.find('.lb-dataContainer').width(newWidth);
|
||||
else self.$lightbox.find('.lb-dataContainer').height(newHeight);
|
||||
self.$lightbox.find('.lb-prevLink').height(newHeight);
|
||||
self.$lightbox.find('.lb-nextLink').height(newHeight);
|
||||
|
||||
// Set focus on one of the two root nodes so keyboard events are captured.
|
||||
self.$overlay.trigger('focus');
|
||||
|
||||
self.showImage();
|
||||
}
|
||||
|
||||
if (oldWidth !== newWidth || oldHeight !== newHeight) {
|
||||
this.$outerContainer.animate({
|
||||
width: newWidth,
|
||||
height: newHeight
|
||||
}, this.options.resizeDuration, 'swing', function() {
|
||||
postResize();
|
||||
});
|
||||
} else {
|
||||
postResize();
|
||||
}
|
||||
};
|
||||
|
||||
// Display the image and its details and begin preload neighboring images.
|
||||
Lightbox.prototype.showImage = function() {
|
||||
this.$lightbox.find('.lb-loader').stop(true).hide();
|
||||
|
||||
if(this.options.hasVideo && this.album[this.currentImageIndex].type == 'video') this.$lightbox.find('.lb-video').fadeIn(this.options.imageFadeDuration);
|
||||
else this.$lightbox.find('.lb-image').fadeIn(this.options.imageFadeDuration);
|
||||
|
||||
this.updateNav();
|
||||
this.updateDetails();
|
||||
this.preloadNeighboringImages();
|
||||
this.enableKeyboardNav();
|
||||
};
|
||||
|
||||
// Display previous and next navigation if appropriate.
|
||||
Lightbox.prototype.updateNav = function() {
|
||||
// Check to see if the browser supports touch events. If so, we take the conservative approach
|
||||
// and assume that mouse hover events are not supported and always show prev/next navigation
|
||||
// arrows in image sets.
|
||||
var alwaysShowNav = false;
|
||||
try {
|
||||
document.createEvent('TouchEvent');
|
||||
alwaysShowNav = (this.options.alwaysShowNavOnTouchDevices) ? true : false;
|
||||
} catch (e) {}
|
||||
|
||||
this.$lightbox.find('.lb-nav').show();
|
||||
|
||||
if (this.album.length > 1) {
|
||||
if (this.options.wrapAround) {
|
||||
if (alwaysShowNav) {
|
||||
this.$lightbox.find('.lb-prev, .lb-next').css('opacity', '1');
|
||||
}
|
||||
this.$lightbox.find('.lb-prev, .lb-next').show();
|
||||
} else {
|
||||
if (this.currentImageIndex > 0) {
|
||||
this.$lightbox.find('.lb-prev').show();
|
||||
if (alwaysShowNav) {
|
||||
this.$lightbox.find('.lb-prev').css('opacity', '1');
|
||||
}
|
||||
}
|
||||
if (this.currentImageIndex < this.album.length - 1) {
|
||||
this.$lightbox.find('.lb-next').show();
|
||||
if (alwaysShowNav) {
|
||||
this.$lightbox.find('.lb-next').css('opacity', '1');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Display caption, image number, and closing button.
|
||||
Lightbox.prototype.updateDetails = function() {
|
||||
var self = this;
|
||||
|
||||
// Enable anchor clicks in the injected caption html.
|
||||
// Thanks Nate Wright for the fix. @https://github.com/NateWr
|
||||
if (typeof this.album[this.currentImageIndex].title !== 'undefined' &&
|
||||
this.album[this.currentImageIndex].title !== '') {
|
||||
var $caption = this.$lightbox.find('.lb-caption');
|
||||
if (this.options.sanitizeTitle) {
|
||||
$caption.text(this.album[this.currentImageIndex].title);
|
||||
} else {
|
||||
$caption.html(this.album[this.currentImageIndex].title);
|
||||
}
|
||||
$caption.add(this.$lightbox.find('.lb-close')).fadeIn('fast');
|
||||
}
|
||||
|
||||
this.$outerContainer.removeClass('animating');
|
||||
|
||||
this.$lightbox.find('.lb-dataContainer').fadeIn(this.options.resizeDuration, function() {
|
||||
return self.sizeOverlay();
|
||||
});
|
||||
};
|
||||
|
||||
// Preload previous and next images in set.
|
||||
Lightbox.prototype.preloadNeighboringImages = function() {
|
||||
if (this.album.length > this.currentImageIndex + 1 && this.album[this.currentImageIndex + 1].type == 'image') {
|
||||
var preloadNext = new Image();
|
||||
preloadNext.src = this.album[this.currentImageIndex + 1].link;
|
||||
}
|
||||
if (this.currentImageIndex > 0 && this.album[this.currentImageIndex - 1].type == 'image') {
|
||||
var preloadPrev = new Image();
|
||||
preloadPrev.src = this.album[this.currentImageIndex - 1].link;
|
||||
}
|
||||
};
|
||||
|
||||
Lightbox.prototype.enableKeyboardNav = function() {
|
||||
this.disableKeyboardNav();
|
||||
this.$lightbox.on('keyup.keyboard', $.proxy(this.keyboardAction, this));
|
||||
this.$overlay.on('keyup.keyboard', $.proxy(this.keyboardAction, this));
|
||||
};
|
||||
|
||||
Lightbox.prototype.disableKeyboardNav = function() {
|
||||
this.$lightbox.off('.keyboard');
|
||||
this.$overlay.off('.keyboard');
|
||||
};
|
||||
|
||||
Lightbox.prototype.keyboardAction = function(event) {
|
||||
var KEYCODE_ESC = 27;
|
||||
var KEYCODE_LEFTARROW = 37;
|
||||
var KEYCODE_RIGHTARROW = 39;
|
||||
|
||||
var keycode = event.keyCode;
|
||||
if (keycode === KEYCODE_ESC) {
|
||||
// Prevent bubbling so as to not affect other components on the page.
|
||||
event.stopPropagation();
|
||||
this.end();
|
||||
} else if (keycode === KEYCODE_LEFTARROW) {
|
||||
if (this.currentImageIndex !== 0) {
|
||||
this.changeImage(this.currentImageIndex - 1);
|
||||
} else if (this.options.wrapAround && this.album.length > 1) {
|
||||
this.changeImage(this.album.length - 1);
|
||||
}
|
||||
} else if (keycode === KEYCODE_RIGHTARROW) {
|
||||
if (this.currentImageIndex !== this.album.length - 1) {
|
||||
this.changeImage(this.currentImageIndex + 1);
|
||||
} else if (this.options.wrapAround && this.album.length > 1) {
|
||||
this.changeImage(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Closing time. :-(
|
||||
Lightbox.prototype.end = function() {
|
||||
this.disableKeyboardNav();
|
||||
|
||||
if(this.options.hasVideo) {
|
||||
var $lbContainer = this.$lightbox.find('.lb-container');
|
||||
var $hasVideoNav = $lbContainer.hasClass('lb-video-nav');
|
||||
this.$video.attr('src', '');
|
||||
|
||||
if($hasVideoNav) $lbContainer.removeClass('lb-video-nav');
|
||||
}
|
||||
oSpot.flushHash();
|
||||
|
||||
$(window).off('resize', this.sizeOverlay);
|
||||
this.$nav.off('mousewheel');
|
||||
this.$lightbox.fadeOut(this.options.fadeDuration);
|
||||
this.$overlay.fadeOut(this.options.fadeDuration);
|
||||
|
||||
if (this.options.disableScrolling) {
|
||||
$('body').removeClass('lb-disable-scrolling');
|
||||
}
|
||||
};
|
||||
|
||||
return new Lightbox();
|
||||
}));
|
||||
468
script/spot.js
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
100
src/Spot.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script>
|
||||
import Project from '@components/project';
|
||||
import Admin from '@components/admin';
|
||||
import Upload from '@components/upload';
|
||||
|
||||
const aoRoutes = {
|
||||
'project': Project,
|
||||
'admin': Admin,
|
||||
'upload': Upload
|
||||
};
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
hash: {page: '', items: []},
|
||||
consts: this.appConfig.consts,
|
||||
mobile: false
|
||||
};
|
||||
},
|
||||
provide() {
|
||||
return {
|
||||
hash: this.hash,
|
||||
consts: this.consts,
|
||||
isMobile: () => this.isMobile()
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
route() {
|
||||
return aoRoutes[this.hash.page];
|
||||
},
|
||||
hashSnapshot() {
|
||||
return JSON.stringify(this.hash);
|
||||
}
|
||||
},
|
||||
inject: ['appConfig'],
|
||||
created() {
|
||||
//Set initial page
|
||||
let asInitHash = this.getBrowserHash();
|
||||
if(!asInitHash.page) asInitHash.page = this.consts.default_page;
|
||||
this.setVarHash(asInitHash);
|
||||
},
|
||||
mounted() {
|
||||
//Catch browser hash change
|
||||
window.addEventListener('hashchange', this.onBrowserHashChange);
|
||||
window.addEventListener('resize', this.updateMobile);
|
||||
this.updateMobile();
|
||||
},
|
||||
watch: {
|
||||
hashSnapshot(jNewHash, jOldHash) {
|
||||
const asNewHash = JSON.parse(jNewHash);
|
||||
//Sync variable -> #hash
|
||||
if(asNewHash != this.getBrowserHash()) {
|
||||
this.setBrowserHash(asNewHash.page, asNewHash.items);
|
||||
}
|
||||
|
||||
this.setPageTitle(asNewHash.page);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isMobile() {
|
||||
return this.mobile;
|
||||
},
|
||||
updateMobile() {
|
||||
this.mobile = getComputedStyle(this.$refs.mobile).display !== 'none';
|
||||
},
|
||||
setPageTitle(sTitle) {
|
||||
document.title = this.consts.title + ' - ' + sTitle.trim();
|
||||
},
|
||||
setVarHash(asHash) {
|
||||
this.hash.page = asHash.page || '';
|
||||
this.hash.items = Array.isArray(asHash.items) ? [...asHash.items.filter(n => n)] : [];
|
||||
},
|
||||
onBrowserHashChange() { //Sync #hash -> variable
|
||||
let asHash = this.getBrowserHash();
|
||||
if(asHash != this.hash) this.setVarHash(asHash);
|
||||
},
|
||||
getBrowserHash() {
|
||||
let sHash = window.location.hash.slice(1);
|
||||
let asHash = sHash.split(this.consts.hash_sep).filter(n => n);
|
||||
let sPage = asHash.shift() || '';
|
||||
return {page: sPage, items: asHash};
|
||||
},
|
||||
setBrowserHash(sPage = '', asItems = []) {
|
||||
if(typeof asItems == 'string' && asItems != '') asItems = [asItems];
|
||||
const sItems = (asItems.length > 0)?(this.consts.hash_sep + asItems.join(this.consts.hash_sep)):'';
|
||||
window.location.hash = '#' + sPage + sItems;
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('hashchange', this.onBrowserHashChange);
|
||||
window.removeEventListener('resize', this.updateMobile);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div id="main">
|
||||
<component :is="route" />
|
||||
</div>
|
||||
<div id="mobile" ref="mobile"></div>
|
||||
</template>
|
||||
36
src/app.js
Normal file
@@ -0,0 +1,36 @@
|
||||
//Librairies
|
||||
import Api from '@scripts/api';
|
||||
import Lang from '@scripts/lang';
|
||||
import Projects from '@scripts/projects';
|
||||
import User from '@scripts/user';
|
||||
import { createApp, reactive } from 'vue';
|
||||
|
||||
//Main template
|
||||
import Spot from './Spot';
|
||||
|
||||
//Style
|
||||
import Css from '@styles/spot';
|
||||
|
||||
//App Configuration from PHP
|
||||
const appConfig = JSON.parse(document.getElementById('app-config').textContent);
|
||||
|
||||
//Instances
|
||||
const oProjects = new Projects(appConfig.projects);
|
||||
const oUser = reactive(new User(appConfig.user, appConfig.consts.default_timezone));
|
||||
const oLang = new Lang({translations: appConfig.consts.lang, prefix: appConfig.consts.lang_prefix});
|
||||
const oApi = new Api({
|
||||
server: appConfig.consts.server,
|
||||
processPage: appConfig.consts.process_page,
|
||||
timezone: oUser.timezone,
|
||||
errorCode: appConfig.consts.error,
|
||||
lang: oLang
|
||||
});
|
||||
|
||||
//Mount app
|
||||
const oSpot = createApp(Spot);
|
||||
oSpot.provide('appConfig', appConfig);
|
||||
oSpot.provide('api', oApi);
|
||||
oSpot.provide('lang', oLang);
|
||||
oSpot.provide('projects', oProjects);
|
||||
oSpot.provide('user', oUser);
|
||||
oSpot.mount('#container');
|
||||
231
src/components/admin.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<script>
|
||||
import SpotIcon from '@components/spotIcon';
|
||||
import SpotButton from '@components/spotButton';
|
||||
import AdminInput from '@components/adminInput';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SpotIcon,
|
||||
SpotButton,
|
||||
AdminInput
|
||||
},
|
||||
inject: ['api', 'lang'],
|
||||
data() {
|
||||
return {
|
||||
elems: {},
|
||||
feedbacks: [],
|
||||
saveTimer: null
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
if(this.saveTimer) clearTimeout(this.saveTimer);
|
||||
},
|
||||
mounted() {
|
||||
this.setProjects();
|
||||
},
|
||||
methods: {
|
||||
l(id) {
|
||||
return this.lang.get(id);
|
||||
},
|
||||
addFeedback(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.api.get('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.api.get('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.addFeedback('success', this.l('admin.create_success'), {'create':sType});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((sMsg) => {this.addFeedback('error', sMsg, {'create':sType});});
|
||||
},
|
||||
deleteElem(oElem) {
|
||||
const asInputs = {
|
||||
type: oElem.type,
|
||||
id: oElem.id
|
||||
};
|
||||
|
||||
this.api.get('admin_delete', asInputs)
|
||||
.then((asData) => {
|
||||
delete this.elems[asInputs.type][asInputs.id];
|
||||
this.addFeedback('success', this.l('admin.delete_success'), asInputs);
|
||||
})
|
||||
.catch((sError) => {
|
||||
this.addFeedback('error', sError, asInputs);
|
||||
});
|
||||
},
|
||||
updateElem(oElem, oEvent) {
|
||||
if(this.saveTimer) clearTimeout(this.saveTimer);
|
||||
|
||||
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.api.get('admin_set', asInputs)
|
||||
.then((asData) => {
|
||||
this.elems[oElem.type][oElem.id][oEvent.target.name] = sNewVal;
|
||||
this.addFeedback('success', this.l('admin.save_success'), asInputs);
|
||||
})
|
||||
.catch((sError) => {
|
||||
oEvent.target.value = sOldVal;
|
||||
this.addFeedback('error', sError, asInputs);
|
||||
});
|
||||
}
|
||||
},
|
||||
queue(oElem, oEvent) {
|
||||
if(this.saveTimer) clearTimeout(this.saveTimer);
|
||||
this.saveTimer = setTimeout(() => {this.updateElem(oElem, oEvent);}, 2000);
|
||||
},
|
||||
updateProject() {
|
||||
this.api.get('update_project')
|
||||
.then((asData, sMsg) => {this.addFeedback('success', sMsg, {'update':'project'});})
|
||||
.catch((sMsg) => {this.addFeedback('error', sMsg, {'update':'project'});});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div id="admin">
|
||||
<a name="back" class="button" href="#project"><SpotIcon :icon="'back'" :text="l('action.back')" /></a>
|
||||
<h1>{{ l('project.plural') }}</h1>
|
||||
<div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ l('project.id') }}</th>
|
||||
<th>{{ l('project.single') }}</th>
|
||||
<th>{{ l('project.mode') }}</th>
|
||||
<th>{{ l('project.code_name') }}</th>
|
||||
<th>{{ l('project.start') }}</th>
|
||||
<th>{{ l('project.end') }}</th>
|
||||
<th>{{ l('action.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'" iconSize="lg" @click="deleteElem(project)" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<SpotButton :classes="'new'" :text="l('project.new')" :icon="'new'" @click="createElem('project')" />
|
||||
</div>
|
||||
<h1>{{ l('feed.plural') }}</h1>
|
||||
<div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ l('feed.id') }}</th>
|
||||
<th>{{ l('feed.ref_id') }}</th>
|
||||
<th>{{ l('spot.id') }}</th>
|
||||
<th>{{ l('project.id') }}</th>
|
||||
<th>{{ l('feed.name') }}</th>
|
||||
<th>{{ l('feed.status') }}</th>
|
||||
<th>{{ l('feed.last_update') }}</th>
|
||||
<th>{{ l('action.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'" iconSize="lg" @click="deleteElem(feed)" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<SpotButton :classes="'new'" :text="l('feed.new')" :icon="'new'" @click="createElem('feed')" />
|
||||
</div>
|
||||
<h1>{{ l('spot.plural') }}</h1>
|
||||
<div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ l('spot.id') }}</th>
|
||||
<th>{{ l('spot.ref_id') }}</th>
|
||||
<th>{{ l('spot.name') }}</th>
|
||||
<th>{{ l('spot.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('user.active') }}</h1>
|
||||
<div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ l('user.id') }}</th>
|
||||
<th>{{ l('user.name') }}</th>
|
||||
<th>{{ l('user.language') }}</th>
|
||||
<th>{{ l('time.zone') }}</th>
|
||||
<th>{{ l('user.clearance') }}</th>
|
||||
<th>{{ l('action.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'" iconSize="lg" @click="deleteElem(user)" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<h1>{{ l('admin.toolbox') }}</h1>
|
||||
<div id="toolbox">
|
||||
<SpotButton :classes="'refresh'" :text="l('project.update_messages')" :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>
|
||||
18
src/components/adminInput.vue
Normal 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>
|
||||
636
src/components/project.vue
Normal file
@@ -0,0 +1,636 @@
|
||||
<script>
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { Map, Marker, LngLatBounds, LngLat, Popup } from 'maplibre-gl';
|
||||
import { createApp } from 'vue';
|
||||
import Simplebar from 'simplebar-vue';
|
||||
|
||||
import Lightbox from '@scripts/lightbox';
|
||||
import { getOuterWidth } from '@scripts/common';
|
||||
|
||||
import SpotIcon from '@components/spotIcon';
|
||||
import SpotIconStack from '@components/spotIconStack';
|
||||
import ProjectPost from '@components/projectPost';
|
||||
import ProjectPopup from '@components/projectPopup';
|
||||
import ProjectNewsletter from '@components/projectNewsletter';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SpotIcon,
|
||||
ProjectPost,
|
||||
ProjectNewsletter,
|
||||
Simplebar
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
feed: {loading:false, updatable:true, outOfData:false, refIdFirst:0, refIdLast:0, firstChunk:true},
|
||||
refreshRate: 60,
|
||||
lastUpdate: { unix_time: 0, relative_time: '', formatted_time: ''},
|
||||
feedPanelOpen: false,
|
||||
settingsPanelOpen: false,
|
||||
track: null,
|
||||
markers: [],
|
||||
markerProps: {
|
||||
image: {mainClasses: 'media', iconMain: 'marker', iconSub: 'image'},
|
||||
video: {mainClasses: 'media', iconMain: 'marker', iconSub: 'video'},
|
||||
message: {mainClasses: 'message', iconMain: 'marker', iconSub: 'footprint', iconSubTransform: 'rotate-270'}
|
||||
},
|
||||
currProject: {},
|
||||
modeHisto: false,
|
||||
posts: [],
|
||||
baseMaps: {},
|
||||
baseMap: null,
|
||||
map: null,
|
||||
lightbox: null,
|
||||
hikes: {
|
||||
colors:{'main':'#00ff78', 'off-track':'#0000ff', 'hitchhiking':'#FF7814'},
|
||||
width: 4
|
||||
},
|
||||
popup: {content: null, element: null}
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
projectClasses() {
|
||||
return [
|
||||
this.feedPanelOpen?'with-feed':'',
|
||||
this.settingsPanelOpen?'with-settings':''
|
||||
].filter(n => n).join(' ');
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
baseMap(sNewBaseMap, sOldBaseMap) {
|
||||
if(this.map?.isStyleLoaded()) {
|
||||
if(sOldBaseMap && this.map.getLayer(sOldBaseMap)) this.map.setLayoutProperty(sOldBaseMap, 'visibility', 'none');
|
||||
if(sNewBaseMap && this.map.getLayer(sNewBaseMap)) this.map.setLayoutProperty(sNewBaseMap, 'visibility', 'visible');
|
||||
}
|
||||
},
|
||||
'hash.items.0'(newProjectCodename, oldProjectCodename) {
|
||||
if(newProjectCodename && newProjectCodename != oldProjectCodename) {
|
||||
this.hash.items = [newProjectCodename];
|
||||
this.toggleSettingsPanel(false, 'none');
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
},
|
||||
provide() {
|
||||
return {
|
||||
map: {
|
||||
panToBetweenPanels: this.panToBetweenPanels,
|
||||
openMarkerPopup: this.openMarkerPopup,
|
||||
closePopup: this.closePopup,
|
||||
isMarkerVisible: this.isMarkerVisible
|
||||
},
|
||||
project: this
|
||||
};
|
||||
},
|
||||
inject: ['api', 'lang', 'hash', 'projects', 'user', 'consts', 'isMobile'],
|
||||
beforeMount() {
|
||||
if(this.hash.items.length == 0) this.hash.items[0] = this.projects.getDefaultCodeName();
|
||||
},
|
||||
mounted() {
|
||||
this.init();
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.quit();
|
||||
},
|
||||
methods: {
|
||||
async init() {
|
||||
this.initProject();
|
||||
this.initLightbox();
|
||||
|
||||
await Promise.all([
|
||||
this.initFeed(),
|
||||
this.initMap()
|
||||
]);
|
||||
|
||||
//Direct link post action
|
||||
if(this.hash.items.length == 3) await this.findPost(this.hash.items[1], this.hash.items[2]);
|
||||
},
|
||||
quit() {
|
||||
this.lightbox.end();
|
||||
this.$refs.feedSimpleBar.scrollElement.removeEventListener('scroll', this.onFeedScroll);
|
||||
this.setFeedUpdateTimer(-1);
|
||||
this.map.remove();
|
||||
},
|
||||
initProject() {
|
||||
this.currProject = this.projects[this.hash.items[0]];
|
||||
this.modeHisto = (this.currProject.mode == this.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() {
|
||||
if(!this.lightbox) {
|
||||
this.lightbox = new Lightbox({
|
||||
alwaysShowNavOnTouchDevices: true,
|
||||
albumLabel: 'Media %1 / %2',
|
||||
fadeDuration: 300,
|
||||
imageFadeDuration: 400,
|
||||
positionFromTop: 0,
|
||||
resizeDuration: 400,
|
||||
hasVideo: true,
|
||||
onMediaChange: async (oMedia) => {
|
||||
this.hash.items = [this.currProject.codename, 'media', oMedia.id];
|
||||
if(oMedia.set == 'post-medias') {
|
||||
this.goToPost('media', oMedia.id)?.panMapToMarker();
|
||||
if(!this.lightbox.hasMediaAfterCurrent()) {
|
||||
await this.getNextFeed();
|
||||
await this.$nextTick();
|
||||
this.lightbox.refreshAlbum();
|
||||
}
|
||||
}
|
||||
},
|
||||
onClosing: () => {this.hash.items = [this.hash.items[0]];}
|
||||
});
|
||||
}
|
||||
},
|
||||
async initFeed() {
|
||||
//Simplebar event
|
||||
this.$refs.feedSimpleBar?.scrollElement.addEventListener('scroll', this.onFeedScroll);
|
||||
|
||||
//Mobile Touchscreen Events
|
||||
//TODO
|
||||
|
||||
this.toggleFeedPanel(!this.isMobile(), 'none');
|
||||
|
||||
//Add post Event handling
|
||||
//TODO
|
||||
|
||||
//Get first posts batch
|
||||
await this.getNextFeed();
|
||||
this.$refs.feedSimpleBar.scrollElement.scrollTop = 0;
|
||||
|
||||
//Start auto-update
|
||||
if(!this.modeHisto) this.setFeedUpdateTimer(this.refreshRate);
|
||||
},
|
||||
async initMap() {
|
||||
//Start async calls
|
||||
[
|
||||
{
|
||||
maps: this.baseMaps,
|
||||
markers: this.markers,
|
||||
last_update: this.lastUpdate
|
||||
},
|
||||
this.track
|
||||
] = await Promise.all([
|
||||
this.api.get('markers', {id_project: this.currProject.id}),
|
||||
this.api.get('geojson', {id_project: this.currProject.id})
|
||||
]);
|
||||
|
||||
//Build Map
|
||||
if(this.map) this.map.remove();
|
||||
this.map = new Map({
|
||||
container: 'map',
|
||||
bounds: this.getInitialMapBounds(),
|
||||
fitBoundsOptions: {
|
||||
padding: {
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
right: 20 + (this.feedPanelOpen?(getOuterWidth(this.$refs.feed)):0)
|
||||
},
|
||||
animate: false,
|
||||
maxZoom: 15
|
||||
},
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: []
|
||||
},
|
||||
attributionControl: false
|
||||
});
|
||||
|
||||
//Get default basemap
|
||||
this.baseMap = this.baseMaps.find((asBM) => asBM.default_map)?.codename ?? null;
|
||||
|
||||
//Force wait for load event
|
||||
await new Promise((resolve) => {
|
||||
if(this.map.loaded()) resolve();
|
||||
else this.map.once('load', resolve);
|
||||
});
|
||||
|
||||
//Base maps (raster tiles)
|
||||
for(const asBaseMap of this.baseMaps) {
|
||||
this.map.addSource(asBaseMap.codename, {
|
||||
type: 'raster',
|
||||
tiles: [asBaseMap.pattern],
|
||||
tileSize: asBaseMap.tile_size
|
||||
});
|
||||
this.map.addLayer({
|
||||
id: asBaseMap.codename,
|
||||
type: 'raster',
|
||||
source: asBaseMap.codename,
|
||||
'layout': {'visibility': asBaseMap.codename == this.baseMap ? 'visible' : 'none'},
|
||||
minZoom: asBaseMap.min_zoom,
|
||||
maxZoom: asBaseMap.max_zoom
|
||||
});
|
||||
}
|
||||
|
||||
//Add track
|
||||
this.addTrack(this.track);
|
||||
|
||||
//Add Markers
|
||||
this.markers.forEach(oMarker => this.addMarker(oMarker));
|
||||
|
||||
//Force wait for idle event
|
||||
await new Promise((resolve) => {
|
||||
if(this.map.loaded() && this.map.areTilesLoaded()) resolve();
|
||||
else this.map.once('idle', resolve);
|
||||
});
|
||||
},
|
||||
addTrack(oTrack, bCenter=false) {
|
||||
this.track = oTrack;
|
||||
this.track.features.forEach((oFeature, iFeatureId) => {
|
||||
oFeature.properties.track_id = iFeatureId;
|
||||
});
|
||||
this.map.addSource('track', {
|
||||
'type': 'geojson',
|
||||
'data': this.track
|
||||
});
|
||||
|
||||
//Color mapping
|
||||
let asColorMapping = ['match', ['get', 'type']];
|
||||
for(const [sHikeType, sColor] of Object.entries(this.hikes.colors)) {
|
||||
asColorMapping.push(sHikeType);
|
||||
asColorMapping.push(sColor);
|
||||
}
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
//Enlarged track (click hit box)
|
||||
this.map.addLayer({
|
||||
'id': 'track-hitbox',
|
||||
'type': 'line',
|
||||
'source': 'track',
|
||||
'paint': {
|
||||
'line-opacity': 0,
|
||||
'line-width': this.hikes.width + 20
|
||||
}
|
||||
});
|
||||
this.map.on('click', 'track-hitbox', this.openTrackPopup);
|
||||
this.map.on('mouseenter', 'track-hitbox', () => {this.map.getCanvas().style.cursor = 'pointer';});
|
||||
this.map.on('mouseleave', 'track-hitbox', () => {this.map.getCanvas().style.cursor = '';});
|
||||
},
|
||||
openTrackPopup(oEvent) {
|
||||
this.closePopup();
|
||||
this.openPopup({
|
||||
lnglat: oEvent.lngLat,
|
||||
options: this.projects.getTrackInfo(oEvent.features[0], this.track, this.lang),
|
||||
});
|
||||
},
|
||||
addMarker(oMarker) {
|
||||
const $Marker = document.createElement('div');
|
||||
createApp(SpotIconStack, this.markerProps[oMarker.subtype]).mount($Marker);
|
||||
|
||||
new Marker({element: $Marker, anchor: 'bottom'})
|
||||
.setLngLat([oMarker.longitude, oMarker.latitude])
|
||||
.addTo(this.map)
|
||||
.getElement()
|
||||
.addEventListener('click', (oEvent) => {
|
||||
oEvent.preventDefault();
|
||||
oEvent.stopPropagation();
|
||||
this.openMarkerPopup(oMarker.id, oMarker.type);
|
||||
});
|
||||
},
|
||||
openMarkerPopup(iMarkerId, sMarkerType) {
|
||||
this.closePopup();
|
||||
let oMarker = this.markers.find((oCandidate) => oCandidate.id == iMarkerId && oCandidate.type == sMarkerType);
|
||||
this.openPopup({
|
||||
lnglat: [oMarker.longitude, oMarker.latitude],
|
||||
options: oMarker,
|
||||
offset: [0, -32] //FIXME
|
||||
});
|
||||
},
|
||||
openPopup({lnglat, options={}, offset=[0, 0]} = {}) {
|
||||
const $Popup = document.createElement('div');
|
||||
this.popup.element = new Popup({
|
||||
anchor: 'bottom',
|
||||
offset: offset,
|
||||
closeButton: false
|
||||
})
|
||||
.setDOMContent($Popup)
|
||||
.setLngLat(lnglat)
|
||||
.addTo(this.map);
|
||||
|
||||
this.popup.content = createApp(ProjectPopup, {
|
||||
options: options,
|
||||
project: this.currProject
|
||||
});
|
||||
this.popup.content
|
||||
.provide('lang', this.lang)
|
||||
.provide('consts', this.consts)
|
||||
.mount($Popup);
|
||||
},
|
||||
closePopup() {
|
||||
if(this.popup.content) {
|
||||
this.popup.content.unmount();
|
||||
this.popup.content = null;
|
||||
}
|
||||
if(this.popup.element) {
|
||||
this.popup.element.remove();
|
||||
this.popup.element = null;
|
||||
}
|
||||
},
|
||||
getInitialMapBounds() {
|
||||
let oBounds = new LngLatBounds();
|
||||
let oHashMarker;
|
||||
|
||||
if(this.hash.items.length == 3) {
|
||||
oHashMarker = this.markers.find((oMarker) => (
|
||||
oMarker.type == this.hash.items[1] &&
|
||||
oMarker.id == this.hash.items[2] &&
|
||||
oMarker.longitude != null &&
|
||||
oMarker.latitude != null
|
||||
)) || null;
|
||||
}
|
||||
|
||||
if(oHashMarker) { //Direct link to marker
|
||||
oBounds.extend(new LngLat(oHashMarker.longitude, oHashMarker.latitude));
|
||||
}
|
||||
else if( //Blog Mode: Fit to last message
|
||||
this.currProject.mode == this.consts.modes.blog &&
|
||||
this.markers.length > 0
|
||||
) {
|
||||
let oLastMsg = this.markers.at(-1);
|
||||
oBounds.extend(new LngLat(oLastMsg.longitude, oLastMsg.latitude));
|
||||
}
|
||||
else { //Pre/Histo Mode: Fit to track
|
||||
for(const iFeatureId in this.track.features) {
|
||||
oBounds = this.track.features[iFeatureId].geometry.coordinates.reduce(
|
||||
(bounds, coord) => {
|
||||
return bounds.extend(coord);
|
||||
},
|
||||
oBounds
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return oBounds;
|
||||
},
|
||||
|
||||
async findPost(sPostType, iPostId) {
|
||||
let vPost = this.goToPost(sPostType, iPostId);
|
||||
if(vPost) {
|
||||
await vPost.executeMainAction(0);
|
||||
return vPost;
|
||||
}
|
||||
else if(!this.feed.outOfData) {
|
||||
await this.getNextFeed();
|
||||
return this.findPost(sPostType, iPostId);
|
||||
}
|
||||
else console.log('Missing element ID "'+iPostId+'" of type "'+sPostType+'"');
|
||||
return null;
|
||||
},
|
||||
goToPost(sPostType, iPostId) {
|
||||
let bFound = false;
|
||||
let avPosts = this.$refs.posts.filter((post) => {return post.postId == sPostType+'-'+iPostId;});
|
||||
if(avPosts.length > 0) {
|
||||
let vPost = avPosts[0];
|
||||
this.$refs.feedSimpleBar.scrollElement.scrollTop += Math.round(
|
||||
vPost.$el.getBoundingClientRect().top
|
||||
+ window.pageYOffset
|
||||
- parseFloat(getComputedStyle(this.$refs.feedSimpleBar.$el).paddingTop)
|
||||
);
|
||||
|
||||
return vPost;
|
||||
}
|
||||
},
|
||||
async getNextFeed() {
|
||||
if(!this.feed.outOfData && !this.feed.loading) {
|
||||
//Get next chunk
|
||||
this.feed.loading = true;
|
||||
let aoData = await this.api.get('next_feed', {id_project: this.currProject.id, id: this.feed.refIdLast});
|
||||
let iPostCount = Object.keys(aoData.feed).length;
|
||||
|
||||
//Update pointers
|
||||
this.feed.outOfData = (iPostCount < this.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);
|
||||
|
||||
this.feed.loading = false;
|
||||
this.feed.firstChunk = false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
onFeedScroll(oEvent) {
|
||||
const box = oEvent.currentTarget
|
||||
const content = box.querySelector('.simplebar-content')
|
||||
|
||||
if ((box.scrollTop + box.clientHeight) / (content?.offsetHeight || 1) >= 0.8) this.getNextFeed();
|
||||
},
|
||||
setFeedUpdateTimer(iSeconds) {
|
||||
if(typeof this.feedTimer != 'undefined') clearTimeout(this.feedTimer);
|
||||
if(iSeconds >= 0) this.feedTimer = setTimeout(this.checkNewFeed, iSeconds * 1000);
|
||||
},
|
||||
async checkNewFeed() {
|
||||
let aoData = await this.api.get('new_feed', {id_project: this.currProject.id, id: this.feed.refIdFirst});
|
||||
const aoFeed = aoData.feed || [];
|
||||
const aoMarkers = aoData.markers || [];
|
||||
|
||||
if(aoFeed.length > 0) {
|
||||
//Update pointer
|
||||
this.feed.refIdFirst = aoData.ref_id_first;
|
||||
|
||||
//Add new posts
|
||||
this.posts.unshift(...aoFeed);
|
||||
}
|
||||
|
||||
//Add new Markers
|
||||
if(aoMarkers.length > 0) {
|
||||
this.markers.push(...aoMarkers);
|
||||
aoMarkers.forEach(oMarker => this.addMarker(oMarker));
|
||||
}
|
||||
|
||||
//Message Last Update
|
||||
this.lastUpdate = aoData.last_update;
|
||||
|
||||
//Reschedule
|
||||
this.setFeedUpdateTimer(this.refreshRate);
|
||||
},
|
||||
panToBetweenPanels(oLngLat, iZoom, iAnimDuration=500) {
|
||||
const iXOffset = (this.settingsPanelOpen?getOuterWidth(this.$refs.settings):0) - (this.feedPanelOpen?getOuterWidth(this.$refs.feed):0);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if(!this.map) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
this.map.once('moveend', resolve);
|
||||
this.map.easeTo({
|
||||
center: oLngLat,
|
||||
zoom: iZoom,
|
||||
offset: [iXOffset / 2, 0],
|
||||
duration: iAnimDuration
|
||||
});
|
||||
});
|
||||
},
|
||||
isMarkerVisible(oLngLat){
|
||||
return !!this.map && this.map.getBounds().contains(oLngLat);
|
||||
},
|
||||
getGoogleMapsLink(asInfo) {
|
||||
return $('<a>', {
|
||||
href:'https://www.google.com/maps/place/'+asInfo.lat_dms+'+'+asInfo.lon_dms+'/@'+asInfo.latitude+','+asInfo.longitude+',10z',
|
||||
title: this.lang.get('map.see_on_google'),
|
||||
target: '_blank',
|
||||
rel: 'noreferrer noopener'
|
||||
}).text(asInfo.lat_dms+' '+asInfo.lon_dms);
|
||||
},
|
||||
toggleFeedPanel(bShow, sMapAction) {
|
||||
let bOldValue = this.feedPanelOpen;
|
||||
this.feedPanelOpen = (typeof bShow === 'object' || typeof bShow === 'undefined')?(!this.feedPanelOpen):bShow;
|
||||
|
||||
if(bOldValue != this.feedPanelOpen && !this.isMobile()) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
},
|
||||
toggleSettingsPanel(bShow, sMapAction) {
|
||||
let bOldValue = this.settingsPanelOpen;
|
||||
this.settingsPanelOpen = (typeof bShow === 'object' || typeof bShow === 'undefined')?(!this.settingsPanelOpen):bShow;
|
||||
|
||||
if(bOldValue != this.settingsPanelOpen && !this.isMobile()) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="projects" :class="projectClasses">
|
||||
<div id="background"></div>
|
||||
<div id="submap">
|
||||
<div class="loader">
|
||||
<SpotIcon :icon="'map'" :classes="'flicker'" width="fixed" />
|
||||
</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" v-if="this.currProject.mode == this.consts.modes.blog && lastUpdate.unix_time > 0">
|
||||
<p><span><img src="images/spot-logo-only.svg" alt="" /></span><abbr :title="lastUpdate.formatted_time">{{ lang.get('feed.last_update')+' '+lastUpdate.relative_time }}</abbr></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-sections">
|
||||
<Simplebar id="settings-sections-scrollbox">
|
||||
<div class="settings-section">
|
||||
<h1><SpotIcon :icon="'project'" width="fixed" :text="lang.get('project.hikes')" /></h1>
|
||||
<div class="settings-section-body">
|
||||
<div class="radio" v-for="project in projects" :key="'project-'+project.id">
|
||||
<input type="radio" :id="'project-'+project.id" :value="project.codename" v-model="$parent.hash.items[0]" />
|
||||
<label :for="'project-'+project.id">
|
||||
<span>{{ project.name }}</span>
|
||||
<a class="download" :href="project.gpxfilepath" :download="project.codename + '.gpx'" :title="lang.get('track.download')" @click.stop="()=>{}">
|
||||
<SpotIcon :icon="'download'" margin="left" />
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h1><SpotIcon :icon="'map'" width="fixed" :text="lang.get('map.title')" /></h1>
|
||||
<div class="settings-section-body">
|
||||
<div class="radio" v-for="bm in baseMaps" :key="'map-'+bm.id_map">
|
||||
<input type="radio" :id="'map-'+bm.id_map" :value="bm.codename" v-model="baseMap" />
|
||||
<label :for="'map-'+bm.id_map">{{ lang.get('map.'+bm.codename) }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section newsletter">
|
||||
<ProjectNewsletter />
|
||||
</div>
|
||||
<div class="settings-section admin" v-if="user.hasClearance(consts.clearances.admin)">
|
||||
<h1><SpotIcon :icon="'admin'" width="fixed" :text="lang.get('admin.title')" /></h1>
|
||||
<div class="admin-actions">
|
||||
<a class="button" href="#admin"><SpotIcon :icon="'config'" :text="lang.get('admin.config')" /></a>
|
||||
<a class="button" href="#upload"><SpotIcon :icon="'upload'" :text="lang.get('admin.upload')" /></a>
|
||||
</div>
|
||||
</div>
|
||||
</Simplebar>
|
||||
</div>
|
||||
<div class="settings-footer">
|
||||
<a href="https://git.lutran.fr/franzz/spot" :title="lang.get('credits.git')" target="_blank" rel="noopener">
|
||||
<SpotIcon :icon="'credits'" :text="lang.get('credits.project')" />
|
||||
</a>
|
||||
<span> {{ lang.get('credits.license') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="'map-control map-control-icon settings-control map-control-'+(isMobile()?'bottom':'top')" @click="toggleSettingsPanel">
|
||||
<SpotIcon :icon="settingsPanelOpen?'prev':'menu'" />
|
||||
</div>
|
||||
<div v-if="!isMobile()" 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">{{ lang.get('track.'+hikeType) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="title" :class="'map-control settings-control map-control-'+(isMobile()?'bottom':'top')">
|
||||
<span>{{ currProject.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: lang.get('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-'+(isMobile()?'bottom':'top')" @click="toggleFeedPanel">
|
||||
<SpotIcon :icon="feedPanelOpen?'next':'post'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
17
src/components/projectMapLink.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
options: Object
|
||||
},
|
||||
inject: ['lang']
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
:href="'https://www.google.com/maps/place/'+options.lat_dms+'+'+options.lon_dms+'/@'+options.latitude+','+options.longitude+',10z'"
|
||||
:title="lang.get('map.see_on_google')"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>{{ options.lat_dms+' '+options.lon_dms }}</a>
|
||||
</template>
|
||||
71
src/components/projectMediaLink.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script>
|
||||
import spotIcon from '@components/spotIcon';
|
||||
import projectRelTime from '@components/projectRelTime';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
spotIcon,
|
||||
projectRelTime
|
||||
},
|
||||
props: {
|
||||
options: Object,
|
||||
type: String
|
||||
},
|
||||
emits: ['opening-lightbox'],
|
||||
data() {
|
||||
return {
|
||||
title:''
|
||||
}
|
||||
},
|
||||
inject: ['lang'],
|
||||
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
|
||||
;
|
||||
},
|
||||
methods: {
|
||||
openMedia() {
|
||||
this.$refs.link.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
</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"
|
||||
@click="$emit('opening-lightbox', $event)"
|
||||
ref="link"
|
||||
>
|
||||
<img
|
||||
:src="options.thumb_path"
|
||||
:width="options.width"
|
||||
:height="options.height"
|
||||
:title="lang.get((options.subtype == 'video')?'media.click_watch':'media.click_zoom')"
|
||||
class="clickable"
|
||||
/>
|
||||
<span class="drill-icon"><spotIcon :icon="'drill-'+options.subtype" /></span>
|
||||
<div v-if="options.comment && type == 'post'" class="comment">
|
||||
<p>{{ options.comment }}</p>
|
||||
</div>
|
||||
</a>
|
||||
<div style="display:none">
|
||||
<span v-if="options.comment" ref="comment" class="lb-caption-line comment desktop">
|
||||
<spotIcon :icon="'post'" width="fixed" size="lg" :text-classes="'comment-text'" :text="options.comment" />
|
||||
</span>
|
||||
<span ref="postedon" class="lb-caption-line">
|
||||
<projectRelTime :icon="'upload'" :localTime="options.posted_on_formatted_time_local" :siteTime="options.posted_on_formatted_time" :offset="options.posted_on_day_offset" />
|
||||
</span>
|
||||
<span ref="takenon" class="lb-caption-line">
|
||||
<projectRelTime :icon="options.subtype+'-shot'" :localTime="options.taken_on_formatted_time_local" :siteTime="options.taken_on_formatted_time" :offset="options.taken_on_day_offset" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
67
src/components/projectNewsletter.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script>
|
||||
import SpotButton from '@components/spotButton';
|
||||
import SpotIcon from '@components/spotIcon';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SpotButton,
|
||||
SpotIcon
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
feedbacks: [],
|
||||
loading: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
buttonClasses() {
|
||||
return [
|
||||
'manage',
|
||||
this.action,
|
||||
this.loading?'loading':''
|
||||
].filter(n => n).join(' ');
|
||||
},
|
||||
subscribed() {
|
||||
return this.user.id_user > 0;
|
||||
},
|
||||
action() {
|
||||
return this.subscribed?'unsubscribe':'subscribe';
|
||||
}
|
||||
},
|
||||
inject: ['api', 'lang', 'user'],
|
||||
methods: {
|
||||
manage() {
|
||||
if(this.loading) return;
|
||||
|
||||
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.feedbacks.push({type:'error', 'msg':this.lang.get('newsletter.invalid_email')});
|
||||
else {
|
||||
const sAction = this.action;
|
||||
this.loading = true;
|
||||
|
||||
this.api.request(sAction, {'email': this.user.email, 'name': this.user.name})
|
||||
.then((asResponse) => {
|
||||
this.feedbacks.push({type: asResponse.result, msg: asResponse.desc});
|
||||
this.user.setInfo(asResponse.data);
|
||||
})
|
||||
.catch((sDesc) => {this.feedbacks.push({type:'error', msg:sDesc?.message || sDesc});})
|
||||
.finally(() => {this.loading = false;});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1><SpotIcon :icon="'newsletter'" width="fixed" :text="lang.get('newsletter.title')" /></h1>
|
||||
<div class="newsletter-form">
|
||||
<input type="email" name="email" id="email" :placeholder="lang.get('newsletter.email_placeholder')" v-model="user.email" :disabled="loading || subscribed" />
|
||||
<SpotButton :classes="buttonClasses" :title="lang.get('newsletter.'+action)" :icon="action" @click="manage" />
|
||||
</div>
|
||||
<div class="feedback">
|
||||
<p v-for="feedback in feedbacks" :key="feedback.type + '-' + feedback.msg" :class="feedback.type">
|
||||
<SpotIcon :icon="feedback.type" :text="feedback.msg" />
|
||||
</p>
|
||||
</div>
|
||||
{{ lang.get('newsletter.'+(subscribed?'subscribed':'unsubscribed')+'_desc') }}
|
||||
</template>
|
||||
85
src/components/projectPopup.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script>
|
||||
import projectMapLink from '@components/projectMapLink';
|
||||
import spotIcon from '@components/spotIcon';
|
||||
import projectRelTime from '@components/projectRelTime';
|
||||
import projectMediaLink from '@components/projectMediaLink';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
spotIcon,
|
||||
projectMapLink,
|
||||
projectMediaLink,
|
||||
projectRelTime
|
||||
},
|
||||
props: {
|
||||
options: Object,
|
||||
project: Object
|
||||
},
|
||||
inject: ['lang', 'consts'],
|
||||
computed: {
|
||||
timeinfo() {
|
||||
return (this.options.type == 'media')?
|
||||
{
|
||||
icon: 'image-shot',
|
||||
local_time: this.options.taken_on_formatted_time_local,
|
||||
site_time: this.options.taken_on_formatted_time,
|
||||
offset: this.options.taken_on_day_offset,
|
||||
relative_time: this.options.taken_on_relative_time
|
||||
}:
|
||||
{
|
||||
icon: 'time',
|
||||
local_time: this.options.formatted_time_local,
|
||||
site_time: this.options.formatted_time,
|
||||
offset: this.options.day_offset,
|
||||
relative_time: this.options.relative_time
|
||||
};
|
||||
},
|
||||
localTime() {
|
||||
return this.timeinfo.local_time +
|
||||
((this.project.mode == this.consts.modes.blog)?' (' + this.timeinfo.relative_time + ')':'')
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="options.type+' '+options.subtype">
|
||||
<div class="header" v-if="options.type=='track'">
|
||||
<h1>
|
||||
<spotIcon :icon="options.subtype" size="lg" :text="this.options.name" width="auto" :textClasses="options.subtype" />
|
||||
</h1>
|
||||
<p v-if="options.description" class="description">{{ options.description }}</p>
|
||||
<div v-if="options.subtype!='hitchhiking'" class="separator"></div>
|
||||
</div>
|
||||
<div v-if="options.type=='track'">
|
||||
<div v-if="options.subtype!='hitchhiking'" class="section track-stats">
|
||||
<spotIcon :title="lang.get('stats.distance')" :icon="'distance'" width="fixed" size="lg" :text="options.distance+'km'" />
|
||||
<spotIcon :title="lang.get('stats.duration')" :icon="'time'" width="fixed" size="lg" :text="options.duration" />
|
||||
<spotIcon :title="lang.get('stats.elevation_gain')" :icon="'elev-gain'" width="fixed" size="lg" :text="options.elev_gain+'m'" />
|
||||
<spotIcon :title="lang.get('stats.elevation_loss')" :icon="'elev-drop'" width="fixed" size="lg" :text="options.elev_drop+'m'" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="section coordinates">
|
||||
<spotIcon :icon="'coords'" width="fixed" size="lg" margin="right" />
|
||||
<projectMapLink :options="options" />
|
||||
</div>
|
||||
<div v-if="options.altitude" class="section altitude">
|
||||
<spotIcon :icon="'altitude'" width="fixed" size="lg" :text="options.altitude+'m'" />
|
||||
</div>
|
||||
<div class="section time">
|
||||
<projectRelTime :icon="timeinfo.icon" :localTime="localTime" :siteTime="timeinfo.site_time" :offset="timeinfo.offset" />
|
||||
</div>
|
||||
<div class="section weather" v-if="options.weather_icon && options.weather_icon!='unknown'" :title="options.weather_cond==''?'':lang.get('weather.'+options.weather_icon)">
|
||||
<spotIcon :icon="options.weather_icon" width="fixed" size="lg" :text="options.weather_temp+'°C'" />
|
||||
</div>
|
||||
<div v-if="options.medias" class="section medias">
|
||||
<spotIcon v-if="options.type=='message'" icon="media" width="fixed" size="lg" :text="lang.get('media.nearby')" />
|
||||
<div class="medias-list">
|
||||
<projectMediaLink v-for="media in options?.medias" :options="media" :type="'marker'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
233
src/components/projectPost.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<script>
|
||||
import spotIcon from '@components/spotIcon';
|
||||
import spotIconStack from '@components/spotIconStack';
|
||||
import spotButton from '@components/spotButton';
|
||||
import projectMediaLink from '@components/projectMediaLink';
|
||||
import projectMapLink from '@components/projectMapLink';
|
||||
import projectRelTime from '@components/projectRelTime';
|
||||
import { LngLat } from 'maplibre-gl';
|
||||
import { copyTextToClipboard } from '@scripts/common';
|
||||
|
||||
import autosize from 'autosize';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
spotIcon,
|
||||
spotIconStack,
|
||||
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.lang.get('post.copy_to_clipboard'),
|
||||
anchorIcon: 'link',
|
||||
popupRequested: false,
|
||||
mouseOverDrill: false,
|
||||
postMessage: '',
|
||||
sending: false,
|
||||
focusZoomLevel: 15
|
||||
};
|
||||
},
|
||||
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.lang.get('feed.counter', this.options.displayed_id)):'';
|
||||
},
|
||||
drillMainIcon() {
|
||||
return this.mouseOverDrill?'drill-message':'marker';
|
||||
},
|
||||
anchorLink() {
|
||||
return '#'+[this.hash.page, this.project.currProject.codename, this.options.type, this.options.id].join(this.consts.hash_sep);
|
||||
},
|
||||
modeHisto() {
|
||||
return (this.project.currProject.mode == this.consts.modes.histo);
|
||||
},
|
||||
relTime() {
|
||||
return this.modeHisto?(this.options.formatted_time || '').substr(0, 10):this.options.relative_time;
|
||||
},
|
||||
relatedMarker() {
|
||||
//Find corresponding marker
|
||||
if(!this.options.longitude && !this.options.latitude && this.options.type == 'media') {
|
||||
return this.project.markers.find((marker) => {
|
||||
return (marker.medias || []).some((media) => {
|
||||
return media.id_media == this.options.id_media;
|
||||
});
|
||||
}) || null;
|
||||
}
|
||||
else if(
|
||||
['message', 'media'].includes(this.options.type)
|
||||
&& this.options.longitude
|
||||
&& this.options.latitude
|
||||
) {
|
||||
return this.options;
|
||||
}
|
||||
else return null;
|
||||
},
|
||||
relatedMarkerLatLng() {
|
||||
let oRelatedMarker = this.relatedMarker;
|
||||
return new LngLat(oRelatedMarker.longitude, oRelatedMarker.latitude);
|
||||
}
|
||||
},
|
||||
inject: ['api', 'lang', 'project', 'user', 'map', 'hash', 'consts', 'isMobile'],
|
||||
methods: {
|
||||
copyAnchor() {
|
||||
copyTextToClipboard(this.consts.server+this.anchorLink);
|
||||
this.anchorTitle = this.lang.get('post.link_copied');
|
||||
this.anchorIcon = 'copied';
|
||||
setTimeout(()=>{ //TODO animation
|
||||
this.anchorTitle = this.lang.get('post.copy_to_clipboard');
|
||||
this.anchorIcon = 'link';
|
||||
}, 5000);
|
||||
},
|
||||
panMapToMarker(iAnimDuration=500) {
|
||||
if(typeof iAnimDuration !== 'number') iAnimDuration = 500; //panMapToMarker will provide event on direct call in vue template
|
||||
|
||||
this.popupRequested = true;
|
||||
|
||||
if(this.isMobile()) this.project.toggleFeedPanel(false, 'panToInstant');
|
||||
this.hash.items = [this.project.currProject.codename, this.options.type, this.options.id];
|
||||
|
||||
return this.map.panToBetweenPanels(this.relatedMarkerLatLng, this.focusZoomLevel, iAnimDuration).then(() => {
|
||||
this.openMarkerPopup();
|
||||
});
|
||||
},
|
||||
onMouseEnter() {
|
||||
this.mouseOverDrill = true;
|
||||
this.openMarkerPopup();
|
||||
},
|
||||
onMouseLeave() {
|
||||
this.mouseOverDrill = false;
|
||||
this.closeMarkerPopup();
|
||||
},
|
||||
openMarkerPopup() {
|
||||
const oRelatedMarker = this.relatedMarker;
|
||||
if(oRelatedMarker && this.map.isMarkerVisible(this.relatedMarkerLatLng)) {
|
||||
this.map.openMarkerPopup(oRelatedMarker.id, oRelatedMarker.type);
|
||||
}
|
||||
},
|
||||
closeMarkerPopup() {
|
||||
if(!this.popupRequested) this.map.closePopup();
|
||||
this.popupRequested = false;
|
||||
},
|
||||
send() {
|
||||
if(this.postMessage != '' && this.user.name != '') {
|
||||
this.sending = true;
|
||||
this.api.get(
|
||||
'add_post',
|
||||
{
|
||||
id_project: this.project.currProject.id,
|
||||
name: this.user.name,
|
||||
content: this.postMessage
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
this.postMessage = '';
|
||||
this.project.checkNewFeed();
|
||||
this.sending = false;
|
||||
})
|
||||
.catch((sDesc) => {
|
||||
this.sending = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
executeMainAction() {
|
||||
switch(this.options.type) {
|
||||
case 'message':
|
||||
return this.openMarkerPopup();
|
||||
case 'media':
|
||||
this.$refs.medialink.openMedia();
|
||||
if(this.relatedMarker) return this.openMarkerPopup();
|
||||
default:
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
},
|
||||
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" v-if="!options.headerless">
|
||||
<div class="index">
|
||||
<spotIcon :icon="subType" :text="displayedId" width="auto" />
|
||||
<a v-if="anchorVisible" class="link desktop" @click="copyAnchor" ref="anchor" :href="anchorLink" :title="anchorTitle">
|
||||
<spotIcon :icon="anchorIcon" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="time" @mouseleave="mouseOverHeader = false" @mouseover="mouseOverHeader = true" :title="timeDiff?lang.get('time.local', absTimeLocal):''">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<span v-if="mouseOverHeader">{{ timeDiff?lang.get('time.user', absTime):absTime }}</span>
|
||||
<span v-else>{{ relTime }}</span>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div v-if="options.type == 'message'" class="body-box">
|
||||
<div class="drill" @click.prevent="panMapToMarker" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave">
|
||||
<span v-if="options.weather_icon && options.weather_icon!='unknown'" class="weather clickable" :title="lang.get('weather.'+options.weather_icon)">
|
||||
<spotIcon :icon="options.weather_icon" :text="Math.round(options.weather_temp)+'°C'" text-classes="temperature" />
|
||||
</span>
|
||||
<img class="staticmap clickable" :title="lang.get('media.click_zoom')" :src="options.static_img_url" />
|
||||
<spotIconStack :mainClasses="'message drill-icon'" :iconMain="drillMainIcon" iconSub="footprint" :icon-sub-transform="'rotate-270'" />
|
||||
<div class="comment">
|
||||
<p>
|
||||
<spotIcon :icon="'coords'" margin="right" size="lg" />
|
||||
<projectMapLink :options="options" />
|
||||
</p>
|
||||
<p v-if="timeDiff">
|
||||
<projectRelTime :icon="'time'" :localTime="absTimeLocal" :siteTime="options.formatted_time" :offset="options.day_offset" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="options.type == 'media'" class="body-box">
|
||||
<projectMediaLink :options="options" :type="'post'" ref="medialink" @opening-lightbox="panMapToMarker" />
|
||||
</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>
|
||||
<div v-else-if="options.type == 'poster'" class="poster-form">
|
||||
<textarea ref="post" name="post" :placeholder="lang.get('post.message')" class="autoExpand" rows="1" v-model="postMessage"></textarea>
|
||||
<div class="poster-actions">
|
||||
<input type="text" name="name" :placeholder="lang.get('post.name')" v-model="user.name" />
|
||||
<spotButton name="submit" :aria-label="lang.get('action.send')" :title="lang.get('action.send')" :icon="'send'" @click="send()" :iconClasses="sending?'flicker':''" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="options.type == 'archived'">
|
||||
<p><spotIcon :icon="'success'" /></p>
|
||||
<p>{{ lang.get('project.modes.histo') }}</p>
|
||||
</div>
|
||||
<div v-else-if="options.type == 'loading'">
|
||||
<p class="flicker"><spotIcon :icon="'post'" /></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
27
src/components/projectRelTime.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script>
|
||||
import spotIcon from '@components/spotIcon';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
spotIcon
|
||||
},
|
||||
props: {
|
||||
localTime: String,
|
||||
siteTime: String,
|
||||
offset: String,
|
||||
classes: String,
|
||||
icon: String,
|
||||
},
|
||||
inject: ['lang'],
|
||||
computed: {
|
||||
title() {
|
||||
if(this.localTime != this.siteTime) return this.lang.get('time.user', this.siteTime.slice(-5)) + ((this.offset != '0')?' ('+this.lang.get('unit.day_short')+this.offset+')':'');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<spotIcon v-if="icon" :icon="icon" :title="title" :text="localTime" width="fixed" size="lg" />
|
||||
<span v-else :class="classes" :title="title">{{ localTime }}</span>
|
||||
</template>
|
||||
19
src/components/spotButton.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script>
|
||||
import SpotIcon from '@components/spotIcon';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SpotIcon
|
||||
},
|
||||
props: {
|
||||
classes: String,
|
||||
text: String,
|
||||
icon: String,
|
||||
iconClasses: String,
|
||||
iconSize: String
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<button :class="classes"><SpotIcon :icon="icon" :text="text" :classes="iconClasses" :size="iconSize" /></button>
|
||||
</template>
|
||||
95
src/components/spotIcon.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script>
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { getIcon } from '@scripts/icons';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FontAwesomeIcon
|
||||
},
|
||||
props: {
|
||||
icon: String,
|
||||
width: String,
|
||||
size: String,
|
||||
classes: String,
|
||||
title: String,
|
||||
text: String,
|
||||
margin: String,
|
||||
textClasses: String,
|
||||
transform: String
|
||||
},
|
||||
computed: {
|
||||
iconClassNames() {
|
||||
return [
|
||||
'spot-icon',
|
||||
this.icon,
|
||||
...(this.classes || '').split(/\s+/),
|
||||
this.margin?'margin-'+this.margin:null
|
||||
].filter(Boolean).join(' ');
|
||||
},
|
||||
resolvedFixedWidth() {
|
||||
return (this.width == 'fixed') || null;
|
||||
},
|
||||
resolvedAutoWidth() {
|
||||
return (this.width == 'auto') || null;
|
||||
},
|
||||
hasText() {
|
||||
return this.text && this.text != '';
|
||||
},
|
||||
iconDefinition() {
|
||||
return getIcon(this.icon);
|
||||
},
|
||||
resolvedSize() {
|
||||
return this.size || null;
|
||||
},
|
||||
resolvedTransform() {
|
||||
return this.transform || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :title="title" :class="hasText?'spot-icon-with-text':null">
|
||||
<span class="spot-icon-symbol">
|
||||
<FontAwesomeIcon
|
||||
:icon="iconDefinition"
|
||||
:class="['spot-icon', this.icon, this.classes, this.margin?'margin-'+this.margin:null]"
|
||||
:fixed-width="resolvedFixedWidth"
|
||||
:width-auto="resolvedAutoWidth"
|
||||
:size="resolvedSize"
|
||||
:transform="resolvedTransform"
|
||||
/>
|
||||
</span>
|
||||
<span v-if="hasText" :class="['spot-icon-text', textClasses]">{{ text }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@styles/var";
|
||||
|
||||
.spot-icon-with-text {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: var.$elem-spacing;
|
||||
|
||||
.spot-icon-symbol {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.spot-icon-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.spot-icon {
|
||||
&.margin-right {
|
||||
margin-right: var.$elem-spacing;
|
||||
}
|
||||
|
||||
&.margin-left {
|
||||
margin-left: var.$elem-spacing;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
51
src/components/spotIconStack.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script>
|
||||
import { FontAwesomeLayers } from '@fortawesome/vue-fontawesome';
|
||||
import spotIcon from '@components/spotIcon';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FontAwesomeLayers,
|
||||
spotIcon
|
||||
},
|
||||
props: {
|
||||
mainClasses: String,
|
||||
iconMain: String,
|
||||
iconMainClasses: String,
|
||||
iconMainTransform: String,
|
||||
iconSub: String,
|
||||
iconSubClasses: String,
|
||||
iconSubTransform: String
|
||||
},
|
||||
computed: {
|
||||
mainClass() {
|
||||
return 'spot-icon-stack clickable'+(this.mainClasses?' '+this.mainClasses:'');
|
||||
},
|
||||
iconMainClass() {
|
||||
return this.mergeClasses('main clickable', this.iconMainClasses);
|
||||
},
|
||||
iconSubClass() {
|
||||
return this.mergeClasses('sub', this.iconSubClasses);
|
||||
},
|
||||
iconSubTransformValue() {
|
||||
return this.mergeClasses('shrink-9 up-2', this.iconSubTransform);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mergeClasses(sClassesA, sClassesB) {
|
||||
return [
|
||||
...(sClassesA || '').split(/\s+/),
|
||||
...(sClassesB || '').split(/\s+/)
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="mainClass">
|
||||
<FontAwesomeLayers>
|
||||
<spotIcon :icon="iconMain" :classes="iconMainClass" :transform="iconMainTransform" />
|
||||
<spotIcon :icon="iconSub" :classes="iconSubClass" :transform="iconSubTransformValue" />
|
||||
</FontAwesomeLayers>
|
||||
</span>
|
||||
</template>
|
||||
149
src/components/upload.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script>
|
||||
import { markRaw } from 'vue';
|
||||
import Uppy from '@uppy/core';
|
||||
import XHRUpload from '@uppy/xhr-upload';
|
||||
import '@uppy/core/css/style.min.css';
|
||||
|
||||
import SpotIcon from '@components/spotIcon';
|
||||
import SpotButton from '@components/spotButton';
|
||||
|
||||
export default {
|
||||
name: 'upload',
|
||||
components: { SpotButton, SpotIcon },
|
||||
inject: ['api', 'lang', 'projects', 'consts', 'user'],
|
||||
data() {
|
||||
return {
|
||||
project: this.projects.getDefaultProject(),
|
||||
files: [],
|
||||
logs: [],
|
||||
progress: 0,
|
||||
uppy: null
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
if(!this.project.editable) {
|
||||
this.logs = [this.lang.get('upload.mode_archived', [this.project.name])];
|
||||
return;
|
||||
}
|
||||
|
||||
this.initUploader();
|
||||
},
|
||||
beforeUnmount() {
|
||||
if(this.uppy) {
|
||||
this.uppy.destroy();
|
||||
this.uppy = null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initUploader() {
|
||||
const endpoint = `${this.consts.process_page}?a=upload`;
|
||||
|
||||
this.uppy = markRaw(new Uppy({
|
||||
autoProceed: true,
|
||||
restrictions: {
|
||||
allowedFileTypes: ['.gif', '.jpg', '.jpeg', '.png', '.mov', '.mp4']
|
||||
}
|
||||
}));
|
||||
|
||||
this.uppy.setMeta({t: this.user.timezone});
|
||||
|
||||
this.uppy.use(XHRUpload, {
|
||||
endpoint,
|
||||
fieldName: 'files[]',
|
||||
formData: true,
|
||||
allowedMetaFields: ['t', 'name', 'type'],
|
||||
getResponseData(xhr) {
|
||||
return JSON.parse(xhr.responseText || '{}');
|
||||
}
|
||||
});
|
||||
|
||||
this.uppy.on('progress', (progress) => {
|
||||
this.progress = progress;
|
||||
});
|
||||
|
||||
this.uppy.on('upload-success', (file, response) => {
|
||||
const uploadedFiles = response?.body?.files || [];
|
||||
uploadedFiles.forEach((uploadedFile) => {
|
||||
const hasError = Object.prototype.hasOwnProperty.call(uploadedFile, 'error');
|
||||
this.logs.push(hasError ? uploadedFile.error : this.lang.get('upload.success', [uploadedFile.name]));
|
||||
if(!hasError) this.files.push({...uploadedFile, content: ''});
|
||||
});
|
||||
});
|
||||
|
||||
this.uppy.on('upload-error', (file, error, response) => {
|
||||
const message = response?.body?.error || error?.message || this.lang.get('error');
|
||||
this.logs.push(message);
|
||||
});
|
||||
|
||||
this.uppy.on('complete', () => {
|
||||
this.progress = 0;
|
||||
});
|
||||
},
|
||||
onFileChange(event) {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if(files.length > 0 && this.uppy) this.uppy.addFiles(files.map((file) => ({source: 'local', name: file.name, type: file.type, data: file})));
|
||||
event.target.value = '';
|
||||
},
|
||||
addComment(oFile) {
|
||||
this.api.get('add_comment', {
|
||||
id: oFile.id,
|
||||
content: oFile.content
|
||||
})
|
||||
.then((asData) => {this.logs.push(this.lang.get('media.comment_update', asData.filename));})
|
||||
.catch((sMsgId) => {this.logs.push(this.lang.get(sMsgId));});
|
||||
},
|
||||
addPosition() {
|
||||
if(navigator.geolocation) {
|
||||
this.logs.push('Determining position...');
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
this.logs.push('Sending position...');
|
||||
this.api.get('add_position', {
|
||||
'latitude': position.coords.latitude,
|
||||
'longitude': position.coords.longitude,
|
||||
'timestamp': Math.round(position.timestamp / 1000)
|
||||
})
|
||||
.then((asData) => {this.logs.push(this.lang.get('success'));})
|
||||
.catch((sMsgId) => {this.logs.push(this.lang.get(sMsgId));});
|
||||
},
|
||||
(error) => {
|
||||
this.logs.push(error.message);
|
||||
}
|
||||
);
|
||||
}
|
||||
else this.logs.push('This browser does not support geolocation');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div id="upload">
|
||||
<div class="section header">
|
||||
<a name="back" class="button" href="#project"><SpotIcon :icon="'back'" :text="lang.get('action.back')" /></a>
|
||||
<h1>{{ this.project.name }}</h1>
|
||||
</div>
|
||||
<div class="section" v-if="project.editable">
|
||||
<h2>{{ lang.get('upload.media.title') }}</h2>
|
||||
<input id="fileupload" type="file" name="files[]" multiple accept=".gif,.jpg,.jpeg,.png,.mov,.mp4" @change="onFileChange" />
|
||||
</div>
|
||||
<div class="section progress" v-if="progress > 0">
|
||||
<div class="total"></div>
|
||||
<div class="bar" :style="{width:progress+'%'}"></div>
|
||||
</div>
|
||||
<div class="section comment" v-for="file in files" :key="file.id">
|
||||
<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="lang.get('action.save')" @click="addComment(file)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="section location" v-if="project.editable">
|
||||
<h2>{{ lang.get('upload.position.title') }}</h2>
|
||||
<SpotButton :icon="'marker'" :text="lang.get('upload.position.new')" @click="addPosition()" />
|
||||
</div>
|
||||
<div class="section logs" v-if="logs.length > 0">
|
||||
<p class="log" v-for="log in logs">{{ log }}.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 821 B After Width: | Height: | Size: 821 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |