Compare commits

..
124 Commits
Author SHA1 Message Date
franzz 56d8f46434 Smoother touch panel sliding
Deploy Livetrail / deploy (push) Successful in 17s
2026-09-09 23:48:24 +02:00
franzz a70f6fe64c Remove lightbox legacy html tags
Deploy Livetrail / deploy (push) Successful in 17s
2026-09-09 17:46:38 +02:00
franzz dc4480c4e2 Flatten lightbox comment into a vue component
Deploy Livetrail / deploy (push) Successful in 18s
2026-09-09 17:27:19 +02:00
franzz e2a694ba03 Upgrade maplibre gl to 6.8
Deploy Livetrail / deploy (push) Successful in 27s
2026-09-09 14:20:24 +02:00
franzz 8efd74406f highlight quoted message
Deploy Livetrail / deploy (push) Successful in 19s
2026-09-06 14:32:05 +02:00
franzz 51837ef1ac Add reply-to function
Deploy Livetrail / deploy (push) Successful in 33s
2026-09-06 14:20:55 +02:00
franzz 842bb56fcc Fix track vertical alignment on popup
Deploy Livetrail / deploy (push) Successful in 29s
2026-09-03 14:43:10 +02:00
franzz 0acec1f37e Adding hover event on feed media posts
Deploy Livetrail / deploy (push) Successful in 18s
2026-09-01 12:33:35 +02:00
franzz 8ebfb2d830 Split multiline posts
Deploy Livetrail / deploy (push) Successful in 23s
2026-09-01 10:06:57 +02:00
franzz aa8f80451d adapt apache config for SSL
Deploy Livetrail / deploy (push) Successful in 26s
2026-08-31 11:30:17 +02:00
franzz f4e2781172 Fix composer objects version
Deploy Livetrail / deploy (push) Successful in 24s
2026-08-28 02:41:46 +02:00
franzz 506c8bd946 Upgrade eslint to v10 & uppy to v6
Deploy Livetrail / deploy (push) Failing after 11s
2026-08-27 21:20:16 +02:00
franzz 4ae0e46a7d Convert Map & Lightbox to vue components 2026-08-27 20:37:02 +02:00
franzz d29403d9c5 Fix redundant css :root rules 2026-08-27 19:54:01 +02:00
franzz da5a75a9de Fix geo constant scope 2026-08-27 12:53:58 +02:00
franzz 17009b641f Implement lint 2026-08-27 12:45:30 +02:00
franzz 171db88400 Update maplibre GL 2026-08-26 23:45:56 +02:00
franzz 2b32b8a683 Fix account inputs on mobiles 2026-08-26 16:27:19 +02:00
franzz bf8bfa1de2 Fix error 500 on feed update when feed ref ID is missing 2026-08-26 11:35:30 +02:00
franzz f1b90a4d83 Harmonize JSON API error messages 2026-08-25 23:44:26 +02:00
franzz 0d4b159ab3 Some minor bug fixes 2026-08-25 18:37:42 +02:00
franzz 46d4702471 Upgrade maplibre GL
Deploy Livetrail / deploy (push) Successful in 38s
2026-08-24 14:25:08 +02:00
franzz 73c5e45164 Fix feed post finder (scroll)
Deploy Livetrail / deploy (push) Successful in 18s
2026-08-20 00:09:12 +02:00
franzz 9242172a12 Harmonize status icons
Deploy Livetrail / deploy (push) Successful in 30s
2026-08-19 20:22:13 +02:00
franzz 2aac7ad5c4 Rename Spot to Livetrail internally
Deploy Livetrail / deploy (push) Successful in 21s
2026-08-18 15:55:55 +02:00
franzz ced5e87542 Ad loading screen on post find
Deploy Spot / deploy (push) Successful in 32s
2026-08-16 14:00:20 +02:00
franzz a09fb9792f Close session earlier if not required
Deploy Spot / deploy (push) Successful in 16s
2026-08-10 23:55:48 +02:00
franzz d6c897beeb Use direct call for geojson files
Deploy Spot / deploy (push) Successful in 16s
2026-08-10 23:14:21 +02:00
franzz dcd3e7339b Fix successful login message
Deploy Spot / deploy (push) Successful in 14s
2026-08-10 16:55:01 +02:00
franzz cb14d2a7c1 Remove SQL update script
Deploy Spot / deploy (push) Successful in 18s
2026-08-10 16:46:39 +02:00
franzz 462502cf9f Split authentication & newsletter subscription
Deploy Spot / deploy (push) Successful in 32s
2026-08-10 16:41:20 +02:00
franzz 3e3c3ee821 Map terrain
Deploy Spot / deploy (push) Successful in 27s
2026-07-29 00:04:20 +02:00
franzz 0ad238b508 Bump app version following rebranding
Deploy Spot / deploy (push) Successful in 14s
2026-07-26 15:42:42 +02:00
franzz 7da504ca6b Migrate Webpack to Vite
Deploy Spot / deploy (push) Successful in 22s
2026-07-26 15:33:07 +02:00
franzz fb500f3c34 Upgrade to map libre 6
Deploy Spot / deploy (push) Successful in 47s
2026-07-25 23:59:31 +02:00
franzz 9a8b95d237 Upgrade depencies
Deploy Spot / deploy (push) Successful in 50s
2026-07-22 20:09:35 +02:00
franzz 9acdd3cb13 Fix panel translate (X) animation
Deploy Spot / deploy (push) Successful in 46s
2026-07-03 20:29:44 +02:00
franzz dfb92a4d96 Use transition duration CSS variables in vue
Deploy Spot / deploy (push) Successful in 45s
2026-06-26 10:45:36 +02:00
franzz 085cfd8ba2 Make project marker scale as well on hover
Deploy Spot / deploy (push) Successful in 40s
2026-06-17 23:42:33 +02:00
franzz 14d827ab66 Harmonize transition durations
Deploy Spot / deploy (push) Successful in 37s
2026-06-15 23:51:52 +02:00
franzz aa30431df8 Fix PCT gpx namespace
Deploy Spot / deploy (push) Successful in 37s
2026-06-15 23:17:23 +02:00
franzz a127535b36 Harmonize track desc display
Deploy Spot / deploy (push) Successful in 43s
2026-06-15 01:25:59 +02:00
franzz aa17ea99a2 Add hitchhike to Mt Shasta
Deploy Spot / deploy (push) Successful in 34s
2026-06-12 01:03:28 +02:00
franzz ae24be2c22 Harmonize PCT track descriptions
Deploy Spot / deploy (push) Successful in 34s
2026-06-12 00:46:12 +02:00
franzz 0b9f886905 Fix pictures comment block size
Deploy Spot / deploy (push) Has been cancelled
2026-06-12 00:45:47 +02:00
franzz 7ecd8094e2 Fix back button -again-
Deploy Spot / deploy (push) Successful in 35s
2026-06-11 15:57:42 +02:00
franzz 9718713eb4 Remove my name from repo and fix some translations
Deploy Spot / deploy (push) Successful in 40s
2026-06-11 13:26:50 +02:00
franzz 36a5900118 Update dependencies
Deploy Spot / deploy (push) Successful in 35s
2026-06-10 14:51:44 +02:00
franzz 1eebfc90fa Fix center on pitched map
Deploy Spot / deploy (push) Successful in 41s
2026-06-09 23:42:38 +02:00
franzz 7b2962be15 Make control buttons bigger, add pitch
Deploy Spot / deploy (push) Successful in 38s
2026-06-08 23:15:17 +02:00
franzz c738fe8d50 Replace button width sass variable with a css one
Deploy Spot / deploy (push) Successful in 34s
2026-06-07 14:09:15 +02:00
franzz 76fdc4be43 Fix logo position on iOS
Deploy Spot / deploy (push) Successful in 38s
2026-06-07 13:22:52 +02:00
franzz ff3fac2ab9 Harmonize topo background
Deploy Spot / deploy (push) Successful in 34s
2026-06-06 16:59:36 +02:00
franzz 62f976b6f3 Harmonize topo background
Deploy Spot / deploy (push) Has been cancelled
2026-06-06 16:59:17 +02:00
franzz 00a06a1ca9 Remove GPX files link on mobile
Deploy Spot / deploy (push) Successful in 40s
2026-06-04 14:27:08 +02:00
franzz 6800256f09 CI/CD: Do not redeploy identical files and do not change timestamp
Deploy Spot / deploy (push) Successful in 35s
2026-06-02 16:16:28 +02:00
franzz 17b998ee60 CI/CD: Do not redeploy identical files
Deploy Spot / deploy (push) Successful in 35s
2026-06-02 16:12:37 +02:00
franzz 87a991eaea Fix back button
Deploy Spot / deploy (push) Successful in 36s
2026-06-02 11:22:28 +02:00
franzz 9ce25e73f0 Fix initial map positionning
Deploy Spot / deploy (push) Successful in 34s
2026-06-01 23:41:16 +02:00
franzz 6cad199431 Fix initial camera on marker
Deploy Spot / deploy (push) Successful in 34s
2026-06-01 09:51:25 +02:00
franzz 36f9057a30 Fix project order
Deploy Spot / deploy (push) Successful in 34s
2026-06-01 09:34:41 +02:00
franzz 05c77f30bd Add GR20 gpx
Deploy Spot / deploy (push) Successful in 35s
2026-05-31 23:18:22 +02:00
franzz 739c593d2a Fix missing gpx
Deploy Spot / deploy (push) Successful in 34s
2026-05-30 01:39:49 +02:00
franzz 034d02f042 Restructure project folders and remove obsolete files
Deploy Spot / deploy (push) Successful in 34s
2026-05-30 01:32:20 +02:00
franzz c2685a2731 Fix logo size on mobile
Deploy Spot / deploy (push) Successful in 34s
2026-05-28 20:13:53 +02:00
franzz 28c6f79fdb Manage image cache
Deploy Spot / deploy (push) Successful in 34s
2026-05-28 19:37:44 +02:00
franzz 77a1c51692 Bigger icons on phone
Deploy Spot / deploy (push) Successful in 34s
2026-05-28 19:19:53 +02:00
franzz 319c288586 Fix link to overview
Deploy Spot / deploy (push) Successful in 34s
2026-05-28 18:29:03 +02:00
franzz 980035e3d1 Reuse favicon for site logo
Deploy Spot / deploy (push) Successful in 39s
2026-05-28 17:43:52 +02:00
franzz 520df5b570 Fix duplicate calls on init()
Deploy Spot / deploy (push) Successful in 34s
2026-05-28 14:21:42 +02:00
franzz fdd0ada815 Implement CSRF
Deploy Spot / deploy (push) Successful in 34s
2026-05-28 13:22:44 +02:00
franzz 8092846d6f Compress favicon
Deploy Spot / deploy (push) Successful in 33s
2026-05-28 10:00:32 +02:00
franzz 7b58b65db3 Fix favicon canvas size
Deploy Spot / deploy (push) Successful in 37s
2026-05-28 09:58:11 +02:00
franzz d0c33c31a8 Update icons
Deploy Spot / deploy (push) Successful in 34s
2026-05-28 02:16:24 +02:00
franzz 313dab26a2 Fix dev composer and update cron script
Deploy Spot / deploy (push) Successful in 34s
2026-05-27 17:59:58 +02:00
franzz 7ead18601c Upgrade vue
Deploy Spot / deploy (push) Successful in 43s
2026-05-27 14:39:57 +02:00
franzz c80e8d1c67 Fix initial globe size
Deploy Spot / deploy (push) Successful in 35s
2026-05-26 18:51:32 +02:00
franzz d4bc73e32c Fix svg font
Deploy Spot / deploy (push) Successful in 34s
2026-05-26 18:23:48 +02:00
franzz 6ee4c8efc7 Fix feed panel opening on mobile
Deploy Spot / deploy (push) Successful in 33s
2026-05-26 17:33:18 +02:00
franzz badae8a3a0 Sort out panel CSS
Deploy Spot / deploy (push) Successful in 34s
2026-05-26 17:25:21 +02:00
franzz c783cbe543 remplace png logo with svg
Deploy Spot / deploy (push) Successful in 33s
2026-05-26 15:27:56 +02:00
franzz cf5ae33ba4 Add icons source materials
Deploy Spot / deploy (push) Successful in 39s
2026-05-26 09:49:58 +02:00
franzz a3d217bbdd Fix credits
Deploy Spot / deploy (push) Successful in 34s
2026-05-26 00:24:05 +02:00
franzz 7cad5fbdf9 New identity
Deploy Spot / deploy (push) Successful in 38s
2026-05-25 22:01:40 +02:00
franzz fe8a8034ca Use OS default font
Deploy Spot / deploy (push) Successful in 38s
2026-05-24 18:53:25 +02:00
franzz 138ce6ec8b Add composer license
Deploy Spot / deploy (push) Successful in 34s
2026-05-23 21:16:30 +02:00
franzz 690fd6d831 Remove html loader
Deploy Spot / deploy (push) Successful in 38s
2026-05-23 21:07:07 +02:00
franzz 24fd224ec6 Temporary fix for duplicate base maps
Deploy Spot / deploy (push) Successful in 34s
2026-05-23 00:35:23 +02:00
franzz c9ce785f12 Upgrade composer dependencies
Deploy Spot / deploy (push) Successful in 33s
2026-05-23 00:22:08 +02:00
franzz e0fc62df84 Split js chunks
Deploy Spot / deploy (push) Successful in 34s
2026-05-23 00:12:15 +02:00
franzz 8a590aa2fc Separate project / feed / settings
Deploy Spot / deploy (push) Successful in 49s
2026-05-22 22:17:04 +02:00
franzz 3fd68fa938 Add swipe effect on feed panel
Deploy Spot / deploy (push) Successful in 38s
2026-05-20 22:56:00 +02:00
franzz 3ba7b2bfab Fix initial world size on mobile
Deploy Spot / deploy (push) Successful in 38s
2026-05-20 17:16:17 +02:00
franzz b44d2960f7 Upgrade sass loader to 17
Deploy Spot / deploy (push) Successful in 39s
2026-05-20 16:38:32 +02:00
franzz c5529d5f94 Refactor map creation / destruction
Deploy Spot / deploy (push) Successful in 41s
2026-05-20 15:14:06 +02:00
franzz f63f5c240e Fix scrolling + hovering effect on feed posts
Deploy Spot / deploy (push) Successful in 40s
2026-05-20 09:42:03 +02:00
franzz 0bb7ae2361 Add media comment on popup
Deploy Spot / deploy (push) Successful in 38s
2026-05-20 00:12:24 +02:00
franzz 7f74263ba2 Fix google maps links
Deploy Spot / deploy (push) Successful in 38s
2026-05-19 21:44:03 +02:00
franzz c43539b640 Add project popup
Deploy Spot / deploy (push) Successful in 38s
2026-05-19 17:49:06 +02:00
franzz 93a72c628e pre select "overview" radio button by default
Deploy Spot / deploy (push) Successful in 37s
2026-05-19 14:44:40 +02:00
franzz 39ddd1cf95 Rename deployment
Deploy Spot / deploy (push) Successful in 44s
2026-05-19 14:02:20 +02:00
franzz 6f11c827c6 Fix symlink issues
Deploy / deploy (push) Successful in 39s
2026-05-19 01:06:29 +02:00
franzz b5de606a3e Another deploy test
Deploy / deploy (push) Successful in 50s
2026-05-19 00:49:51 +02:00
franzz 837c4a327b Fix tmp repositories
Deploy / deploy (push) Failing after 0s
2026-05-19 00:43:45 +02:00
franzz 5b365f1eab Fix branch name
Deploy / deploy (push) Failing after 0s
2026-05-19 00:40:20 +02:00
franzz dfa4f3239c codex file is not generated anymore 2026-05-19 00:38:29 +02:00
franzz 1c69ae56ac Add deployment yaml 2026-05-19 00:35:03 +02:00
franzz 9adfa18e9b Overview radio button 2026-05-14 00:08:35 +02:00
franzz a2e7b235fe Force projet if on blog mode 2026-05-13 23:55:26 +02:00
franzz 49f37465bd Pick project from globe 2026-05-13 23:28:36 +02:00
franzz c3835f45c5 Remove hard-coded colors in vue 2026-05-13 13:23:10 +02:00
franzz 17fe2330c6 Fix globe center 2026-05-13 12:49:40 +02:00
franzz b88fb4ca9d Fix isMobile trigger and add more details on media 2026-05-13 10:41:01 +02:00
franzz daca0a8294 Fix scale display on mobile 2026-05-13 10:11:46 +02:00
franzz e80e3ff3f3 Local apache conf 2026-05-13 09:44:33 +02:00
franzz 8e17db7a2e Globe earth 2026-05-12 15:25:27 +02:00
franzz 238001ae93 Add scale 2026-05-12 15:10:05 +02:00
franzz b7956766e8 chmod changes 2026-05-12 11:08:21 +02:00
franzz ca1183d88a Avoid click focus on mobile 2026-05-10 22:59:49 +02:00
franzz 4c34994ac7 Fix isMobile detection 2026-05-10 20:03:56 +02:00
franzz 8385c85820 Change webpack destination 2026-05-10 19:03:42 +02:00
franzz 71e9c1a45a More merge conflict 2026-05-10 16:06:09 +02:00
franzz 880bbc3d9a Upgrade php dependencies 2026-05-10 15:47:27 +02:00
franzz e293193dd7 Fix conflicting files 2026-05-10 15:45:25 +02:00
152 changed files with 390050 additions and 170352 deletions
+60
View File
@@ -0,0 +1,60 @@
name: Deploy Livetrail
on:
push:
branches:
- master
jobs:
deploy:
runs-on: livetrail
env:
COMPOSER_NO_INTERACTION: "1"
COMPOSER_HOME: .composer
DEPLOY_PATH: /var/www/livetrail
npm_config_cache: .npm-cache
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check runner tools
run: |
command -v composer
command -v npm
command -v rsync
- name: Check deploy path
run: |
test -d "$DEPLOY_PATH"
test -w "$DEPLOY_PATH"
- name: Validate Composer configuration
run: composer validate --no-check-publish
- name: Install PHP dependencies
run: composer install --no-dev --prefer-dist --optimize-autoloader
- name: Install npm dependencies
run: npm ci
- name: Prepare runtime missing mount points
run: mkdir -p files resources/geo
- name: Build frontend
run: npm run prod
- name: Deploy to production
run: |
rsync -azc --no-times --delete \
--exclude "/.git/" \
--exclude "/.gitea/" \
--exclude "/.composer/" \
--exclude "/.npm-cache/" \
--exclude "/node_modules/" \
--exclude "/config/settings.php" \
--exclude "/log.html" \
--exclude "/files/" \
--exclude "/resources/geo/*.geojson" \
./ "$DEPLOY_PATH/"
+17 -6
View File
@@ -1,8 +1,19 @@
/vendor/
# App config files
/config/settings.php
/files/
/geo/
/node_modules/
/log.html
/dist/
.codex
# Upload folders
/files/
/resources/geo/*.geojson
# Build folder
/public/*
!/public/index.php
# Dependencies files
/vendor/
/node_modules/
/composer.dev.lock
# Lint caches
/.php-cs-fixer.cache
+53
View File
@@ -0,0 +1,53 @@
<?php
/*
* PHP-CS-Fixer config codifying the style already in use under lib/ and
* public/. Deliberately NOT based on @PSR2/@PSR12 - this codebase diverges
* from those on purpose (tabs, no space before control-structure parens),
* so pulling in a preset would rewrite most of the codebase to match a
* style nobody uses here. Rules below were derived by sampling lib/*.php,
* not from a style guide.
*/
$finder = (new PhpCsFixer\Finder())
->in([__DIR__.'/lib', __DIR__.'/public'])
->append([__DIR__.'/config/settings-sample.php'])
->name('*.php');
return (new PhpCsFixer\Config())
->setIndent("\t")
->setLineEnding("\n")
->setRules([
//Strings: single quotes except where interpolation needs double
'single_quote' => true,
//Arrays: short [] syntax, not long-form array()
'array_syntax' => ['syntax' => 'short'],
//Braces: same line as class/function signature (dominant in lib/,
//not the PSR-2 next-line-for-class convention)
'braces_position' => [
'classes_opening_brace' => 'same_line',
'functions_opening_brace' => 'same_line',
'anonymous_functions_opening_brace' => 'same_line'
],
//Concatenation: no padding around `.`
'concat_space' => ['spacing' => 'none'],
//true/false/null: lowercase (100% consistent already)
'constant_case' => ['case' => 'lower'],
'lowercase_keywords' => true,
'visibility_required' => ['elements' => ['method', 'property', 'const']],
//Housekeeping - safe regardless of style
'no_unused_imports' => true,
'no_trailing_whitespace' => true,
'no_trailing_whitespace_in_comment' => true,
'single_blank_line_at_eof' => true,
'no_empty_statement' => true,
'trim_array_spaces' => true,
'new_with_parentheses' => true
])
->setFinder($finder);
-104
View File
@@ -1,104 +0,0 @@
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')
}
}
};
};
Regular → Executable
+3 -2
View File
@@ -1,4 +1,5 @@
#!/bin/bash
wget -qO- https://spot.lutran.fr/index.php?a=update_project > /dev/null
cd "$(dirname "$0")/../public" || exit 1 #Execute from public folder
php -f index.php a=update_project > /dev/null
#Crontab job: 0 * * * * . /var/www/spot/spot_cron.sh > /dev/null
#Crontab job: 0 * * * * /path/to/livetrail/cli/cron.sh > /dev/null
+36
View File
@@ -0,0 +1,36 @@
{
"name": "franzz/livetrail",
"description": "LiveTrail",
"type": "project",
"license": "GPL-3.0-or-later",
"repositories": [
{
"type": "path",
"url": "../objects",
"options": {
"symlink": true
}
}
],
"require": {
"php": ">=8.5",
"franzz/objects": "dev-vue",
"phpmailer/phpmailer": "^7.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.75"
},
"autoload": {
"psr-4": {
"Franzz\\Livetrail\\": "lib/",
"Franzz\\Objects\\": "../objects/inc/"
},
"files": [
"config/settings.php"
]
},
"scripts": {
"lint": "php-cs-fixer fix --dry-run --diff",
"lint-fix": "php-cs-fixer fix"
}
}
+15 -6
View File
@@ -1,23 +1,32 @@
{
"name": "franzz/spot",
"description": "Spotty",
"name": "franzz/livetrail",
"description": "LiveTrail",
"type": "project",
"license": "GPL-3.0-or-later",
"repositories": [
{
"type": "path",
"url": "../objects"
"type": "git",
"url": "https://git.lutran.fr/franzz/objects"
}
],
"require": {
"php": ">=8.5",
"franzz/objects": "dev-vue",
"phpmailer/phpmailer": "^6.5"
"phpmailer/phpmailer": "^7.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.75"
},
"autoload": {
"psr-4": {
"Franzz\\Spot\\": "lib/"
"Franzz\\Livetrail\\": "lib/"
},
"files": [
"config/settings.php"
]
},
"scripts": {
"lint": "php-cs-fixer fix --dry-run --diff",
"lint-fix": "php-cs-fixer fix"
}
}
Generated
+2858 -20
View File
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
<VirtualHost *:80>
ServerName localhost
ServerAlias maui.local
DocumentRoot /var/www/html/
RewriteEngine on
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [END,NE,R=permanent]
</VirtualHost>
<IfModule mod_ssl.c>
<VirtualHost *:443>
ServerName localhost
ServerAlias maui.local
DocumentRoot /var/www/html/
SSLCertificateFile /etc/letsencrypt/live/mushu.nest.lutran.ch/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/mushu.nest.lutran.ch/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
DirectoryIndex index.php
# Serve https://localhost/livetrail/ from the public web root.
Alias /livetrail /var/www/html/livetrail/public
<Directory /var/www/html/livetrail/public>
Options FollowSymLinks
AllowOverride None
Require all granted
AddType application/geo+json .geojson
<IfModule mod_headers.c>
<FilesMatch "\.geojson$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
<IfModule mod_brotli.c>
AddOutputFilterByType BROTLI_COMPRESS \
text/html \
text/plain \
text/css \
text/javascript \
text/xml \
application/javascript \
application/json \
application/geo+json \
application/manifest+json \
application/xml \
image/svg+xml
</IfModule>
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE \
text/html \
text/plain \
text/css \
text/javascript \
text/xml \
application/javascript \
application/json \
application/geo+json \
application/manifest+json \
application/xml \
image/svg+xml
</IfModule>
</Directory>
</VirtualHost>
</IfModule>
-38
View File
@@ -1,38 +0,0 @@
CREATE TABLE `maps` (
`id_map` int(10) UNSIGNED auto_increment,
`codename` VARCHAR(100),
`geo_name` VARCHAR(100),
`min_zoom` TINYINT UNSIGNED,
`max_zoom` TINYINT UNSIGNED,
`attribution` VARCHAR(100),
`led` TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id_map`));
CREATE TABLE `mappings` (
`id_mapping` int(10) UNSIGNED auto_increment,
`id_map` int(10) UNSIGNED,
`id_project` int(10) UNSIGNED,
`led` TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id_mapping`));
ALTER TABLE mappings ADD INDEX(`id_map`);
ALTER TABLE mappings ADD FOREIGN KEY (`id_map`) REFERENCES maps(`id_map`);
ALTER TABLE mappings ADD INDEX(`id_project`);
ALTER TABLE mappings ADD FOREIGN KEY (`id_project`) REFERENCES projects(`id_project`);
INSERT INTO maps(codename, geo_name, min_zoom, max_zoom, attribution) VALUES
/*1*/('satellite', 'mapbox.satellite-streets', 0, 19, ''),
/*2*/('otm', 'opentopomap', 2, 19, ''),
/*3*/('ign_france', 'ign.fr', 2, 19, ''),
/*4*/('ign_spain', 'ign.es', 1, 20, ''),
/*5*/('linz', 'linz', 0, 17, 'Sourced from LINZ. CC BY 4.0'),
/*6*/('usgs', 'usgs', 1, 16, ''),
/*7*/('natgeo', 'natgeo.pct', 5, 14, '');
INSERT INTO mappings(id_map, id_project) VALUES
(1, NULL),
(2, NULL),
(3, 2),
(4, 2),
(5, 1),
(6, 3);
-1
View File
@@ -1 +0,0 @@
ALTER TABLE users ADD gravatar LONGTEXT AFTER email;
-50
View File
@@ -1,50 +0,0 @@
ALTER TABLE medias ADD timezone CHAR(64) AFTER posted_on;
ALTER TABLE messages ADD timezone CHAR(64) AFTER site_time;
ALTER TABLE posts ADD timezone CHAR(64) AFTER site_time;
UPDATE messages
SET iso_time = DATE_FORMAT(CONVERT_TZ(LEFT(iso_time, 19),'+00:00','+02:00'), '%Y-%m-%dT%T+0200'),
led = led
WHERE id_feed = 2;
UPDATE messages
INNER JOIN feeds ON feeds.id_feed = messages.id_feed
INNER JOIN projects ON projects.id_project = feeds.id_project
SET messages.timezone = projects.timezone,
messages.led = messages.led;
UPDATE posts
SET timezone = 'Europe/Paris',
led = led;
UPDATE posts
INNER JOIN projects ON projects.id_project = posts.id_project
SET posts.id_user = 1,
posts.timezone = projects.timezone,
posts.led = posts.led
WHERE posts.name IN ('francois', 'françois','Francois', 'François', 'franzz');
UPDATE posts
SET timezone = 'Pacific/Auckland',
led = led
WHERE name = 'nz';
UPDATE posts
SET timezone = 'Atlantic/Madeira',
led = led
WHERE id_post IN (141, 142);
UPDATE medias
INNER JOIN projects ON projects.id_project = medias.id_project
SET medias.timezone = projects.timezone,
medias.led = medias.led;
UPDATE medias
SET timezone = 'Atlantic/Madeira',
taken_on = posted_on,
led = led
WHERE id_media IN (64, 65);
ALTER TABLE projects DROP COLUMN timezone;
UPDATE maps SET attribution = 'OpenTopoMap (CC-BY-SA)' WHERE id_map = 2;
-2
View File
@@ -1,2 +0,0 @@
ALTER TABLE users ADD clearance TINYINT(1) DEFAULT 0 AFTER active;
UPDATE users SET clearance = 9 WHERE email = 'francois.lutran@gmail.com';
-2
View File
@@ -1,2 +0,0 @@
ALTER TABLE messages ADD posted_on TIMESTAMP DEFAULT 0 AFTER battery_state;
UPDATE messages SET posted_on = led, led = led;
-3
View File
@@ -1,3 +0,0 @@
ALTER TABLE messages ADD weather_icon VARCHAR(30) AFTER posted_on;
ALTER TABLE messages ADD weather_cond VARCHAR(30) AFTER weather_icon;
ALTER TABLE messages ADD weather_temp DECIMAL(3,1) AFTER weather_cond;
-4
View File
@@ -1,4 +0,0 @@
INSERT INTO maps (codename, geo_name, min_zoom, max_zoom, attribution) VALUES ('outdoors', 'mapbox.outdoors', 0, 19, '');
ALTER TABLE maps ADD COLUMN tile_size SMALLINT UNSIGNED DEFAULT 256 AFTER geo_name;
UPDATE maps SET tile_size = 512 WHERE geo_name = 'mapbox.outdoors';
UPDATE maps SET tile_size = 512 WHERE geo_name = 'mapbox.satellite-streets';
-15
View File
@@ -1,15 +0,0 @@
ALTER TABLE maps ADD pattern VARCHAR(200) NOT NULL AFTER geo_name;
UPDATE maps SET pattern = CONCAT('http://localhost/geo/?a=tile&id=', geo_name, '&z={z}&x={x}&y={y}') WHERE geo_name <> '';
ALTER TABLE maps ADD token VARCHAR(4096) AFTER pattern;
UPDATE maps SET token = '';
ALTER TABLE maps DROP geo_name;
INSERT INTO maps (codename, pattern, token, tile_size, min_zoom, max_zoom, attribution)
VALUES ('static', 'http://localhost/geo/?a=tile&id=static&z=13&x={x}&y={y}', '', 400, 13, 13, '');
INSERT INTO maps (codename, pattern, token, tile_size, min_zoom, max_zoom, attribution)
VALUES ('static_marker', 'http://localhost/geo/?a=tile&id=static.marker&z=13&x={x}&y={y}&marker=http://localhost/spot/images/footprint_mapbox.png', '', 400, 13, 13, '');
UPDATE maps SET max_zoom = 17 WHERE codename = 'otm';
-233
View File
@@ -1,233 +0,0 @@
ALTER TABLE medias ADD width INT AFTER timezone;
ALTER TABLE medias ADD height INT AFTER width;
UPDATE medias SET width = 3264, height = 2448 WHERE filename = 'image (1).jpg';
UPDATE medias SET width = 2448, height = 3264 WHERE filename = 'image (10).jpg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'image (11).jpg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'image (12).jpg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'image (13).jpg';
UPDATE medias SET width = 3648, height = 5472 WHERE filename = 'image (14).jpg';
UPDATE medias SET width = 3648, height = 5472 WHERE filename = 'image (15).jpg';
UPDATE medias SET width = 2448, height = 3264 WHERE filename = 'image (16).jpg';
UPDATE medias SET width = 2448, height = 3264 WHERE filename = 'image (17).jpg';
UPDATE medias SET width = 2448, height = 3264 WHERE filename = 'image (18).jpg';
UPDATE medias SET width = 2448, height = 3264 WHERE filename = 'image (19).jpg';
UPDATE medias SET width = 3264, height = 2448 WHERE filename = 'image (2).jpg';
UPDATE medias SET width = 3648, height = 5472 WHERE filename = 'image (20).jpg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'image (21).jpg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'image (22).jpg';
UPDATE medias SET width = 2448, height = 3264 WHERE filename = 'image (23).jpg';
UPDATE medias SET width = 3264, height = 2448 WHERE filename = 'image (24).jpg';
UPDATE medias SET width = 640, height = 1136 WHERE filename = 'image (25).jpg';
UPDATE medias SET width = 3264, height = 2448 WHERE filename = 'image (26).jpg';
UPDATE medias SET width = 2448, height = 3264 WHERE filename = 'image (27).jpg';
UPDATE medias SET width = 3264, height = 2448 WHERE filename = 'image (28).jpg';
UPDATE medias SET width = 960, height = 1280 WHERE filename = 'image (29).jpg';
UPDATE medias SET width = 2448, height = 3264 WHERE filename = 'image (3).jpg';
UPDATE medias SET width = 960, height = 1280 WHERE filename = 'image (30).jpg';
UPDATE medias SET width = 2448, height = 3264 WHERE filename = 'image (31).jpg';
UPDATE medias SET width = 3264, height = 2448 WHERE filename = 'image (32).jpg';
UPDATE medias SET width = 3264, height = 2448 WHERE filename = 'image (33).jpg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'image (34).jpg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'image (35).jpg';
UPDATE medias SET width = 3648, height = 5472 WHERE filename = 'image (36).jpg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'image (37).jpg';
UPDATE medias SET width = 3648, height = 5472 WHERE filename = 'image (38).jpg';
UPDATE medias SET width = 2048, height = 1365 WHERE filename = 'image (39).jpg';
UPDATE medias SET width = 1280, height = 960 WHERE filename = 'image (4).jpg';
UPDATE medias SET width = 3264, height = 2448 WHERE filename = 'image (40).jpg';
UPDATE medias SET width = 8192, height = 1856 WHERE filename = 'image (41).jpg';
UPDATE medias SET width = 3648, height = 5472 WHERE filename = 'image (42).jpg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'image (43).jpg';
UPDATE medias SET width = 8192, height = 1856 WHERE filename = 'image (44).jpg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'image (45).jpg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'image (46).jpg';
UPDATE medias SET width = 640, height = 1038 WHERE filename = 'image (47).jpg';
UPDATE medias SET width = 3648, height = 5472 WHERE filename = 'image (48).jpg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'image (49).jpg';
UPDATE medias SET width = 3264, height = 2448 WHERE filename = 'image (5).jpg';
UPDATE medias SET width = 3648, height = 5472 WHERE filename = 'image (50).jpg';
UPDATE medias SET width = 3264, height = 2448 WHERE filename = 'image (51).jpg';
UPDATE medias SET width = 3648, height = 5472 WHERE filename = 'image (52).jpg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'image (53).jpg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'image (54).jpg';
UPDATE medias SET width = 3648, height = 5472 WHERE filename = 'image (55).jpg';
UPDATE medias SET width = 3648, height = 5472 WHERE filename = 'image (56).jpg';
UPDATE medias SET width = 3264, height = 2448 WHERE filename = 'image (57).jpg';
UPDATE medias SET width = 2448, height = 3264 WHERE filename = 'image (58).jpg';
UPDATE medias SET width = 3264, height = 2448 WHERE filename = 'image (59).jpg';
UPDATE medias SET width = 2448, height = 3264 WHERE filename = 'image (6).jpg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'image (60).jpg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'image (61).jpg';
UPDATE medias SET width = 3264, height = 2448 WHERE filename = 'image (62).jpg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'image (63).jpg';
UPDATE medias SET width = 3264, height = 2448 WHERE filename = 'image (7).jpg';
UPDATE medias SET width = 2448, height = 3264 WHERE filename = 'image (8).jpg';
UPDATE medias SET width = 2448, height = 3264 WHERE filename = 'image (9).jpg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'DSC01187[1].JPG';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'DSC01477.JPG';
UPDATE medias SET width = 2726, height = 4089 WHERE filename = 'DSC03114.jpg';
UPDATE medias SET width = 1920, height = 1080 WHERE filename = 'IMG_6011.MOV';
UPDATE medias SET width = 1920, height = 1080 WHERE filename = '156E315D-88AD-492C-BE97-9854FED48FF7.MOV';
UPDATE medias SET width = 2576, height = 1932 WHERE filename = '4D06AAF9-A244-4AEF-A1B0-A3C439673840.jpeg';
UPDATE medias SET width = 1280, height = 720 WHERE filename = '06361CBA-F514-4789-A498-47D681881DDF.MOV';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'B0A15DCC-B5BB-4BE3-ADBA-CBCB51C178F6.jpeg';
UPDATE medias SET width = 1280, height = 720 WHERE filename = 'F827F48B-5CC4-4DA7-A80B-FFED47F6EC60.MOV';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'EC407D2D-0ECF-46C7-BC05-73F48D152182.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = '7961DA2D-288D-4B0B-B4A6-400673E23BA6.jpeg';
UPDATE medias SET width = 1920, height = 1080 WHERE filename = 'B43B0160-DAB4-433A-9064-918C40744D27.MOV';
UPDATE medias SET width = 1536, height = 2134 WHERE filename = 'C0D5CCF6-FE72-424C-A040-96D8C34FBE9E.jpeg';
UPDATE medias SET width = 2154, height = 1850 WHERE filename = '0940EF97-4C27-4304-A663-654F0DE5FAB2.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = '7597DEC0-1BD3-4B93-926B-E4D8A4B28E61.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = '3A1F777D-58F6-40BA-8AAE-1EA236581BA3.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'C6AC86F9-C819-4866-AA71-A31375E4CCC4.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'B936E022-4D70-4C9E-9EAE-308FB6F91816.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'C777A1C7-7C3A-4ADC-8A5C-CEE8FB047B79.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'B76D8452-FCC4-4FCE-8686-2A4B8C7832FF.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = '313F8490-E885-42F5-BB59-ACFB12250F0F.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'F0C1EFDB-0A4F-4FC0-A46D-A396D8724054.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'E9339EEF-A21C-48A6-B6CC-23C3A512DA29.jpeg';
UPDATE medias SET width = 1280, height = 720 WHERE filename = '5150FFEF-A715-4F5A-8562-1502BE778B6A.MOV';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'C8CD8B78-3EC9-4700-9A46-478556496239.jpeg';
UPDATE medias SET width = 1536, height = 2048 WHERE filename = '3D83F7D3-B746-440F-8DD3-6DADF230AC28.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'B9121A78-0B21-42E2-8392-A60BCE240EFE.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = '5A3755D0-81CB-43B1-9750-AB34F413D526.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'B9EFBC4F-4FC4-4D03-9722-9A1FF61F3B8E.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'F7C6FFFF-BF61-42BF-949B-72CD2CAAEFE2.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'B0557309-579D-4C1A-863F-CF4898C42E77.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = '2C75F8E9-C3E0-4FD5-AF2E-CE339545D6BC.jpeg';
UPDATE medias SET width = 3891, height = 2917 WHERE filename = 'C6E18BC2-B407-4F2C-82F9-87244A9AD311.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = '0A4B5812-D5CD-4CB0-989B-C95D15526F1A.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = '1548CCA0-C8C8-4DC6-BD96-ABAA4589E825.jpeg';
UPDATE medias SET width = 4685, height = 3116 WHERE filename = '7F80807F-D40F-4714-81AE-3CD7CD3FE8CD.jpeg';
UPDATE medias SET width = 5283, height = 3519 WHERE filename = '32F5B460-958D-4A13-92BD-371BBC0DA769.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'F99EEC87-C5CC-49AD-BCBF-8A14ADADD262.jpeg';
UPDATE medias SET width = 8192, height = 1856 WHERE filename = 'C243E61F-2A31-4179-97B4-AD54C02E88D5.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = '85807A81-91CB-4B10-861B-1B3C5A60F066.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'DE99B389-738F-48CB-BEF1-D448DEB16E7E.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = '6BBD7291-BDFC-460F-A620-9D2F68AF637F.jpeg';
UPDATE medias SET width = 1280, height = 720 WHERE filename = '7DB6662E-353A-4AC5-9331-D9122D956FE7.MOV';
UPDATE medias SET width = 1920, height = 1080 WHERE filename = '974841E5-AB13-4067-AD5F-3EDEFEAE28E4.MOV';
UPDATE medias SET width = 1536, height = 2304 WHERE filename = '09F007C7-53BA-4214-B3AF-DB0E0CDD1994.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = '6B92D63D-EDA5-4787-8AC7-4A0F65DC071F.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'BAF3BB55-E012-40CD-8D8B-27C4398C1D00.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = '8FA2F4F0-F220-4E98-9BC0-ACB607FC2F8E.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'C260B1AD-BA4C-4835-B132-A529A9319D4D.jpeg';
UPDATE medias SET width = 1536, height = 2304 WHERE filename = '92F8E35B-5FF8-414D-A627-46FF45E6CCCA.jpeg';
UPDATE medias SET width = 1280, height = 720 WHERE filename = '33AA17D3-CFD6-426A-B2D5-CD639F53C081.MOV';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = '0015713D-18A2-45A7-B26F-3954AC0979D3.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = '223A23C5-71BB-4419-8765-CE51F3E69480.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = '9ED663DD-0257-4211-A993-C86CDAC9066B.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = '1ECF5050-8821-441C-8DF7-E93B24941F8A.jpeg';
UPDATE medias SET width = 1536, height = 2304 WHERE filename = '68211D98-D6E7-4D7C-BB82-7E5E30884C7D.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = '61747CFA-859E-4859-852F-3AE2650C7578.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'F17CD761-DFDC-4E5F-97A7-C16035A8D8EB.jpeg';
UPDATE medias SET width = 1920, height = 1080 WHERE filename = '7EE0B25A-0BC6-4BF1-9CDF-9E4547CFEAF4.MOV';
UPDATE medias SET width = 3503, height = 2625 WHERE filename = '273092DD-F6CE-4C74-8878-A4D94A9C78F9.jpeg';
UPDATE medias SET width = 1280, height = 720 WHERE filename = '4853B2CB-5BC4-45FE-AD02-0980953DB59A.MOV';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = '789210A7-293D-4119-87EE-1B7CA0ACBE0A.jpeg';
UPDATE medias SET width = 2665, height = 1996 WHERE filename = '52D57195-22A2-423B-AA35-05D48B889950.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'F5644C0F-5244-4574-BBE1-4059794F87E1.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = '7BB3D57A-D5BE-45D5-8CAC-C3172A89C67A.jpeg';
UPDATE medias SET width = 2687, height = 2013 WHERE filename = '8345C480-1274-45DB-A990-40913DD219A7.jpeg';
UPDATE medias SET width = 4128, height = 3096 WHERE filename = '28567B3E-A067-41AE-AE69-37DD3C98F820.jpeg';
UPDATE medias SET width = 3096, height = 4128 WHERE filename = '20190827_133201_DxO.jpg';
UPDATE medias SET width = 3096, height = 4128 WHERE filename = '20190827_133201_DxO (1).jpg';
UPDATE medias SET width = 3096, height = 4128 WHERE filename = '20190827_133201_DxO (2).jpg';
UPDATE medias SET width = 3096, height = 4128 WHERE filename = '20190827_133201_DxO (3).jpg';
UPDATE medias SET width = 2000, height = 3000 WHERE filename = 'Untitled.png';
UPDATE medias SET width = 2000, height = 3000 WHERE filename = 'Untitled (1).png';
UPDATE medias SET width = 2000, height = 3000 WHERE filename = 'Untitled (2).png';
UPDATE medias SET width = 2000, height = 3000 WHERE filename = 'Untitled (3).png';
UPDATE medias SET width = 2000, height = 3000 WHERE filename = 'Untitled (4).png';
UPDATE medias SET width = 2000, height = 3000 WHERE filename = 'Untitled (5).png';
UPDATE medias SET width = 2000, height = 3000 WHERE filename = 'Untitled (6).png';
UPDATE medias SET width = 2000, height = 3000 WHERE filename = 'Untitled (7).png';
UPDATE medias SET width = 2000, height = 3000 WHERE filename = 'Untitled (8).png';
UPDATE medias SET width = 365, height = 600 WHERE filename = 'asunabg.png';
UPDATE medias SET width = 365, height = 600 WHERE filename = 'asunabg (1).png';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'iPhone 6S (1).jpeg';
UPDATE medias SET width = 3024, height = 4032 WHERE filename = 'IMG_2591.JPG';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'CF0432E7-5221-433D-A681-DBABE131EE99.jpeg';
UPDATE medias SET width = 694, height = 628 WHERE filename = 'Capture.PNG';
UPDATE medias SET width = 3024, height = 4032 WHERE filename = 'IMG_2232 (1).jpg';
UPDATE medias SET width = 1350, height = 900 WHERE filename = 'Photo 2014-06-21 20-17-11 (_MG_1217).jpg';
UPDATE medias SET width = 2000, height = 1500 WHERE filename = 'Photo 2015-01-11 20-30-03 (P1020945) (9).jpg';
UPDATE medias SET width = 1154, height = 770 WHERE filename = 'Photo 2014-06-21 20-17-11 (_MG_1217)_DxO (3).jpg';
UPDATE medias SET width = 3024, height = 4032 WHERE filename = 'IMG_2232 (2).jpg';
UPDATE medias SET width = 2320, height = 3088 WHERE filename = '9C0965E1-6A40-4691-8127-35B2F1D6BB58.jpeg';
UPDATE medias SET width = 3024, height = 4032 WHERE filename = 'IMG_3077.jpg';
UPDATE medias SET width = 3024, height = 4032 WHERE filename = 'AF7A1DBC-E1CC-4FEE-8411-AC25BF69C0B5.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'F7FC1246-6304-4662-A5F2-230A29728EBC.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'CA5BA9A6-2A53-430A-A0BF-B8D0C0CDC983.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = '12E87114-6B4B-4051-945C-9042FBBF5E3E.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'F97B5058-C7D7-4B25-B727-914A8897CE58.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'D1E626E6-6F49-4F62-A3D5-B19CBC906F7B.jpeg';
UPDATE medias SET width = 3024, height = 4032 WHERE filename = 'IMG_1228_DxO.jpg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'IMG_1268_DxO.jpg';
UPDATE medias SET width = 3024, height = 4032 WHERE filename = 'IMG_2232.jpg';
UPDATE medias SET width = 2475, height = 2475 WHERE filename = 'IMG_2232_DxO (1).jpg';
UPDATE medias SET width = 2475, height = 2475 WHERE filename = 'IMG_2232_DxO.jpg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'IMG_2317.jpg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'IMG_2574 (1).jpg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'IMG_2574 (2).jpg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'IMG_2574.jpg';
UPDATE medias SET width = 1344, height = 672 WHERE filename = 'PHOTO-2019-12-24-23-40-26.jpg';
UPDATE medias SET width = 1512, height = 2688 WHERE filename = 'PXL_20210405_083835976.jpg';
UPDATE medias SET width = 3840, height = 2160 WHERE filename = 'PXL_20210405_091516836.jpg';
UPDATE medias SET width = 1350, height = 900 WHERE filename = 'Photo 2014-06-21 20-17-11 (_MG_1217)_DxO (1).jpg';
UPDATE medias SET width = 1350, height = 900 WHERE filename = 'Photo 2014-06-21 20-17-11 (_MG_1217)_DxO (2).jpg';
UPDATE medias SET width = 1350, height = 900 WHERE filename = 'Photo 2014-06-21 20-17-11 (_MG_1217)_DxO.jpg';
UPDATE medias SET width = 2000, height = 1500 WHERE filename = 'Photo 2015-01-11 20-30-03 (P1020945) (1).jpg';
UPDATE medias SET width = 2000, height = 1500 WHERE filename = 'Photo 2015-01-11 20-30-03 (P1020945) (2).jpg';
UPDATE medias SET width = 2000, height = 1500 WHERE filename = 'Photo 2015-01-11 20-30-03 (P1020945) (3).jpg';
UPDATE medias SET width = 2000, height = 1500 WHERE filename = 'Photo 2015-01-11 20-30-03 (P1020945) (4).jpg';
UPDATE medias SET width = 2000, height = 1500 WHERE filename = 'Photo 2015-01-11 20-30-03 (P1020945) (5).jpg';
UPDATE medias SET width = 2000, height = 1500 WHERE filename = 'Photo 2015-01-11 20-30-03 (P1020945) (6).jpg';
UPDATE medias SET width = 2000, height = 1500 WHERE filename = 'Photo 2015-01-11 20-30-03 (P1020945) (7).jpg';
UPDATE medias SET width = 2000, height = 1500 WHERE filename = 'Photo 2015-01-11 20-30-03 (P1020945) (8).jpg';
UPDATE medias SET width = 2000, height = 1500 WHERE filename = 'Photo 2015-01-11 20-30-03 (P1020945).jpg';
UPDATE medias SET width = 1500, height = 2000 WHERE filename = 'Photo 2015-01-19 19-59-34 (P1040055).jpg';
UPDATE medias SET width = 3264, height = 2448 WHERE filename = 'TEST.jpg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'image (64).jpg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'image (65).jpg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'image.jpg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = '083932A9-4248-4208-91CC-34DCDC374E7A.jpeg';
UPDATE medias SET width = 3024, height = 4032 WHERE filename = '102ED557-69B0-41F2-9193-DF52FEEFD35C.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = '45289F93-5D82-40DE-A0B1-1871317C56C1.jpeg';
UPDATE medias SET width = 1200, height = 1600 WHERE filename = '5F97CB55-46C7-4BB7-9335-BBDFF77F1310.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = '634716D8-D7E7-4128-B0D2-EEB4437411F3.jpeg';
UPDATE medias SET width = 3024, height = 4032 WHERE filename = '6C111386-591F-450D-9794-E812FB6FF036.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = '6D718B33-020D-460A-9194-D637A2590ABC.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = '866196A9-5428-4E6C-8C4A-03DB284A9448.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'A68E6383-095C-4CB2-876B-4EC59DB24419.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'APPLE (1).jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'APPLE.jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'B9EFBC4F-4FC4-4D03-9722-9A1FF61F3B8E (1).jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'B9EFBC4F-4FC4-4D03-9722-9A1FF61F3B8E (2).jpeg';
UPDATE medias SET width = 5472, height = 3648 WHERE filename = 'B9EFBC4F-4FC4-4D03-9722-9A1FF61F3B8E (3).jpeg';
UPDATE medias SET width = 1152, height = 1536 WHERE filename = 'BA37E256-C0B2-4DE6-B0E0-659EB2C3411B.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'D4009C41-7B4C-42D5-9FB4-9CEC7CC1B4B0.jpeg';
UPDATE medias SET width = 1200, height = 1600 WHERE filename = 'DDFADE5F-2785-4168-9EAB-D63818566929.jpeg';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = 'iPhone 6S.jpeg';
UPDATE medias SET width = 365, height = 600 WHERE filename = 'asunabg (2).png';
UPDATE medias SET width = 365, height = 600 WHERE filename = 'asunabg (3).png';
UPDATE medias SET width = 365, height = 600 WHERE filename = 'asunabg (4).png';
UPDATE medias SET width = 200, height = 120 WHERE filename = 'ffprobeall.png';
UPDATE medias SET width = 1280, height = 720 WHERE filename = 'temp_60a6954ea732b.png';
UPDATE medias SET width = 1280, height = 720 WHERE filename = 'temp_60a695501d507.png';
UPDATE medias SET width = 1080, height = 1920 WHERE filename = 'temp_60a695509009d.png';
UPDATE medias SET width = 1080, height = 1920 WHERE filename = 'temp_60a695520a9a0.png';
UPDATE medias SET width = 1080, height = 1920 WHERE filename = 'temp_60a695535d689.png';
UPDATE medias SET width = 1280, height = 720 WHERE filename = '7DB6662E-353A-4AC5-9331-D9122D956FE7 (1).MOV';
UPDATE medias SET width = 1280, height = 720 WHERE filename = '7DB6662E-353A-4AC5-9331-D9122D956FE7 (2).MOV';
UPDATE medias SET width = 1920, height = 1080 WHERE filename = 'IMG_2584 (1).MOV';
UPDATE medias SET width = 1920, height = 1080 WHERE filename = 'IMG_2584.MOV';
UPDATE medias SET width = 1920, height = 1080 WHERE filename = 'IMG_2585 (1).MOV';
UPDATE medias SET width = 1920, height = 1080 WHERE filename = 'IMG_2585 (2).MOV';
UPDATE medias SET width = 1920, height = 1080 WHERE filename = 'IMG_2585 (3).MOV';
UPDATE medias SET width = 1920, height = 1080 WHERE filename = 'IMG_2585 (4).MOV';
UPDATE medias SET width = 1920, height = 1080 WHERE filename = 'IMG_2585 (5).MOV';
UPDATE medias SET width = 1920, height = 1080 WHERE filename = 'IMG_2585.MOV';
UPDATE medias SET width = 1920, height = 1080 WHERE filename = 'iPhone 6S.MOV';
UPDATE medias SET width = 4032, height = 3024 WHERE filename = '1620E5B9-C65D-4252-A495-18D5CAD63C6E.jpeg';
-5
View File
@@ -1,5 +0,0 @@
ALTER TABLE messages MODIFY ref_msg_id VARCHAR(15) NOT NULL;
ALTER TABLE messages ADD display BOOLEAN DEFAULT 1 AFTER weather_temp;
UPDATE messages SET display = 0 WHERE id_message = 197;
UPDATE messages SET display = 0 WHERE id_message = 216;
-25
View File
@@ -1,25 +0,0 @@
CREATE TABLE `projects` (
`id_project` int(10) UNSIGNED auto_increment,
`codename` VARCHAR(100),
`name` VARCHAR(100),
`active_from` DATETIME,
`active_to` DATETIME,
`geofile` VARCHAR(50),
`timezone` VARCHAR(100),
`led` TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id_project`));
INSERT INTO projects (name, codename, active_from, active_to, geofile, timezone) VALUES ('Te Araroa', 'te_araroa', '2015-12-29 00:00:00', '2016-03-05 23:59:59', 'te_araroa.geojson', 'Pacific/Auckland');
INSERT INTO projects (name, codename, active_from, active_to, geofile, timezone) VALUES ('HRP', 'hrp', '2019-06-01 00:00:00', '2019-09-10 23:59:59', 'hrp.geojson', 'Europe/Paris');
ALTER TABLE feeds ADD COLUMN id_project int(10) UNSIGNED AFTER id_spot;
ALTER TABLE feeds ADD last_update DATETIME AFTER status;
ALTER TABLE feeds ADD FOREIGN KEY (`id_project`) REFERENCES projects(`id_project`);
UPDATE feeds SET last_update = led;
UPDATE feeds SET id_project = 1;
ALTER TABLE posts ADD COLUMN id_project int(10) UNSIGNED AFTER id_post;
ALTER TABLE posts ADD timestamp DATETIME AFTER content;
ALTER TABLE posts ADD FOREIGN KEY (`id_project`) REFERENCES projects(`id_project`);
UPDATE posts SET timestamp = led;
UPDATE posts SET id_project = 1;
-3
View File
@@ -1,3 +0,0 @@
ALTER TABLE medias ADD latitude DECIMAL(7,5) AFTER timezone;
ALTER TABLE medias ADD longitude DECIMAL(8,5) AFTER latitude;
ALTER TABLE medias ADD altitude SMALLINT AFTER longitude;
-5
View File
@@ -1,5 +0,0 @@
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
View File
@@ -1,23 +0,0 @@
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 deau, cest 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;
-14
View File
@@ -1,14 +0,0 @@
CREATE TABLE `pictures` (
`id_picture` int(10) UNSIGNED auto_increment,
`id_project` int(10) UNSIGNED,
`filename` VARCHAR(100) NOT NULL,
`taken_on` DATETIME,
`timestamp` DATETIME,
`rotate` SMALLINT,
`led` TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id_picture`),
UNIQUE KEY `uni_file_name` (`filename`)
);
ALTER TABLE pictures ADD INDEX(`id_project`);
ALTER TABLE pictures ADD FOREIGN KEY (`id_project`) REFERENCES projects(`id_project`);
-15
View File
@@ -1,15 +0,0 @@
ALTER TABLE messages ADD iso_time VARCHAR(24) AFTER longitude;
ALTER TABLE messages CHANGE COLUMN timestamp site_time TIMESTAMP DEFAULT 0;
ALTER TABLE messages CHANGE COLUMN unix_timestamp unix_time INT;
UPDATE messages SET iso_time = CONCAT(REPLACE(CONVERT_TZ(FROM_UNIXTIME(unix_time), @@session.time_zone, 'Pacific/Auckland'), ' ', 'T'), '+1200') WHERE id_feed = 1;
ALTER TABLE feeds MODIFY last_update TIMESTAMP DEFAULT 0;
ALTER TABLE projects MODIFY active_from TIMESTAMP DEFAULT 0;
ALTER TABLE projects MODIFY active_to TIMESTAMP DEFAULT 0;
ALTER TABLE posts CHANGE COLUMN timestamp site_time TIMESTAMP DEFAULT 0;
ALTER TABLE pictures CHANGE COLUMN timestamp posted_on TIMESTAMP DEFAULT 0;
UPDATE pictures INNER JOIN projects USING(id_project) SET taken_on = CONVERT_TZ(taken_on, projects.timezone, @@session.time_zone) where id_project = 1;
ALTER TABLE pictures MODIFY taken_on TIMESTAMP DEFAULT 0;
-1
View File
@@ -1 +0,0 @@
UPDATE projects SET geofile = REPLACE(geofile, '.geojson', '');
-4
View File
@@ -1,4 +0,0 @@
RENAME TABLE pictures TO medias;
ALTER TABLE medias CHANGE COLUMN id_picture id_media INT(10) UNSIGNED NOT NULL auto_increment;
ALTER TABLE medias ADD COLUMN type VARCHAR(20) AFTER filename;
UPDATE medias SET type = 'image';
-2
View File
@@ -1,2 +0,0 @@
/* Remove NO_ZERO_DATE mode, checks mode with: SELECT @@SQL_MODE, @@GLOBAL.SQL_MODE; and: SET @@SQL_MODE = REPLACE(@@SQL_MODE, 'NO_ZERO_DATE', ''); */
ALTER TABLE medias ADD comment LONGTEXT AFTER rotate;
-1
View File
@@ -1 +0,0 @@
ALTER TABLE projects DROP COLUMN geofile;
-14
View File
@@ -1,14 +0,0 @@
CREATE TABLE `users` (
`id_user` int(10) UNSIGNED auto_increment,
`name` VARCHAR(100),
`email` VARCHAR(320),
`language` VARCHAR(2),
`active` BOOLEAN,
`led` TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id_user`),
UNIQUE KEY `uni_email` (`email`)
);
ALTER TABLE posts ADD COLUMN id_user int(10) UNSIGNED AFTER id_project;
ALTER TABLE posts ADD INDEX(`id_user`);
ALTER TABLE posts ADD FOREIGN KEY (`id_user`) REFERENCES users(`id_user`);
-5
View File
@@ -1,5 +0,0 @@
ALTER TABLE users ADD COLUMN timezone char(64) AFTER language;
ALTER TABLE users MODIFY COLUMN email VARCHAR(320) NOT NULL;
UPDATE users SET timezone = 'Europe/Paris';
ALTER TABLE projects MODIFY COLUMN timezone char(64);
Executable → Regular
+16 -17
View File
@@ -1,20 +1,19 @@
<?php
class Settings
{
const DB_SERVER = 'localhost';
const DB_LOGIN = '';
const DB_PASS = '';
const DB_NAME = 'spot';
const DB_ENC = 'utf8mb4';
const TEXT_ENC = 'UTF-8';
const TIMEZONE = 'Europe/Zurich';
const MAIL_SERVER = '';
const MAIL_FROM = '';
const MAIL_USER = '';
const MAIL_PASS = '';
const WEATHER_TOKEN = ''; //visualcrossing.com
const TIMEZONE_USER = ''; //geonames.org
const DEBUG = true;
const LOG_FOLDER = __DIR__;
class Settings {
public const DB_SERVER = 'localhost';
public const DB_LOGIN = '';
public const DB_PASS = '';
public const DB_NAME = 'livetrail';
public const DB_ENC = 'utf8mb4';
public const TEXT_ENC = 'UTF-8';
public const TIMEZONE = 'Europe/Zurich';
public const MAIL_SERVER = '';
public const MAIL_FROM = '';
public const MAIL_USER = '';
public const MAIL_PASS = '';
public const WEATHER_TOKEN = ''; //visualcrossing.com
public const TIMEZONE_USER = ''; //geonames.org
public const DEBUG = true;
public const LOG_FOLDER = __DIR__;
}
+94
View File
@@ -0,0 +1,94 @@
// ESLint flat config codifying the style already in use across src/.
// Goal: catch real mistakes and enforce the existing conventions, not impose
// an external style guide. Rules were derived by sampling src/scripts/*.js
// and src/components/*.vue, not from a template.
import js from '@eslint/js';
import vue from 'eslint-plugin-vue';
import globals from 'globals';
export default [
js.configs.recommended,
...vue.configs['flat/essential'],
{
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser
}
},
rules: {
//Indentation: tabs everywhere, one indent per level
'indent': 'off', //too many false positives on Vue template attr wrapping; keep manual
'no-tabs': 'off',
//Strings: single quotes (project uses single-quoted strings exclusively)
'quotes': ['warn', 'single', {avoidEscape: true, allowTemplateLiterals: true}],
//Semicolons: required almost everywhere. The one consistent
//exception is the top-level `export default {...}` in Vue SFCs,
//which never gets a trailing semicolon - ESLint's `semi` rule
//can't carve that one spot out, so this is 'warn' rather than
//'error' to avoid flagging every component's SFC boilerplate
//as a hard failure.
'semi': ['warn', 'always'],
//Equality: codebase mixes == and === deliberately (e.g. loose checks
//against numeric strings from the API) - don't force either
'eqeqeq': 'off',
//Function parens: no space before the parameter list - `function(x)`, not `function (x)`
'space-before-function-paren': ['warn', 'never'],
//Control structures: no space before the parenthesis -
//`if(x)`, `for(...)`, `switch(x)`, not `if (x)` etc.
//(100% consistent across src/ - checked via grep before writing this)
'keyword-spacing': ['warn', {
after: true,
overrides: {
if: {after: false},
for: {after: false},
while: {after: false},
switch: {after: false},
catch: {after: false}
}
}],
'space-before-blocks': ['warn', 'always'],
//Object-curly-spacing is deliberately NOT configured: the codebase
//consistently uses `{a, b}` (no padding) for object literals and
//destructuring, but `import { x } from 'y'` (padded) for imports -
//and this rule can't apply different spacing to those two node
//kinds, so enforcing either would misfire on the other.
'object-curly-spacing': 'off',
'array-bracket-spacing': ['warn', 'never'],
//Prefix convention (Hungarian-ish: sString, iInt, aArray, oObject, bBool)
//is a project-wide naming discipline, not something ESLint can check;
//left undocumented here on purpose.
//Real-bug catchers - keep these strict regardless of style
//ignoreRestSiblings covers the `const {prev, ...rest} = obj` idiom
//(destructuring a key out just to exclude it from the rest), used
//in App.vue - that `prev` binding is intentionally unused.
'no-unused-vars': ['warn', {argsIgnorePattern: '^_', ignoreRestSiblings: true}],
'no-undef': 'error',
'no-var': 'warn', //codebase is ES module/class based; var only appears in one legacy fallback block
'prefer-const': 'off', //not consistently followed, don't force churn
//Vue-specific: components are registered and used with camelCase
//tags in templates (<appIcon>, <projectMapLink>). ESLint's
//component-name-in-template-casing rule only supports PascalCase
//or kebab-case, neither of which matches, so it's left off here
//rather than forced into a casing the codebase doesn't use.
'vue/component-name-in-template-casing': 'off',
'vue/multi-word-component-names': 'off', //AppIcon, Admin, Project etc. mix single/multi-word by design
'vue/attribute-hyphenation': 'off', //existing templates mix camelCase and kebab-case attrs; not consistent enough to enforce yet
'vue/require-default-prop': 'off',
'vue/no-v-html': 'error' //codebase currently has zero v-html usage - keep it that way
}
},
{
ignores: ['public/**', 'vendor/**', 'node_modules/**']
}
];
-265
View File
File diff suppressed because one or more lines are too long
-782
View File
@@ -1,782 +0,0 @@
// ==UserScript==
// @name GaiaGps Uploader
// @namespace https://greasyfork.org/users/583371
// @description Allow the user to upload multiple files at once and more than 1000 waypoints
// @grant none
// @version 3.1.2
// @author Franzz
// @license GNU GPLv3
// @match https://www.gaiagps.com/map/*
// @require https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js
// ==/UserScript==
/* jshint esversion: 6 */
//Ctrl+Alt+Shift+I to open network tab on addon
/* GPXParser - v3.0.8 - https://github.com/Luuka/GPXParser.js/blob/master/src/GPXParser.js */
/* Personnal modifications have been applied, care on upgrade */
/**
* GPX file parser
*
* @constructor
*/
let gpxParser = function () {
this.xmlSource = "";
this.metadata = {};
this.waypoints = [];
this.tracks = [];
this.routes = [];
};
/**
* Parse a gpx formatted string to a GPXParser Object
*
* @param {string} gpxstring - A GPX formatted String
*
* @return {gpxParser} A GPXParser object
*/
gpxParser.prototype.parse = function (gpxstring) {
let keepThis = this;
let domParser = new window.DOMParser();
this.xmlSource = domParser.parseFromString(gpxstring, 'text/xml');
let metadata = this.xmlSource.querySelector('metadata');
if(metadata != null){
this.metadata.name = this.getElementValue(metadata, "name");
this.metadata.desc = this.getElementValue(metadata, "desc");
this.metadata.time = this.getElementValue(metadata, "time");
let author = {};
let authorElem = metadata.querySelector('author');
if(authorElem != null){
author.name = this.getElementValue(authorElem, "name");
author.email = {};
let emailElem = authorElem.querySelector('email');
if(emailElem != null){
author.email.id = emailElem.getAttribute("id");
author.email.domain = emailElem.getAttribute("domain");
}
let link = {};
let linkElem = authorElem.querySelector('link');
if(linkElem != null){
link.href = linkElem.getAttribute('href');
link.text = this.getElementValue(linkElem, "text");
link.type = this.getElementValue(linkElem, "type");
}
author.link = link;
}
this.metadata.author = author;
let link = {};
let linkElem = this.queryDirectSelector(metadata, 'link');
if(linkElem != null){
link.href = linkElem.getAttribute('href');
link.text = this.getElementValue(linkElem, "text");
link.type = this.getElementValue(linkElem, "type");
this.metadata.link = link;
}
}
var wpts = [].slice.call(this.xmlSource.querySelectorAll('wpt'));
for (let idx in wpts){
var wpt = wpts[idx];
let pt = {};
pt.name = keepThis.getElementValue(wpt, "name");
pt.lat = parseFloat(wpt.getAttribute("lat"));
pt.lon = parseFloat(wpt.getAttribute("lon"));
//let floatValue = parseFloat(keepThis.getElementValue(wpt, "ele"));
//pt.ele = isNaN(floatValue) ? null : floatValue;
pt.ele = parseFloat(keepThis.getElementValue(wpt, "ele")) || null;
pt.cmt = keepThis.getElementValue(wpt, "cmt");
pt.desc = keepThis.getElementValue(wpt, "desc");
pt.sym = keepThis.getElementValue(wpt, "sym");
//let time = keepThis.getElementValue(wpt, "time");
//pt.time = time == null ? null : new Date(time);
pt.time = (keepThis.getElementValue(wpt, "time") || keepThis.metadata.time) || null;
keepThis.waypoints.push(pt);
}
var rtes = [].slice.call(this.xmlSource.querySelectorAll('rte'));
for (let idx in rtes){
let rte = rtes[idx];
let route = {};
route.name = keepThis.getElementValue(rte, "name");
route.cmt = keepThis.getElementValue(rte, "cmt");
route.desc = keepThis.getElementValue(rte, "desc");
route.src = keepThis.getElementValue(rte, "src");
route.number= keepThis.getElementValue(rte, "number");
let type = keepThis.queryDirectSelector(rte, "type");
route.type = type != null ? type.innerHTML : null;
let link = {};
let linkElem= rte.querySelector('link');
if(linkElem != null){
link.href = linkElem.getAttribute('href');
link.text = keepThis.getElementValue(linkElem, "text");
link.type = keepThis.getElementValue(linkElem, "type");
}
route.link = link;
let routepoints = [];
var rtepts = [].slice.call(rte.querySelectorAll('rtept'));
for (let idxIn in rtepts){
let rtept = rtepts[idxIn];
let pt = {};
pt.lat = parseFloat(rtept.getAttribute("lat"));
pt.lon = parseFloat(rtept.getAttribute("lon"));
//let floatValue = parseFloat(keepThis.getElementValue(rtept, "ele"));
//pt.ele = isNaN(floatValue) ? null : floatValue;
pt.ele = parseFloat(keepThis.getElementValue(rtept, "ele")) || null;
//let time = keepThis.getElementValue(rtept, "time");
//pt.time = time == null ? null : new Date(time);
pt.time = (keepThis.getElementValue(rtept, "time") || keepThis.metadata.time) || null;
routepoints.push(pt);
}
//route.distance = keepThis.calculDistance(routepoints);
//route.elevation = keepThis.calcElevation(routepoints);
//route.slopes = keepThis.calculSlope(routepoints, route.distance.cumul);
route.points = routepoints;
keepThis.routes.push(route);
}
var trks = [].slice.call(this.xmlSource.querySelectorAll('trk'));
for (let idx in trks){
let trk = trks[idx];
let track = {};
track.name = keepThis.getElementValue(trk, "name");
track.cmt = keepThis.getElementValue(trk, "cmt");
track.desc = keepThis.getElementValue(trk, "desc");
track.src = keepThis.getElementValue(trk, "src");
track.number= keepThis.getElementValue(trk, "number");
track.color = keepThis.getElementValue(trk, "DisplayColor");
track.time = keepThis.metadata.time;
let type = keepThis.queryDirectSelector(trk, "type");
track.type = type != null ? type.innerHTML : null;
let link = {};
let linkElem= trk.querySelector('link');
if(linkElem != null){
link.href = linkElem.getAttribute('href');
link.text = keepThis.getElementValue(linkElem, "text");
link.type = keepThis.getElementValue(linkElem, "type");
}
track.link = link;
let trackpoints = [];
let trkpts = [].slice.call(trk.querySelectorAll('trkpt'));
for (let idxIn in trkpts){
var trkpt = trkpts[idxIn];
let pt = {};
pt.lat = parseFloat(trkpt.getAttribute("lat"));
pt.lon = parseFloat(trkpt.getAttribute("lon"));
//let floatValue = parseFloat(keepThis.getElementValue(trkpt, "ele"));
//pt.ele = isNaN(floatValue) ? null : floatValue;
pt.ele = parseFloat(keepThis.getElementValue(trkpt, "ele")) || null;
//let time = keepThis.getElementValue(trkpt, "time");
//pt.time = time == null ? null : new Date(time);
pt.time = (keepThis.getElementValue(trkpt, "time") || keepThis.metadata.time) || null;
trackpoints.push(pt);
}
//track.distance = keepThis.calculDistance(trackpoints);
//track.elevation = keepThis.calcElevation(trackpoints);
//track.slopes = keepThis.calculSlope(trackpoints, track.distance.cumul);
track.points = trackpoints;
keepThis.tracks.push(track);
}
};
/**
* Get value from a XML DOM element
*
* @param {Element} parent - Parent DOM Element
* @param {string} needle - Name of the searched element
*
* @return {} The element value
*/
gpxParser.prototype.getElementValue = function(parent, needle){
let elem = parent.querySelector(needle);
if(elem != null){
//Get value (in case of CDATA)
let sValue = (elem.innerHTML != undefined && elem.innerHTML.substring(0, 8) != '<![CDATA') ? elem.innerHTML : elem.childNodes[0].data;
//If decoded HTML, re-encode
if(sValue.substr(0, 4)== '&lt;') sValue = $('<div>').html(sValue).text();
//Strip HTML tags & trim value
return $('<div>').html(sValue).text().trim();
}
return elem;
};
/**
* Search the value of a direct child XML DOM element
*
* @param {Element} parent - Parent DOM Element
* @param {string} needle - Name of the searched element
*
* @return {} The element value
*/
gpxParser.prototype.queryDirectSelector = function(parent, needle) {
let elements = parent.querySelectorAll(needle);
let finalElem = elements[0];
if(elements.length > 1) {
let directChilds = parent.childNodes;
for(idx in directChilds) {
elem = directChilds[idx];
if(elem.tagName === needle) {
finalElem = elem;
}
}
}
return finalElem;
};
class Gaia {
static get URL() { return 'https://www.gaiagps.com'; }
static get API() { return Gaia.URL+'/api/objects'; }
constructor() {
this.reset();
}
reset() {
this.asFiles = [];
this.aoWaypoints = [];
this.aoTracks = [];
this.asFolders = {};
this.progress = {current:0, total:0};
}
setLayout() {
this.reset();
/* FIXME: adapts on GaiaGPS upgrade */
let $InputButton = $('a[href="https://help.gaiagps.com/hc/en-us/articles/360052763513"]').parent().find('button');
//If the button is found (=displayed)
if($InputButton.length > 0) {
this.$InputBox = $InputButton.parent();
//Add Feedback box
this.$Feedback = ($('#ggu-feedback').length > 0)?$('#ggu-feedback'):($('<div>', {
'id':'ggu-feedback',
'style':'width:calc(100% + 64px); height:400px; margin:1em 0 0 -32px; display:inline-block; text-align:left; overflow:auto;'
}).insertAfter($InputButton));
/*
//Add Folder Name Input next to button
this.$InputName = ($('#ggu-inputname').length > 0)?$('#ggu-inputname'):($('<input>', {
'type':'text',
'id': 'ggu-inputname',
'name':'ggu-inputname',
'placeholder':'Folder Name',
'style': 'border-width:2px; border-color:rgba(0, 0, 0, 0.1); border-radius:4px; font-family:Inter,Helvetica Neue,Helvetica,Arial; font-size:15px; padding:8px 12px; margin-bottom:12px;'
}).val('PCT').prependTo(this.$InputBox));
*/
//Reset File Input DOM Element
let $InputFile = $('input[type=file]');
this.$InputFile = $InputFile.clone()
.insertAfter($InputFile)
.attr('multiple', 'multiple')
.attr('name', 'files[]')
.change(() => { this.readInputFiles(); });
$InputFile.remove();
//Reset button
this.$InputButton = $InputButton.clone()
.insertAfter($InputButton)
.click(() => {this.$InputFile.click();});
$InputButton.remove();
//Clear all upload notifications
this.resetNotif();
}
}
feedback(sType, sMsg) {
let sColor = 'black';
let sIcon = '';
switch(sType) {
case 'error': sColor = 'red'; sIcon = '\u274C'; break;
case 'warning': sColor = 'orange'; sIcon = '\u26A0'; break;
case 'info': sColor = '#2D5E38'; sIcon = '\u2713'; break;
}
var sFormattedMsg = sIcon+' '+sMsg+(sMsg.slice(-1)=='.'?'':'.');
console.log(sFormattedMsg);
this.$Feedback.append($('<p>', {'style': 'color: '+sColor+';'}).text(sFormattedMsg));
this.$Feedback.scrollTop(this.$Feedback.prop("scrollHeight"));
}
incProgress() {
if(!this.progress.current) {
this.progress.$Done = $('<div>', {'style':'overflow:hidden; background:#2D5E38; color: white; text-align:right;'});
this.progress.$Left = $('<div>', {'style':'overflow:hidden; background:white; color: #2D5E38; text-align:left;'});
this.progress.$Box = $('<div>', {'id':'ggu-progress', 'style':'margin-top:1em;'})
.append($('<div>', {'style':'display:flex; width:calc(100% + 64px); margin-left:-32px; border-radius:3px; border: 1px solid #2D5E38; box-sizing: border-box;'})
.append(this.progress.$Done)
.append(this.progress.$Left)
);
this.$Feedback.before(this.progress.$Box);
}
this.progress.current++;
if(this.progress.current < this.progress.total) {
let iRatio = Math.round(this.progress.current / this.progress.total * 100);
let sTextDone = '', sTextLeft = '';
if(iRatio < 50) {
sTextDone = '';
sTextLeft = '&nbsp;'+iRatio+'% ('+this.progress.current+' / '+this.progress.total+')';
}
else {
sTextDone = '('+this.progress.current+' / '+this.progress.total+') '+iRatio+'%&nbsp;';
sTextLeft = '';
}
this.progress.$Done.css('flex', iRatio+' 1 0%').html(sTextDone);
this.progress.$Left.css('flex', (100 - iRatio)+' 1 0%').html(sTextLeft);
}
else {
this.progress.$Box.remove();
this.progress = {current:0, total:0};
}
}
//Marking all upload notifications as read
resetNotif() {
this.feedback('info', 'Marking all upload notifications as read');
$.get(Gaia.URL+'/social/notifications/popup/').done((asNotifs) => {
for(var i in asNotifs) {
if(!asNotifs[i].isViewed && asNotifs[i].html.indexOf('has completed') != -1) {
$.post(Gaia.URL+'/social/notifications/'+asNotifs[i].id+'/markviewed/');
}
}
});
}
//Parse files from input (multiple)
readInputFiles() {
if (!window.File || !window.FileReader || !window.FileList || !window.Blob) {
this.feedback('error', 'File APIs are not fully supported in this browser');
return;
}
else this.feedback('info', 'Parsing input files');
//Get Folder Name (text input)
let aoReaders = [];
let iCount = 0;
this.asFiles = this.$InputFile.prop('files');
for(var i=0 ; i < this.asFiles.length ; i++) {
aoReaders[i] = new FileReader();
aoReaders[i].onload = ((oFileReader) => {
return (asResult) => {
iCount++;
this.feedback('info', 'Reading file "'+oFileReader.name+'" ('+iCount+'/'+this.asFiles.length+')');
this.parseFile(oFileReader.name, asResult.target.result);
this.asFolders[oFileReader.name] = {};
if(iCount == this.asFiles.length) {
this.progress.total = this.aoTracks.length + this.aoWaypoints.length + this.asFiles.length; //extra action per file: Create folder
this.createFolders();
}
};
})(this.asFiles[i]);
aoReaders[i].readAsText(this.asFiles[i]);
}
}
//Parse GPX files to consolidate tracks & waypoints
parseFile(sFileName, oContent) {
this.feedback('info', 'Parsing file "'+sFileName+'"');
var oGPX = new gpxParser();
oGPX.parse(oContent);
//Waypoints
for(var w in oGPX.waypoints) {
oGPX.waypoints[w].filename = sFileName;
var sWaypointName = oGPX.waypoints[w].name;
/* if(sWaypointName.indexOf('Milestone - ') != -1 && sWaypointName.substr(-2) != '00') { //1 milestone every 100 miles
this.feedback('info', 'Ignoring milestone waypoint "'+sWaypointName+'"');
}
else */this.aoWaypoints.push(oGPX.waypoints[w]);
}
//Tracks
for(var t in oGPX.tracks) {
oGPX.tracks[t].filename = sFileName;
this.aoTracks.push(oGPX.tracks[t]);
}
}
//Delete existing folder with same name & recreating it
createFolders() {
let iCount = 0;
$.each(this.asFolders, (sFileName, asFolder) => {
//Folder Name
let sFolderName = sFileName.replace(/\.[^\.]+$/, '');
this.feedback('info', 'Looking for existing folder "'+sFolderName+'"...');
$.get(Gaia.API+'/folder/?search='+sFolderName).done((asFolders) => {
if(asFolders != '' && !$.isEmptyObject(asFolders)) {
for(var f in asFolders) {
this.feedback('info', 'Deleting "'+asFolders[f].title+'" folder');
$.ajax({
url: Gaia.API+'/folder/'+asFolders[f].id+'/',
type: 'DELETE'
});
}
}
else this.feedback('info', 'No folder named "'+sFolderName+'" found');
this.feedback('info', 'Creating folder "'+sFolderName+'"');
let sTime = (new Date()).toISOString();
$.post({
url: Gaia.API+'/folder/',
contentType: 'application/json',
data: JSON.stringify({
title: sFolderName,
imported: true
})
}).done((asFolder) => {
this.feedback('info', 'Folder "'+asFolder.properties.name+'" created');
this.asFolders[sFileName] = asFolder;
this.incProgress();
iCount++;
if(iCount == Object.keys(this.asFolders).length) this.uploadTrack();
}).fail(() => {
this.feedback('error', 'Folder "'+sFolderName+'" could not be created');
});
});
});
}
//Build & Upload Track File
uploadTrack(iIndex) {
iIndex = iIndex || 0;
if(iIndex == 0) this.feedback('info', 'Uploading tracks...');
let aoTrack = this.aoTracks[iIndex];
this.feedback('info', 'Uploading track "'+aoTrack.name+'"');
let sColor = '#4ABD32';
switch(aoTrack.color) {
//Personal Colors
case 'DarkBlue': sColor = '#2D3FC7'; break;
case 'Magenta': sColor = '#B60DC3'; break;
//Garmin Colors
case 'Black': sColor = '#000000'; break;
case 'DarkRed': sColor = '#F90553'; break;
case 'DarkGreen': sColor = '#009B89'; break;
case 'DarkYellow': sColor = '#DCEE0E'; break;
//case 'DarkBlue': sColor = '#5E23CA'; break;
case 'DarkMagenta': sColor = '#B60DC3'; break;
case 'DarkCyan': sColor = '#00ACF8'; break;
case 'LightGray': sColor = '#A4A4A4'; break;
case 'DarkGray': sColor = '#577B8E'; break;
case 'Red': sColor = '#F90553'; break;
case 'Green': sColor = '#36C03B'; break;
case 'Yellow': sColor = '#FFF011'; break;
case 'Blue': sColor = '#2D3FC7'; break;
//case 'Magenta': sColor = '#B60DC3'; break;
case 'Cyan': sColor = '#00C3DD'; break;
case 'White': sColor = '#FFFFFF'; break;
case 'Transparent': sColor = '#784D3E'; break;
}
//Add track points
let aoCoords = [];
for(var p in aoTrack.points) {
let pt = aoTrack.points[p];
aoCoords.push([pt.lon, pt.lat, pt.ele, 0]);
}
//Convert to geojson
let sPostedData = JSON.stringify({
geometry: {
coordinates: [aoCoords],
type: 'MultiLineString'
},
properties: {
archived: false,
color: sColor,
//distance: 168405.62350073704,
filename: aoTrack.filename,
hexcolor: sColor,
isLatestImport: true,
isLocallyCreated: true,
isPublicTrack: false,
isValid: true,
localId: 'track'+(iIndex + 1),
notes: aoTrack.desc,
parent_folder_id: this.asFolders[aoTrack.filename].id,
routing_mode: null,
time_created: aoTrack.time || (new Date()).toISOString(),
title: aoTrack.name,
type: 'track',
writable: true
},
type: 'Feature'
});
var self = this;
$.post({
url: Gaia.API+'/track/',
contentType: 'application/json',
data: sPostedData,
trackName: aoTrack.name
}).done(function(asTrack) {
self.aoTracks[iIndex] = asTrack;
self.confirmTrack(iIndex, asTrack, this.data);
}).fail(function() {
self.feedback('error', 'Track "'+this.trackName+'" upload failed (stage 1). Retrying...');
self.uploadTrack(iIndex);
});
}
confirmTrack(iIndex, asTrack, sPostedData) {
iIndex = iIndex || 0;
var self = this;
$.ajax({
url: Gaia.API+'/track/'+asTrack.features[0].id+'/',
type: 'PUT',
contentType: 'application/json',
data: sPostedData,
trackName: asTrack.features[0].properties.title
}).done(function() {
self.feedback('info', 'Track "'+this.trackName+'" uploaded');
self.incProgress();
iIndex++;
if(iIndex < self.aoTracks.length) self.uploadTrack(iIndex);
else {
self.feedback('info', 'All tracks uploaded');
self.uploadWayPoints();
}
}).fail(function() {
self.feedback('error', 'Track "'+this.trackName+'" upload failed (stage 2). Retrying...');
self.confirmTrack(iIndex, asTrack, sPostedData);
});
}
/*
//Wait for file to be processed by Gaia
checkNotif() {
this.feedback('info', 'Waiting for Gaia to process consolidated track');
$.get(Gaia.URL+'/social/notifications/popup/').done((asNotifs) => {
for(var i in asNotifs) {
if(!asNotifs[i].isViewed && asNotifs[i].html.indexOf('has completed') != -1) {
this.feedback('info', 'Notification '+asNotifs[i].id+' found. Marking as read');
var $Notif = $('<span>').html(asNotifs[i].html);
this.sFolderId = $Notif.find('a').attr('href').split('/')[3];
$.post(Gaia.URL+'/social/notifications/'+asNotifs[i].id+'/markviewed/');
}
}
if(this.sFolderId != '') {
this.setTracksColor();
this.uploadWayPoints();
}
else setTimeout((() => {this.checkNotif();}), 1000);
});
}
*/
uploadWayPoints(iIndex) {
iIndex = iIndex || 0;
//Upload waypoints
var sWaypointName = this.aoWaypoints[iIndex].name;
var aoWaypoint = this.aoWaypoints[iIndex];
this.feedback('info', 'Uploading waypoint '+(iIndex + 1)+'/'+this.aoWaypoints.length+' ('+aoWaypoint.name+')');
var asPost = {
geometry: {
coordinates: [
aoWaypoint.lon,
aoWaypoint.lat,
aoWaypoint.ele
],
type: 'Point'
},
properties: {
archived: false,
filename: aoWaypoint.filename,
icon: Gaia.getIconName(aoWaypoint.sym),
isLatestImport: true,
isLocallyCreated: true,
isValid: true,
localId: iIndex+'',
notes: aoWaypoint.desc,
parent_folder_id: this.asFolders[aoWaypoint.filename].id,
time_created: aoWaypoint.time || (new Date()).toISOString(),
title: aoWaypoint.name,
type: 'waypoint',
writable: true
},
type: 'Feature'
};
let sData = JSON.stringify(asPost);
var self = this;
$.post({
url: Gaia.API+'/waypoint/',
contentType: 'application/json',
data: sData
}).done(function(asWaypoint) {
self.aoWaypoints[iIndex] = asWaypoint;
self.confirmWayPoint(iIndex, asWaypoint, this.data);
}).fail(function(){
self.feedback('error', 'Failed to upload waypoint #'+(iIndex + 1)+' "'+sWaypointName+'" (Stage 1). Trying again...');
self.uploadWayPoints(iIndex);
});
}
confirmWayPoint(iIndex, asWaypoint, sPostedData) {
$.ajax({
url: Gaia.API+'/waypoint/'+asWaypoint.properties.id+'/',
type: 'PUT',
contentType: 'application/json',
data: sPostedData
}).done(() => {
iIndex++;
this.incProgress();
if(iIndex < this.aoWaypoints.length) this.uploadWayPoints(iIndex);
//else this.assignElementsToFolders();
else this.feedback('info', 'Done');
}).fail(() => {
this.feedback('error', 'Failed to upload waypoint #'+(iIndex + 1)+' "'+asWaypoint.properties.title+'" (Stage 2). Trying again...');
this.confirmWayPoint(iIndex, asWaypoint, sPostedData);
});
}
/*
assignElementsToFolders(iIndex) {
iIndex = iIndex || 0;
let asFolders = Object.keys(this.asFolders).map(key => this.asFolders[key]);
let asFolder = asFolders[iIndex];
this.feedback('info', 'Assigning elements of folder "'+asFolder.properties.name+'"');
//Folder metadata
let asData = {
cover_photo_id: asFolder.properties.cover_photo_id,
id: asFolder.id,
name: asFolder.properties.name,
notes: asFolder.properties.notes,
time_created: asFolder.properties.time_created,
updated_date: asFolder.properties.updated_date
}
//Assign waypoints to folder
asData.waypoints = [];
for(var w in this.aoWaypoints) {
if(this.aoWaypoints[w].parent_folder_id = asFolder.id) asData.waypoints.push(this.aoWaypoints[w].properties.id);
}
//Assign tracks to folder
asData.tracks = [];
for(var t in this.aoTracks) {
if(this.aoTracks[t].parent_folder_id = asFolder.id) asData.tracks.push(this.aoTracks[t].features[0].id);
}
$.ajax({
url: Gaia.API+'/folder/'+asFolder.id+'/',
type: 'PUT',
contentType: 'application/json',
data: JSON.stringify(asData)
}).done(() => {
iIndex++;
this.incProgress();
this.feedback('info', 'Tracks & waypoints assigned to folder "'+asFolder.properties.name+'"');
if(iIndex < asFolders.length) this.assignElementsToFolders(iIndex);
else this.feedback('info', 'Done');
}).fail(() => {
this.feedback('warning', 'Failed to assign waypoints & tracks to folder "'+asFolder.properties.name+'". Trying again...');
this.assignElementsToFolders(iIndex);
});
}
*/
static getIconName(sGarminName) {
var asMapping = {
'Bridge': 'bridge',
'Campground': 'campsite-24',
'Car': 'car-24',
'Cemetery': 'cemetery-24',
'Church': 'ghost-town',
'City (Capitol)': 'city-24',
'Convenience Store': 'market',
'Drinking Water': 'potable-water',
'Flag, Blue': 'blue-pin-down',
'Flag, Green': 'green-pin',
'Flag, Red': 'red-pin-down',
'Forest': 'forest',
'Ground Transportation': 'car-24',
'Lodging': 'lodging-24',
'Park': 'park-24',
'Pharmacy': 'hospital-24',
'Picnic Area': 'picnic',
'Post Office': 'resupply',
'Powerline': 'petroglyph',
'Residence': 'building-24',
'Restaurant': 'restaurant-24',
'Restroom': 'toilets-24',
'Shopping Center': 'market',
'Ski Resort': 'skiing-24',
'Summit': 'peak',
'Toll Booth': 'ranger-station',
'Trail Head': 'known-route',
'Truck': 'car-24',
'Water Source': 'water-24'
};
return (sGarminName in asMapping)?asMapping[sGarminName]:'red-pin-down';
}
}
console.log('Loading GaiaGps Uploader '+GM_info.script.version);
let oGaia = new Gaia();
MutationObserver = window.MutationObserver || window.WebKitMutationObserver;
let observer = new MutationObserver((mutations, observer) => {
/* FIXME: adapts on GaiaGPS upgrade */
let $Import = $('div[aria-label="Import Data"]');
if($Import.length > 0) {
observer.disconnect();
$Import.parent('li').on('click', () => { setTimeout(() => { oGaia.setLayout(); }, 500)});
}
});
observer.observe(document, { subtree: true, attributes: true});
-228
View File
File diff suppressed because one or more lines are too long
-204
View File
@@ -1,204 +0,0 @@
{
"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)"
}
}
+172
View File
@@ -0,0 +1,172 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use Franzz\Objects\ToolBox;
//TODO Keep only local specificities and move bulk to Franzz\Objects\Controller
class Controller extends PhpObject {
private const MUTATING_ACTIONS = [
'add_post',
'subscribe',
'unsubscribe',
'login',
'logout',
'update_project',
'upload',
'add_comment',
'add_position',
'admin_set',
'admin_create',
'admin_delete'
];
private const SESSION_WRITING_ACTIONS = [
'login',
'logout'
];
private Livetrail $oLivetrail;
private array $asReq;
private string $sCsrfToken = '';
public function __construct() {
parent::__construct(__CLASS__);
}
private function setReqVal(string $sKey, $oValue, string $sValidation=''): void {
$this->asReq[$sKey] = $this->validateValue($sValidation, $oValue);
}
public function handle($sProcessPage, array $argv = []): string {
//Start buffering so warnings/notices can be collected
ob_start();
//Parse variables
$asReq = ToolBox::getRequest($argv);
$this->asReq = [];
$sAction = $asReq['a'] ?? '';
$this->setReqVal('t', $asReq['t'] ?? '');
$this->setReqVal('name', $asReq['name'] ?? '');
$this->setReqVal('content', $asReq['content'] ?? '');
$this->setReqVal('ref_type', $asReq['ref_type'] ?? '');
$this->setReqVal('ref_id', $asReq['ref_id'] ?? 0, 'positiveInt');
$this->setReqVal('id_project', $asReq['id_project'] ?? 0, 'positiveInt');
$this->setReqVal('id', $asReq['id'] ?? 0);
$this->setReqVal('id_entity', $asReq['id'] ?? 0, 'positiveInt');
$this->setReqVal('field', $asReq['field'] ?? '');
$this->setReqVal('value', $asReq['value'] ?? '');
$this->setReqVal('type', $asReq['type'] ?? '');
$this->setReqVal('email', $asReq['email'] ?? '');
$this->setReqVal('password', $asReq['password'] ?? '');
$this->setReqVal('latitude', $asReq['latitude'] ?? '');
$this->setReqVal('longitude', $asReq['longitude'] ?? '');
$this->setReqVal('timestamp', $asReq['timestamp'] ?? 0, 'positiveInt');
$this->setReqVal('csrf_token', $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ($_POST['csrf_token'] ?? ''));
//Authentication and CSRF protection share the same server-side session.
$this->initCsrfToken();
//Create Livetrail instance
$this->oLivetrail = new Livetrail($sProcessPage, $this->asReq['t']);
//Validate CSRF, then release the session lock before long-running work.
$bValidMutationRequest = $this->validateMutationRequest($sAction);
if(!$bValidMutationRequest || !in_array($sAction, self::SESSION_WRITING_ACTIONS, true)) $this->closeSession();
if(!$bValidMutationRequest) $sResult = Livetrail::getJsonResult(false, Livetrail::UNAUTHORIZED);
else {
$this->oLivetrail->setProjectId($this->asReq['id_project']);
$sResult = ($sAction == '')?$this->oLivetrail->getAppMainPage($this->getCsrfToken()):$this->dispatch($sAction);
}
//Clean errors
$sDebug = ob_get_clean();
if($sDebug != '') $this->oLivetrail->addUncaughtError($sDebug);
$this->closeSession();
return $sResult;
}
private function validateMutationRequest(string $sAction): bool {
return
PHP_SAPI === 'cli'
||
!in_array($sAction, self::MUTATING_ACTIONS, true)
||
($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && $this->checkCsrfToken($this->asReq['csrf_token'])
;
}
private function getCsrfToken(): string {
if($this->sCsrfToken === '') $this->initCsrfToken();
return $this->sCsrfToken;
}
private function setCsrfToken(): void {
if(empty($_SESSION['csrf_token'])) $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
$this->sCsrfToken = $_SESSION['csrf_token'];
}
private function initCsrfToken(): void {
if(PHP_SAPI === 'cli') return;
if(session_status() !== PHP_SESSION_ACTIVE) {
$bSecure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
session_set_cookie_params(['httponly' => true, 'secure' => $bSecure, 'samesite' => 'Lax']);
session_start();
}
$this->setCsrfToken();
}
private function checkCsrfToken(string $sClientToken): bool {
$sServerToken = $this->getCsrfToken();
return PHP_SAPI === 'cli' || ($sServerToken !== '' && is_string($sClientToken) && hash_equals($sServerToken, $sClientToken));
}
private function closeSession(): void {
if(session_status() === PHP_SESSION_ACTIVE) session_write_close();
}
private function dispatch(string $sAction): string {
return match($sAction) {
'markers' => $this->oLivetrail->getMarkers(),
'last_update' => $this->oLivetrail->getLastUpdate(),
'next_feed' => $this->oLivetrail->getNextFeed($this->asReq['id']),
'new_feed' => $this->oLivetrail->getNewFeed($this->asReq['id']),
'add_post' => $this->oLivetrail->addPost($this->asReq['name'], $this->asReq['content'], $this->asReq['ref_type'], $this->asReq['ref_id']),
'subscribe' => $this->oLivetrail->subscribe(),
'unsubscribe' => $this->oLivetrail->unsubscribe(),
'login' => $this->oLivetrail->login($this->asReq['email'], $this->asReq['password'], $this->asReq['name']),
'logout' => $this->oLivetrail->logout(),
'update_project' => $this->oLivetrail->updateProject(),
default => $this->dispatchAdmin($sAction)
};
}
private function dispatchAdmin(string $sAction): string {
if(!$this->oLivetrail->checkUserClearance(User::CLEARANCE_ADMIN)) {
return Livetrail::getJsonResult(false, Livetrail::NOT_FOUND);
}
return match($sAction) {
'upload' => $this->oLivetrail->upload(),
'add_comment' => $this->oLivetrail->addComment($this->asReq['id_entity'], $this->asReq['content']),
'add_position' => $this->oLivetrail->addPosition($this->asReq['latitude'], $this->asReq['longitude'], $this->asReq['timestamp']),
'admin_get' => $this->oLivetrail->getAdminSettings(),
'admin_set' => $this->oLivetrail->setAdminSettings($this->asReq['type'], $this->asReq['id_entity'], $this->asReq['field'], $this->asReq['value']),
'admin_create' => $this->oLivetrail->createAdminSettings($this->asReq['type']),
'admin_delete' => $this->oLivetrail->deleteAdminSettings($this->asReq['type'], $this->asReq['id_entity']),
'sql' => $this->oLivetrail->getDbBuildScript(),
default => Livetrail::getJsonResult(false, Livetrail::NOT_FOUND)
};
}
private static function validateValue(string $sValidation, $oValue=0) {
return match($sValidation) {
'' => $oValue,
'positiveInt' => filter_var($oValue, FILTER_VALIDATE_INT, ['options' => ['default' => 0, 'min_range' => 0]])
};
}
}
+17 -11
View File
@@ -1,6 +1,6 @@
<?php
namespace Franzz\Spot;
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
/**
@@ -10,9 +10,6 @@ use Franzz\Objects\PhpObject;
* 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 {
@@ -20,7 +17,7 @@ class Converter extends PhpObject {
parent::__construct(__CLASS__);
}
public static function convertToGeoJson($sCodeName) {
public static function convertToGeoJson(string $sCodeName) {
$oGpx = new Gpx($sCodeName);
$oGeoJson = new GeoJson($sCodeName);
@@ -29,14 +26,23 @@ class Converter extends PhpObject {
$oGeoJson->sortOffTracks();
$oGeoJson->saveFile();
return $oGpx->getLog().'<br />'.$oGeoJson->getLog();
return [
'logs' => $oGpx->getLog().'<br />'.$oGeoJson->getLog(),
'center' => $oGeoJson->getCenter()
];
}
public static function isGeoJsonValid($sCodeName) {
$sGpxFilePath = Gpx::getFilePath($sCodeName);
$sGeoJsonFilePath = GeoJson::getFilePath($sCodeName);
public static function hasGpxFile(string $sCodeName) {
return ($sCodeName != '' && file_exists(Gpx::getBackendFilePath($sCodeName)));
}
//No need to generate if gpx is missing
return !file_exists($sGpxFilePath) || file_exists($sGeoJsonFilePath) && filemtime($sGeoJsonFilePath) >= filemtime($sGpxFilePath);
public static function isGeoJsonValid(string $sCodeName) {
$sGpxFilePath = Gpx::getBackendFilePath($sCodeName);
$sGeoJsonFilePath = GeoJson::getBackendFilePath($sCodeName);
return
!self::hasGpxFile($sCodeName) //No GPX, no need for geoJSON
||
file_exists($sGeoJsonFilePath) && filemtime($sGeoJsonFilePath) >= filemtime($sGpxFilePath); //geoJSON needs to be (re)generated
}
}
+6 -6
View File
@@ -1,6 +1,6 @@
<?php
namespace Franzz\Spot;
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use Franzz\Objects\Mask;
use PHPMailer\PHPMailer\PHPMailer;
@@ -25,7 +25,7 @@ class Email extends PhpObject {
parent::__construct(__CLASS__);
$this->sServName = $sServName;
$this->setTemplate($sTemplateName);
$this->asDests = array();
$this->asDests = [];
}
public function setTemplate($sTemplateName) {
@@ -39,7 +39,7 @@ class Email extends PhpObject {
* @param array $asDests Contains: id_user, name, email, language, timezone, active
*/
public function setDestInfo($asDests) {
if(array_key_exists('email', $asDests)) $asDests = array($asDests);
if(array_key_exists('email', $asDests)) $asDests = [$asDests];
$this->asDests = $asDests;
}
@@ -57,13 +57,13 @@ class Email extends PhpObject {
$oPHPMailer->Password = Settings::MAIL_PASS; //SMTP password
$oPHPMailer->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; //Enable TLS encryption; `PHPMailer::ENCRYPTION_SMTPS` encouraged
$oPHPMailer->Port = 587; //TCP port to connect to, use 465 for `PHPMailer::ENCRYPTION_SMTPS` above
$oPHPMailer->setFrom(Settings::MAIL_FROM, 'Spotty');
$oPHPMailer->addReplyTo(Settings::MAIL_FROM, 'Spotty');
$oPHPMailer->setFrom(Settings::MAIL_FROM, Livetrail::PROJECT_NAME);
$oPHPMailer->addReplyTo(Settings::MAIL_FROM, Livetrail::PROJECT_NAME);
$bSuccess = true;
foreach($this->asDests as $asDest) {
//Message
$this->oTemplate->setLanguage($asDest['language'], Spot::DEFAULT_LANG);
$this->oTemplate->setLanguage($asDest['language'], Livetrail::DEFAULT_LANG);
$this->oTemplate->setTimezone($asDest['timezone']);
//Unsubscribe Link
+76 -69
View File
@@ -1,6 +1,6 @@
<?php
namespace Franzz\Spot;
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use Franzz\Objects\Db;
use Franzz\Objects\Translator;
@@ -13,35 +13,32 @@ use \Settings;
class Feed extends PhpObject {
//Spot feed
const FEED_HOOK = 'https://api.findmespot.com/spot-main-web/consumer/rest-api/2.0/public/feed/';
const FEED_TYPE_XML = '/message.xml';
const FEED_TYPE_JSON = '/message.json';
const FEED_MAX_REFRESH = 5 * 60; //Seconds
private const FEED_HOOK = 'https://api.findmespot.com/spot-main-web/consumer/rest-api/2.0/public/feed/';
private const FEED_TYPE_XML = '/message.xml';
private const FEED_TYPE_JSON = '/message.json';
private const FEED_MAX_REFRESH = 5 * 60; //Seconds
//Weather
const WEATHER_HOOK = 'https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline';
const WEATHER_PARAM = array(
private const WEATHER_HOOK = 'https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline';
private const WEATHER_PARAM = [
'key' => Settings::WEATHER_TOKEN,
'unitGroup' => 'metric',
'lang' => 'en',
'include' => 'current',
'iconSet' => 'icons2'
);
];
//Timezone
const TIMEZONE_HOOK = 'http://api.geonames.org/timezoneJSON';
const TIMEZONE_PARAM = array(
'username' => Settings::TIMEZONE_USER
);
private const TIMEZONE_HOOK = 'http://api.geonames.org/timezoneJSON';
//DB Tables
const SPOT_TABLE = 'spots';
const FEED_TABLE = 'feeds';
const MSG_TABLE = 'messages';
public const SPOT_TABLE = 'spots';
public const FEED_TABLE = 'feeds';
public const MSG_TABLE = 'messages';
//Hide/Display values
const MSG_HIDDEN = 0;
const MSG_DISPLAYED = 1;
public const MSG_HIDDEN = 0;
public const MSG_DISPLAYED = 1;
/**
* Database Handle
@@ -75,10 +72,10 @@ class Feed extends PhpObject {
}
public function createFeedId($oProjectId) {
$this->setFeedId($this->oDb->insertRow(self::FEED_TABLE, array(
$this->setFeedId($this->oDb->insertRow(self::FEED_TABLE, [
Db::getId(Project::PROJ_TABLE) => $oProjectId,
'status' => 'INACTIVE'
)));
]));
return $this->getFeedId();
}
@@ -95,14 +92,14 @@ class Feed extends PhpObject {
}
public function getSpots() {
$asSpots = $this->oDb->selectRows(array('from'=>self::SPOT_TABLE));
$asSpots = $this->oDb->selectRows(['from'=>self::SPOT_TABLE]);
foreach($asSpots as &$asSpot) $asSpot['id'] = $asSpot[Db::getId(self::SPOT_TABLE)];
return $asSpots;
}
public function getFeeds($iFeedId=0) {
$asInfo = array('from'=>self::FEED_TABLE);
if($iFeedId > 0) $asInfo['constraint'] = array(Db::getId(self::FEED_TABLE)=>$iFeedId);
$asInfo = ['from'=>self::FEED_TABLE];
if($iFeedId > 0) $asInfo['constraint'] = [Db::getId(self::FEED_TABLE)=>$iFeedId];
$asFeeds = $this->oDb->selectRows($asInfo);
foreach($asFeeds as &$asFeed) $asFeed['id'] = $asFeed[Db::getId(self::FEED_TABLE)];
@@ -114,21 +111,21 @@ class Feed extends PhpObject {
return array_shift($asFeeds);
}
public function getMessages($asConstraints=array()) {
public function getMessages($asConstraints=[]) {
$sFeedIdCol = Db::getId(self::FEED_TABLE, true);
$asInfo = array(
'select' => array(
$asInfo = [
'select' => [
Db::getId(self::MSG_TABLE), 'ref_msg_id', 'type', //ID
'latitude', 'longitude', //Position
'site_time', 'timezone', 'unix_time', //Time
'weather_icon', 'weather_cond', 'weather_temp' //Weather
),
],
'from' => self::MSG_TABLE,
'join' => array(self::FEED_TABLE => Db::getId(self::FEED_TABLE)),
'constraint'=> array($sFeedIdCol => $this->getFeedId(), 'display' => self::MSG_DISPLAYED),
'constOpe' => array($sFeedIdCol => "=", 'display' => "="),
'orderBy' => array('site_time'=>'ASC')
);
'join' => [self::FEED_TABLE => Db::getId(self::FEED_TABLE)],
'constraint'=> [$sFeedIdCol => $this->getFeedId(), 'display' => self::MSG_DISPLAYED],
'constOpe' => [$sFeedIdCol => '=', 'display' => '='],
'orderBy' => ['site_time'=>'ASC']
];
if(!empty($asConstraints)) $asInfo = array_merge($asInfo, $asConstraints);
$asResult = $this->oDb->selectRows($asInfo);
@@ -137,7 +134,7 @@ class Feed extends PhpObject {
$iCount = 0;
foreach($asResult as &$asMsg) {
if($asMsg['weather_icon'] == '' && $iCount < 3) {
$asWeather = $this->getWeather(array($asMsg['latitude'], $asMsg['longitude']), $asMsg['unix_time']);
$asWeather = $this->getWeather([$asMsg['latitude'], $asMsg['longitude']], $asMsg['unix_time']);
$asMsg = array_merge($asMsg, $asWeather);
$this->oDb->updateRow(self::MSG_TABLE, $asMsg[Db::getId(self::MSG_TABLE)], $asWeather, false);
$iCount++;
@@ -148,7 +145,7 @@ class Feed extends PhpObject {
return $asResult;
}
public function getLastMessageId($asConstraints=array()) {
public function getLastMessageId($asConstraints=[]) {
$asMessages = $this->getMessages($asConstraints);
return end($asMessages)[Db::getId(self::MSG_TABLE)] ?? 0;
}
@@ -172,7 +169,7 @@ class Feed extends PhpObject {
$sTimeZone = date_default_timezone_get();
$oDateTime = new \DateTime('@'.$iTimestamp);
$oDateTime->setTimezone(new \DateTimeZone($sTimeZone));
$asWeather = $this->getWeather(array($sLat, $sLng), $iTimestamp);
$asWeather = $this->getWeather([$sLat, $sLng], $iTimestamp);
$asMsg = [
'ref_msg_id' => $iTimestamp.'/man',
@@ -203,33 +200,33 @@ class Feed extends PhpObject {
//Fix unstable Spot API Structure
if(array_key_exists('message', $asMsgs)) $asMsgs = $asMsgs['message']; //Sometimes adds an extra "message" level
if(!array_key_exists(0, $asMsgs)) $asMsgs = array($asMsgs); //Jumps a level when there is only 1 message
if(!array_key_exists(0, $asMsgs)) $asMsgs = [$asMsgs]; //Jumps a level when there is only 1 message
//Update Spot, Feed & Messages
if(!empty($asMsgs) && array_key_exists('messengerId', $asMsgs[0])) {
//Update Spot Info from the first message
$asSpotInfo = array(
$asSpotInfo = [
'ref_spot_id' => $asMsgs[0]['messengerId'],
'name' => $asMsgs[0]['messengerName'],
'model' => $asMsgs[0]['modelId']
);
$iSpotId = $this->oDb->insertUpdateRow(self::SPOT_TABLE, $asSpotInfo, array('ref_spot_id'));
];
$iSpotId = $this->oDb->insertUpdateRow(self::SPOT_TABLE, $asSpotInfo, ['ref_spot_id']);
//Update Feed Info and last update date
$asFeedInfo = array(
$asFeedInfo = [
'ref_feed_id' => $asFeed['id'],
Db::getId(self::SPOT_TABLE) => $iSpotId,
'name' => $asFeed['name'],
'description' => $asFeed['description'],
'status' => $asFeed['status'],
'last_update' => $sNow
);
$iFeedId = $this->oDb->insertUpdateRow(self::FEED_TABLE, $asFeedInfo, array('ref_feed_id'));
];
$iFeedId = $this->oDb->insertUpdateRow(self::FEED_TABLE, $asFeedInfo, ['ref_feed_id']);
//Update Messages
foreach($asMsgs as $asMsg) {
$asMsg = array(
$asMsg = [
'ref_msg_id' => $asMsg['id'],
Db::getId(self::FEED_TABLE) => $iFeedId,
'type' => $asMsg['messageType'],
@@ -237,19 +234,19 @@ class Feed extends PhpObject {
'longitude' => $asMsg['longitude'],
'iso_time' => $asMsg['dateTime'], //ISO 8601 time (backup)
'site_time' => date(Db::TIMESTAMP_FORMAT, $asMsg['unixTime']), //Conversion to Site Time
'timezone' => $this->getTimeZone(array($asMsg['latitude'], $asMsg['longitude']), $asMsg['unixTime']),
'timezone' => $this->getTimeZone($asMsg['latitude'], $asMsg['longitude']), //Get message time zone
'unix_time' => $asMsg['unixTime'], //UNIX Time (backup)
'content' => $asMsg['messageContent'],
'battery_state' => $asMsg['batteryState']
);
];
$iMsgId = $this->oDb->selectId(self::MSG_TABLE, array('ref_msg_id'=>$asMsg['ref_msg_id']));
$iMsgId = $this->oDb->selectId(self::MSG_TABLE, ['ref_msg_id'=>$asMsg['ref_msg_id']]);
if(!$iMsgId) {
//First Catch
$asMsg['posted_on'] = $sNow;
//Weather Data
$asMsg = array_merge($asMsg, $this->getWeather(array($asMsg['latitude'], $asMsg['longitude']), $asMsg['unix_time']));
$asMsg = array_merge($asMsg, $this->getWeather([$asMsg['latitude'], $asMsg['longitude']], $asMsg['unix_time']));
$this->oDb->insertRow(self::MSG_TABLE, $asMsg);
$bNewMsg = true;
@@ -258,7 +255,7 @@ class Feed extends PhpObject {
}
}
}
else $this->oDb->updateRow(self::FEED_TABLE, $this->getFeedId(), array('last_update'=>$sNow));
else $this->oDb->updateRow(self::FEED_TABLE, $this->getFeedId(), ['last_update'=>$sNow]);
return $bNewMsg;
}
@@ -281,20 +278,22 @@ class Feed extends PhpObject {
$sWeatherIcon = 'unknown';
}
//Get Condition ID
$sCondKey = (new Translator(self::WEATHER_PARAM['lang']))->getTranslationKey($sWeatherCond);
//Get Condition Language ID
$sCondLangId = (new Translator(self::WEATHER_PARAM['lang']))->getTranslationKey($sWeatherCond);
return array(
return [
'weather_icon' => $sWeatherIcon,
'weather_cond' => $sCondKey,
'weather_cond' => $sCondLangId,
'weather_temp' => floatval($sWeatherTemp)
);
];
}
private function getTimeZone($asLatLng, $iTimeStamp) {
$asParams = self::TIMEZONE_PARAM;
$asParams['lat'] = $asLatLng[0];
$asParams['lng'] = $asLatLng[1];
private function getTimeZone($iLat, $iLng) {
$asParams = [
'username' => Settings::TIMEZONE_USER,
'lat' => $iLat,
'lng' => $iLng
];
$sApiUrl = self::TIMEZONE_HOOK.'?'.http_build_query($asParams);
$asTimeZone = json_decode(file_get_contents($sApiUrl), true);
@@ -303,32 +302,40 @@ class Feed extends PhpObject {
}
private function retrieveFeed() {
$sContent = '[]';
if($this->sRefFeedId !='') {
if($this->sRefFeedId == '') {
$sContent = '{"response":{"errors":{"error":{"code":"","text":"Feed reference ID missing","description":"Feed reference ID missing"}}}}';
}
else {
$sUrl = self::FEED_HOOK.$this->sRefFeedId.self::FEED_TYPE_JSON;
$sContent = file_get_contents($sUrl);
}
return json_decode($sContent, true);
}
private function updateField($sField, $oValue) {
$bResult = ($this->oDb->updateRow(self::FEED_TABLE, $this->getFeedId(), array($sField=>$oValue)) > 0);
$bResult = ($this->oDb->updateRow(self::FEED_TABLE, $this->getFeedId(), [$sField=>$oValue]) > 0);
$this->setFeedId($this->getFeedId());
return $bResult;
}
public function delete() {
$asResult = array();
if($this->getFeedId() > 0) {
$asResult = array(
'id' => $this->getFeedId(),
'del' => $this->oDb->deleteRow(self::FEED_TABLE, $this->getFeedId()),
'desc' => $this->oDb->getLastError()
);
}
else $asResult = array('del'=>false, 'desc'=>'Error while setting project: no Feed ID');
$bSuccess = false;
$sLangId = '';
$asLangParams = [];
$asData = [];
return $asResult;
if($this->getFeedId() > 0) {
$asData['id'] = $this->getFeedId();
$bSuccess = $this->oDb->deleteRow(self::FEED_TABLE, $this->getFeedId());
if(!$bSuccess) $sLangId = 'error.commit_db';
}
else {
$sLangId = 'error.impossible_value';
$asLangParams = [$this->getFeedId(), 'feed ID'];
}
return Livetrail::getResult($bSuccess, $sLangId, $asData, $asLangParams);
}
}
+16 -13
View File
@@ -1,29 +1,32 @@
<?php
namespace Franzz\Spot;
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use \Settings;
class Geo extends PhpObject {
abstract class Geo extends PhpObject {
protected const EXT = '';
const GEO_FOLDER = '../geo/';
const OPT_SIMPLE = 'simplification';
protected const GEO_FOLDER = 'geo';
protected const OPT_SIMPLE = 'simplification';
protected $asTracks;
protected $sFilePath;
protected array $asTracks;
protected string $sFilePath;
public function __construct($sCodeName) {
public function __construct(string $sCodeName) {
parent::__construct(get_class($this), Settings::DEBUG, PhpObject::MODE_HTML);
$this->sFilePath = self::getFilePath($sCodeName);
$this->asTracks = array();
$this->sFilePath = self::getBackEndFilePath($sCodeName);
$this->asTracks = [];
}
public static function getFilePath($sCodeName) {
return self::GEO_FOLDER.$sCodeName.static::EXT;
//Access from backend
public static function getBackendFilePath(string $sCodeName) {
return '../resources/'.self::GEO_FOLDER.'/'.$sCodeName.static::EXT;
}
public static function getDistFilePath($sCodeName) {
return 'geo/'.$sCodeName.static::EXT;
//Access from frontend (/public/geo is a symlink of /resources/geo)
public static function getFrontendFilePath(string $sCodeName) {
return self::GEO_FOLDER.'/'.$sCodeName.static::EXT;
}
public function getLog() {
+41 -25
View File
@@ -1,13 +1,13 @@
<?php
namespace Franzz\Spot;
namespace Franzz\Livetrail;
class GeoJson extends Geo {
protected const EXT = '.geojson';
const EXT = '.geojson';
const MAX_FILESIZE = 2; //MB
const MAX_DEVIATION_FLAT = 0.1; //10%
const MAX_DEVIATION_ELEV = 0.1; //10%
private const MAX_FILESIZE = 2; //MB
private const MAX_DEVIATION_FLAT = 0.1; //10%
private const MAX_DEVIATION_ELEV = 0.1; //10%
public function __construct($sCodeName) {
parent::__construct($sCodeName);
@@ -38,7 +38,7 @@ class GeoJson extends Geo {
$iGlobalInvalidPointCount = 0;
$iGlobalPointCount = 0;
$this->asTracks = array();
$this->asTracks = [];
foreach($asTracks as $asTrackProps) {
$asOptions = $this->parseOptions($asTrackProps['cmt']);
@@ -64,26 +64,31 @@ class GeoJson extends Geo {
continue 2; //discard tracks
}
$asTrack = array(
$asTrack = [
'type' => 'Feature',
'properties' => array(
'properties' => [
'name' => $asTrackProps['name'],
'type' => $sType,
'description' => $asTrackProps['desc']
),
'geometry' => array(
],
'geometry' => [
'type' => 'LineString',
'coordinates' => array()
)
);
'coordinates' => []
]
];
if($sType != 'hitchhiking' && str_contains($asTrackProps['desc'], ' ➜ ')) {
list($sFrom, $sTo) = explode(' ➜ ', $asTrackProps['desc']);
$asTrack['properties']['leg'] = ['from'=> $sFrom, 'to'=> $sTo];
}
//Track points
$asTrackPoints = $asTrackProps['points'];
$iPointCount = count($asTrackPoints);
$iInvalidPointCount = 0;
$asPrevPoint = array();
$asPrevPoint = [];
foreach($asTrackPoints as $iIndex=>$asPoint) {
$asNextPoint = ($iIndex < ($iPointCount - 1))?$asTrackPoints[$iIndex + 1]:array();
$asNextPoint = ($iIndex < ($iPointCount - 1))?$asTrackPoints[$iIndex + 1]:[];
if($bSimplify && !empty($asPrevPoint) && !empty($asNextPoint)) {
if(!$this->isPointValid($asPrevPoint, $asPoint, $asNextPoint)) {
$iInvalidPointCount++;
@@ -103,16 +108,15 @@ class GeoJson extends Geo {
if($bSimplify) $this->addNotice('Total: '.$iGlobalInvalidPointCount.'/'.$iGlobalPointCount.' points removed ('.round($iGlobalInvalidPointCount / $iGlobalPointCount * 100, 1).'%)');
}
public function sortOffTracks() {
$this->addNotice('Sorting off-tracks');
//Find first & last track points
$asTracksEnds = array();
$asTracks = array();
$asTracksEnds = [];
$asTracks = [];
foreach($this->asTracks as $iTrackId=>$asTrack) {
$sTrackId = 't'.$iTrackId;
$asTracksEnds[$sTrackId] = array('first'=>reset($asTrack['geometry']['coordinates']), 'last'=>end($asTrack['geometry']['coordinates']));
$asTracksEnds[$sTrackId] = ['first'=>reset($asTrack['geometry']['coordinates']), 'last'=>end($asTrack['geometry']['coordinates'])];
$asTracks[$sTrackId] = $asTrack;
}
@@ -149,15 +153,27 @@ class GeoJson extends Geo {
//Move track
unset($asTracks[$sTrackId]);
$iOffset = array_search($sConnectedTrackId, array_keys($asTracks)) + $iPosition;
$asTracks = array_slice($asTracks, 0, $iOffset) + array($sTrackId => $asTrack) + array_slice($asTracks, $iOffset);
$asTracks = array_slice($asTracks, 0, $iOffset) + [$sTrackId => $asTrack] + array_slice($asTracks, $iOffset);
}
$this->asTracks = array_values($asTracks);
}
private function parseOptions($sComment){
public function getCenter() {
$asCoords = [];
$asMainTracks = array_filter($this->asTracks, function ($astrack) {return $astrack['properties']['type'] == 'main';});
foreach($asMainTracks as $asMainTrack) {
foreach($asMainTrack['geometry']['coordinates'] as $aiCoords) {
$asCoords[] = $aiCoords;
}
}
return $asCoords[(int) floor(count($asCoords) / 2)];
}
private function parseOptions($sComment) {
$sComment = strip_tags(html_entity_decode($sComment));
$asOptions = array(self::OPT_SIMPLE=>'');
$asOptions = [self::OPT_SIMPLE=>''];
foreach(explode("\n", $sComment) as $sLine) {
$asOptions[mb_strtolower(trim(mb_strstr($sLine, ':', true)))] = mb_strtolower(trim(mb_substr(mb_strstr($sLine, ':'), 1)));
}
@@ -173,8 +189,8 @@ class GeoJson extends Geo {
//Path Turn Check -> -> -> ->
//Law of Cosines (vector): angle = arccos(OA.OB / ||OA||.||OB||)
$fVectorOA = array('lon'=>($asPointA['lon'] - $asPointO['lon']), 'lat'=> ($asPointA['lat'] - $asPointO['lat']));
$fVectorOB = array('lon'=>($asPointB['lon'] - $asPointO['lon']), 'lat'=> ($asPointB['lat'] - $asPointO['lat']));
$fVectorOA = ['lon'=>($asPointA['lon'] - $asPointO['lon']), 'lat'=> ($asPointA['lat'] - $asPointO['lat'])];
$fVectorOB = ['lon'=>($asPointB['lon'] - $asPointO['lon']), 'lat'=> ($asPointB['lat'] - $asPointO['lat'])];
$fLengthOA = sqrt(pow($asPointA['lon'] - $asPointO['lon'], 2) + pow($asPointA['lat'] - $asPointO['lat'], 2));
$fLengthOB = sqrt(pow($asPointO['lon'] - $asPointB['lon'], 2) + pow($asPointO['lat'] - $asPointB['lat'], 2));
@@ -194,7 +210,7 @@ class GeoJson extends Geo {
}
private function buildGeoJson() {
return json_encode(array('type'=>'FeatureCollection', 'features'=>$this->asTracks));
return json_encode(['type'=>'FeatureCollection', 'features'=>$this->asTracks]);
}
private static function getDistance($asPointA, $asPointB) {
+7 -7
View File
@@ -1,11 +1,11 @@
<?php
namespace Franzz\Spot;
namespace Franzz\Livetrail;
use Franzz\Objects\ToolBox;
class Gpx extends Geo {
const EXT = '.gpx';
public const EXT = '.gpx';
public function __construct($sCodeName) {
parent::__construct($sCodeName);
@@ -25,21 +25,21 @@ class Gpx extends Geo {
//Tracks
$this->addNotice('Converting '.count($oXml->trk).' tracks');
foreach($oXml->trk as $aoTrack) {
$asTrack = array(
$asTrack = [
'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()
);
'points'=> []
];
foreach($aoTrack->trkseg as $asSegment) {
foreach($asSegment as $asPoint) {
$asTrack['points'][] = array(
$asTrack['points'][] = [
'lon' => (float) $asPoint['lon'],
'lat' => (float) $asPoint['lat'],
'ele' => (int) $asPoint->ele
);
];
}
}
$this->asTracks[] = $asTrack;
File diff suppressed because it is too large Load Diff
+10 -11
View File
@@ -1,14 +1,13 @@
<?php
namespace Franzz\Spot;
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use Franzz\Objects\Db;
use \Settings;
class Map extends PhpObject {
const MAP_TABLE = 'maps';
const MAPPING_TABLE = 'mappings';
public const MAP_TABLE = 'maps';
public const MAPPING_TABLE = 'mappings';
private Db $oDb;
private $asMaps;
@@ -16,11 +15,11 @@ class Map extends PhpObject {
public function __construct(Db &$oDb) {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
$this->asMaps = array();
$this->asMaps = [];
}
private function setMaps() {
$asMaps = $this->oDb->selectRows(array('from'=>self::MAP_TABLE));
$asMaps = $this->oDb->selectRows(['from'=>self::MAP_TABLE]);
foreach($asMaps as $asMap) $this->asMaps[$asMap['codename']] = $asMap;
}
@@ -31,15 +30,15 @@ class Map extends PhpObject {
public function getProjectMaps($iProjectId) {
$asMappings = $this->oDb->selectRows(
array(
'select' => array(Db::getId(self::MAP_TABLE), 'default_map'),
[
'select' => [Db::getId(self::MAP_TABLE), 'default_map'],
'from' => self::MAPPING_TABLE,
'constraint'=> array("IFNULL(id_project, {$iProjectId})" => $iProjectId)
),
'constraint'=> ["IFNULL(id_project, {$iProjectId})" => $iProjectId]
],
Db::getId(self::MAP_TABLE)
);
$asProjectMaps = array();
$asProjectMaps = [];
foreach($this->getMaps() as $asMap) {
if(array_key_exists($asMap['id_map'], $asMappings)) {
$asMap['default_map'] = $asMappings[$asMap['id_map']];
+54 -57
View File
@@ -1,27 +1,25 @@
<?php
namespace Franzz\Spot;
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use Franzz\Objects\Db;
use Franzz\Objects\ToolBox;
use \Settings;
class Media extends PhpObject {
//DB Tables
const MEDIA_TABLE = 'medias';
public const MEDIA_TABLE = 'medias';
//Media folders
const MEDIA_FOLDER = 'files/';
const THUMB_FOLDER = self::MEDIA_FOLDER.'thumbs/';
//Media folders (works because /public/files is a symlink of /files)
public const MEDIA_FOLDER = 'files';
public const THUMB_FOLDER = self::MEDIA_FOLDER.'/thumbs';
const THUMB_MAX_WIDTH = 400;
private const THUMB_MAX_WIDTH = 400;
private Db $oDb;
private Project $oProject;
private $asMedia;
private $asMedias;
//private $sSystemType;
private $iMediaId;
@@ -29,9 +27,8 @@ class Media extends PhpObject {
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->asMedia = [];
$this->asMedias = [];
$this->setMediaId($iMediaId);
}
@@ -48,16 +45,16 @@ class Media extends PhpObject {
}
public function setComment($sComment) {
$sError = '';
$asData = array();
$sLangId = '';
$asData = [];
if($this->iMediaId > 0) {
$bResult = $this->oDb->updateRow(self::MEDIA_TABLE, $this->iMediaId, array('comment'=>$sComment));
if(!$bResult) $sError = 'error.commit_db';
$bResult = $this->oDb->updateRow(self::MEDIA_TABLE, $this->iMediaId, ['comment'=>$sComment]);
if(!$bResult) $sLangId = 'error.commit_db';
else $asData = $this->getInfo();
}
else $sError = 'media.no_id';
else $sLangId = 'media.no_id';
return Spot::getResult(($sError==''), $sError, $asData);
return Livetrail::getResult(($sLangId==''), $sLangId, $asData);
}
public function getMediasInfo($oMediaIds=null) {
@@ -66,11 +63,11 @@ class Media extends PhpObject {
if($bOwnMedia && empty($this->asMedia) || !$bOwnMedia && empty($this->asMedias) || $bConstraintArray) {
if($this->oProject->getProjectId()) {
$asParams = array(
'select' => array(Db::getId(self::MEDIA_TABLE), 'filename', 'taken_on', 'posted_on', 'timezone', 'latitude', 'longitude', 'altitude', 'width', 'height', 'rotate', 'type AS subtype', 'comment'),
$asParams = [
'select' => [Db::getId(self::MEDIA_TABLE), 'filename', 'taken_on', 'posted_on', 'timezone', 'latitude', 'longitude', 'altitude', 'width', 'height', 'rotate', 'type AS subtype', 'comment'],
'from' => self::MEDIA_TABLE,
'constraint'=> array(Db::getId(Project::PROJ_TABLE) => $this->oProject->getProjectId())
);
'constraint'=> [Db::getId(Project::PROJ_TABLE) => $this->oProject->getProjectId()]
];
if($bOwnMedia) $asParams['constraint'][Db::getId(self::MEDIA_TABLE)] = $oMediaIds;
if($bConstraintArray) $asParams = array_merge($asParams, $oMediaIds);
@@ -86,7 +83,7 @@ class Media extends PhpObject {
}
}
}
return $bOwnMedia?$this->asMedia:$asMedias;
return $bOwnMedia?$this->asMedia:$asMedias ?? [];
}
public function getInfo() {
@@ -98,14 +95,14 @@ class Media extends PhpObject {
}
public function addMedia($sMediaName, $sMethod='upload') {
$sError = '';
$asParams = array();
$sLangId = '';
$asParams = [];
if(!$this->isProjectEditable() && $sMethod!='sync') {
$sError = 'upload.mode_archived';
$sLangId = 'upload.mode_archived';
$asParams[] = $this->oProject->getProjectCodeName();
}
elseif($this->oDb->pingValue(self::MEDIA_TABLE, array('filename'=>$sMediaName)) && $sMethod!='sync') {
$sError = 'upload.media.exists';
elseif($this->oDb->pingValue(self::MEDIA_TABLE, ['filename'=>$sMediaName]) && $sMethod!='sync') {
$sLangId = 'upload.media.exists';
$asParams[] = $sMediaName;
}
else {
@@ -113,7 +110,7 @@ class Media extends PhpObject {
//Converting times to Site Time Zone, by using date()
//Media Timezone is kept in a separate field for later conversion to Local Time
$asDbInfo = array(
$asDbInfo = [
Db::getId(Project::PROJ_TABLE) => $this->oProject->getProjectId(),
'filename' => $sMediaName,
'taken_on' => date(Db::TIMESTAMP_FORMAT, ($asMediaInfo['taken_ts'] > 0)?$asMediaInfo['taken_ts']:$asMediaInfo['file_ts']),
@@ -126,23 +123,22 @@ class Media extends PhpObject {
'height' => $asMediaInfo['height'],
'rotate' => $asMediaInfo['rotate'],
'type' => $asMediaInfo['type']
);
];
if($sMethod=='sync') $iMediaId = $this->oDb->insertUpdateRow(self::MEDIA_TABLE, $asDbInfo, array('filename'));
if($sMethod=='sync') $iMediaId = $this->oDb->insertUpdateRow(self::MEDIA_TABLE, $asDbInfo, ['filename']);
else $iMediaId = $this->oDb->insertRow(self::MEDIA_TABLE, $asDbInfo);
if(!$iMediaId) $sError = 'error.commit_db';
if(!$iMediaId) $sLangId = 'error.commit_db';
else {
$this->setMediaId($iMediaId);
$asParams = $this->getInfo(); //Creates thumbnail
}
}
return Spot::getResult(($sError==''), $sError, $asParams);
return Livetrail::getResult(($sLangId==''), $sLangId, $asParams);
}
private function getMediaInfoFromFile($sMediaName)
{
private function getMediaInfoFromFile($sMediaName) {
$sMediaPath = self::getMediaPath($sMediaName);
$sType = self::getMediaType($sMediaName);
$iPostedOn = filemtime($sMediaPath);
@@ -156,8 +152,8 @@ class Media extends PhpObject {
$iAlt = null;
switch($sType) {
case 'video':
$asResult = array();
$sParams = implode(' ', array(
$asResult = [];
$sParams = implode(' ', [
'-loglevel error', //Remove comments
'-select_streams v:0', //First video channel
'-show_entries '. //filter tags : Width, Height, Creation Time, Location & Rotation
@@ -166,14 +162,14 @@ class Media extends PhpObject {
'stream=width,height',
'-print_format json', //output format: json
'-i' //input file
));
exec('ffprobe '.$sParams.' "'.$sMediaPath.'"', $asResult);
]);
exec('ffprobe '.$sParams.' '.escapeshellarg($sMediaPath), $asResult);
$asExif = json_decode(implode('', $asResult), true);
//Taken On
if(isset($asExif['format']['tags']['com.apple.quicktime.creationdate'])) {
$sTakenOn = $asExif['format']['tags']['com.apple.quicktime.creationdate']; //contains Time Zone
$sTimeZone = Spot::getTimeZoneFromDate($sTakenOn) ?? $sTimeZone;
$sTimeZone = Livetrail::getTimeZoneFromDate($sTakenOn) ?? $sTimeZone;
}
else $sTakenOn = $asExif['format']['tags']['creation_time'] ?? $asExif['streams'][0]['tags']['creation_time'];
@@ -191,7 +187,7 @@ class Media extends PhpObject {
break;
case 'image':
$asExif = @exif_read_data($sMediaPath, 0, true);
if($asExif === false) $asExif = array();
if($asExif === false) $asExif = [];
list($iWidth, $iHeight) = getimagesize($sMediaPath);
//Posted On
@@ -207,7 +203,7 @@ class Media extends PhpObject {
* 3. Timezone extracted from DateTimeOriginal
* 4. Uploader Browser Time Zone (PHP Default Time Zone)
*/
$sTimeZone = $asExif['EXIF']['OffsetTimeOriginal'] ?? $asExif['EXIF']['UndefinedTag:0x9011'] ?? Spot::getTimeZoneFromDate($sTakenOn) ?? $sTimeZone;
$sTimeZone = $asExif['EXIF']['OffsetTimeOriginal'] ?? $asExif['EXIF']['UndefinedTag:0x9011'] ?? Livetrail::getTimeZoneFromDate($sTakenOn) ?? $sTimeZone;
}
//Location
@@ -220,8 +216,7 @@ class Media extends PhpObject {
//Orientation
if(array_key_exists('IFD0', $asExif) && array_key_exists('Orientation', $asExif['IFD0'])) {
switch($asExif['IFD0']['Orientation'])
{
switch($asExif['IFD0']['Orientation']) {
case 1: $sRotate = '0'; break; //None
case 3: $sRotate = '180'; break; //Flip over
case 6: $sRotate = '90'; break; //Clockwise
@@ -239,7 +234,7 @@ class Media extends PhpObject {
$iTakenOn = $oTakenOn->format('U');
}
return array(
return [
'timezone' => $sTimeZone,
'latitude' => $fLat,
'longitude' => $fLng,
@@ -250,11 +245,10 @@ class Media extends PhpObject {
'height' => $iHeight,
'rotate' => $sRotate,
'type' => $sType
);
];
}
private function getMediaThumbnail($sMediaName)
{
private function getMediaThumbnail($sMediaName) {
$sMediaPath = self::getMediaPath($sMediaName);
$sThumbPath = self::getMediaPath($sMediaName, 'thumbnail');
@@ -267,36 +261,39 @@ class Media extends PhpObject {
case 'video':
//Get a screenshot of the video 1 second in
$sTempPath = self::getMediaPath(uniqid('temp_').'.png');
$asResult = array();
$sParams = implode(' ', array(
'-i "'.$sMediaPath.'"', //input file
$asResult = [];
$sParams = implode(' ', [
'-i '.escapeshellarg($sMediaPath), //input file
'-ss 00:00:01.000', //Image taken after x seconds
'-vframes 1', //number of video frames to output
'"'.$sTempPath.'"', //output file
));
escapeshellarg($sTempPath), //output file
]);
exec('ffmpeg '.$sParams, $asResult);
//Resize
$asThumbInfo = ToolBox::createThumbnail($sTempPath, self::THUMB_MAX_WIDTH, 0, $sThumbPath, true);
break;
default:
$asThumbInfo = ['error'=>'error.unknown_type', 'out'=>''];
}
}
else $asThumbInfo = array('error'=>'', 'out'=>$sThumbPath);
else $asThumbInfo = ['error'=>'', 'out'=>$sThumbPath];
return ($asThumbInfo['error']=='')?$asThumbInfo['out']:$sMediaPath;
}
private static function getMediaPath($sMediaName, $sFileType='media') {
if($sFileType=='thumbnail') return self::THUMB_FOLDER.$sMediaName.(strtolower(substr($sMediaName, -3))=='mov'?'.png':'');
else return self::MEDIA_FOLDER.$sMediaName;
if($sFileType=='thumbnail') return self::THUMB_FOLDER.'/'.$sMediaName.(strtolower(substr($sMediaName, -3))=='mov'?'.png':'');
else return self::MEDIA_FOLDER.'/'.$sMediaName;
}
private static function getMediaType($sMediaName) {
$sMediaPath = self::getMediaPath($sMediaName);
$sMediaMime = mime_content_type($sMediaPath);
switch($sMediaMime) {
case 'video/quicktime': $sType = 'video'; break;
case 'video/quicktime':
case 'video/mp4': $sType = 'video'; break;
default: $sType = 'image'; break;
}
@@ -325,6 +322,6 @@ class Media extends PhpObject {
private static function getLatLngAltFromISO6709($sIso6709) {
preg_match('/^(?P<lat>[\+\-][0,1]?\d{2}\.\d+)(?P<lng>[\+\-][0,1]?\d{2}\.\d+)(?P<alt>[\+\-]\d+)?/', $sIso6709, $asMatches);
return array(floatval($asMatches['lat']), floatval($asMatches['lng']), floatval($asMatches['alt'] ?? 0));
return [floatval($asMatches['lat']), floatval($asMatches['lng']), floatval($asMatches['alt'] ?? 0)];
}
}
+53 -51
View File
@@ -1,20 +1,19 @@
<?php
namespace Franzz\Spot;
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use Franzz\Objects\Db;
use \Settings;
class Project extends PhpObject {
//Spot Mode
const MODE_PREVIZ = 'P';
const MODE_BLOG = 'B';
const MODE_HISTO = 'H';
const MODES = array('previz'=>self::MODE_PREVIZ, 'blog'=>self::MODE_BLOG, 'histo'=>self::MODE_HISTO);
public const MODE_PREVIZ = 'P';
public const MODE_BLOG = 'B';
public const MODE_HISTO = 'H';
public const MODES = ['previz'=>self::MODE_PREVIZ, 'blog'=>self::MODE_BLOG, 'histo'=>self::MODE_HISTO];
//DB Tables
const PROJ_TABLE = 'projects';
public const PROJ_TABLE = 'projects';
/**
* Database Handle
@@ -22,13 +21,11 @@ class Project extends PhpObject {
*/
private $oDb;
private $iProjectId;
private $sName;
private $sCodeName;
private $sMode;
private $asActive;
private $asGeo;
public function __construct(Db &$oDb, $iProjectId=0) {
parent::__construct(__CLASS__);
@@ -53,17 +50,17 @@ class Project extends PhpObject {
* Mode --P--][--------B--------][--P--][-----------B---------------][---P---][-----B-----][---------H----------
*/
$sQuery =
"SELECT MAX(id_project) ".
"FROM projects ".
"WHERE active_to = (".
"SELECT MIN(active_to) ". //Select closest project in the future
"FROM projects ".
"WHERE active_to > NOW() ". //Select Next project
"OR active_to = (". //In case there is no next project, select the last one
"SELECT MAX(active_to) ".
"FROM projects".
")".
")";
'SELECT MAX(id_project) '.
'FROM projects '.
'WHERE active_to = ('.
'SELECT MIN(active_to) '. //Select closest project in the future
'FROM projects '.
'WHERE active_to > NOW() '. //Select Next project
'OR active_to = ('. //In case there is no next project, select the last one
'SELECT MAX(active_to) '.
'FROM projects'.
')'.
')';
$asResult = $this->oDb->getArrayQuery($sQuery, true);
$this->iProjectId = array_shift($asResult);
}
@@ -72,7 +69,7 @@ class Project extends PhpObject {
}
public function createProjectId() {
$this->setProjectId($this->oDb->insertRow(self::PROJ_TABLE, array('codename'=>'')));
$this->setProjectId($this->oDb->insertRow(self::PROJ_TABLE, ['codename'=>'']));
return $this->getProjectId();
}
@@ -114,49 +111,53 @@ class Project extends PhpObject {
return $this->oDb->selectColumn(
Feed::FEED_TABLE,
Db::getId(Feed::FEED_TABLE),
array(Db::getId(self::PROJ_TABLE) => $this->getProjectId())
[Db::getId(self::PROJ_TABLE) => $this->getProjectId()]
);
}
public function getProjects($iProjectId=0) {
$bSpecificProj = ($iProjectId > 0);
$sDefaultProjectCodeName = $this->getProjectCodeName();
$asInfo = array(
'select'=> array(
Db::getId(self::PROJ_TABLE)." AS id",
$asInfo = [
'select'=> [
Db::getId(self::PROJ_TABLE).' AS id',
'codename',
'name',
'latitude',
'longitude',
'active_from',
'active_to',
"IF(NOW() BETWEEN active_from AND active_to, 1, IF(NOW() < active_from, 0, 2)) AS mode"
),
'from' => self::PROJ_TABLE
);
if($bSpecificProj) $asInfo['constraint'] = array(Db::getId(self::PROJ_TABLE)=>$iProjectId);
'IF(NOW() BETWEEN active_from AND active_to, 1, IF(NOW() < active_from, 0, 2)) AS mode'
],
'from' => self::PROJ_TABLE,
'orderBy' => ['active_from' => 'ASC']
];
if($bSpecificProj) $asInfo['constraint'] = [Db::getId(self::PROJ_TABLE)=>$iProjectId];
$asProjects = $this->oDb->selectRows($asInfo, 'codename');
foreach($asProjects as $sCodeName=>&$asProject) {
foreach($asProjects as $sCodeName => &$asProject) {
//Update geoJSON
if(Converter::hasGpxFile($sCodeName) && (!Converter::isGeoJsonValid($sCodeName) || !$asProject['latitude'] && !$asProject['longitude'])) {
$aiCenter = Converter::convertToGeoJson($sCodeName)['center'];
$this->oDb->updateRow(self::PROJ_TABLE, $asProject['id'], ['latitude' => $aiCenter[1], 'longitude' => $aiCenter[0]]);
$asProject['latitude'] = $aiCenter[1];
$asProject['longitude'] = $aiCenter[0];
}
switch($asProject['mode']) {
case 0: $asProject['mode'] = self::MODE_PREVIZ; break;
case 1: $asProject['mode'] = self::MODE_BLOG; break;
case 2: $asProject['mode'] = self::MODE_HISTO; break;
}
$asProject['editable'] = $this->isModeEditable($asProject['mode']);
//$asProject['geofilepath'] = Spot::addTimestampToFilePath(GeoJson::getDistFilePath($sCodeName));
$asProject['gpxfilepath'] = Spot::addTimestampToFilePath(Gpx::getDistFilePath($sCodeName));
$asProject['gpxfilepath'] = Livetrail::addTimestampToFilePath(Gpx::getFrontendFilePath($sCodeName));
$asProject['geofilepath'] = Livetrail::addTimestampToFilePath(GeoJson::getFrontendFilePath($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());
}
@@ -172,7 +173,7 @@ class Project extends PhpObject {
return $iLastUpdate;
}
public function getLastMessageId($asConstraints=array()): int {
public function getLastMessageId($asConstraints=[]): int {
$iLastMsg = 0;
$asFeedIds = $this->getFeedIds();
@@ -190,34 +191,35 @@ class Project extends PhpObject {
$this->sName = $asProject['name'];
$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->asActive = ['from'=>$asProject['active_from'], 'to'=>$asProject['active_to']];
}
else $this->addError('Error while setting project: no project ID');
}
private function updateField($sField, $oValue) {
$bResult = ($this->oDb->updateRow(self::PROJ_TABLE, $this->getProjectId(), array($sField=>$oValue)) > 0);
$bResult = ($this->oDb->updateRow(self::PROJ_TABLE, $this->getProjectId(), [$sField=>$oValue]) > 0);
$this->setProjectInfo();
return $bResult;
}
public function delete() {
$asResult = array();
$asResult = [];
if($this->getProjectId() > 0) {
$asFeedIds = $this->getFeedIds();
foreach($asFeedIds as $iFeedId) {
$asResult['feed'][] = (new Feed($this->oDb, $iFeedId))->delete();
}
$asResult['project'][] = array(
$bDeleted = $this->oDb->deleteRow(self::PROJ_TABLE, $this->getProjectId());
$asResult['project'][] = [
'id' => $this->getProjectId(),
'del' => $this->oDb->deleteRow(self::PROJ_TABLE, $this->getProjectId()),
'desc' => $this->oDb->getLastError()
);
'del' => $bDeleted,
'desc_lang_id' => $bDeleted?'':'error.commit_db',
'desc_lang_params' => []
];
}
else $asResult['project'][] = array('del'=>false, 'desc'=>'Error while setting project: no project ID');
else $asResult['project'][] = ['del'=>false, 'desc_lang_id'=>'error.impossible_value', 'desc_lang_params'=>[$this->getProjectId(), 'project ID']];
return $asResult;
}
@@ -226,7 +228,7 @@ class Project extends PhpObject {
return self::isModeEditable($this->getMode());
}
static public function isModeEditable($sMode) {
public static function isModeEditable($sMode) {
return ($sMode != self::MODE_HISTO);
}
}
+28 -31
View File
@@ -1,36 +1,22 @@
<?php
namespace Franzz\Spot;
namespace Franzz\Livetrail;
use Franzz\Objects\UploadHandler;
use Franzz\Objects\Translator;
class Uploader extends UploadHandler
{
/**
* Medias Management
* @var Media
*/
private $oMedia;
class Uploader extends UploadHandler {
private Media $oMedia;
/**
* Languages
* @var Translator
*/
private $oLang;
public string $sBody;
public $sBody;
function __construct(Media &$oMedia, Translator &$oLang)
{
public function __construct(Media &$oMedia) {
$this->oMedia = &$oMedia;
$this->oLang = &$oLang;
$this->sBody = '';
parent::__construct(array(
'upload_dir' => Media::MEDIA_FOLDER,
'image_versions' => array(),
parent::__construct([
'upload_dir' => Media::MEDIA_FOLDER.'/',
'image_versions' => [],
'accept_file_types' => '/\.(gif|jpe?g|png|mov|mp4)$/i'
));
]);
}
protected function validate($uploaded_file, $file, $error, $index, $content_range) {
@@ -38,7 +24,9 @@ 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 = true;
$file->desc_lang_id = 'upload.mode_archived';
$file->desc_lang_params = [$this->oMedia->getProjectCodeName()];
$bResult = false;
}
@@ -46,16 +34,28 @@ class Uploader extends UploadHandler
}
protected function handle_file_upload($uploaded_file, $name, $size, $type, $error, $index = null, $content_range = null) {
$file = parent::handle_file_upload($uploaded_file, $name, $size, $type, $error, $index, $content_range);
$sExt = strtolower(pathinfo((string) $name, PATHINFO_EXTENSION));
$sStoredName = bin2hex(random_bytes(16)).($sExt !== ''?'.'.$sExt:'');
$file = parent::handle_file_upload($uploaded_file, $sStoredName, $size, $type, $error, $index, $content_range);
if(empty($file->error)) {
$asResult = $this->oMedia->addMedia($file->name);
if(!$asResult['result']) $file->error = $this->get_error_message($asResult['desc'], $asResult['data']);
if(!$asResult['result']) {
$file->error = true;
$file->desc_lang_id = $asResult['desc_lang_id'];
$file->desc_lang_params = $asResult['data'];
}
else {
$file->original_name = basename((string) $name);
$file->id = $this->oMedia->getMediaId();
$file->thumbnail = $asResult['data']['thumb_path'];
}
}
if(!empty($file->error)) {
if(empty($file->desc_lang_id)) $file->desc_lang_id = is_string($file->error)?$file->error:'upload.error';
if(empty($file->desc_lang_params)) $file->desc_lang_params = [];
$file->error = true;
}
return $file;
}
@@ -64,10 +64,7 @@ class Uploader extends UploadHandler
$this->sBody .= $sBodyPart;
}
protected function get_error_message($sError, $asParams=array()) {
$sTranslatedError = $this->oLang->getTranslation($sError, $asParams);
if($sTranslatedError) return $sTranslatedError;
elseif(array_key_exists($sError, $this->error_messages)) return $this->error_messages[$sError];
else return $sError;
protected function get_error_message($sLangId, $asParams=[]) {
return array_key_exists($sLangId, $this->error_messages)?'upload.error':$sLangId;
}
}
+230 -101
View File
@@ -1,36 +1,38 @@
<?php
namespace Franzz\Spot;
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use Franzz\Objects\Db;
use \Settings;
class User extends PhpObject {
//DB Tables
const USER_TABLE = 'users';
public const USER_TABLE = 'users';
//Clearance Levels
const USER_ACTIVE = 1;
const USER_INACTIVE = 0;
const CLEARANCE_USER = 0;
const CLEARANCE_ADMIN = 9;
const CLEARANCES = array('user'=>self::CLEARANCE_USER, 'admin'=>self::CLEARANCE_ADMIN);
public const CLEARANCE_USER = 0;
public const CLEARANCE_ADMIN = 9;
public const CLEARANCES = ['user'=>self::CLEARANCE_USER, 'admin'=>self::CLEARANCE_ADMIN];
//Cookie
const COOKIE_ID_USER = 'subscriber';
const COOKIE_DURATION = 60 * 60 * 24 * 365; //1 year
public const USER_SUBSCRIBED = 1;
public const USER_UNSUBSCRIBED = 0;
const DEFAULT_USER = array(
public const DEFAULT_USER = [
'id' => 0,
'id_user' => 0,
'name' => '',
'email' => '',
'language' => '',
'timezone' => '',
'active' => self::USER_INACTIVE,
'subscribed'=> self::USER_UNSUBSCRIBED,
'clearance' => self::CLEARANCE_USER
);
];
//Session & Cookie
private const SESSION_ID_USER = 'id_user';
private const SESSION_ADMIN = 'admin_authenticated';
private const COOKIE_TOKEN = 'login';
private const COOKIE_DURATION = 60 * 60 * 24 * 365; //1 year
/**
* Database Handle
@@ -46,8 +48,7 @@ class User extends PhpObject {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
$this->setUserId(0);
$this->asUserInfo = self::DEFAULT_USER;
$this->checkUserCookie();
$this->checkSession();
}
public function getUserId() {
@@ -56,9 +57,10 @@ class User extends PhpObject {
public function setUserId($iUserId) {
$this->iUserId = 0;
$this->asUserInfo = self::DEFAULT_USER;
if($iUserId > 0) {
$asUser = $this->getActiveUserInfo($iUserId);
$asUser = $this->getUserById($iUserId);
if(!empty($asUser)) {
$this->iUserId = $iUserId;
$this->asUserInfo = $asUser;
@@ -70,27 +72,23 @@ class User extends PhpObject {
return $this->asUserInfo;
}
public function getActiveUserInfo($iUserId) {
$asUsersInfo = array();
if($iUserId > 0) $asUsersInfo = $this->getActiveUsersInfo($iUserId);
return empty($asUsersInfo)?array():array_shift($asUsersInfo);
public function getUserById($iUserId) {
$asUsersInfo = [];
if($iUserId > 0) $asUsersInfo = $this->getUsersInfo($iUserId);
return empty($asUsersInfo)?[]:array_shift($asUsersInfo);
}
public function getActiveUsersInfo($iUserId=-1) {
public function getUsersInfo($iUserId=-1) {
//Mapping between user fields and DB fields
$asSelect = array_keys($this->asUserInfo);
$asSelect[array_search('id', $asSelect)] = Db::getId(self::USER_TABLE)." AS id";
$asSelect[array_search('id', $asSelect)] = Db::getId(self::USER_TABLE).' AS id';
//Non-admin cannot access clearance info
if(!$this->checkUserClearance(self::CLEARANCE_ADMIN)) unset($asSelect['clearance']);
$asInfo = array(
$asInfo = [
'select' => $asSelect,
'from' => self::USER_TABLE,
'constraint'=> array('active'=>self::USER_ACTIVE)
);
if($iUserId != -1) $asInfo['constraint'][Db::getId(self::USER_TABLE)] = $iUserId;
'from' => self::USER_TABLE
];
if($iUserId != -1) $asInfo['constraint'] = [Db::getId(self::USER_TABLE) => $iUserId];
return $this->oDb->selectRows($asInfo);
}
@@ -99,114 +97,245 @@ class User extends PhpObject {
return $this->asUserInfo['language'];
}
public function addUser($sEmail, $sLang, $sTimezone, $sNickName='') {
private function addUser($sEmail, $sLang, $sTimezone, $sNickName='') {
$bSuccess = false;
$sDesc = '';
$sEmail = trim($sEmail);
$sLangId = '';
//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(
$iUserId = $this->oDb->insertRow(
self::USER_TABLE,
array('email'=>$sEmail, 'language'=>$sLang, 'timezone'=>$sTimezone, 'active'=>self::USER_ACTIVE),
array('email')
['email'=>$sEmail, 'language'=>$sLang, 'timezone'=>$sTimezone]
);
if($iUserId==0) $sDesc = 'lang:error.commit_db';
if($iUserId == 0) $sLangId = 'error.commit_db';
else $bSuccess = true;
//Extra optional values
if($bSuccess) {
$this->updateNickname($sNickName);
$this->updateGravatar($iUserId, $sEmail);
}
return Livetrail::getResult($bSuccess, $sLangId, [Db::getId(self::USER_TABLE) => $iUserId]);
}
public function setSubscription($bSubscribed) {
if($this->getUserId() > 0) {
$iSubscribed = $bSubscribed?1:0;
$iUserId = $this->oDb->updateRow(self::USER_TABLE, $this->getUserId(), ['subscribed'=>$iSubscribed]);
if(!$iUserId) return Livetrail::getResult(false, 'error.commit_db');
$this->asUserInfo['subscribed'] = $iSubscribed;
return Livetrail::getResult(true, $iSubscribed?'account.subscribed':'account.unsubscribed');
}
}
public function getSubscribedUsersInfo() {
$asSelect = array_keys($this->asUserInfo);
$asSelect[array_search('id', $asSelect)] = Db::getId(self::USER_TABLE).' AS id';
return $this->oDb->selectRows([
'select'=>$asSelect,
'from'=>self::USER_TABLE,
'constraint'=>['subscribed'=>self::USER_SUBSCRIBED]
]);
}
public function login($sEmail, $sPassword, $sLang, $sTimezone, $sNickName='') {
$bSuccess = false;
$bSubscribe = false;
$sLangId = '';
$sEmail = strtolower(trim($sEmail));
//Check email value
if(!filter_var($sEmail, FILTER_VALIDATE_EMAIL)) {
$sLangId = 'account.invalid_email';
}
else {
$sDesc = 'lang:newsletter.subscribed';
//Check Email presence in DB
$asDBUser = $this->oDb->selectRow(
self::USER_TABLE,
['email' => $sEmail],
[Db::getId(self::USER_TABLE), 'password', 'clearance']
);
$iUserId = $asDBUser[Db::getId(self::USER_TABLE)] ?? 0;
//User exists
if($iUserId > 0) {
//Is Admin
if($asDBUser['clearance'] >= self::CLEARANCE_ADMIN) {
//Request a password
if($sPassword === '') $sLangId = empty($asDBUser['password'])?'account.set_password':'account.password_required';
//Set password
elseif(empty($asDBUser['password'])) {
if(!$this->oDb->updateRow(self::USER_TABLE, $iUserId, ['password' => password_hash($sPassword, PASSWORD_DEFAULT)])) $sLangId = 'error.commit_db';
else {
$sLangId = 'account.password_set';
$bSuccess = true;
}
}
//Check password
elseif(password_verify($sPassword, $asDBUser['password'])) {
$bSuccess = true;
$sLangId = 'account.logged_in';
}
else $sLangId = 'account.invalid_credentials';
}
else {
$bSuccess = true;
$sLangId = 'account.logged_in';
}
}
else {
//Unknown user, create it
$asAddResult = $this->addUser($sEmail, $sLang, $sTimezone, $sNickName);
$bSuccess = $asAddResult['result'];
$bSubscribe = $bSuccess;
$sLangId = $bSuccess?'':$asAddResult['desc_lang_id'];
$iUserId = $asAddResult['data'][Db::getId(self::USER_TABLE)] ?? 0;
}
}
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);
$this->setSession();
$this->setTokenCookie();
}
return Spot::getResult($bSuccess, $sDesc);
return Livetrail::getResult($bSuccess, $sLangId, ['subscribe'=>$bSubscribe]);
}
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 logout() {
if($this->getUserId() > 0) $this->oDb->updateRow(self::USER_TABLE, $this->getUserId(), ['token' => '', 'token_exp' => '0000-00-00 00:00:00']);
$this->clearSession();
$this->clearCookie();
$this->setUserId(0);
return Livetrail::getResult(true, 'account.logged_out');
}
public function updateNickname($sNickname) {
if($this->getUserId() > 0 && $sNickname!='') $this->oDb->updateRow(self::USER_TABLE, $this->getUserId(), array('name'=>$sNickname));
if($this->getUserId() > 0 && $sNickname!='') $this->oDb->updateRow(self::USER_TABLE, $this->getUserId(), ['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)));
$this->oDb->updateRow(self::USER_TABLE, $iUserId, ['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)
{
public function checkUserClearance($iClearance) {
return ($this->asUserInfo['clearance'] >= $iClearance);
}
public function setUserClearance($iUserId, $iClearance) {
$bSuccess = false;
$sDesc = '';
$sLangId = '';
$asLangParams = [];
if(!$this->checkUserClearance(self::CLEARANCE_ADMIN)) $sDesc = 'unauthorized';
if(!$this->checkUserClearance(self::CLEARANCE_ADMIN)) $sLangId = 'error.no_auth';
else {
if(!in_array($iClearance, self::CLEARANCES)) $sDesc = 'Setting wrong clearance "'.$iClearance.'" to user ID "'.$iUserId.'"';
if(!in_array($iClearance, self::CLEARANCES)) {
$sLangId = 'error.impossible_value';
$asLangParams = [$iClearance, 'clearance'];
}
else {
$iUserId = $this->oDb->updateRow(self::USER_TABLE, $iUserId, array('clearance'=>$iClearance));
if(!$iUserId) $sDesc = 'lang:error.commit_db';
$iUserId = $this->oDb->updateRow(self::USER_TABLE, $iUserId, ['clearance'=>$iClearance]);
if(!$iUserId) $sLangId = 'error.commit_db';
else $bSuccess = true;
}
}
return Spot::getResult($bSuccess, $sDesc);
return Livetrail::getResult($bSuccess, $sLangId, [], $asLangParams);
}
private function updateCookie($iDeltaTime) {
setcookie(self::COOKIE_ID_USER, ($iDeltaTime < 0)?'':$this->getUserId(), array('samesite' => 'Lax', 'expires' => time() + $iDeltaTime));
/* Session */
private function checkSession() {
$iUserId = (int) ($_SESSION[self::SESSION_ID_USER] ?? 0);
if($iUserId > 0) {
$this->setUserId($iUserId);
if($this->checkUserClearance(self::CLEARANCE_ADMIN) && empty($_SESSION[self::SESSION_ADMIN])) $this->logout();
}
else $this->checkTokenCookie();
}
private function setSession() {
session_regenerate_id(true);
$_SESSION[self::SESSION_ID_USER] = $this->getUserId();
$_SESSION[self::SESSION_ADMIN] = $this->checkUserClearance(self::CLEARANCE_ADMIN);
}
private function clearSession() {
unset($_SESSION[self::SESSION_ID_USER], $_SESSION[self::SESSION_ADMIN]);
}
/* Cookie */
private function checkTokenCookie() {
$sCookieValue = $this->getCookie();
//Check cookie structure
$asToken = explode(':', $sCookieValue, 3);
$iUserId = $asToken[0];
if(count($asToken) !== 3 || !ctype_digit($iUserId) || !ctype_digit($asToken[1]) || !preg_match('/^[a-f0-9]{64}$/', $asToken[2])) {
if($sCookieValue !== '') $this->clearCookie();
return;
}
//Get user info
$asUser = $this->oDb->selectRow(
self::USER_TABLE,
$iUserId,
['clearance', 'token', 'token_exp']
);
//Check token value
if(!empty($asUser)
&& $asUser['clearance'] == $asToken[1]
&& time() <= strtotime($asUser['token_exp'])
&& hash_equals($asUser['token'], hash('sha256', $sCookieValue))
) {
$this->setUserId($iUserId);
$this->setSession();
}
else $this->clearCookie();
}
private function setTokenCookie() {
$sToken = bin2hex(random_bytes(32));
$iClearance = $this->asUserInfo['clearance'];
$sCookieValue = implode(':', [$this->getUserId(), $iClearance, $sToken]);
$this->oDb->updateRow(
self::USER_TABLE,
$this->getUserId(),
[
'token' => hash('sha256', $sCookieValue),
'token_exp' => date(Db::TIMESTAMP_FORMAT, time() + self::COOKIE_DURATION)
]
);
$this->setCookie($sCookieValue, time() + self::COOKIE_DURATION);
}
private function getCookie() {
return $_COOKIE[self::COOKIE_TOKEN] ?? '';
}
private function setCookie($sValue, $iExpires) {
setcookie(
self::COOKIE_TOKEN,
$sValue,
[
'expires' => $iExpires,
'path' => '/',
'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'),
'httponly' => true,
'samesite' => 'Lax'
]
);
}
private function clearCookie() {
$this->setCookie('', time() - 3600);
unset($_COOKIE[self::COOKIE_TOKEN]);
}
}
-122
View File
@@ -1,122 +0,0 @@
<?php
/* Requests Handler */
//Start buffering
ob_start();
//Run from /dist/
$oLoader = require __DIR__.'/../vendor/autoload.php';
use Franzz\Objects\ToolBox;
use Franzz\Objects\Main;
use Franzz\Spot\Spot;
use Franzz\Spot\User;
ToolBox::fixGlobalVars($argv ?? array());
//Available variables
$sAction = $_REQUEST['a'] ?? '';
$sTimezone = $_REQUEST['t'] ?? '';
$sName = $_GET['name'] ?? '';
$sContent = $_GET['content'] ?? '';
$iProjectId = $_REQUEST['id_project'] ?? 0 ;
$sField = $_REQUEST['field'] ?? '';
$oValue = $_REQUEST['value'] ?? '';
$iId = $_REQUEST['id'] ?? 0 ;
$sType = $_REQUEST['type'] ?? '';
$sEmail = $_REQUEST['email'] ?? '';
$sLat = $_REQUEST['latitude'] ?? '';
$sLng = $_REQUEST['longitude'] ?? '';
$iTimestamp = $_REQUEST['timestamp'] ?? 0;
//Initiate class
$oSpot = new Spot(__FILE__, $sTimezone);
$oSpot->setProjectId($iProjectId);
$sResult = '';
if($sAction!='')
{
switch($sAction)
{
case 'markers':
$sResult = $oSpot->getMarkers();
break;
case 'geojson':
$sResult = $oSpot->getProjectGeoJson();
break;
case 'next_feed':
$sResult = $oSpot->getNextFeed($iId);
break;
case 'new_feed':
$sResult = $oSpot->getNewFeed($iId);
break;
case 'add_post':
$sResult = $oSpot->addPost($sName, $sContent);
break;
case 'subscribe':
$sResult = $oSpot->subscribe($sEmail, $sName);
break;
case 'unsubscribe':
$sResult = $oSpot->unsubscribe();
break;
case 'unsubscribe_email':
$sResult = $oSpot->unsubscribeFromEmail($iId);
break;
case 'update_project':
$sResult = $oSpot->updateProject();
break;
default:
if($oSpot->checkUserClearance(User::CLEARANCE_ADMIN))
{
switch($sAction)
{
case 'upload':
$sResult = $oSpot->upload();
break;
case 'add_comment':
$sResult = $oSpot->addComment($iId, $sContent);
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();
break;
case 'admin_set':
$sResult = $oSpot->setAdminSettings($sType, $iId, $sField, $oValue);
break;
case 'admin_create':
$sResult = $oSpot->createAdminSettings($sType);
break;
case 'admin_delete':
$sResult = $oSpot->deleteAdminSettings($sType, $iId);
break;
case 'generate_cron':
$sResult = $oSpot->genCronFile();
break;
case 'sql':
$sResult = $oSpot->getDbBuildScript();
break;
case 'build_geojson':
$sResult = $oSpot->buildGeoJSON($sName);
break;
default:
$sResult = Main::getJsonResult(false, Main::NOT_FOUND);
}
}
else $sResult = Main::getJsonResult(false, Main::NOT_FOUND);
}
}
else $sResult = $oSpot->getAppMainPage();
$sDebug = ob_get_clean();
if(Settings::DEBUG && $sDebug!='') $oSpot->addUncaughtError($sDebug);
echo $sResult;
-33
View File
@@ -1,33 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
<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: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:email.confirmation.thanks_subject[#]</h1></td>
</tr>
<tr>
<td colspan="2">
<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: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.unsubscribe_button[#]</a></p>
</td>
</tr>
</table>
</body>
</html>
+1696 -4035
View File
File diff suppressed because it is too large Load Diff
+22 -24
View File
@@ -1,40 +1,38 @@
{
"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",
"name": "livetrail",
"description": "Self-hosted live journey map combining SPOT GPS tracking, GPX routes, posts, and media",
"version": "3.0.1",
"main": "index.js",
"private": true,
"scripts": {
"dev": "webpack --config build/webpack.config.js --mode development",
"prod": "webpack --config build/webpack.config.js --mode production"
"dev": "vite build --mode development --watch",
"prod": "vite build",
"lint": "eslint src"
},
"keywords": [],
"author": "Franzz",
"devDependencies": {
"@eslint/js": "^10.0.1",
"@vitejs/plugin-vue": "^6.0.8",
"eslint": "^10.9.1",
"eslint-plugin-vue": "^10.10.0",
"globals": "^17.11.0",
"vite": "^8.1.5"
},
"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",
"@uppy/core": "^6.0.0",
"@uppy/xhr-upload": "^6.0.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",
"maplibre-gl": "^6.0.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"
"vue": "^3.5.40"
},
"allowScripts": {
"@parcel/watcher@2.6.0": true,
"vue-demi@0.13.11": true
}
}
+7
View File
@@ -0,0 +1,7 @@
<?php
require __DIR__.'/../vendor/autoload.php';
use Franzz\Livetrail\Controller;
echo (new Controller())->handle(__FILE__, $argv ?? []);
+35 -20
View File
@@ -1,9 +1,10 @@
# Spot Project
[Spot](https://www.findmespot.com) & GPX integration
# Live Trail Project
Live Trail is a self-hosted web application for sharing journeys on an interactive map. It combines live GPS positions from a [SPOT](https://www.findmespot.com) tracker with GPX routes, posts, photos, videos, and a chronological activity feed, supporting both real-time tracking and historical travel stories.
## Dependencies
* npm 18+
* npm 24+
* composer
* php-mbstring
* php-imagick
@@ -25,28 +26,42 @@
## 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
2. 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/
5. Follow CI/CD script in .gitea/workflows/deploy.yml
6. Add a GPX file named <project_codename>.gpx to /resources/geo/
7. Go to #admin and create a new project (with code name = <project_codename>) & feed
## Web Root
The web server should serve `public/` as the application document root. PHP source, configuration, Composer dependencies, uploaded files, and GPX data stay outside the public tree; `public/index.php` is the front controller and Vite writes generated frontend assets to `public/assets/`.
Runtime data is exposed through symlinks only: `public/files -> ../files` and `public/geo -> ../resources/geo`. The build must not copy uploaded media or GPX data into `public/`.
## Local Development
`npm run dev` watches and builds development assets into `public/assets/`. `npm run prod` builds hashed production assets into `public/assets/`. PHP reads `public/.vite/manifest.json` and populates the CSS, module, and preload parts defined in `resources/masks/index.html` while rendering the request.
When developing Livetrail and the sibling `objects` library together, install dependencies through the local Composer manifest:
```bash
COMPOSER=composer.dev.json composer update
```
This makes Composer link `vendor/franzz/objects` to `../objects` and autoload that namespace directly from the local source path. Production continues to use `composer.json`, which installs `franzz/objects` from its Git repository. Commit and publish `objects` changes before updating/deploying a Livetrail version that relies on them.
## Before Committing
Run manually before pushing:
* `composer lint` - checks PHP style (php-cs-fixer, dry-run); `composer lint-fix` applies it. Requires the dev dependencies (`COMPOSER=composer.dev.json composer update`, see Local Development above).
* `npm run lint` - checks JS/Vue style (ESLint) on demand. It also already runs automatically as part of `npm run dev` (reports issues but never blocks the watcher) and `npm run prod` (aborts the build if any lint error is found).
>>>>>>> vue
## To Do List
* Add mail frequency slider
* Use WMTS servers directly when not using Geo Caching Server
* Allow HEIF picture format
* Fix .MOV playback on windows firefox
* Garmin InReach Integration
+215376
View File
File diff suppressed because it is too large Load Diff
+104 -189
View File
@@ -284,8 +284,7 @@
<ele>792</ele>
<time>2019-03-16T19:58:28.000Z</time>
<name>Abri des Couloumates</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">8 places, table, cheminée, eau&lt;/p>&lt;/div></desc>
<desc>8 places, table, cheminée, eau</desc>
<sym>Residence</sym>
<extensions>
<ql:key>ea4716b92363404d90272b73c50e2a23</ql:key>
@@ -333,8 +332,7 @@
<ele>0</ele>
<time>2019-07-23T14:21:36.000Z</time>
<name>Albergue Aysa</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://www.albergueaysa.com/tarifas-58/&lt;/p>&lt;/div></desc>
<desc>http://www.albergueaysa.com/tarifas-58/</desc>
<sym>Residence</sym>
<extensions>
<ql:key>effcbb203a05237e501feab97e95e467</ql:key>
@@ -359,8 +357,7 @@
<ele>1518</ele>
<time>2019-01-03T13:46:55.000Z</time>
<name>Auberge de la Munia</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://www.aubergedelamunia.com&lt;/p>&lt;/div></desc>
<desc>http://www.aubergedelamunia.com</desc>
<sym>Residence</sym>
<extensions>
<ql:key>c361f2d3b06b552daea31885fb04fb57</ql:key>
@@ -385,8 +382,7 @@
<ele>1825</ele>
<time>2019-03-05T17:41:02.000Z</time>
<name>Auberge Le Maillet</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">https://www.facebook.com/aubergemaillet/&lt;/p>&lt;/div></desc>
<desc>https://www.facebook.com/aubergemaillet/</desc>
<sym>Residence</sym>
<extensions>
<ql:key>de4797e810d8eacfe065187f98ee4b4f</ql:key>
@@ -463,8 +459,7 @@
<ele>2796</ele>
<time>2019-01-01T17:45:16.000Z</time>
<name>Brèche de Roland</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">2804m&lt;/p>&lt;/div></desc>
<desc>2804m</desc>
<sym>Valley</sym>
<extensions>
<ql:key>100762362bf6955ec53b1a19efb5453e</ql:key>
@@ -566,10 +561,9 @@ This is waypoint no: 467</desc>
<wpt lat="42.50383704" lon="2.44530734">
<time>2010-06-09T19:58:08.000Z</time>
<name>Cabane Arago</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Cabane en bon état avec point d'eau.&lt;/p>
<desc>Cabane en bon état avec point d'eau.&lt;/p>
&lt;p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">&lt;br />&lt;/p>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Détails : http://www.pyrenees-refuges.com/fr/affiche.php?numenr=19#15/42.5039/2.4449&lt;/p>&lt;/div></desc>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Détails : http://www.pyrenees-refuges.com/fr/affiche.php?numenr=19#15/42.5039/2.4449</desc>
<sym>Residence</sym>
<extensions>
<ql:key>fb82be0d23628bbbf4a9a0d7f7ec00a7</ql:key>
@@ -641,10 +635,9 @@ This is waypoint no: 467</desc>
<ele>0</ele>
<time>2019-07-23T14:21:36.000Z</time>
<name>Cabane d'Azpegi</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Eau dispo.&lt;/p>
<desc>Eau dispo.&lt;/p>
&lt;p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">&lt;br />&lt;/p>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://www.pyrenees-refuges.com/fr/affiche.php?numenr=1234#15/43.0323/-1.2273&lt;/p>&lt;/div></desc>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://www.pyrenees-refuges.com/fr/affiche.php?numenr=1234#15/43.0323/-1.2273</desc>
<sym>Residence</sym>
<extensions>
<ql:key>5de226709a81feef249d27923449f91d</ql:key>
@@ -691,8 +684,7 @@ This is waypoint no: 467</desc>
<wpt lat="42.74526001" lon="-0.09167068">
<time>2010-06-07T19:05:10.000Z</time>
<name>Cabane de Lourdes</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://www.pyrenees-refuges.com/fr/affiche.php?numenr=210#15/42.7470/-0.0921&lt;/p>&lt;/div></desc>
<desc>http://www.pyrenees-refuges.com/fr/affiche.php?numenr=210#15/42.7470/-0.0921</desc>
<sym>Residence</sym>
<extensions>
<ql:key>c1e7cc7d74df5fc6433223182919e90f</ql:key>
@@ -831,12 +823,11 @@ This is waypoint no: 467</desc>
<wpt lat="42.75625706" lon="0.11215925">
<time>2010-06-07T20:45:44.000Z</time>
<name>Cabane Des Aguilous</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Eau disponible.&lt;/p>
<desc>Eau disponible.&lt;/p>
&lt;p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">&lt;br />&lt;/p>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">A priori privé.&lt;/p>
&lt;p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">&lt;br />&lt;/p>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://www.pyrenees-refuges.com/fr/affiche.php?numenr=739&lt;/p>&lt;/div></desc>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://www.pyrenees-refuges.com/fr/affiche.php?numenr=739</desc>
<sym>Residence</sym>
<extensions>
<ql:key>7db2219ea46a4d9b659790d6f2866d61</ql:key>
@@ -1183,8 +1174,7 @@ This is waypoint no: 122</desc>
<wpt lat="42.93983283" lon="-0.69825623">
<time>2010-06-06T08:31:13.000Z</time>
<name>Cayolars d'Anaye (cabane)</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://www.pyrenees-refuges.com/fr/affiche.php?numenr=8#15/42.9395/-0.6982&lt;/p>&lt;/div></desc>
<desc>http://www.pyrenees-refuges.com/fr/affiche.php?numenr=8#15/42.9395/-0.6982</desc>
<sym>Residence</sym>
<extensions>
<ql:key>f26c7a98925caa71568322af33513a25</ql:key>
@@ -1209,10 +1199,9 @@ This is waypoint no: 122</desc>
<ele>943</ele>
<time>2019-01-01T15:42:56.000Z</time>
<name>Chalet de l'Albère</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://lalbere.net/quelsServices/Chalet.htm&lt;/p>
<desc>http://lalbere.net/quelsServices/Chalet.htm&lt;/p>
&lt;p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">&lt;br />&lt;/p>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">2 dortoirs de 10 et 14 couchages&lt;/p>&lt;/div></desc>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">2 dortoirs de 10 et 14 couchages</desc>
<sym>Residence</sym>
<extensions>
<ql:key>cb03db82142bc5eb5453e8bfa21575ca</ql:key>
@@ -1283,8 +1272,7 @@ This is waypoint no: 122</desc>
<ele>1318</ele>
<time>2019-01-03T14:45:51.000Z</time>
<name>Chalets d'Iraty</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Gite 13 places disponibles. Voir en bas de la page http://www.chalets-iraty.com/preparez-votre-sejour/les-hebergements/&lt;/p>&lt;/div></desc>
<desc>Gite 13 places disponibles. Voir en bas de la page http://www.chalets-iraty.com/preparez-votre-sejour/les-hebergements/</desc>
<sym>Residence</sym>
<extensions>
<ql:key>7fb5f721f009d4e159eef2731ade0241</ql:key>
@@ -3058,8 +3046,7 @@ This is waypoint no: 13</desc>
<wpt lat="42.43853331" lon="2.71061897">
<time>2010-06-09T21:16:12.000Z</time>
<name>Coll Del Ric</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">958m&lt;/p>&lt;/div></desc>
<desc>958m</desc>
<sym>Valley</sym>
<extensions>
<ql:key>9fd7b75d96f77439315cdcaeaa7d71c1</ql:key>
@@ -5567,8 +5554,7 @@ This is waypoint no: 450</desc>
<ele>1445</ele>
<time>2019-07-23T14:21:36.000Z</time>
<name>Gîte d'étape l'Hospitalité</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">https://www.gitelhospitalite.com/&lt;/p>&lt;/div></desc>
<desc>https://www.gitelhospitalite.com/</desc>
<sym>Residence</sym>
<extensions>
<ql:key>ed62023e3282c3f95c2d4f281b160e25</ql:key>
@@ -5641,8 +5627,7 @@ This is waypoint no: 450</desc>
<ele>1354</ele>
<time>2019-03-03T18:38:42.000Z</time>
<name>Gîte Le Gypaète</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://legypaete.pagesperso-orange.fr/france/tarifs.htm&lt;/p>&lt;/div></desc>
<desc>http://legypaete.pagesperso-orange.fr/france/tarifs.htm</desc>
<sym>Residence</sym>
<extensions>
<ql:key>019e5dbc025c17ecc78d89e22e6e7ea7</ql:key>
@@ -5845,8 +5830,7 @@ This is waypoint no: 450</desc>
<ele>200</ele>
<time>2019-03-24T19:08:50.000Z</time>
<name>Hostal Elizondo</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">https://hostalelizondo.com/tarifas-reservas/&lt;/p>&lt;/div></desc>
<desc>https://hostalelizondo.com/tarifas-reservas/</desc>
<sym>Residence</sym>
<extensions>
<ql:key>f6d87056620938b3dd61d035878e6371</ql:key>
@@ -5871,8 +5855,7 @@ This is waypoint no: 450</desc>
<ele>1117</ele>
<time>2019-03-05T18:14:03.000Z</time>
<name>Hostal La Fuen</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://www.lafuen.com/tarifas&lt;/p>&lt;/div></desc>
<desc>http://www.lafuen.com/tarifas</desc>
<sym>Residence</sym>
<extensions>
<ql:key>b4ccd9a66257d871f35c38476d17a037</ql:key>
@@ -5897,8 +5880,7 @@ This is waypoint no: 450</desc>
<ele>950</ele>
<time>2019-02-19T19:26:12.000Z</time>
<name>Hostel Roncesvalles - Orreaga</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://www.alberguederoncesvalles.com&lt;/p>&lt;/div></desc>
<desc>http://www.alberguederoncesvalles.com</desc>
<sym>Residence</sym>
<extensions>
<ql:key>4ff045dfbbcfd8b2b377cda457f9b0f9</ql:key>
@@ -5969,8 +5951,7 @@ This is waypoint no: 450</desc>
<wpt lat="42.47334936" lon="2.66873621">
<time>2019-04-07T08:01:57.000Z</time>
<name>Hotel de France</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://www.h.df66110.com/#Tarifs.D&lt;/p>&lt;/div></desc>
<desc>http://www.h.df66110.com/#Tarifs.D</desc>
<sym>Residence</sym>
<extensions>
<ql:key>5759f6f2f48a33e959eac31d26ae7c43</ql:key>
@@ -6214,8 +6195,7 @@ This is waypoint no: 450</desc>
<wpt lat="42.95145600" lon="-0.89058500">
<time>2019-07-23T14:21:36.000Z</time>
<name>Lakartxela</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">1979m&lt;/p>&lt;/div></desc>
<desc>1979m</desc>
<sym>Summit</sym>
<extensions>
<ql:key>a2006c92f1943060d1033d2d55556c02</ql:key>
@@ -6287,8 +6267,7 @@ This is waypoint no: 450</desc>
<wpt lat="42.51794245" lon="2.52444886">
<time>2010-06-09T20:21:54.000Z</time>
<name>Maison Forestiere l'estanyol</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Cabane non gardée&lt;/p>&lt;/div></desc>
<desc>Cabane non gardée</desc>
<sym>Residence</sym>
<extensions>
<ql:key>81af5476ea38b90473fa5eda89027bed</ql:key>
@@ -6313,8 +6292,7 @@ This is waypoint no: 450</desc>
<ele>3331</ele>
<time>2019-07-23T14:21:36.000Z</time>
<name>Monte Perdido</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">3348m&lt;/p>&lt;/div></desc>
<desc>3348m</desc>
<sym>Summit</sym>
<extensions>
<ql:key>7609003845b52fc6dd2ad0c7b018a691</ql:key>
@@ -6339,8 +6317,7 @@ This is waypoint no: 450</desc>
<ele>1847</ele>
<time>2019-07-23T14:21:36.000Z</time>
<name>Mujer Muerta</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">1859m&lt;/p>&lt;/div></desc>
<desc>1859m</desc>
<sym>Summit</sym>
<extensions>
<ql:key>fe68b3f5bfaed7d61097710532b22a5a</ql:key>
@@ -6906,8 +6883,7 @@ This is waypoint no: 450</desc>
<wpt lat="42.84374800" lon="-0.43785900">
<time>2018-12-23T14:20:42.000Z</time>
<name>Pic du Midi d'Ossau</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">15/08/19&lt;/p>&lt;/div></desc>
<desc>15/08/19</desc>
<sym>Summit</sym>
<extensions>
<ql:key>fd028610b69593330af80c2cfa0b6606</ql:key>
@@ -6931,8 +6907,7 @@ This is waypoint no: 450</desc>
<wpt lat="42.48251726" lon="2.94813889">
<time>2010-06-09T21:42:53.000Z</time>
<name>Pic Neulos</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">1259m&lt;/p>&lt;/div></desc>
<desc>1259m</desc>
<sym>Summit</sym>
<extensions>
<ql:key>7ebcf9fe04b0b0dbf2311c90752a4274</ql:key>
@@ -6957,8 +6932,7 @@ This is waypoint no: 450</desc>
<ele>3388</ele>
<time>2019-07-23T14:21:36.000Z</time>
<name>Pico Aneto</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">3404m &lt;/p>&lt;/div></desc>
<desc>3404m </desc>
<sym>Summit</sym>
<extensions>
<ql:key>3cabab68c61ea3fe78792d8c98da1f97</ql:key>
@@ -7262,8 +7236,7 @@ This is waypoint no: 450</desc>
<wpt lat="42.56985426" lon="1.93211317">
<time>2010-06-09T18:43:54.000Z</time>
<name>Puig Carlit</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">2921m&lt;/p>&lt;/div></desc>
<desc>2921m</desc>
<sym>Summit</sym>
<extensions>
<ql:key>5327d145ed2ca1b7cec310f0e6083ed0</ql:key>
@@ -7287,8 +7260,7 @@ This is waypoint no: 450</desc>
<wpt lat="42.47702837" lon="3.04010153">
<time>2010-06-09T21:52:13.000Z</time>
<name>Puig de Sallfort</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">978m&lt;/p>&lt;/div></desc>
<desc>978m</desc>
<sym>Summit</sym>
<extensions>
<ql:key>4545cf0ebd06ec5f34614926ce8e2fea</ql:key>
@@ -7312,8 +7284,7 @@ This is waypoint no: 450</desc>
<wpt lat="42.47297287" lon="2.99832344">
<time>2010-06-09T21:46:49.000Z</time>
<name>Puig Dels Quatre Termes</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">1158m&lt;/p>&lt;/div></desc>
<desc>1158m</desc>
<sym>Summit</sym>
<extensions>
<ql:key>c00ef5c9fc6312af27e421e91e941801</ql:key>
@@ -7338,8 +7309,7 @@ This is waypoint no: 450</desc>
<ele>2751</ele>
<time>2019-07-23T14:21:36.000Z</time>
<name>Punta Escuzana</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">2847m&lt;/p>&lt;/div></desc>
<desc>2847m</desc>
<sym>Summit</sym>
<extensions>
<ql:key>865626bf14dc7fde70a26412219abcc5</ql:key>
@@ -7481,8 +7451,7 @@ This is waypoint no: 450</desc>
<ele>2452</ele>
<time>2019-03-17T19:38:52.000Z</time>
<name>Refuge de Baborte (cabane)</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">9 places&lt;/p>&lt;/div></desc>
<desc>9 places</desc>
<sym>Residence</sym>
<extensions>
<ql:key>9d283198bd5257de5cf19f3d7f98fd65</ql:key>
@@ -7506,8 +7475,7 @@ This is waypoint no: 450</desc>
<wpt lat="42.50199194" lon="2.55074222">
<time>2010-06-09T21:01:14.000Z</time>
<name>Refuge de Batère</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://www.refugedebatere.fr/&lt;/p>&lt;/div></desc>
<desc>http://www.refugedebatere.fr/</desc>
<sym>Residence</sym>
<extensions>
<ql:key>fd1a4f32290aaf4d1bc36410b694ad44</ql:key>
@@ -7579,9 +7547,8 @@ This is waypoint no: 450</desc>
<ele>2215</ele>
<time>2010-06-20T08:37:37.000Z</time>
<name>Refuge de Cóms de Jan (cabane)</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">6 à 8 places.&lt;/p>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Eau&lt;/p>&lt;/div></desc>
<desc>6 à 8 places.&lt;/p>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Eau</desc>
<sym>Residence</sym>
<extensions>
<ql:key>696be584f34734f98a23161d302362c0</ql:key>
@@ -7868,8 +7835,7 @@ This is waypoint no: 61</desc>
<ele>1687</ele>
<time>2010-06-09T19:55:51.000Z</time>
<name>Refuge des Mariailles</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://www.refugedemariailles.fr&lt;/p>&lt;/div></desc>
<desc>http://www.refugedemariailles.fr</desc>
<sym>Residence</sym>
<extensions>
<ql:key>bdf8ba39360cd80335ced574be2bc6a3</ql:key>
@@ -7922,8 +7888,7 @@ This is waypoint no: 61</desc>
<ele>2231</ele>
<time>2019-03-17T19:31:12.000Z</time>
<name>Refuge du Pinet</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">https://refugeetangpinet.ffcam.fr/&lt;/p>&lt;/div></desc>
<desc>https://refugeetangpinet.ffcam.fr/</desc>
<sym>Residence</sym>
<extensions>
<ql:key>429442ea51231ee38143d5b28a1479d7</ql:key>
@@ -8064,12 +8029,11 @@ This is waypoint no: 61</desc>
<ele>0</ele>
<time>2019-07-23T14:21:36.000Z</time>
<name>Refugi d'Alós</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Refuge potentiellement ouvert: &lt;/p>
<desc>Refuge potentiellement ouvert: &lt;/p>
&lt;p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">&lt;br />&lt;/p>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">https://www.facebook.com/refugidalos/app/298669810188442/&lt;/p>
&lt;p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">&lt;br />&lt;/p>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">email : refugialos@gmail.com (à contacter en mai pour confimer)&lt;/p>&lt;/div></desc>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">email : refugialos@gmail.com (à contacter en mai pour confimer)</desc>
<sym>Residence</sym>
<extensions>
<ql:key>a46888616221d4cc2a687fb81b2ffd3b</ql:key>
@@ -8094,8 +8058,7 @@ This is waypoint no: 61</desc>
<ele>2348</ele>
<time>2019-03-22T19:27:22.000Z</time>
<name>Refugi d'Armitges</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://www.amitges.com/fr/refuge/&lt;/p>&lt;/div></desc>
<desc>http://www.amitges.com/fr/refuge/</desc>
<sym>Residence</sym>
<extensions>
<ql:key>17a261d7af5168511bbd82d94c2cabc4</ql:key>
@@ -8146,10 +8109,9 @@ This is waypoint no: 191</desc>
<ele>2232</ele>
<time>2010-06-20T08:15:37.000Z</time>
<name>Refugi de Certascan</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://www.certascan.com/muntanyes_de_llibertat/frances/refuge_certascan_pyrenees.htm&lt;/p>
<desc>http://www.certascan.com/muntanyes_de_llibertat/frances/refuge_certascan_pyrenees.htm&lt;/p>
&lt;p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">&lt;br />&lt;/p>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Picnic disponible.&lt;/p>&lt;/div></desc>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Picnic disponible.</desc>
<sym>Residence</sym>
<extensions>
<ql:key>2c0c33260f385cc448334a7632533424</ql:key>
@@ -8174,8 +8136,7 @@ This is waypoint no: 191</desc>
<ele>0</ele>
<time>2019-07-23T14:21:36.000Z</time>
<name>Refugi de Conangles</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">https://eraportadaran.com/refugi-de-conangles/&lt;/p>&lt;/div></desc>
<desc>https://eraportadaran.com/refugi-de-conangles/</desc>
<sym>Residence</sym>
<extensions>
<ql:key>d2803c148a3704682fd487e41bd7578f</ql:key>
@@ -8200,9 +8161,8 @@ This is waypoint no: 191</desc>
<ele>0</ele>
<time>2019-07-23T14:21:36.000Z</time>
<name>Refugi de Juclar</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://www.refugidejuclar.com/tarifes/&lt;/p>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Demi pension + pic nic possible&lt;/p>&lt;/div></desc>
<desc>http://www.refugidejuclar.com/tarifes/&lt;/p>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Demi pension + pic nic possible</desc>
<sym>Residence</sym>
<extensions>
<ql:key>a73c0358d64fc3e6b02227d49b00a0ec</ql:key>
@@ -8344,8 +8304,7 @@ This is waypoint no: 191</desc>
<ele>2292</ele>
<time>2010-06-18T21:08:53.000Z</time>
<name>Refugi de Saboredo</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">https://www.refusaboredo.com/&lt;/p>&lt;/div></desc>
<desc>https://www.refusaboredo.com/</desc>
<sym>Residence</sym>
<extensions>
<ql:key>77f3140bde82a4405b0d9f3568129a00</ql:key>
@@ -8370,8 +8329,7 @@ This is waypoint no: 191</desc>
<ele>1969</ele>
<time>2010-06-20T08:35:40.000Z</time>
<name>Refugi de Sorteny</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">https://www.refugisorteny.com/bienvenue-1/prix-et-conditions-1/&lt;/p>&lt;/div></desc>
<desc>https://www.refugisorteny.com/bienvenue-1/prix-et-conditions-1/</desc>
<sym>Residence</sym>
<extensions>
<ql:key>203952dfb191452858e0ac99341e2373</ql:key>
@@ -8469,8 +8427,7 @@ This is waypoint no: 166</desc>
<ele>1247</ele>
<time>2019-03-22T19:09:35.000Z</time>
<name>Refugi Rosta</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://www.refugirosta.com/&lt;/p>&lt;/div></desc>
<desc>http://www.refugirosta.com/</desc>
<sym>Residence</sym>
<extensions>
<ql:key>cf5b4f9675112d1ae475f9c12886c02a</ql:key>
@@ -8495,8 +8452,7 @@ This is waypoint no: 166</desc>
<ele>2209</ele>
<time>2019-03-22T19:41:21.000Z</time>
<name>Refugi Ventosa i Calvell</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">http://www.refugiventosa.com/&lt;/p>&lt;/div></desc>
<desc>http://www.refugiventosa.com/</desc>
<sym>Residence</sym>
<extensions>
<ql:key>e8de798e8af7954f7ced8b9d56f661ad</ql:key>
@@ -8547,8 +8503,7 @@ This is waypoint no: 271</desc>
<ele>1876</ele>
<time>2010-06-17T22:17:23.000Z</time>
<name>Refugio de Barrosa (cabane)</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Eau disponible.&lt;/p>&lt;/div></desc>
<desc>Eau disponible.</desc>
<sym>Residence</sym>
<extensions>
<ql:key>ce1187ab5962b8095a1419455baf7ed7</ql:key>
@@ -8671,8 +8626,7 @@ This is waypoint no: 269</desc>
<ele>2142</ele>
<time>2019-07-23T14:21:36.000Z</time>
<name>Refugio de la Renclusa</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">https://www.alberguesyrefugios.com/larenclusa/&lt;/p>&lt;/div></desc>
<desc>https://www.alberguesyrefugios.com/larenclusa/</desc>
<sym>Residence</sym>
<extensions>
<ql:key>520291e75621d50f9dbf0d908421fe60</ql:key>
@@ -9754,8 +9708,7 @@ This is waypoint no: 425</desc>
<ele>3278</ele>
<time>2019-01-02T16:01:38.000Z</time>
<name>Vignemale</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">3299m&lt;/p>&lt;/div></desc>
<desc>3299m</desc>
<sym>Summit</sym>
<extensions>
<ql:key>9fe8fffc6c21e43dd3413d8ea4b14e63</ql:key>
@@ -9800,9 +9753,8 @@ This is waypoint no: 425</desc>
</extensions>
</wpt>
<trk>
<name>Etape 01</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Banyuls-sur-Mer au Chalet de l'Albère&lt;/p>&lt;/div></desc>
<name>Etape 1</name>
<desc>Banyuls-sur-Mer ➜ Chalet de l'Albère</desc>
<extensions>
<ql:key>b6a67b2586a43bada478bd77cb97c25b</ql:key>
<ql:flags>0</ql:flags>
@@ -11358,9 +11310,8 @@ This is waypoint no: 425</desc>
</trkseg>
</trk>
<trk>
<name>Etape 02</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Chalet de l'Albère à Las Illas&lt;/p>&lt;/div></desc>
<name>Etape 2</name>
<desc>Chalet de l'Albère ➜ Las Illas</desc>
<extensions>
<ql:key>5e4204b68d14b14d7b853e8b789f5f02</ql:key>
<ql:flags>0</ql:flags>
@@ -13220,9 +13171,8 @@ This is waypoint no: 425</desc>
</trkseg>
</trk>
<trk>
<name>Etape 03</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Las Illas à Amélie-les-Bains&lt;/p>&lt;/div></desc>
<name>Etape 3</name>
<desc>Las Illas ➜ Amélie-les-Bains</desc>
<extensions>
<ql:key>194d6fcabb2635a3e6d53ec725602fca</ql:key>
<ql:flags>0</ql:flags>
@@ -14450,9 +14400,8 @@ This is waypoint no: 425</desc>
</trkseg>
</trk>
<trk>
<name>Etape 04</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Amélie-les-Bains au Gîte de Batère&lt;/p>&lt;/div></desc>
<name>Etape 4</name>
<desc>Amélie-les-Bains ➜ Gîte de Batère</desc>
<extensions>
<ql:key>42f893f700691772a5e1ecbeee786288</ql:key>
<ql:flags>0</ql:flags>
@@ -15656,9 +15605,8 @@ This is waypoint no: 425</desc>
</trkseg>
</trk>
<trk>
<name>Etape 05</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Gîte de Batère au Refuge des Mariailles&lt;/p>&lt;/div></desc>
<name>Etape 5</name>
<desc>Gîte de Batère ➜ Refuge des Mariailles</desc>
<extensions>
<ql:key>032c72bfc98ad3a5890aa08e6bd25179</ql:key>
<ql:flags>0</ql:flags>
@@ -17944,9 +17892,8 @@ This is waypoint no: 425</desc>
</trkseg>
</trk>
<trk>
<name>Etape 06</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Refuge des Mariailles au Refuge d'Ull de Ter&lt;/p>&lt;/div></desc>
<name>Etape 6</name>
<desc>Refuge des Mariailles ➜ Refuge d'Ull de Ter</desc>
<extensions>
<ql:key>0afcd060b61c1b8c95632af4a86eb1ac</ql:key>
<ql:flags>0</ql:flags>
@@ -23574,9 +23521,8 @@ This is waypoint no: 425</desc>
</trkseg>
</trk>
<trk>
<name>Etape 07</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Refuge d'Ull de Ter au Gîte les Ramiers&lt;/p>&lt;/div></desc>
<name>Etape 7</name>
<desc>Refuge d'Ull de Ter ➜ Gîte les Ramiers</desc>
<extensions>
<ql:key>a1fc0e61ced1dff5dc3cae08940d5548</ql:key>
<ql:flags>0</ql:flags>
@@ -25230,9 +25176,8 @@ This is waypoint no: 425</desc>
</trkseg>
</trk>
<trk>
<name>Etape 08</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Gîte les Ramiers au Gîte d'étape l'Hospitalité&lt;/p>&lt;/div></desc>
<name>Etape 8</name>
<desc>Gîte les Ramiers ➜ Gîte d'étape l'Hospitalité</desc>
<extensions>
<ql:key>a830d5354220dc21baf491062bb5f5b7</ql:key>
<ql:flags>0</ql:flags>
@@ -28172,9 +28117,8 @@ This is waypoint no: 425</desc>
</trkseg>
</trk>
<trk>
<name>Etape 08 - HRP original</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Gîte les Ramiers au Gîte d'étape l'Hospitalité&lt;/p>&lt;/div></desc>
<name>Etape 8 - HRP original</name>
<desc>Gîte les Ramiers ➜ Gîte d'étape l'Hospitalité</desc>
<extensions>
<ql:key>e4a405d8d6431425d26def48cfb8bdc2</ql:key>
<ql:flags>0</ql:flags>
@@ -28442,9 +28386,8 @@ This is waypoint no: 425</desc>
</trkseg>
</trk>
<trk>
<name>Etape 09</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Gîte d'étape l'Hospitalité au Refuge de Sorteny&lt;/p>&lt;/div></desc>
<name>Etape 9</name>
<desc>Gîte d'étape l'Hospitalité ➜ Refuge de Sorteny</desc>
<extensions>
<ql:key>6f8aefaa3cec6ee3deb5ebdba8cb78c2</ql:key>
<ql:flags>3</ql:flags>
@@ -39696,9 +39639,8 @@ This is waypoint no: 425</desc>
</trkseg>
</trk>
<trk>
<name>Etape 09 - HRP orginal</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Gîte d'étape l'Hospitalité au Refuge de Coms de Jan&lt;/p>&lt;/div></desc>
<name>Etape 9 - HRP orginal</name>
<desc>Gîte d'étape l'Hospitalité ➜ Refuge de Coms de Jan</desc>
<extensions>
<ql:key>129ec58de2626772313bb5f5a69c57f0</ql:key>
<ql:flags>0</ql:flags>
@@ -41063,8 +41005,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 10</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Refuge de Sorteny au Village de Marc&lt;/p>&lt;/div></desc>
<desc>Refuge de Sorteny ➜ Village de Marc</desc>
<extensions>
<ql:key>34c70c0bbb43e48a0aa292ec5f1988ea</ql:key>
<ql:flags>0</ql:flags>
@@ -42495,8 +42436,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 11</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Village de Marc au Refugi de Certascan&lt;/p>&lt;/div></desc>
<desc>Village de Marc ➜ Refugi de Certascan</desc>
<extensions>
<ql:key>dba07fa9a705cf9fdeb53d83d44209ea</ql:key>
<ql:flags>0</ql:flags>
@@ -43269,8 +43209,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 12</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Refugi de Certascan au Refuge del Fornet&lt;/p>&lt;/div></desc>
<desc>Refugi de Certascan ➜ Refuge del Fornet</desc>
<extensions>
<ql:key>d7e97a2f0d5dbfe670bd4f4e05ec25cc</ql:key>
<ql:flags>3</ql:flags>
@@ -49049,8 +48988,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 13</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Refuge del Fornet au Refuge de Colomèrs II&lt;/p>&lt;/div></desc>
<desc>Refuge del Fornet ➜ Refuge de Colomèrs II</desc>
<extensions>
<ql:key>dbb7d6c539233a18cab2ed8b6fd4f621</ql:key>
<ql:flags>7</ql:flags>
@@ -57294,8 +57232,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 14</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Refuge de Colomèrs II au Refugi de Conangles&lt;/p>&lt;/div></desc>
<desc>Refuge de Colomèrs II ➜ Refugi de Conangles</desc>
<extensions>
<ql:key>27bc24fdad161d97804b9c42e190ca22</ql:key>
<ql:flags>6</ql:flags>
@@ -58821,8 +58758,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 15</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Refugi de Conangles au Refugio de la Renclusa&lt;/p>&lt;/div></desc>
<desc>Refugi de Conangles ➜ Refugio de la Renclusa</desc>
<extensions>
<ql:key>5d011dacc7bf2927f3343e6f451467f9</ql:key>
<ql:flags>3</ql:flags>
@@ -64468,8 +64404,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 15 - Variante Aneto</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Ascension Aneto&lt;/p>&lt;/div></desc>
<desc>Ascension Aneto</desc>
<extensions>
<ql:key>1e42af4594337d4fa78cb425c6349615</ql:key>
<ql:flags>6</ql:flags>
@@ -68958,8 +68893,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 16</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Refugio de la Renclusa au Refuge du Portillon&lt;/p>&lt;/div></desc>
<desc>Refugio de la Renclusa ➜ Refuge du Portillon</desc>
<extensions>
<ql:key>574d4668eb44937395115da70d5a30fd</ql:key>
<ql:flags>0</ql:flags>
@@ -69644,8 +69578,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 17</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Refuge du Portillon au Refugio de Viados&lt;/p>&lt;/div></desc>
<desc>Refuge du Portillon ➜ Refugio de Viados</desc>
<extensions>
<ql:key>c93882c99ba4026293e355a549b84b71</ql:key>
<ql:flags>7</ql:flags>
@@ -76190,8 +76123,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 18</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Refugio de Viados à Parzan&lt;/p>&lt;/div></desc>
<desc>Refugio de Viados ➜ Parzan</desc>
<extensions>
<ql:key>612d9c9dd01e11f75b5ae05547835536</ql:key>
<ql:flags>7</ql:flags>
@@ -80071,8 +80003,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 18 - HRP original</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Refugio de Viados au Refuge de Barroude&lt;/p>&lt;/div></desc>
<desc>Refugio de Viados ➜ Refuge de Barroude</desc>
<extensions>
<ql:key>e5069e67b53d2880bdf1581a13b757c4</ql:key>
<ql:flags>0</ql:flags>
@@ -80549,8 +80480,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 19</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Parzan à l'Auberge le Maillet&lt;/p>&lt;/div></desc>
<desc>Parzan ➜ l'Auberge le Maillet</desc>
<extensions>
<ql:key>651effda48dd9093dc73f3a8a24e2e40</ql:key>
<ql:flags>0</ql:flags>
@@ -84291,8 +84221,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 19 - HRP original</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Refugio de Viados au Refuge de Barroude&lt;/p>&lt;/div></desc>
<desc>Refugio de Viados ➜ Refuge de Barroude</desc>
<extensions>
<ql:key>99ae4a3b940c385481c4b42eb3b4a196</ql:key>
<ql:flags>0</ql:flags>
@@ -85785,8 +85714,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 20</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">L'Auberge le Maillet à Gavarnie&lt;/p>&lt;/div></desc>
<desc>L'Auberge le Maillet ➜ Gavarnie</desc>
<extensions>
<ql:key>b48c63d55bd654c8f93870a39571251a</ql:key>
<ql:flags>0</ql:flags>
@@ -89423,8 +89351,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 21</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Gavarnie au Refuge de Bayssellance&lt;/p>&lt;/div></desc>
<desc>Gavarnie ➜ Refuge de Bayssellance</desc>
<extensions>
<ql:key>d158ee70a5de9e81361df2b4b64c8a4b</ql:key>
<ql:flags>3</ql:flags>
@@ -93612,8 +93539,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 21 - Variante Brèche de Roland</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Ascension Brèche de Roland&lt;/p>&lt;/div></desc>
<desc>Ascension Brèche de Roland</desc>
<extensions>
<ql:key>5fc73a8c5b88608af36f38e0cf4a150f</ql:key>
<ql:flags>3</ql:flags>
@@ -99973,8 +99899,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 22</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Refuge de Bayssellance au Refuge Des Oulettes de Gaube et ascension Vignemale&lt;/p>&lt;/div></desc>
<desc>Refuge de Bayssellance ➜ Refuge Des Oulettes de Gaube + ascension Vignemale</desc>
<extensions>
<ql:key>7a5bc318d11418c4d75a50ecbf87c0ad</ql:key>
<ql:flags>3</ql:flags>
@@ -102699,8 +102624,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 23</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Refuge Des Oulettes de Gaube au Refuge D'Arrémoulit&lt;/p>&lt;/div></desc>
<desc>Refuge Des Oulettes de Gaube ➜ Refuge D'Arrémoulit</desc>
<extensions>
<ql:key>1bde0c938a8befba899343806c9f4f7b</ql:key>
<ql:flags>3</ql:flags>
@@ -111942,8 +111866,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 24</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Refuge D'Arrémoulit à Albergue Aysa&lt;/p>&lt;/div></desc>
<desc>Refuge D'Arrémoulit ➜ Albergue Aysa</desc>
<extensions>
<ql:key>e4b81d076b1c29b16dcd05b838f8c230</ql:key>
<ql:flags>0</ql:flags>
@@ -113820,8 +113743,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 25</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Albergue Aysa au Camping du Lauzart&lt;/p>&lt;/div></desc>
<desc>Albergue Aysa ➜ Camping du Lauzart</desc>
<extensions>
<ql:key>a5a91f642dafe534bcfb2a937396a17e</ql:key>
<ql:flags>3</ql:flags>
@@ -123049,8 +122971,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 25 - HRP original</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Refugio Pepe Garces à l'Ibón de Acherito&lt;/p>&lt;/div></desc>
<desc>Refugio Pepe Garces ➜ l'Ibón de Acherito</desc>
<extensions>
<ql:key>b7eefd1505014117f27f73afad1872f4</ql:key>
<ql:flags>0</ql:flags>
@@ -124743,8 +124664,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 26</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Camping du Lauzart au Camping Asolaze&lt;/p>&lt;/div></desc>
<desc>Camping du Lauzart ➜ Camping Asolaze</desc>
<extensions>
<ql:key>7a282ffe2293e1e5350313f348a34482</ql:key>
<ql:flags>3</ql:flags>
@@ -130075,8 +129995,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 27</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Camping Asolaze au Camping dIraty&lt;/p>&lt;/div></desc>
<desc>Camping Asolaze ➜ Camping dIraty</desc>
<extensions>
<ql:key>7de5c4e4124cf7eeca85e540b1e5c309</ql:key>
<ql:flags>3</ql:flags>
@@ -140181,8 +140100,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 28</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Camping dIraty à Hostel Roncesvalles&lt;/p>&lt;/div></desc>
<desc>Camping dIraty ➜ Hostel Roncesvalles</desc>
<extensions>
<ql:key>41bf55af75d1a405efcfcfa57fcea50b</ql:key>
<ql:flags>3</ql:flags>
@@ -148278,8 +148196,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 29</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Hostel Roncesvalles à Elizondo&lt;/p>&lt;/div></desc>
<desc>Hostel Roncesvalles ➜ Elizondo</desc>
<extensions>
<ql:key>ca6af086e6d59f01497dbb2b1bf3985f</ql:key>
<ql:flags>3</ql:flags>
@@ -156767,8 +156684,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 30</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Elizondo au Camping La Petite Rhune&lt;/p>&lt;/div></desc>
<desc>Elizondo ➜ Camping La Petite Rhune</desc>
<extensions>
<ql:key>695b939a623e6abc65ed717583435370</ql:key>
<ql:flags>3</ql:flags>
@@ -165464,8 +165380,7 @@ This is waypoint no: 425</desc>
</trk>
<trk>
<name>Etape 31</name>
<desc>&lt;div>
&lt;p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Camping La Petite Rhune à Hendaye&lt;/p>&lt;/div></desc>
<desc>Camping La Petite Rhune ➜ Hendaye</desc>
<extensions>
<ql:key>c3bc833d7a5fcb33e8698738d1e516f4</ql:key>
<ql:flags>6</ql:flags>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+237
View File
@@ -0,0 +1,237 @@
{
"action": {
"back": "Back",
"delete": "Delete",
"save": "Save",
"send": "Send"
},
"account": {
"login": "Log in",
"logout": "Log out",
"confirm_password": "Confirm password",
"email_placeholder": "my@email.com",
"invalid_credentials": "Incorrect password",
"invalid_email": "This doesn't look like a valid email address",
"logged_in": "Logged in",
"logged_out": "Logged out",
"newsletter": "Email me new updates",
"password": "Password",
"password_mismatch": "The passwords don't match",
"password_required": "Enter your password to continue",
"password_set": "Your password has been set successfully.",
"set_password": "Set a password for this administrator account",
"subscribed": "Thanks! You'll receive a confirmation email shortly.",
"unsubscribed": "Done. No more junk mail from me.",
"title": "Account",
"status": {
"logged_in": "You're logged in.",
"logged_in_named": "Hello $0, you're logged in.",
"logged_out": "Enter your email address below to receive my position as soon as I find it :)",
"subscribed": "You're all set. I'll send you updates!"
}
},
"admin": {
"config": "Settings",
"create_success": "Created",
"delete_success": "Deleted",
"save_success": "Saved",
"title": "Admin Panel",
"toolbox": "Toolbox",
"upload": "Upload"
},
"credits": {
"git": "Git repository",
"license": "licensed under GPLv3",
"project": "$0 Project"
},
"email": {
"confirmation": {
"body_1": "Thank you for following 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, and sometimes at special moments too, like successful peak ascents. I'm using a GPS-based device (PLB), which doesn't require phone reception to work. So messages should be fairly frequent, but — being awestruck by the beauty of nature — I might also just forget to send a signal once in a while. So don't worry if you don't receive anything for a couple of days.",
"body_3": "If I've posted any pictures recently, you'll also find them in the same email.",
"conclusion": "See you down the road!",
"preheader": "Thanks for keeping in touch!",
"signature": "--François",
"subject": "Registration confirmed",
"thanks_subject": "You're all set!"
},
"failure": "Failed to send the update email",
"sent": "Update email sent",
"unsubscribe": "PS: Changed your mind?",
"unsubscribe_button": "Unsubscribe",
"update": {
"latest_news": "Latest news:",
"preheader": "New position received",
"subject": "Spotted!",
"title": "Message"
}
},
"error": {
"commit_db": "Error committing to database",
"file_missing": "$0 file \"$1\" is missing",
"impossible_value": "Value \"$0\" is not valid for field \"$1\"",
"no_auth": "Not authorized",
"not_found": "Unknown action",
"unknown_field": "Unknown field \"$0\"",
"unknown_type": "Unknown type \"$0\""
},
"feed": {
"counter": "#$0",
"id": "Feed ID",
"last_update": "Last SPOT update",
"name": "Name",
"new": "New feed",
"plural": "Feeds",
"ref_id": "Feed reference 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": "View position on Google Maps",
"terrain": "3D terrain",
"title": "Base maps",
"usgs": "USGS"
},
"media": {
"click_watch": "Click to watch the video",
"click_zoom": "Click to zoom",
"comment_update": "Comment for media \"$0\" updated",
"image": "Picture",
"image_taken_on": "Taken on $0",
"images": "Pictures",
"nearby": "Nearby pictures",
"no_id": "Missing media ID in request",
"posted_on": "Posted on $0",
"video": "Video",
"video_taken_on": "Shot on $0"
},
"meta": {
"locale": "en_NZ",
"page_og_desc": "Stay in touch while I'm away hiking."
},
"post": {
"copy_to_clipboard": "Copy direct link to clipboard",
"link_copied": "Link copied!",
"message": "Message",
"name": "Name",
"new_message": "New message",
"reply": "Reply to this message"
},
"project": {
"wip": "In progress",
"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",
"overview": "Overview",
"plural": "Projects",
"single": "Project",
"start": "Start",
"update_messages": "Update project messages"
},
"spot": {
"id": "Spot ID",
"model": "Model",
"name": "Spot name",
"no_new_msg": "No new messages",
"plural": "Spots",
"ref_id": "Ref. Spot ID"
},
"stats": {
"duration": "Duration",
"distance": "Distance",
"elevation": "Elevation",
"elevation_gain": "Elevation gain",
"elevation_loss": "Elevation loss",
"from": "Start",
"legend": "Legend",
"segment_length": "Segment length",
"to": "Finish",
"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": {
"error": "Upload failed",
"media": {
"exists": "Picture $0 already exists",
"title": "Picture and video uploads"
},
"mode_archived": "Project \"$0\" is archived. Uploads are not allowed.",
"position": {
"determining": "Determining position…",
"error": "Unable to determine position",
"new": "New position",
"permission_denied": "Location permission was denied",
"sending": "Sending position…",
"success": "Position uploaded successfully",
"timeout": "Position request timed out",
"title": "Add position",
"unavailable": "Position is unavailable",
"unsupported": "This browser does not support geolocation"
},
"success": "$0 uploaded successfully"
},
"user": {
"subscribed": "Subscribed users",
"clearance": "Clearance",
"email": "Email",
"id": "User ID",
"language": "Language",
"name": "User name"
},
"weather": {
"clear-day": "Cloud cover is less than 20% during the day",
"clear-night": "Cloud cover is less than 20% during the night",
"cloudy": "Cloud cover is greater than 90%",
"fog": "Visibility is low (less than 1km)",
"hail": "Hail showers",
"partly-cloudy-day": "Cloud cover is greater than 20% during the day",
"partly-cloudy-night": "Cloud cover is greater than 20% during the night",
"rain": "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": "Snowfall 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 km/h)"
}
}
+80 -47
View File
@@ -5,6 +5,31 @@
"save": "Guardar",
"send": "Enviar"
},
"account": {
"login": "Iniciar sesión",
"logout": "Cerrar sesión",
"confirm_password": "Confirmar contraseña",
"email_placeholder": "nombre@email.com",
"invalid_credentials": "Contraseña incorrecta",
"invalid_email": "Esto no parece una dirección de correo electrónico",
"logged_in": "Sesión iniciada",
"logged_out": "Sesión cerrada",
"newsletter": "Enviarme nuevas actualizaciones por correo",
"password": "Contraseña",
"password_mismatch": "Las contraseñas no coinciden",
"password_required": "Introduce tu contraseña para continuar",
"password_set": "Tu contraseña se ha establecido correctamente.",
"set_password": "Establece una contraseña para esta cuenta administradora",
"subscribed": "¡Gracias! Recibirás un correo electrónico de confirmación.",
"unsubscribed": "Listo. ¡No más spam!",
"title": "Cuenta",
"status": {
"logged_in": "Has iniciado sesión.",
"logged_in_named": "Hola $0, has iniciado sesión.",
"logged_out": "Añade tu dirección de correo electrónico y te enviaré mi posición actualizada tan pronto como la reciba :)",
"subscribed": "Todo está listo. Te enviaré noticias frescas en cuanto las reciba. Prometido..."
}
},
"admin": {
"config": "Configuración",
"create_success": "Creado",
@@ -17,21 +42,23 @@
"credits": {
"git": "Repositorio de Git",
"license": "bajo licencia GPLv3",
"project": "Proyecto Spotty"
"project": "Proyecto $0"
},
"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.",
"body_1": "Te agradezco mucho que sigas mi proyecto y que te intereses por su evolución. Te prometo que te mantendré informado sobre mi progreso.",
"body_2": "Normalmente envío un mensaje una vez al día. Cuando voy a lugares especiales, envío uno extra (cimas, ese tipo de cosas). Uso un dispositivo GPS para enviar la señal, así que no necesito cobertura telefónica para que funcione. Sin embargo, puede haber ocasiones en las que no presione el botón. Por lo tanto, no te preocupes si no recibes mensajes durante uno o dos días.",
"body_3": "Cuando añada fotos a la página, también las encontrarás 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!"
},
"failure": "No se pudo enviar el correo de actualización",
"sent": "Correo de actualización enviado",
"unsubscribe": "PD: ¿Demasiados correos electrónicos?",
"unsubscribe_button": "Desinscribirse",
"unsubscribe_button": "Darse de baja",
"update": {
"latest_news": "Últimas noticias:",
"preheader": "¡Nueva posición!",
@@ -41,14 +68,17 @@
},
"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"
"file_missing": "Falta el archivo $0 \"$1\"",
"impossible_value": "El valor \"$0\" no es posible para el campo \"$1\"",
"no_auth": "Sin autorización",
"not_found": "Acción desconocida",
"unknown_field": "Campo \"$0\" desconocido",
"unknown_type": "Tipo \"$0\" desconocido"
},
"feed": {
"counter": "No. $0",
"counter": "N.º $0",
"id": "ID Feed",
"last_update": "Última actualización de Spot",
"last_update": "Última actualización de SPOT",
"name": "Descripción",
"new": "Nuevo feed",
"plural": "Feeds",
@@ -64,52 +94,41 @@
"outdoors": "Mapbox Topo",
"satellite": "Satélite",
"see_on_google": "Ver la posición en Google Maps",
"terrain": "Relieve 3D",
"title": "Mapas de base",
"usgs": "USGS"
},
"media": {
"add_on": "Agregado el $0",
"click_watch": "Haz clic para ver el video",
"click_watch": "Haz clic para ver el vídeo",
"click_zoom": "Haz clic para ampliar",
"comment_update": "Comentario \"$0\" actualizado",
"count": "Media $0 de $1",
"image": "Foto",
"image_taken": "Foto tomada el $0",
"image_taken_on": "Foto tomada el $0",
"images": "Fotos",
"nearby": "Fotos cercanas",
"no_id": "Falta el ID del sujeto",
"video": "Video",
"video_taken": "Video filmado el $0"
"no_id": "Falta el ID del archivo multimedia",
"posted_on": "Añadido el $0",
"video": "Vídeo",
"video_taken_on": "Vídeo grabado 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 :)"
"page_og_desc": "Mantente en contacto conmigo durante mis aventuras en la montaña."
},
"post": {
"copy_to_clipboard": "Copiar el enlace",
"link_copied": "¡Enlace copiado!",
"message": "Mensaje",
"name": "Nombre",
"new_message": "Mensaje nuevo"
"new_message": "Mensaje nuevo",
"reply": "Responder a este mensaje"
},
"project": {
"wip": "En curso",
"code_name": "Nombre clave",
"end": "Fin",
"hikes": "Senderos",
"id": "Proyecto ID",
"id": "ID del proyecto",
"mode": "Modo",
"modes": {
"blog": "Proyecto activo",
@@ -117,17 +136,19 @@
"previz": "Proyecto en preparación"
},
"new": "Nuevo proyecto",
"overview": "Vista general",
"plural": "Proyectos",
"single": "Proyecto",
"start": "Inicio",
"update_messages": "Actualizar los mensajes del proyecto"
},
"spot": {
"id": "ID Spot",
"id": "ID de Spot",
"model": "Modelo",
"name": "Spot",
"no_new_msg": "No hay mensajes nuevos",
"plural": "Spots",
"ref_id": "ID Spot ref."
"ref_id": "ID de referencia de Spot"
},
"stats": {
"duration": "Duración",
@@ -135,44 +156,56 @@
"elevation": "Elevación",
"elevation_gain": "Ascenso acumulado",
"elevation_loss": "Descenso acumulado",
"from": "Inicio",
"legend": "Leyenda",
"segment_length": "Tamaño del segmento",
"to": "Fin",
"type": "Tipo de sendero"
},
"time": {
"city": "Hora de $0",
"date_time": "$0 a la $1",
"city": "Hora en $0",
"date_time": "$0 a las $1",
"local": "$0 hora local",
"user": "$0 en tu zona horaria",
"zone": "Huso horario"
},
"track": {
"download": "Descarga la ruta GPX",
"download": "Descargar la ruta GPX",
"hitchhiking": "Autostop",
"main": "Camino principal",
"off-track": "Variante"
},
"unit": {
"day": "Día",
"day": "día",
"day_short": "D",
"days": "Días",
"days": "días",
"hour": "h"
},
"upload": {
"error": "Error al subir el archivo",
"media": {
"exists": "La imagen $0 ya existe",
"title": "Cargar fotos y videos"
"title": "Cargar fotos y vídeos"
},
"mode_archived": "El proyecto \"$0\" esta archivado. No se puede cargar",
"mode_archived": "El proyecto \"$0\" está archivado. No se puede cargar.",
"position": {
"determining": "Determinando la posición…",
"error": "No se puede determinar la posición",
"new": "Nueva posición",
"title": "Subir posición"
"permission_denied": "Se ha denegado el permiso de ubicación",
"sending": "Enviando la posición…",
"success": "Posición subida correctamente",
"timeout": "La solicitud de posición ha agotado el tiempo de espera",
"title": "Subir posición",
"unavailable": "La posición no está disponible",
"unsupported": "Este navegador no admite la geolocalización"
},
"success": "$0 ha sido subido"
"success": "$0 se ha subido correctamente."
},
"user": {
"active": "Usuarios activos",
"subscribed": "Usuarios suscritos",
"clearance": "Nivel de autorización",
"email": "Correo electrónico",
"id": "ID del usuario",
"language": "Idioma",
"name": "Nombre"
@@ -181,7 +214,7 @@
"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)",
"fog": "La visibilidad es baja (menos de 1km)",
"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",
@@ -199,6 +232,6 @@
"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)"
"wind": "La velocidad del viento es alta (más de 30 km/h)"
}
}
+82 -49
View File
@@ -5,32 +5,59 @@
"save": "Sauvegarder",
"send": "Envoyer"
},
"account": {
"login": "Se connecter",
"logout": "Se déconnecter",
"confirm_password": "Confirmer le mot de passe",
"email_placeholder": "mon@email.com",
"invalid_credentials": "Mot de passe incorrect",
"invalid_email": "Ceci ne ressemble pas à une adresse e-mail",
"logged_in": "Connexion réussie",
"logged_out": "Déconnexion réussie",
"newsletter": "M'envoyer les nouvelles mises à jour par e-mail",
"password": "Mot de passe",
"password_mismatch": "Les mots de passe ne correspondent pas",
"password_required": "Saisis ton mot de passe pour continuer",
"password_set": "Ton mot de passe a bien été défini.",
"set_password": "Définis un mot de passe pour ce compte administrateur",
"subscribed": "Merci ! Tu vas recevoir un e-mail de confirmation très bientôt.",
"unsubscribed": "C'est fait. Fini le spam !",
"title": "Compte",
"status": {
"logged_in": "Tu es connecté.",
"logged_in_named": "Bonjour $0, tu es connecté.",
"logged_out": "Ajoute ton adresse e-mail et je t'enverrai ma nouvelle position dès que je la trouve :)",
"subscribed": "C'est tout bon. Je t'enverrai des nouvelles fraîches. Parole de scout."
}
},
"admin": {
"config": "Paramètres",
"create_success": "Créé",
"delete_success": "Supprimé",
"save_success": "Sauvegardé",
"title": "Administration",
"toolbox": "Boite à outils",
"upload": "Uploader"
"toolbox": "Boîte à outils",
"upload": "Téléverser"
},
"credits": {
"git": "Dépôt Git",
"license": "sous licence GPLv3",
"project": "Projet Spotty"
"project": "Projet $0"
},
"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 !",
"body_1": "C'est gentil de venir voir où j'en suis. Promis, je te tiendrai au courant de mon avancée.",
"body_2": "En général, j'envoie un message une fois par jour. Lorsque je passe par des endroits sympas, j'en envoie un supplémentaire (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'oublier d'appuyer sur le bouton. Donc pas de raison de t'inquiéter si tu ne reçois pas de messages pendant une journée ou deux.",
"body_3": "Si j'ai ajouté des photos sur le site récemment, tu devrais aussi les retrouver dans cet e-mail.",
"conclusion": "À 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 ?",
"failure": "Échec de lenvoi de le-mail de mise à jour",
"sent": "E-mail de mise à jour envoyé",
"unsubscribe": "PS : Trop d'e-mails ?",
"unsubscribe_button": "Se désinscrire",
"update": {
"latest_news": "Dernières nouvelles :",
@@ -40,15 +67,18 @@
}
},
"error": {
"commit_db": "Error lors de la requête SQL",
"commit_db": "Erreur lors de la requête SQL",
"file_missing": "Le fichier $0 \"$1\" est manquant",
"impossible_value": "La valeur \"$0\" n'est pas possible pour le champ \"$1\"",
"no_auth": "Pas d'authorisation",
"unknown_field": "Champ \"$0\" inconnu"
"no_auth": "Pas d'autorisation",
"not_found": "Action inconnue",
"unknown_field": "Champ \"$0\" inconnu",
"unknown_type": "Type \"$0\" inconnu"
},
"feed": {
"counter": "N°$0",
"id": "ID Feed",
"last_update": "Dernière vérification Spot",
"id": "ID feed",
"last_update": "Dernière vérification de SPOT",
"name": "Description",
"new": "Nouveau feed",
"plural": "Feeds",
@@ -64,48 +94,37 @@
"outdoors": "Mapbox Topo",
"satellite": "Satellite",
"see_on_google": "Voir la position sur Google Maps",
"terrain": "Relief 3D",
"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",
"click_watch": "Cliquer pour voir la vidéo",
"click_zoom": "Cliquer pour zoomer",
"comment_update": "Commentaire du média \"$0\" mis à jour",
"image": "Photo",
"image_taken": "prise le $0",
"image_taken_on": "Prise le $0",
"images": "Photos",
"nearby": "Photos prises dans le coin",
"no_id": "ID du média manquant",
"posted_on": "Ajouté le $0",
"video": "Vidéo",
"video_taken": "filmée le $0"
"video_taken_on": "Filmé 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 :)"
"page_og_desc": "Garde le contact lorsque je suis sur les chemins."
},
"post": {
"copy_to_clipboard": "Copie le lien dans le presse-papier",
"copy_to_clipboard": "Copier le lien dans le presse-papiers",
"link_copied": "Lien copié !",
"message": "Message",
"name": "Nom",
"new_message": "Nouveau message"
"new_message": "Nouveau message",
"reply": "Répondre à ce message"
},
"project": {
"wip": "En cours",
"code_name": "Nom de code",
"end": "Arrivée",
"hikes": "Randonnées",
@@ -117,6 +136,7 @@
"previz": "Projet en cours de préparation"
},
"new": "Nouveau projet",
"overview": "Vue d'ensemble",
"plural": "Projets",
"single": "Projet",
"start": "Départ",
@@ -126,6 +146,7 @@
"id": "ID Spot",
"model": "Modèle",
"name": "Spot",
"no_new_msg": "Aucun nouveau message",
"plural": "Spots",
"ref_id": "ID Spot ref."
},
@@ -135,21 +156,23 @@
"elevation": "Dénivelé",
"elevation_gain": "Dénivelé positif",
"elevation_loss": "Dénivelé négatif",
"from": "Départ",
"legend": "Légende",
"segment_length": "Taille du segment",
"to": "Arrivée",
"type": "Type de rando"
},
"time": {
"city": "heure de $0",
"city": "Heure à $0",
"date_time": "$0 à $1",
"local": "$0 heure locale",
"user": "$0 dans votre fuseau horaire",
"user": "$0 dans ton fuseau horaire",
"zone": "Fuseau horaire"
},
"track": {
"download": "Télécharger la trace GPX",
"hitchhiking": "Hors rando",
"main": "Trajet principal",
"hitchhiking": "Auto-stop",
"main": "Itinéraire",
"off-track": "Variante"
},
"unit": {
@@ -159,21 +182,31 @@
"hour": "h"
},
"upload": {
"error": "Erreur lors du téléversement",
"media": {
"exists": "l'image $0 existe déjà",
"title": "Uploader photos & vidéos"
"exists": "L'image $0 existe déjà",
"title": "Téléverser des photos et vidéos"
},
"mode_archived": "Le projet \"$0\" a été archivé. Aucun upload possible",
"mode_archived": "Le projet \"$0\" a été archivé. Aucun téléversement possible.",
"position": {
"determining": "Détermination de la position…",
"error": "Impossible de déterminer la position",
"new": "Nouvelle position",
"title": "Position supplémentaire"
"permission_denied": "Lautorisation daccéder à la position a été refusée",
"sending": "Envoi de la position…",
"success": "Position téléversée avec succès",
"timeout": "La demande de position a expiré",
"title": "Position supplémentaire",
"unavailable": "La position nest pas disponible",
"unsupported": "Ce navigateur ne prend pas en charge la géolocalisation"
},
"success": "$0 a été uploadé"
"success": "$0 a été téléversé"
},
"user": {
"active": "Utilisateurs actifs",
"subscribed": "Utilisateurs abonnés",
"clearance": "Niveau d'autorisation",
"id": "ID Utilisateur",
"email": "E-mail",
"id": "ID utilisateur",
"language": "Langue",
"name": "Nom"
},
@@ -181,7 +214,7 @@
"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 dun kilomètre ou dun mile)",
"fog": "La visibilité est faible (moins de 1km)",
"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",
@@ -199,6 +232,6 @@
"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)"
"wind": "La vitesse du vent est élevée (plus de 30 km/h)"
}
}
@@ -8,7 +8,7 @@
<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 width="20%"><img src="[#]local_server[#]assets/images/icons/favicon-96x96.png" width="90%" border="0" alt="logo" /></td>
<td><h1>[#]lang:email.confirmation.thanks_subject[#]</h1></td>
</tr>
<tr>
@@ -8,7 +8,7 @@
<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 width="20%"><img src="[#]local_server[#]assets/images/icons/favicon-96x96.png" width="90%" border="0" alt="logo" /></td>
<td><h1>[#]lang:email.update.title[#] [#]type[#] #[#]displayed_id[#]</h1></td>
</tr>
<tr>
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="[#]language[#]">
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="[#]lang:meta.page_og_desc[#]">
<meta property="og:title" content="[#]title[#]" />
<meta property="og:description" content="[#]lang:meta.page_og_desc[#]" />
<meta property="og:type" content="website" />
<meta property="og:url" content="[#]server[#]" />
<meta property="og:image" content="assets/images/icons/ogp.svg?v=20260528" />
<meta property="og:locale" content="[#]lang:meta.locale[#]" />
<link rel="icon" type="image/png" href="assets/images/icons/favicon-96x96.png?v=20260528" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="assets/images/icons/favicon.svg?v=20260528" />
<link rel="shortcut icon" href="assets/images/icons/favicon.ico?v=20260528" />
<link rel="apple-touch-icon" sizes="180x180" href="assets/images/icons/apple-touch-icon.png?v=20260528" />
<meta name="apple-mobile-web-app-title" content="[#]title[#]" />
<link rel="manifest" href="assets/images/icons/site.webmanifest?v=20260528" />
<meta name="theme-color" content="#081B19">
<script id="app-config" type="application/json">[#]app_config[#]</script>
<title>[#]title[#]</title>
<!-- [PART] css [START] -->
<link rel="stylesheet" href="[#]filename[#]" />
<!-- [PART] css [END] -->
<!-- [PART] module [START] -->
<link rel="modulepreload" href="[#]filename[#]" />
<!-- [PART] module [END] -->
<script type="module" src="[#]app_entry[#]"></script>
</head>
<body>
<div id="container"></div>
</body>
</html>
+35 -21
View File
@@ -1,18 +1,17 @@
<script>
import Project from '@components/project';
import Admin from '@components/admin';
import Upload from '@components/upload';
import { defineAsyncComponent } from 'vue';
import Project from '@components/Project';
const aoRoutes = {
'project': Project,
'admin': Admin,
'upload': Upload
'project': Project, //Merge app.js and project.js calls to avoid extra http request on inital page
'admin': defineAsyncComponent(() => import('@components/Admin')),
'upload': defineAsyncComponent(() => import('@components/Upload'))
};
export default {
data() {
return {
hash: {page: '', items: []},
hash: {page: '', items: [], prev: {}},
consts: this.appConfig.consts,
mobile: false
};
@@ -21,7 +20,9 @@ export default {
return {
hash: this.hash,
consts: this.consts,
isMobile: () => this.isMobile()
isMobile: () => this.isMobile(),
getAnchor: this.getAnchor,
getPrevAnchor: () => this.getPrevAnchor(),
};
},
computed: {
@@ -29,30 +30,35 @@ export default {
return aoRoutes[this.hash.page];
},
hashSnapshot() {
return JSON.stringify(this.hash);
const { prev, ...asHash } = this.hash;
return JSON.stringify(asHash);
}
},
inject: ['appConfig'],
created() {
this.mobileMediaQuery = window.matchMedia('only screen and (max-width: 800px)');
this.mobileMediaQuery.addEventListener('change', this.updateMobile);
this.updateMobile();
//Set initial page
let asInitHash = this.getBrowserHash();
if(!asInitHash.page) asInitHash.page = this.consts.default_page;
this.setVarHash(asInitHash);
this.setVarHash(this.validateRoute(this.getBrowserHash()));
},
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);
const asNewHash = this.validateRoute(JSON.parse(jNewHash));
//Sync variable -> #hash
if(asNewHash != this.getBrowserHash()) {
this.setBrowserHash(asNewHash.page, asNewHash.items);
}
//Store previous value
this.hash.prev = JSON.parse(jOldHash);
this.setPageTitle(asNewHash.page);
}
},
@@ -61,7 +67,7 @@ export default {
return this.mobile;
},
updateMobile() {
this.mobile = getComputedStyle(this.$refs.mobile).display !== 'none';
this.mobile = this.mobileMediaQuery.matches;
},
setPageTitle(sTitle) {
document.title = this.consts.title + ' - ' + sTitle.trim();
@@ -82,19 +88,27 @@ export default {
},
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;
window.location.hash = this.getAnchor([sPage, ...asItems]);
},
getAnchor(asBreadCrumbs) {
return '#' + asBreadCrumbs.filter(Boolean).join(this.consts.hash_sep);
},
getPrevAnchor() {
return this.getAnchor([this.hash.prev.page, ...this.hash.prev.items]);
},
validateRoute(asHash) {
if(!Object.keys(aoRoutes).includes(asHash.page)) asHash.page = this.consts.default_page;
return asHash;
}
},
beforeUnmount() {
window.removeEventListener('hashchange', this.onBrowserHashChange);
window.removeEventListener('resize', this.updateMobile);
this.mobileMediaQuery.removeEventListener('change', this.updateMobile);
}
}
};
</script>
<template>
<div id="main">
<component :is="route" />
</div>
<div id="mobile" ref="mobile"></div>
</template>
+11 -9
View File
@@ -1,4 +1,5 @@
//Librairies
import 'vite/modulepreload-polyfill';
import Api from '@scripts/api';
import Lang from '@scripts/lang';
import Projects from '@scripts/projects';
@@ -6,10 +7,10 @@ import User from '@scripts/user';
import { createApp, reactive } from 'vue';
//Main template
import Spot from './Spot';
import App from './App.vue';
//Style
import Css from '@styles/spot';
import '@styles/livetrail.scss';
//App Configuration from PHP
const appConfig = JSON.parse(document.getElementById('app-config').textContent);
@@ -22,15 +23,16 @@ const oApi = new Api({
server: appConfig.consts.server,
processPage: appConfig.consts.process_page,
timezone: oUser.timezone,
csrfToken: appConfig.consts.csrf_token,
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');
const oApp = createApp(App);
oApp.provide('appConfig', appConfig);
oApp.provide('api', oApi);
oApp.provide('lang', oLang);
oApp.provide('projects', oProjects);
oApp.provide('user', oUser);
oApp.mount('#container');
@@ -1,15 +1,15 @@
<script>
import SpotIcon from '@components/spotIcon';
import SpotButton from '@components/spotButton';
import AdminInput from '@components/adminInput';
import AppIcon from '@components/AppIcon';
import AppButton from '@components/AppButton';
import AdminInput from '@components/AdminInput';
export default {
components: {
SpotIcon,
SpotButton,
AppIcon,
AppButton,
AdminInput
},
inject: ['api', 'lang'],
inject: ['api', 'lang', 'getPrevAnchor'],
data() {
return {
elems: {},
@@ -24,8 +24,8 @@ export default {
this.setProjects();
},
methods: {
l(id) {
return this.lang.get(id);
l(sLangId, asLangParams=[]) {
return this.lang.get(sLangId, asLangParams);
},
addFeedback(sType, sMsg, asContext = {}) {
delete asContext.a;
@@ -43,39 +43,24 @@ export default {
for(const [sType, aoElems] of Object.entries(aoElemTypes)) {
this.elems[sType] = {};
for(const [iKey, oElem] of Object.entries(aoElems)) {
for(const oElem of Object.values(aoElems)) {
oElem.type = sType;
this.elems[sType][oElem.id] = oElem;
}
}
},
createElem(sType) {
this.api.get('admin_create', {type: sType})
this.api.post('admin_create', {type: sType})
.then((aoNewElemTypes) => {
for(const [sType, aoNewElems] of Object.entries(aoNewElemTypes)) {
for(const [iKey, oNewElem] of Object.entries(aoNewElems)) {
for(const oNewElem of Object.values(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);
});
.catch((oError) => {this.addFeedback('error', oError.desc_lang_text, {'create':sType});});
},
updateElem(oElem, oEvent) {
if(this.saveTimer) clearTimeout(this.saveTimer);
@@ -90,32 +75,50 @@ export default {
value: sNewVal
};
this.api.get('admin_set', asInputs)
.then((asData) => {
this.api.request('admin_set', asInputs, 'POST')
.then((oResponse) => {
this.elems[oElem.type][oElem.id][oEvent.target.name] = sNewVal;
this.addFeedback('success', this.l('admin.save_success'), asInputs);
const bSuccess = (oResponse.desc_lang_id == '');
const sType = bSuccess?'success':'warning';
const sText = this.l(bSuccess?'admin.save_success':oResponse.desc_lang_id, oResponse.desc_lang_params);
this.addFeedback(sType, sText, asInputs);
})
.catch((sError) => {
.catch((oError) => {
oEvent.target.value = sOldVal;
this.addFeedback('error', sError, asInputs);
this.addFeedback('error', oError.desc_lang_text, asInputs);
});
}
},
deleteElem(oElem) {
const asInputs = {
type: oElem.type,
id: oElem.id
};
this.api.post('admin_delete', asInputs)
.then(() => {
delete this.elems[asInputs.type][asInputs.id];
this.addFeedback('success', this.l('admin.delete_success'), asInputs);
})
.catch((oError) => {
this.addFeedback('error', oError.desc_lang_text, 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'});});
this.api.request('update_project', {}, 'POST')
.then((oResponse) => {this.addFeedback('success', oResponse.desc_lang_text, {'update':'project'});})
.catch((oError) => {this.addFeedback('error', oError.desc_lang_text, {'update':'project'});});
}
}
}
};
</script>
<template>
<div id="admin">
<a name="back" class="button" href="#project"><SpotIcon :icon="'back'" :text="l('action.back')" /></a>
<a name="back" class="button" :href="getPrevAnchor()"><AppIcon :icon="'back'" :text="l('action.back')" /></a>
<h1>{{ l('project.plural') }}</h1>
<div>
<table>
@@ -131,18 +134,18 @@ export default {
</tr>
</thead>
<tbody>
<tr v-for="project in elems.project">
<tr v-for="project in elems.project" :key="project.id">
<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>
<td><AppButton :icon="'close'" iconSize="lg" @click="deleteElem(project)" /></td>
</tr>
</tbody>
</table>
<SpotButton :classes="'new'" :text="l('project.new')" :icon="'new'" @click="createElem('project')" />
<AppButton :classes="'new'" :text="l('project.new')" :icon="'new'" @click="createElem('project')" />
</div>
<h1>{{ l('feed.plural') }}</h1>
<div>
@@ -160,7 +163,7 @@ export default {
</tr>
</thead>
<tbody>
<tr v-for="feed in elems.feed">
<tr v-for="feed in elems.feed" :key="feed.id">
<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>
@@ -168,11 +171,11 @@ export default {
<td>{{ feed.name }}</td>
<td>{{ feed.status }}</td>
<td>{{ feed.last_update }}</td>
<td><SpotButton :icon="'close'" iconSize="lg" @click="deleteElem(feed)" /></td>
<td><AppButton :icon="'close'" iconSize="lg" @click="deleteElem(feed)" /></td>
</tr>
</tbody>
</table>
<SpotButton :classes="'new'" :text="l('feed.new')" :icon="'new'" @click="createElem('feed')" />
<AppButton :classes="'new'" :text="l('feed.new')" :icon="'new'" @click="createElem('feed')" />
</div>
<h1>{{ l('spot.plural') }}</h1>
<div>
@@ -186,7 +189,7 @@ export default {
</tr>
</thead>
<tbody>
<tr v-for="spot in elems.spot">
<tr v-for="spot in elems.spot" :key="spot.id">
<td>{{ spot.id }}</td>
<td>{{ spot.ref_spot_id }}</td>
<td>{{ spot.name }}</td>
@@ -195,37 +198,37 @@ export default {
</tbody>
</table>
</div>
<h1>{{ l('user.active') }}</h1>
<h1>{{ l('user.subscribed') }}</h1>
<div>
<table>
<thead>
<tr>
<th>{{ l('user.id') }}</th>
<th>{{ l('user.name') }}</th>
<th>{{ l('user.email') }}</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">
<tr v-for="user in elems.user" :key="user.id">
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td class="left">{{ user.name }}</td>
<td class="left">{{ user.email }}</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" />
<AppButton :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>
<p v-for="(feedback, index) in feedbacks" :key="index" :class="feedback.type">{{ feedback.msg }}</p>
</div>
</div>
</template>
@@ -10,7 +10,7 @@
return this.elem[this.name];
}
}
}
};
</script>
<template>
@@ -1,9 +1,9 @@
<script>
import SpotIcon from '@components/spotIcon';
import AppIcon from '@components/AppIcon';
export default {
components: {
SpotIcon
AppIcon
},
props: {
classes: String,
@@ -12,8 +12,8 @@ export default {
iconClasses: String,
iconSize: String
}
}
};
</script>
<template>
<button :class="classes"><SpotIcon :icon="icon" :text="text" :classes="iconClasses" :size="iconSize" /></button>
<button :class="classes"><AppIcon :icon="icon" :text="text" :classes="iconClasses" :size="iconSize" /></button>
</template>
@@ -18,14 +18,6 @@ export default {
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;
},
@@ -45,51 +37,52 @@ export default {
return this.transform || null;
}
}
}
};
</script>
<template>
<span :title="title" :class="hasText?'spot-icon-with-text':null">
<span class="spot-icon-symbol">
<span :title="title" :class="hasText?'app-icon-with-text':null">
<span class="app-icon-symbol">
<FontAwesomeIcon
:icon="iconDefinition"
:class="['spot-icon', this.icon, this.classes, this.margin?'margin-'+this.margin:null]"
:class="['app-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 v-if="hasText" :class="['app-icon-text', textClasses]">{{ text }}</span>
</span>
</template>
<style lang="scss">
@use "@styles/var";
.spot-icon-with-text {
.app-icon-with-text {
display: inline-flex;
align-items: flex-start;
gap: var.$elem-spacing;
gap: var.$text-spacing;
.spot-icon-symbol {
.app-icon-symbol {
flex: 0 0 auto;
align-self: center;
}
.spot-icon-text {
.app-icon-text {
flex: 1 1 auto;
min-width: 0;
align-self: center;
}
}
.spot-icon {
.app-icon {
&.margin-right {
margin-right: var.$elem-spacing;
margin-right: var.$text-spacing;
}
&.margin-left {
margin-left: var.$elem-spacing;
margin-left: var.$text-spacing;
}
}
</style>
@@ -1,11 +1,11 @@
<script>
import { FontAwesomeLayers } from '@fortawesome/vue-fontawesome';
import spotIcon from '@components/spotIcon';
import appIcon from '@components/AppIcon';
export default {
components: {
FontAwesomeLayers,
spotIcon
appIcon
},
props: {
mainClasses: String,
@@ -18,7 +18,7 @@ export default {
},
computed: {
mainClass() {
return 'spot-icon-stack clickable'+(this.mainClasses?' '+this.mainClasses:'');
return 'app-icon-stack clickable'+(this.mainClasses?' '+this.mainClasses:'');
},
iconMainClass() {
return this.mergeClasses('main clickable', this.iconMainClasses);
@@ -38,14 +38,14 @@ export default {
].filter(Boolean).join(' ');
}
}
}
};
</script>
<template>
<span :class="mainClass">
<FontAwesomeLayers>
<spotIcon :icon="iconMain" :classes="iconMainClass" :transform="iconMainTransform" />
<spotIcon :icon="iconSub" :classes="iconSubClass" :transform="iconSubTransformValue" />
<appIcon :icon="iconMain" :classes="iconMainClass" :transform="iconMainTransform" />
<appIcon v-if="iconSub" :icon="iconSub" :classes="iconSubClass" :transform="iconSubTransformValue" />
</FontAwesomeLayers>
</span>
</template>
+596
View File
@@ -0,0 +1,596 @@
<script>
import AppIcon from '@components/AppIcon';
import ProjectRelTime from '@components/ProjectRelTime';
import { getStyleProperty } from '@scripts/common';
/* lightbox (https://github.com/lokesh/lightbox2) converted to a vue component and improved to support videos */
export default {
components: {
AppIcon,
ProjectRelTime
},
props: {
alwaysShowNavOnTouchDevices: {type: Boolean, default: false},
positionFromTop: {type: Number, default: 50},
wrapAround: {type: Boolean, default: false},
disableScrolling: {type: Boolean, default: false},
hasVideo: {type: Boolean, default: true},
maxWidth: {type: Number, default: null},
maxHeight: {type: Number, default: null}
},
emits: ['media-change', 'closing'],
inject: ['isMobile'],
data() {
/*
fadeDuration/imageFadeDuration/resizeDuration read --trans-quick/--trans-slow
directly rather than being passed down as props from Project.vue: they're
declared on :root (_common.scss), so they're available immediately.
No need to wait on Project.vue's own $el to mount first, and no risk of
a parent-to-child prop update lagging behind the first time this opens.
*/
const fadeDuration = parseFloat(getStyleProperty('--trans-quick'));
const resizeDuration = parseFloat(getStyleProperty('--trans-slow'));
return {
album: [],
currentImageIndex: 0,
gMouseDownOffsetX: 0,
gMouseDownOffsetY: 0,
resizeTimer: null,
containerPadding: null,
imageBorderWidth: null,
videoBorderWidth: null,
fadeDuration,
imageFadeDuration: fadeDuration,
resizeDuration
};
},
computed: {
currentMedia() {
return this.album[this.currentImageIndex] || null;
},
relTimeLines() {
const media = this.currentMedia;
if(!media || this.isMobile()) return [];
const postedOn = {
key: 'posted',
icon: 'upload',
titleWrapperLangId: 'media.posted_on',
localTime: media.postedOnLocalTime,
siteTime: media.postedOnSiteTime,
offset: media.postedOnDayOffset
};
const takenOn = {
key: 'taken',
icon: media.type+'-shot',
titleWrapperLangId: 'media.'+media.type+'_taken_on',
localTime: media.takenOnLocalTime,
siteTime: media.takenOnSiteTime,
offset: media.takenOnDayOffset
};
return (media.source == 'marker') ? [takenOn, postedOn] : [postedOn, takenOn];
}
},
mounted() {
this.setVisible(this.$refs.overlay, false);
this.setVisible(this.$refs.lightboxEl, false);
this.containerPadding = this.getBoxMetrics(this.$refs.container, 'padding');
this.imageBorderWidth = this.getBoxMetrics(this.$refs.image, 'border');
this.videoBorderWidth = this.getBoxMetrics(this.$refs.video, 'border');
this.$refs.nav.addEventListener('wheel', this.onWheel, {passive: false});
this.$refs.nav.addEventListener('mousedown', this.onDragStart);
window.addEventListener('mouseup', this.onDragEnd);
},
beforeUnmount() {
if(this.resizeTimer) clearTimeout(this.resizeTimer);
window.removeEventListener('mouseup', this.onDragEnd);
window.removeEventListener('resize', this.sizeOverlay);
window.removeEventListener('mousemove', this.onDragMove);
},
methods: {
open(album, startId, source) {
this.sizeOverlay();
this.album = album.map((options) => this.mapMedia(options, source));
const startIndex = Math.max(this.album.findIndex((media) => media.id == startId), 0);
this.fade(this.$refs.overlay, true, this.fadeDuration);
this.fade(this.$refs.lightboxEl, true, this.fadeDuration);
if(this.disableScrolling) document.body.classList.add('lb-disable-scrolling');
window.addEventListener('resize', this.sizeOverlay);
this.changeImage(startIndex);
},
end(dispose = false) {
this.disableKeyboardNav();
this.$refs.video?.pause();
this.$refs.video?.removeAttribute('src');
this.$refs.container?.classList.remove('lb-video-nav', 'moveable', 'moving');
window.removeEventListener('resize', this.sizeOverlay);
window.removeEventListener('mousemove', this.onDragMove);
if(dispose) {
this.album = [];
}
else {
this.fade(this.$refs.lightboxEl, false, this.fadeDuration);
this.fade(this.$refs.overlay, false, this.fadeDuration);
this.$emit('closing');
}
if(this.disableScrolling) document.body.classList.remove('lb-disable-scrolling');
},
mapMedia(options, source) {
return {
alt: '',
link: options.media_path,
orientation: parseInt(options.rotate || '0', 10),
type: options.subtype || 'image',
id: options.id_media,
width: parseInt(options.width || '0', 10),
height: parseInt(options.height || '0', 10),
source,
comment: options.comment || '',
postedOnLocalTime: options.posted_on_formatted_time_local || '',
postedOnSiteTime: options.posted_on_formatted_time || '',
postedOnDayOffset: options.posted_on_day_offset || '',
takenOnLocalTime: options.taken_on_formatted_time_local || '',
takenOnSiteTime: options.taken_on_formatted_time || '',
takenOnDayOffset: options.taken_on_day_offset || ''
};
},
hasMediaAfterCurrent() {
return this.currentImageIndex < this.album.length - 1;
},
refreshAlbum(album) {
const current = this.currentMedia;
if(!current) return;
const existingIds = new Set(this.album.map((media) => media.id));
album.forEach((options) => {
if(existingIds.has(options.id_media)) return;
this.album.push(this.mapMedia(options, current.source));
existingIds.add(options.id_media);
});
this.updateNav();
},
getMaxSizes(mediaType) {
let maxWidth = window.innerWidth - this.containerPadding.left - this.containerPadding.right;
let maxHeight = window.innerHeight - this.containerPadding.top - this.containerPadding.bottom - this.positionFromTop;
const border = mediaType === 'image' ? this.imageBorderWidth : this.videoBorderWidth;
maxWidth -= border.left + border.right;
maxHeight -= border.top + border.bottom;
maxHeight -= this.getDataContainerHeight(maxWidth + this.containerPadding.left + this.containerPadding.right + border.left + border.right);
return {
maxWidth: Math.max(maxWidth, 1),
maxHeight: Math.max(maxHeight, 1)
};
},
getDataContainerHeight(width = null) {
if(!this.$refs.dataContainer) return 0;
const currentWidth = this.$refs.dataContainer.style.width;
if(width !== null) this.$refs.dataContainer.style.width = `${width}px`;
const height = Math.ceil(this.$refs.dataContainer.getBoundingClientRect().height || this.$refs.dataContainer.offsetHeight || 0);
this.$refs.dataContainer.style.width = currentWidth;
return height;
},
getMediaSize(media, maxWidth, maxHeight) {
if(media.width <= maxWidth && media.height <= maxHeight) {
return {
width: media.width,
height: media.height
};
}
const widthRatio = media.width / maxWidth;
const heightRatio = media.height / maxHeight;
if(widthRatio > heightRatio) {
return {
width: maxWidth,
height: Math.round(media.height / widthRatio)
};
}
return {
width: Math.round(media.width / heightRatio),
height: maxHeight
};
},
fitSizeWithDataContainer(size, mediaType) {
const border = mediaType === 'image' ? this.imageBorderWidth : this.videoBorderWidth;
const maxOuterHeight = Math.max(window.innerHeight - this.positionFromTop, 1);
let fittedSize = size;
for(let i = 0; i < 5; i++) {
const containerWidth = fittedSize.width + this.containerPadding.left + this.containerPadding.right + border.left + border.right;
const containerHeight = fittedSize.height + this.containerPadding.top + this.containerPadding.bottom + border.top + border.bottom;
const dataHeight = this.getDataContainerHeight(containerWidth);
const overflow = Math.ceil(containerHeight + dataHeight - maxOuterHeight);
if(overflow <= 0 || fittedSize.height <= 1) break;
const height = Math.max(fittedSize.height - overflow, 1);
fittedSize = {
width: Math.max(Math.round(fittedSize.width * (height / fittedSize.height)), 1),
height
};
}
return fittedSize;
},
updateSize(index) {
const media = this.album[index];
const maxSizes = this.getMaxSizes(media.type);
const maxWidth = this.maxWidth ? Math.min(this.maxWidth, maxSizes.maxWidth) : maxSizes.maxWidth;
const maxHeight = this.maxHeight ? Math.min(this.maxHeight, maxSizes.maxHeight) : maxSizes.maxHeight;
const size = this.fitSizeWithDataContainer(this.getMediaSize(media, maxWidth, maxHeight), media.type);
const target = media.type === 'video' ? this.$refs.video : this.$refs.image;
target.width = size.width;
target.height = size.height;
this.sizeContainer(size.width, size.height, media.type);
},
changeImage(index) {
const media = this.album[index];
if(!media) return;
this.updateDetails(media, false);
this.hideElements([this.$refs.dataContainer]);
this.disableKeyboardNav();
this.fade(this.$refs.overlay, true, this.fadeDuration);
this.fade(this.$refs.loader, true, 200);
this.hideElements([this.$refs.image, this.$refs.video, this.$refs.nav, this.$refs.prev, this.$refs.next]);
this.resetImageTransform();
this.$refs.outerContainer.classList.add('animating');
this.$refs.container.classList.remove('moveable', 'moving', 'lb-video-nav');
this.currentImageIndex = index;
this.$emit('media-change', media);
if(media.type === 'video') {
this.$refs.image.removeAttribute('src');
this.$refs.container.classList.add('lb-video-nav');
this.$refs.video.onloadedmetadata = () => {
media.width = this.$refs.video.videoWidth;
media.height = this.$refs.video.videoHeight;
this.$refs.video.onloadedmetadata = null;
this.updateSize(index);
};
this.$refs.video.src = media.link;
} else {
this.$refs.video.pause();
this.$refs.video.removeAttribute('src');
this.$refs.image.onload = () => {
this.$refs.image.alt = media.alt;
let width = this.$refs.image.naturalWidth;
let height = this.$refs.image.naturalHeight;
if(Math.abs(media.orientation) === 90 && width > height) {
const tmp = width;
width = height;
height = tmp;
}
media.width = width;
media.height = height;
this.$refs.image.onload = null;
this.updateSize(index);
};
this.$refs.image.src = media.link;
}
},
sizeOverlay() {
if(this.resizeTimer) clearTimeout(this.resizeTimer);
if(!this.album.length) return;
this.resizeTimer = window.setTimeout(() => {
const current = this.album[this.currentImageIndex];
if(!current) return;
if(current.type === 'image') this.changeImage(this.currentImageIndex);
else this.updateSize(this.currentImageIndex);
}, 200);
},
sizeContainer(width, height, mediaType = 'image') {
const border = mediaType === 'image' ? this.imageBorderWidth : this.videoBorderWidth;
const newWidth = width + this.containerPadding.left + this.containerPadding.right + border.left + border.right;
const newHeight = height + this.containerPadding.top + this.containerPadding.bottom + border.top + border.bottom;
const dataHeight = this.getDataContainerHeight(newWidth);
this.$refs.outerContainer.style.transition = `width ${this.resizeDuration}ms, height ${this.resizeDuration}ms`;
this.$refs.outerContainer.style.width = `${newWidth}px`;
this.$refs.outerContainer.style.height = `${newHeight + dataHeight}px`;
this.$refs.container.style.height = `${newHeight}px`;
window.setTimeout(() => {
this.$refs.overlay.focus();
this.showImage();
this.$refs.outerContainer.style.transition = '';
}, this.resizeDuration);
},
showImage() {
this.fade(this.$refs.loader, false, 0);
if(this.hasVideo && this.album[this.currentImageIndex].type === 'video') this.fade(this.$refs.video, true, this.imageFadeDuration);
else this.fade(this.$refs.image, true, this.imageFadeDuration);
this.updateNav();
this.updateDetails();
this.preloadNeighboringImages();
this.enableKeyboardNav();
},
updateNav() {
this.setVisible(this.$refs.nav, true);
this.setVisible(this.$refs.prev, false);
this.setVisible(this.$refs.next, false);
const alwaysShowNav = ('ontouchstart' in window) && this.alwaysShowNavOnTouchDevices;
if(this.album.length <= 1) return;
if(this.wrapAround) {
this.setVisible(this.$refs.prev, true);
this.setVisible(this.$refs.next, true);
} else {
if(this.currentImageIndex > 0) this.setVisible(this.$refs.prev, true);
if(this.currentImageIndex < this.album.length - 1) this.setVisible(this.$refs.next, true);
}
if(alwaysShowNav) {
this.$refs.prev.style.opacity = '1';
this.$refs.next.style.opacity = '1';
} else {
this.$refs.prev.style.opacity = '';
this.$refs.next.style.opacity = '';
}
},
hasCaption(media) {
return !!(media && (media.comment || media.postedOnSiteTime || media.takenOnSiteTime));
},
updateDetails(media = this.album[this.currentImageIndex], show = true) {
if(!media) return;
if(this.hasCaption(media)) {
if(show) this.fade(this.$refs.caption, true, 200);
else this.setVisible(this.$refs.caption, true);
} else {
this.setVisible(this.$refs.caption, false);
}
if(show) {
this.fade(this.$refs.closeButton, true, 200);
this.$refs.outerContainer.classList.remove('animating');
this.fade(this.$refs.dataContainer, true, this.resizeDuration);
} else {
this.setVisible(this.$refs.closeButton, true);
this.setVisible(this.$refs.dataContainer, false);
this.$refs.dataContainer.style.transition = '';
this.$refs.dataContainer.style.opacity = '0';
}
},
preloadNeighboringImages() {
const next = this.album[this.currentImageIndex + 1];
const prev = this.album[this.currentImageIndex - 1];
if(next && next.type === 'image') {
const preloadNext = new Image();
preloadNext.src = next.link;
}
if(prev && prev.type === 'image') {
const preloadPrev = new Image();
preloadPrev.src = prev.link;
}
},
enableKeyboardNav() {
this.disableKeyboardNav();
this.$refs.lightboxEl.addEventListener('keyup', this.keyboardAction);
this.$refs.overlay.addEventListener('keyup', this.keyboardAction);
},
disableKeyboardNav() {
this.$refs.lightboxEl?.removeEventListener('keyup', this.keyboardAction);
this.$refs.overlay?.removeEventListener('keyup', this.keyboardAction);
},
keyboardAction(event) {
switch(event.key) {
case 'Escape':
event.stopPropagation();
this.end();
break;
case 'ArrowLeft':
if(this.currentImageIndex !== 0) this.changeImage(this.currentImageIndex - 1);
else if(this.wrapAround && this.album.length > 1) this.changeImage(this.album.length - 1);
break;
case 'ArrowRight':
if(this.currentImageIndex !== this.album.length - 1) this.changeImage(this.currentImageIndex + 1);
else if(this.wrapAround && this.album.length > 1) this.changeImage(0);
break;
}
},
onCloseKeyup(event) {
if(event.key === 'Enter' || event.key === ' ') this.end();
},
onPrevClick() {
if(this.currentImageIndex === 0) this.changeImage(this.album.length - 1);
else this.changeImage(this.currentImageIndex - 1);
},
onNextClick() {
if(this.currentImageIndex === this.album.length - 1) this.changeImage(0);
else this.changeImage(this.currentImageIndex + 1);
},
onOuterContainerClick(event) {
if(event.target === this.$refs.outerContainer) this.end();
},
onLightboxClick(event) {
if(event.target === this.$refs.lightboxEl) this.end();
},
onWheel(event) {
const media = this.album[this.currentImageIndex];
if(!media || media.type === 'video') return;
event.preventDefault();
const rect = this.$refs.image.getBoundingClientRect();
const oldTransform = this.getImageTransform();
const oldZoom = oldTransform.scale;
const maxZoom = Math.max(media.width / Math.max(this.$refs.image.width, 1), media.height / Math.max(this.$refs.image.height, 1), 1);
const newZoom = Math.min(Math.max(oldZoom + (-Math.sign(event.deltaY) / 10), 1), maxZoom);
const imageCenterX = rect.left + rect.width / 2 - oldTransform.translateX;
const imageCenterY = rect.top + rect.height / 2 - oldTransform.translateY;
const cursorX = event.clientX - imageCenterX;
const cursorY = event.clientY - imageCenterY;
const zoomRatio = newZoom / oldZoom;
const transform = this.clampImageTransform({
scale: newZoom,
translateX: cursorX - zoomRatio * (cursorX - oldTransform.translateX),
translateY: cursorY - zoomRatio * (cursorY - oldTransform.translateY)
});
this.$refs.container.classList.toggle('moveable', newZoom > 1);
this.setImageTransform(transform);
},
onDragStart(event) {
const scale = parseFloat(this.$refs.image.style.getPropertyValue('--scale') || '1');
if(scale <= 1) return;
this.gMouseDownOffsetX = event.clientX - parseFloat(this.$refs.image.style.getPropertyValue('--translate-x') || '0');
this.gMouseDownOffsetY = event.clientY - parseFloat(this.$refs.image.style.getPropertyValue('--translate-y') || '0');
this.$refs.container.classList.add('moving');
window.addEventListener('mousemove', this.onDragMove);
},
onDragMove(event) {
const zoom = parseFloat(this.$refs.image.style.getPropertyValue('--scale') || '1');
const transform = this.clampImageTransform({
scale: zoom,
translateX: event.clientX - this.gMouseDownOffsetX,
translateY: event.clientY - this.gMouseDownOffsetY
});
this.setImageTransform(transform);
},
onDragEnd() {
window.removeEventListener('mousemove', this.onDragMove);
this.$refs.container?.classList.remove('moving');
},
getBoxMetrics(element, type) {
const styles = getComputedStyle(element);
return {
top: parseInt(styles[`${type}-top-width`], 10) || 0,
right: parseInt(styles[`${type}-right-width`], 10) || 0,
bottom: parseInt(styles[`${type}-bottom-width`], 10) || 0,
left: parseInt(styles[`${type}-left-width`], 10) || 0
};
},
resetImageTransform() {
this.setImageTransform({scale: 1, translateX: 0, translateY: 0});
},
getImageTransform() {
return {
scale: parseFloat(this.$refs.image.style.getPropertyValue('--scale') || '1'),
translateX: parseFloat(this.$refs.image.style.getPropertyValue('--translate-x') || '0'),
translateY: parseFloat(this.$refs.image.style.getPropertyValue('--translate-y') || '0')
};
},
clampImageTransform(transform) {
const maxTranslateX = (transform.scale - 1) * this.$refs.image.width / 2;
const maxTranslateY = (transform.scale - 1) * this.$refs.image.height / 2;
return {
scale: transform.scale,
translateX: Math.max(Math.min(transform.translateX, maxTranslateX), -maxTranslateX),
translateY: Math.max(Math.min(transform.translateY, maxTranslateY), -maxTranslateY)
};
},
setImageTransform(transform) {
if(!this.$refs.image) return;
this.$refs.image.style.setProperty('--scale', String(transform.scale));
this.$refs.image.style.setProperty('--translate-x', `${transform.translateX}px`);
this.$refs.image.style.setProperty('--translate-y', `${transform.translateY}px`);
},
hideElements(elements) {
elements.forEach((element) => {
this.setVisible(element, false);
});
},
setVisible(element, visible) {
if(!element) return;
element.style.visibility = visible ? 'visible' : 'hidden';
element.style.pointerEvents = visible ? '' : 'none';
},
fade(element, show, duration, done) {
if(!element) return;
const safeDuration = duration || 0;
element.style.transition = `opacity ${safeDuration}ms`;
if(show) {
this.setVisible(element, true);
requestAnimationFrame(() => {
element.style.opacity = element === this.$refs.overlay ? '0.8' : '1';
});
} else {
element.style.opacity = '0';
element.style.pointerEvents = 'none';
window.setTimeout(() => {
this.setVisible(element, false);
}, safeDuration);
}
if(typeof done === 'function') {
window.setTimeout(done, safeDuration);
}
}
}
};
</script>
<template>
<Teleport to="body">
<div id="lightboxOverlay" ref="overlay" tabindex="-1" class="lightboxOverlay" @click="end()"></div>
<div id="lightbox" ref="lightboxEl" tabindex="-1" class="lightbox" @click="onLightboxClick">
<div class="lb-outerContainer" ref="outerContainer" @click.stop="onOuterContainerClick">
<div class="lb-container" ref="container">
<img class="lb-image" ref="image" src="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==" alt="" />
<video class="lb-video" ref="video" controls autoplay></video>
<div class="lb-nav" ref="nav">
<div class="lb-prev-area">
<a class="lb-prev" ref="prev" aria-label="Previous image" href="" role="button" @click.prevent="onPrevClick">
<AppIcon :icon="'prev'" />
</a>
</div>
<div class="lb-next-area">
<a class="lb-next" ref="next" aria-label="Next image" href="" role="button" @click.prevent="onNextClick"><AppIcon :icon="'next'" /></a>
</div>
</div>
<div class="lb-loader" ref="loader" @click.prevent="end()">
<a class="lb-cancel" ref="cancel" href="#">
<AppIcon :icon="'cancel'" />
</a>
</div>
</div>
<div class="lb-dataContainer" ref="dataContainer" @click="end()">
<div class="lb-data">
<div class="lb-details">
<span class="lb-caption" ref="caption">
<template v-if="hasCaption(currentMedia)">
<span v-if="!!currentMedia?.comment" class="lb-caption-line comment">
<AppIcon icon="post" width="fixed" size="lg" text-classes="comment-text" :text="currentMedia.comment" />
</span>
<span v-for="line in relTimeLines" :key="line.key" class="lb-caption-line">
<ProjectRelTime :icon="line.icon" :titleWrapperLangId="line.titleWrapperLangId" :localTime="line.localTime" :siteTime="line.siteTime" :offset="line.offset" />
</span>
</template>
</span>
</div>
<div class="lb-closeContainer">
<a class="lb-close" ref="closeButton" href="#" role="button" @click.prevent.stop="end()" @keyup="onCloseKeyup">
<AppIcon :icon="'close'" :classes="'fa-lg'" />
</a>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
+260
View File
@@ -0,0 +1,260 @@
<script>
import Lightbox from '@components/Lightbox';
import ProjectMap, { BASE_MAP_PADDING } from '@components/ProjectMap';
import { getStyleProperty } from '@scripts/common';
import AppIcon from '@components/AppIcon';
import ProjectFeed from '@components/ProjectFeed';
import ProjectSettings from '@components/ProjectSettings';
export default {
components: {
AppIcon,
ProjectFeed,
ProjectSettings,
ProjectMap,
Lightbox
},
data() {
return {
panels: {
leftOpen: false,
rightOpen: false
},
feed: null,
settings: null,
project: null,
modeHisto: null,
baseMaps: [],
baseMap: null,
terrainEnabled: false,
mapInitializing: false,
hikes: {
colors: {},
width: null,
transitions: {
lineWidthTransition: {duration:null}
}
},
overview: {
id: 0,
codename: 'overview',
name: this.lang.get('project.overview')
},
};
},
computed: {
projectOptions() {
return [
this.overview,
...Object.values(this.projects)
];
}
},
watch: {
'hash.items.0'(newProjectCodename, oldProjectCodename) { //hash.items.0 = Project Code Name
if(newProjectCodename != oldProjectCodename) {
this.hash.items = [newProjectCodename]; //Force removal of direct link
this.settings.toggle(false, 0);
this.init();
}
}
},
provide() {
return {
map: {
panToBetweenPanels: this.panToBetweenPanels,
openMarkerPopup: (iMarkerId, sMarkerType) => this.$refs.mapCtrl.openMarkerPopup(iMarkerId, sMarkerType),
closePopup: () => this.$refs.mapCtrl.closePopup(),
isMarkerVisible: (oLngLat) => this.$refs.mapCtrl.isMarkerVisible(oLngLat),
findMarkerByMediaId: (iMediaId) => this.$refs.mapCtrl.findMarkerByMediaId(iMediaId)
},
lightbox: {
open: (iMediaId, sSource) => this.openLightbox(iMediaId, sSource)
},
project: this
};
},
inject: ['api', 'lang', 'hash', 'projects', 'consts', 'isMobile'],
mounted() {
//Starts default project init() through watcher
if(this.hash.items.length == 0) {
this.hash.items[0] = (this.projects.getDefaultProject().mode == this.consts.modes.blog)?this.projects.getDefaultCodeName():this.overview.codename;
}
else this.init();
},
beforeUnmount() {
this.$refs.lightbox.end(true);
this.$refs.mapCtrl.destroy();
},
methods: {
async init() {
this.hikes.colors = {
'main': getStyleProperty('--track-main'),
'off-track': getStyleProperty('--track-off-track'),
'hitchhiking': getStyleProperty('--track-hitchhiking')
};
this.hikes.width = parseFloat(getStyleProperty('--track-width'));
this.hikes.transitions.lineWidthTransition.duration = parseFloat(getStyleProperty('--trans-quick'));
//Reset values
this.project = null;
this.$refs.mapCtrl.removeMapContent();
//Build Map
this.mapInitializing = true;
if(this.hash.items[0] && this.projects[this.hash.items[0]]) await this.initProject(this.hash.items[0]);
else await this.initOverview();
this.mapInitializing = false;
},
async initOverview() {
this.modeHisto = true;
this.hash.items = [this.overview.codename];
this.feed.toggle(false, 0);
await this.initOverviewMap();
},
async initProject(sProjectCodeName) {
this.project = this.projects[sProjectCodeName];
this.modeHisto = (this.project.mode == this.consts.modes.histo);
await this.$nextTick();
const pMapReady = this.initProjectMap();
await this.feed.init(pMapReady);
},
openLightbox(iMediaId, sSource) {
const aoAlbum = (sSource == 'marker')
? (this.$refs.mapCtrl.findMarkerByMediaId(iMediaId)?.medias || [])
: this.feed.posts.filter((oPost) => oPost.type == 'media');
this.$refs.lightbox.open(aoAlbum, iMediaId, sSource);
},
async onLightboxMediaChange(oMedia) {
this.hash.items = [this.project.codename, 'media', oMedia.id];
if(oMedia.source == 'post') {
(await this.feed.findPost('media', oMedia.id))?.panMapToMarker();
if(!this.$refs.lightbox.hasMediaAfterCurrent()) {
await this.feed.getNextFeed();
this.$refs.lightbox.refreshAlbum(this.feed.posts.filter((oPost) => oPost.type == 'media'));
}
}
},
onLightboxClosing() {
this.hash.items = [this.hash.items[0]];
},
async initProjectMap() {
const [{maps: baseMaps, markers}, track] = await Promise.all([
this.api.get('markers', {id_project: this.project.id}),
this.api.getAsset(this.project.geofilepath)
]);
this.baseMaps = baseMaps;
await this.$refs.mapCtrl.initMap({
project: this.project,
hikes: this.hikes,
baseMaps,
markers,
track,
padding: this.getMapPadding()
});
},
async initOverviewMap() {
this.baseMaps = this.consts.default_maps;
const markers = Object.values(this.projects).map((asProject) => ({
type: 'project',
subtype: 'project',
...asProject,
opacityWhenCovered: 0.3
}));
await this.$refs.mapCtrl.initMap({
project: null,
hikes: this.hikes,
baseMaps: this.baseMaps,
markers,
track: null,
padding: this.getMapPadding()
});
},
addNewMarkers(aoMarkers) {
this.$refs.mapCtrl.addNewMarkers(aoMarkers);
},
panToBetweenPanels(oLngLat, iZoom, iAnimDuration=500) {
return this.$refs.mapCtrl.panTo(oLngLat, iZoom, this.getMapPadding(), iAnimDuration);
},
getMapPadding() {
let bIsMobile = this.isMobile();
return {
top: BASE_MAP_PADDING,
bottom: BASE_MAP_PADDING,
left: BASE_MAP_PADDING + ((!bIsMobile && this.panels.leftOpen && this.settings)?this.settings.getWidth():0),
right: BASE_MAP_PADDING + ((!bIsMobile && this.panels.rightOpen && this.feed)?this.feed.getWidth():0)
};
},
updateMapPadding(iDuration=0) {
this.$refs.mapCtrl.setPadding(this.getMapPadding(), iDuration);
},
onPanelToggle(sPanel, bNewValue, iAnimDuration=500) {
const sPanelKey = sPanel + 'Open';
let bOldValue = this.panels[sPanelKey];
this.panels[sPanelKey] = bNewValue;
if(bOldValue != bNewValue) {
//Adjust map center
if(!this.isMobile() && this.$refs.mapCtrl.map) this.updateMapPadding(iAnimDuration);
//Open Close panels
this.$el.classList.toggle('with-'+sPanel+'-panel');
}
},
setFeed(vPanel) {
this.feed = vPanel;
},
setSettings(vPanel) {
this.settings = vPanel;
}
}
};
</script>
<template>
<div class="projects">
<div id="space"></div>
<div id="submap">
<div class="loader">
<AppIcon :icon="'map'" :classes="'flicker'" width="fixed" />
</div>
</div>
<ProjectMap
ref="mapCtrl"
v-model:base-map="baseMap"
:terrain-enabled="terrainEnabled"
/>
<ProjectSettings
:ref="setSettings"
:projects="projectOptions"
v-model:project-code-name="hash.items[0]"
:base-maps="baseMaps"
v-model:base-map="baseMap"
v-model:terrain-enabled="terrainEnabled"
:map-initializing="mapInitializing"
:hikes="hikes"
@toggle="(bIsOpen, iAnimDuration) => onPanelToggle('left', bIsOpen, iAnimDuration)"
/>
<ProjectFeed
:ref="setFeed"
:project="project"
:mode-histo="modeHisto"
@request-last-update="settings?.setLastUpdate"
@new-markers="addNewMarkers"
@toggle="(bIsOpen, iAnimDuration) => onPanelToggle('right', bIsOpen, iAnimDuration)"
/>
<Lightbox
ref="lightbox"
:always-show-nav-on-touch-devices="true"
:position-from-top="0"
:has-video="true"
@media-change="onLightboxMediaChange"
@closing="onLightboxClosing"
/>
</div>
</template>
+113
View File
@@ -0,0 +1,113 @@
<script>
import AppButton from '@components/AppButton';
import AppIcon from '@components/AppIcon';
export default {
components: {
AppButton,
AppIcon
},
data() {
return {
feedbacks: [],
loginLoading: false,
newsletterLoading: false,
passwordMode: false,
settingPassword: false,
password: '',
passwordConfirmation: ''
};
},
computed: {
buttonClasses() {
return [
'manage',
this.action,
this.loginLoading?'loading':''
].filter(n => n).join(' ');
},
loggedIn() {
return this.user.id_user > 0;
},
action() {
return this.loggedIn?'logout':'login';
},
status() {
return this.loggedIn?(this.user.subscribed?'subscribed':(this.user.name?'logged_in_named':'logged_in')):'logged_out';
}
},
inject: ['api', 'lang', 'user'],
methods: {
manageSubscription() {
if(this.newsletterLoading) return;
this.newsletterLoading = true;
this.api.request(this.user.subscribed?'subscribe':'unsubscribe', {}, 'POST')
.then((asResponse) => {
this.user.setInfo(asResponse.data);
this.feedbacks.push({type:asResponse.result, msg:asResponse.desc_lang_text});
})
.catch((sDesc) => {
this.user.subscribed = !this.user.subscribed;
this.feedbacks.push({type:'error', msg:sDesc?.message || sDesc});
})
.finally(() => {this.newsletterLoading = false;});
},
manageLogin() {
if(this.loginLoading) return;
let 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('account.invalid_email')});
else if(this.settingPassword && this.password !== this.passwordConfirmation) this.feedbacks.push({type:'error', 'msg':this.lang.get('account.password_mismatch')});
else {
const sAction = this.action;
this.loginLoading = true;
this.api.request(sAction, {'email': this.user.email, 'password': this.password, 'name': this.user.name}, 'POST')
.then((asResponse) => {
this.feedbacks.push({type: asResponse.result, msg: asResponse.desc_lang_text});
this.user.setInfo(asResponse.data);
this.passwordMode = false;
this.settingPassword = false;
this.password = '';
this.passwordConfirmation = '';
})
.catch((oError) => {
switch(oError.desc_lang_id) {
case 'account.set_password':
this.passwordMode = true;
this.settingPassword = true;
this.feedbacks.push({type:'warning', msg:oError.message});
break;
case 'account.password_required':
this.passwordMode = true;
this.feedbacks.push({type:'success', msg:oError.message});
break;
default:
this.feedbacks.push({type:'error', msg:oError.message});
}
})
.finally(() => {this.loginLoading = false;});
}
}
}
};
</script>
<template>
<div class="sub-section status">{{ lang.get('account.status.'+status, this.user.name) }}</div>
<div class="sub-section account-login">
<input type="email" name="email" id="email" :placeholder="lang.get('account.email_placeholder')" v-model="user.email" :disabled="loginLoading || loggedIn || passwordMode" @keyup.enter="manageLogin" />
<input v-if="passwordMode" type="password" name="password" id="password" :placeholder="lang.get('account.password')" v-model="password" :disabled="loginLoading" :autocomplete="settingPassword?'new-password':'current-password'" @keyup.enter="manageLogin" />
<input v-if="settingPassword" type="password" name="password_confirmation" id="password-confirmation" :placeholder="lang.get('account.confirm_password')" v-model="passwordConfirmation" :disabled="loginLoading" autocomplete="new-password" @keyup.enter="manageLogin" />
<AppButton :classes="buttonClasses" :title="lang.get('account.'+action)" :icon="action" @click="manageLogin" />
</div>
<div class="sub-section newsletter radio" v-if="loggedIn">
<input type="checkbox" id="account-newsletter" v-model="user.subscribed" :true-value="1" :false-value="0" :disabled="newsletterLoading" @change="manageSubscription" />
<label for="account-newsletter">{{ lang.get('account.newsletter') }}</label>
</div>
<div v-if="feedbacks.length" class="sub-section feedback">
<p v-for="feedback in feedbacks" :key="feedback.type + '-' + feedback.msg" :class="feedback.type">
<AppIcon :icon="feedback.type" :text="feedback.msg" />
</p>
</div>
</template>
+230
View File
@@ -0,0 +1,230 @@
<script>
import Simplebar from 'simplebar-vue';
import AppIcon from '@components/AppIcon';
import ProjectPost from '@components/ProjectPost';
import { createSwipeToClose, initialSwipeState } from '@scripts/swipeToClose';
export default {
components: {
AppIcon,
ProjectPost,
Simplebar
},
props: {
project: Object,
modeHisto: Boolean
},
data() {
return {
loadingFeed: false,
loadingPost: false,
updatable: true,
outOfData: false,
refIdFirst: 0,
refIdLast: 0,
firstChunk: true,
isOpen: false,
posts: [],
refreshRate: 60,
swipe: initialSwipeState()
};
},
emits: ['request-last-update', 'new-markers', 'toggle'],
inject: ['api', 'lang', 'consts', 'hash', 'isMobile'],
provide() {
return {
feed: {
checkNewFeed: this.checkNewFeed,
toggle: this.toggle,
reply: this.reply,
findLinkedPost: this.findLinkedPost
}
};
},
watch: {
project() {
this.syncUpdateTimer();
},
modeHisto() {
this.syncUpdateTimer();
},
firstChunk() {
this.syncUpdateTimer();
}
},
mounted() {
this.getScrollElement()?.addEventListener('scroll', this.onFeedScroll);
this.syncUpdateTimer();
},
beforeUnmount() {
this.getScrollElement()?.removeEventListener('scroll', this.onFeedScroll);
this.setUpdateTimer(-1);
},
computed: {
loading() {
return this.loadingFeed || this.loadingPost;
}
},
methods: {
async init(pMapIsReady) {
this.setUpdateTimer(-1);
this.loadingFeed = false;
this.loadingPost = false;
this.updatable = true;
this.outOfData = false;
this.refIdFirst = 0;
this.refIdLast = 0;
this.firstChunk = true;
this.posts = [];
this.toggle(!this.isMobile(), 0);
this.getScrollElement().scrollTop = 0;
//Direct link post action
await(this.hash.items.length == 3
? this.findLinkedPost(this.hash.items[1], this.hash.items[2], pMapIsReady)
: this.getNextFeed()
);
this.syncUpdateTimer();
},
getScrollElement() {
return this.$refs.feedSimpleBar?.scrollElement;
},
async findLinkedPost(sPostType, iPostId, pMapIsReady=Promise.resolve()) {
let vPost = await this.findPost(sPostType, iPostId, true);
if(vPost) {
await pMapIsReady;
await vPost.executeMainAction(0);
return vPost;
}
else return null;
},
async findPost(sPostType, iPostId, bHidePosts=false) {
if(bHidePosts) this.loadingPost = true;
let avPosts = this.$refs?.posts?.filter((post) => {return post.postId == sPostType+'-'+iPostId;});
if(avPosts && avPosts.length > 0) {
let vPost = avPosts[0];
this.loadingPost = false;
//Force next update to have enough subsequent elements to position the post on top of the page
await this.getNextFeed();
//Position the post on top of the page
const $ScrollElement = this.getScrollElement();
$ScrollElement.scrollTop += Math.round(
vPost.$el.getBoundingClientRect().top
- $ScrollElement.getBoundingClientRect().top
- parseFloat(getComputedStyle(this.$refs.feedSimpleBar.$el).paddingTop)
);
return vPost;
}
else if(!this.outOfData) {
await this.getNextFeed();
return this.findPost(sPostType, iPostId);
}
else {
this.loadingPost = false;
console.log('Missing element ID "'+iPostId+'" of type "'+sPostType+'"');
return null;
}
},
async getNextFeed() {
if(!this.project || this.outOfData || this.loadingFeed) return true;
//Get next chunk
this.loadingFeed = true;
let aoData = await this.api.get('next_feed', {id_project: this.project.id, id: this.refIdLast});
let iPostCount = Object.keys(aoData.feed).length;
//Update pointers
this.outOfData = (iPostCount < this.consts.chunk_size);
if(iPostCount > 0) {
this.refIdLast = aoData.ref_id_last;
if(this.firstChunk) this.refIdFirst = aoData.ref_id_first;
}
//Add posts
this.posts.push(...aoData.feed);
this.loadingFeed = false;
this.firstChunk = false;
await this.$nextTick();
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();
},
...createSwipeToClose(1),
setUpdateTimer(iSeconds) {
if(typeof this.feedTimer != 'undefined') clearTimeout(this.feedTimer);
if(iSeconds >= 0) this.feedTimer = setTimeout(this.onUpdateTimer, iSeconds * 1000);
},
syncUpdateTimer() {
this.setUpdateTimer((!!this.project && !this.modeHisto && !this.firstChunk)?this.refreshRate:-1);
},
async onUpdateTimer() {
await this.checkNewFeed();
this.syncUpdateTimer();
},
async checkNewFeed() {
if(!this.project) return;
let aoData = await this.api.get('new_feed', {id_project: this.project.id, id: this.refIdFirst});
const aoFeed = aoData.feed || [];
const aoMarkers = aoData.markers || [];
if(aoFeed.length > 0) {
//Update pointer
this.refIdFirst = aoData.ref_id_first;
//Add new posts
this.posts.unshift(...aoFeed);
}
if(aoMarkers.length > 0) this.$emit('new-markers', aoMarkers);
this.$emit('request-last-update');
},
reply(oQuote) {
this.$refs.poster?.addQuote(oQuote);
},
toggle(bShow, iAnimDuration=500) {
this.isOpen = (typeof bShow == 'boolean')?bShow:(!this.isOpen);
this.$emit('toggle', this.isOpen, iAnimDuration);
return this.isOpen;
},
getWidth() {
return this.$el.getBoundingClientRect().width;
}
}
};
</script>
<template>
<div id="feed" class="panel panel-right" @touchstart.passive="onTouchStart" @touchmove.passive="onTouchMove" @touchend.passive="onTouchEnd">
<Simplebar id="feed-panel" class="panel-content" ref="feedSimpleBar" :aria-busy="loading">
<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')}" ref="poster" />
</div>
<div v-if="project" v-show="!loadingPost" id="feed-posts">
<ProjectPost v-for="post in posts" :key="post.ref" :options="post" ref="posts" />
</div>
<div id="feed-footer" v-if="loading">
<ProjectPost :options="{type: 'loading', headerless: true}" />
</div>
</Simplebar>
<div v-if="project" :class="'panel-control panel-control-'+(isMobile()?'bottom':'top')" @click="toggle">
<div class="panel-control-elem panel-control-icon">
<AppIcon :icon="isOpen?'next':'post'" />
</div>
</div>
</div>
</template>
+522
View File
@@ -0,0 +1,522 @@
<script>
import { Map, Marker, LngLatBounds, LngLat, Popup, ScaleControl, NavigationControl, setWorkerUrl } from 'maplibre-gl';
import maplibreWorkerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
import 'maplibre-gl/dist/maplibre-gl.css';
import { createApp } from 'vue';
import { getStyleProperty } from '@scripts/common';
import ProjectPopup from '@components/ProjectPopup';
import AppIconStack from '@components/AppIconStack';
setWorkerUrl(maplibreWorkerUrl);
//Padding shared with Project.vue's own panel-width padding calculation: 1rem + marker height
export const BASE_MAP_PADDING = 16 + 32;
const MARKER_PROPS = {
project: {mainClasses: 'project', iconMain: 'marker', iconSub: 'project'},
image: {mainClasses: 'media', iconMain: 'marker', iconSub: 'image'},
video: {mainClasses: 'media', iconMain: 'marker', iconSub: 'video'},
message: {mainClasses: 'message', iconMain: 'marker', iconSub: 'footprint', iconSubTransform: 'rotate-270'}
};
class GroupedScaleControl {
constructor(options) {
this.scale = new ScaleControl(options);
}
onAdd(map) {
this.container = document.createElement('div');
this.container.className = 'maplibregl-ctrl maplibregl-ctrl-group';
const scaleElement = this.scale.onAdd(map);
scaleElement.classList.remove('maplibregl-ctrl');
this.container.appendChild(scaleElement);
return this.container;
}
onRemove() {
this.scale.onRemove();
this.container.remove();
}
}
//Owns the MapLibre map instance and everything drawn on it (base maps,
//terrain, track, markers, popups). Project.vue keeps the reactive state
//its own template/props need (baseMaps, hikes, project...) and drives
//this component's imperative API through a template ref, the same way
//it would drive a plain class - most of what happens here is imperative
//MapLibre calls (addLayer, jumpTo) rather than declarative rendering, so
//the <template> is just the map container div.
export default {
props: {
terrainEnabled: Boolean,
baseMap: String
},
emits: ['update:base-map'],
inject: ['lang', 'consts', 'isMobile', 'projects', 'hash', 'lightbox'],
data() {
return {
map: null,
baseMaps: [],
markers: [],
track: null,
hikes: null,
project: null,
popup: {content: null, element: null},
markerHeight: 32, //FIXME
maxZoom: 15,
initialPitch: 45
};
},
watch: {
baseMap(sNewBaseMap, sOldBaseMap) {
if(!this.map?.isStyleLoaded()) return;
if(sOldBaseMap && this.map.getLayer(sOldBaseMap)) this.map.setLayoutProperty(sOldBaseMap, 'visibility', 'none');
if(sNewBaseMap && this.map.getLayer(sNewBaseMap)) this.map.setLayoutProperty(sNewBaseMap, 'visibility', 'visible');
},
terrainEnabled(bEnabled) {
if(!this.map?.isStyleLoaded()) return;
if(bEnabled) this.addTerrain();
else this.removeTerrain();
}
},
methods: {
async initMap({project, hikes, baseMaps, markers, track, padding}) {
this.project = project;
this.hikes = hikes;
this.baseMaps = baseMaps;
this.markers = markers;
this.track = track;
if(!this.map) this.addMap();
this.setPadding(padding);
//Force wait for load event
await new Promise((resolve) => {
if(this.map.isStyleLoaded()) resolve();
else this.map.once('load', resolve);
});
this.map.resize();
this.setInitialProjectCamera();
//Add content: Base Maps, Tracks, Markers
this.addMapContent();
await new Promise((resolve) => {
if(this.map.loaded() && this.map.areTilesLoaded()) resolve();
else this.map.once('idle', resolve);
});
},
addMap() {
this.map = new Map({
container: this.$el,
aroundCenter: true,
style: {
version: 8,
projection: {type: 'globe'},
sky: {
'sky-color': getStyleProperty('--space'),
'horizon-color': getStyleProperty('--horizon'),
'sky-horizon-blend': 0.35,
'atmosphere-blend': 0.8
},
sources: {},
layers: []
},
attributionControl: false
});
this.map.addControl(new GroupedScaleControl({unit: 'metric'}), 'bottom-right');
this.map.addControl(new NavigationControl({showZoom: false, visualizePitch: true}), 'bottom-right');
},
destroy() {
this.removeMapContent();
this.map?.remove();
this.map = null;
},
addMapContent() {
this.baseMaps.forEach(this.addBaseMap);
if(this.terrainEnabled) this.addTerrain();
this.addTrack();
this.markers.forEach(this.addMarker);
},
removeMapContent() {
if(!this.map) return;
this.closePopup();
this.removeTrack();
this.markers.forEach(this.removeMarker);
this.removeTerrain();
this.baseMaps.forEach(this.removeBaseMap);
},
addTerrain() {
// MapLibre terrain's fog matrix is only implemented for Mercator.
this.map.setProjection({type: 'mercator'});
//One shared raster-dem source feeds both the 3D elevation mesh
//(setTerrain) and the hillshade layer below - they used to be two
//separate sources pointed at the identical tile URL, which meant
//every DEM tile was downloaded and decoded twice per viewport.
if(!this.map.getSource('terrain-dem')) {
this.map.addSource('terrain-dem', {
type: 'raster-dem',
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 15,
encoding: 'terrarium'
});
}
if(!this.map.getLayer('terrain-hillshade')) {
this.map.addLayer({
id: 'terrain-hillshade',
type: 'hillshade',
source: 'terrain-dem',
paint: {
'hillshade-exaggeration': 0.35,
'hillshade-shadow-color': '#2d342b',
'hillshade-highlight-color': '#ffffff'
}
});
}
this.map.setTerrain({source: 'terrain-dem', exaggeration: 1.25});
},
removeTerrain() {
if(!this.map) return;
if(this.map.getTerrain()) this.map.setTerrain(null);
if(this.map.getLayer('terrain-hillshade')) this.map.removeLayer('terrain-hillshade');
if(this.map.getSource('terrain-dem')) this.map.removeSource('terrain-dem');
this.map.setProjection({type: 'globe'});
},
addBaseMap(asBaseMap) {
if(asBaseMap.default_map) this.$emit('update:base-map', asBaseMap.codename);
if(this.map.getSource(asBaseMap.codename) && this.map.getLayer(asBaseMap.codename)) return;
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.default_map?'visible':'none'},
minZoom: asBaseMap.min_zoom,
maxZoom: asBaseMap.max_zoom
});
},
removeBaseMap(asBaseMap) {
if(this.map.getLayer(asBaseMap.codename)) this.map.removeLayer(asBaseMap.codename);
if(this.map.getSource(asBaseMap.codename)) this.map.removeSource(asBaseMap.codename);
},
addTrack() {
if(!this.track) return;
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 + BASE_MAP_PADDING
}
});
this.map.on('click', 'track-hitbox', this.openTrackPopup);
this.map.on('mouseenter', 'track-hitbox', this.onTrackHover);
this.map.on('mouseleave', 'track-hitbox', this.onTrackHover);
},
removeTrack() {
//Over clickable track
if(this.map.getLayer('track-hitbox')) {
this.map.off('click', 'track-hitbox', this.openTrackPopup);
this.map.off('mouseenter', 'track-hitbox', this.onTrackHover);
this.map.off('mouseleave', 'track-hitbox', this.onTrackHover);
this.map.removeLayer('track-hitbox');
}
//Actual track
if(this.map.getLayer('track')) this.map.removeLayer('track');
//Track source
if(this.map.getSource('track')) this.map.removeSource('track');
},
addMarker(oMarker) {
const $Marker = document.createElement('div');
oMarker.app = createApp(AppIconStack, MARKER_PROPS[oMarker.subtype]);
oMarker.app.mount($Marker);
oMarker.marker = new Marker({element: $Marker, anchor: 'bottom', opacityWhenCovered: oMarker.opacityWhenCovered ?? 0})
.setLngLat([oMarker.longitude, oMarker.latitude])
.addTo(this.map);
const $MarkerElement = oMarker.marker.getElement();
$MarkerElement.addEventListener('click', (oEvent) => {this.onMarkerClick(oEvent, oMarker);});
$MarkerElement.addEventListener('mouseenter', (oEvent) => {this.onMarkerHover(oEvent, oMarker);});
$MarkerElement.addEventListener('mouseleave', (oEvent) => {this.onMarkerHover(oEvent, oMarker);});
},
removeMarker(oMarker) {
if(oMarker.app) {
oMarker.app.unmount();
delete oMarker.app;
}
if(oMarker.marker) {
oMarker.marker.remove();
delete oMarker.marker;
}
},
addNewMarkers(aoMarkers) { //FIXME Use its own marker update API
this.markers.push(...aoMarkers);
aoMarkers.forEach(this.addMarker);
},
onTrackHover(oEvent) {
this.map.getCanvas().style.cursor = (oEvent.type == 'mouseenter')?'pointer':'';
},
onMarkerClick(oEvent, oMarker) {
oEvent.preventDefault();
oEvent.stopPropagation();
switch(oMarker.type) {
case 'project':
this.hash.items = [oMarker.codename];
break;
default:
this.openMarkerPopup(oMarker.id, oMarker.type);
}
},
onMarkerHover(oEvent, oMarker) {
switch(oMarker.type) {
case 'project':
if(oEvent.type == 'mouseenter') this.openProjectPopup(oMarker);
else this.closePopup();
break;
}
},
openProjectPopup(oProject) {
this.openPopup({
lnglat: [oProject.longitude, oProject.latitude],
options: oProject,
offset: [0, -1 * this.markerHeight * getStyleProperty('--zoom-scale')]
});
},
findMarkerByMediaId(iMediaId) {
return this.markers.find((oMarker) => oMarker.type == 'media' && oMarker.id == iMediaId) //Look for an actual media marker first
|| this.markers.find((oMarker) => (oMarker.medias || []).some((oMedia) => oMedia.id_media == iMediaId)) //Look for a media within a message marker
|| null;
},
openMarkerPopup(iMarkerId, sMarkerType) {
let oMarker = this.markers.find((oCandidate) => oCandidate.id == iMarkerId && oCandidate.type == sMarkerType);
this.openPopup({
lnglat: [oMarker.longitude, oMarker.latitude],
options: oMarker,
//NB: `this.isMobile` here is the injected function itself, not a call to it - always
//truthy, so this branch always applies. Pre-existing behavior, kept as-is by this split.
offset: [0, -1 * this.markerHeight * (this.isMobile?getStyleProperty('--zoom-scale'):1)]
});
},
openTrackPopup(oEvent) {
this.openPopup({
lnglat: oEvent.lngLat,
options: this.projects.getTrackInfo(oEvent.features[0], this.track, this.lang),
});
},
openPopup({lnglat, options={}, offset=[0, 0]} = {}) {
this.closePopup();
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.project,
hikes: this.hikes
});
this.popup.content
.provide('lang', this.lang)
.provide('consts', this.consts)
.provide('isMobile', this.isMobile)
.provide('lightbox', this.lightbox)
.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;
}
},
setInitialProjectCamera() {
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;
}
let oLastMarker = this.markers.at(-1);
//Overview map: Center on default project
if(!this.project) {
//Center on default project
const oDefaultProject = this.projects.getDefaultProject();
//Get Map / Canvas size
const $Canvas = this.map.getCanvas();
const oMapBounds = this.map.getContainer().getBoundingClientRect();
//Adapt zoom to see whole planet
const iTargetRadius = Math.max(1, Math.min(oMapBounds.width || $Canvas.clientWidth, oMapBounds.height || $Canvas.clientHeight) / 2);
const iWorldSize = iTargetRadius * 2 * Math.PI * Math.cos(oDefaultProject.latitude * Math.PI / 180);
this.map.jumpTo({
center: new LngLat(oDefaultProject.longitude, oDefaultProject.latitude),
zoom: Math.log2(iWorldSize / 512),
pitch: 0,
bearing: 0
});
}
//Direct link to marker
else if(oHashMarker) {
this.map.jumpTo({
center: new LngLat(oHashMarker.longitude, oHashMarker.latitude),
zoom: 13,
pitch: this.initialPitch
});
}
//Blog Mode: Fit to last marker
else if(this.project.mode == this.consts.modes.blog && oLastMarker) {
this.map.jumpTo({
center: new LngLat(oLastMarker.longitude, oLastMarker.latitude),
zoom: this.maxZoom,
pitch: this.initialPitch,
bearing: 0
});
}
//Pre Mode, Histo Mode, Blog Mode without markers or missing direct link marker: Fit to track
else {
let oBounds = new LngLatBounds();
const aoTrackCoordinates = [];
for(const iFeatureId in this.track.features) {
oBounds = this.track.features[iFeatureId].geometry.coordinates.reduce(
(bounds, coord) => {
aoTrackCoordinates.push(coord);
return bounds.extend(coord);
},
oBounds
);
}
this.map.fitBounds(oBounds, {
padding: BASE_MAP_PADDING,
animate: false,
maxZoom: this.maxZoom,
pitch: this.initialPitch,
bearing: 0
});
this.fixPitchedCameraCenter(aoTrackCoordinates);
}
},
fixPitchedCameraCenter(aoTrackCoordinates) {
//Project min/max coords (lat, lng) onto map rectangle corner points (x, y)
const oScreenBounds = aoTrackCoordinates.reduce((oBounds, coord) => {
const oPoint = this.map.project(coord);
return {
minX: Math.min(oBounds.minX, oPoint.x),
minY: Math.min(oBounds.minY, oPoint.y),
maxX: Math.max(oBounds.maxX, oPoint.x),
maxY: Math.max(oBounds.maxY, oPoint.y)
};
}, {
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity
});
//Current Rectangle center
const oTrackCenter = {
x: (oScreenBounds.minX + oScreenBounds.maxX) / 2,
y: (oScreenBounds.minY + oScreenBounds.maxY) / 2
};
//Convert back center point (x, y) to coords and Move map to the track center
this.map.jumpTo({
center: this.map.unproject([
oTrackCenter.x,
oTrackCenter.y
])
});
},
panTo(oLngLat, iZoom, padding, iAnimDuration=500) {
return new Promise((resolve) => {
if(!this.map) {
resolve();
return;
}
this.map.once('moveend', resolve);
this.map.easeTo({
center: oLngLat,
zoom: iZoom,
padding: padding,
duration: iAnimDuration
});
});
},
setPadding(padding, iDuration=0) {
if(iDuration > 0) this.map.easeTo({padding, duration: iDuration});
else this.map.jumpTo({padding});
},
isMarkerVisible(oLngLat) {
return !!this.map && this.map.getBounds().contains(oLngLat);
}
}
};
</script>
<template>
<div id="map"></div>
</template>
@@ -4,7 +4,7 @@ export default {
options: Object
},
inject: ['lang']
}
};
</script>
<template>
@@ -13,5 +13,6 @@ export default {
:title="lang.get('map.see_on_google')"
target="_blank"
rel="noreferrer noopener"
@click.stop
>{{ options.lat_dms+' '+options.lon_dms }}</a>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script>
import appIcon from '@components/AppIcon';
import projectMapLink from '@components/ProjectMapLink';
export default {
components: {
appIcon,
projectMapLink
},
props: {
options: Object,
type: String
},
emits: ['opening-lightbox'],
inject: ['lang', 'isMobile', 'lightbox'],
methods: {
openMedia() {
this.$emit('opening-lightbox', this.options);
this.lightbox.open(this.options.id_media, this.type);
}
}
};
</script>
<template>
<a
class="media-link drill"
:href="options.media_path"
@click.prevent="openMedia"
>
<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"><appIcon :icon="'drill-'+options.subtype" /></span>
<div v-if="options.comment && type == 'post'" class="comment">
<p>
<appIcon icon="post" :text="options.comment" size="lg" />
</p>
<p v-if="!isMobile() && options.latitude && options.longitude">
<appIcon icon="coords" margin="right" size="lg" />
<projectMapLink :options="options" />
</p>
</div>
</a>
</template>
+106
View File
@@ -0,0 +1,106 @@
<script>
import projectMapLink from '@components/ProjectMapLink';
import appIcon from '@components/AppIcon';
import projectRelTime from '@components/ProjectRelTime';
import projectMediaLink from '@components/ProjectMediaLink';
export default {
components: {
appIcon,
projectMapLink,
projectMediaLink,
projectRelTime
},
props: {
options: Object,
project: Object,
hikes: Object
},
inject: ['lang', 'consts', 'isMobile'],
computed: {
activeTimeInterval() {
if(this.options.mode == this.consts.modes.blog) return this.lang.get('project.wip');
const sYearFrom = this.options.active_from.substr(0, 4);
const sYearTo = this.options.active_to.substr(0, 4);
return (sYearFrom == sYearTo)?sYearFrom:(sYearFrom + '-' + sYearTo);
},
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' || options.type == 'project'">
<h1>
<appIcon v-if="options.type == 'project'" :icon="options.subtype" :classes="'track-'+options.subtype" size="lg" :text="this.options.name" width="auto" />
<span v-else class="track" :title="lang.get('track.'+options.subtype)">
<span class="line" :style="'background-color:'+hikes.colors[options.subtype]+';'"></span>
<span class="desc">{{ this.options.name }}</span>
</span>
</h1>
<p v-if="options.description && !options.leg" 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.leg" class="section destination">
<appIcon :title="lang.get('stats.from')" icon="start" width="fixed" size="lg" :text="options.leg.from" />
<appIcon :title="lang.get('stats.to')" icon="start" transform="rotate-180" width="fixed" size="lg" :text="options.leg.to" />
</div>
<div v-if="options.leg" class="separator"></div>
<div v-if="options.subtype!='hitchhiking'" class="section track-stats">
<appIcon :title="lang.get('stats.distance')" icon="distance" width="fixed" size="lg" :text="options.distance+'km'" />
<appIcon :title="lang.get('stats.duration')" icon="time" width="fixed" size="lg" :text="options.duration" />
<appIcon :title="lang.get('stats.elevation_gain')" icon="elev-gain" width="fixed" size="lg" :text="options.elev_gain+'m'" />
<appIcon :title="lang.get('stats.elevation_loss')" icon="elev-drop" width="fixed" size="lg" :text="options.elev_drop+'m'" />
</div>
</div>
<div v-else-if="options.type=='project'" class="section year">{{ activeTimeInterval }}</div>
<div v-else>
<div class="section" v-if="options.comment">
<appIcon icon="post" width="fixed" size="lg" :text="options.comment" />
</div>
<div class="section time">
<projectRelTime :icon="timeinfo.icon" :localTime="localTime" :siteTime="timeinfo.site_time" :offset="timeinfo.offset" />
</div>
<div v-if="!isMobile()" class="section coordinates">
<appIcon icon="coords" width="fixed" size="lg" margin="right" />
<projectMapLink :options="options" />
</div>
<div v-if="options.altitude" class="section altitude">
<appIcon icon="altitude" width="fixed" size="lg" :text="options.altitude+'m'" />
</div>
<div class="section weather" v-if="options.weather_icon && options.weather_icon!='unknown'" :title="options.weather_cond==''?'':lang.get('weather.'+options.weather_icon)">
<appIcon :icon="options.weather_icon" width="fixed" size="lg" :text="options.weather_temp+'°C'" />
</div>
<div v-if="options.medias" class="section medias">
<appIcon 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" :key="media.id_media" :options="media" :type="'marker'" />
</div>
</div>
</div>
</div>
</template>
@@ -1,10 +1,12 @@
<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 appIcon from '@components/AppIcon';
import appIconStack from '@components/AppIconStack';
import appButton from '@components/AppButton';
import projectMediaLink from '@components/ProjectMediaLink';
import projectMapLink from '@components/ProjectMapLink';
import projectRelTime from '@components/ProjectRelTime';
import projectPostQuote from '@components/ProjectPostQuote';
import projectPostSignature from '@components/ProjectPostSignature';
import { LngLat } from 'maplibre-gl';
import { copyTextToClipboard } from '@scripts/common';
@@ -12,12 +14,14 @@
export default {
components: {
spotIcon,
spotIconStack,
spotButton,
appIcon,
appIconStack,
appButton,
projectMediaLink,
projectMapLink,
projectRelTime
projectRelTime,
projectPostQuote,
projectPostSignature
},
props: {
options: Object
@@ -31,13 +35,17 @@
anchorVisible: ['message', 'media', 'post'].includes(this.options.type),
anchorTitle: this.lang.get('post.copy_to_clipboard'),
anchorIcon: 'link',
replyTitle: this.lang.get('post.reply'),
popupRequested: false,
mouseOverDrill: false,
postMessage: '',
quote: null,
sending: false,
focusZoomLevel: 15
focusZoomLevel: 15,
panAnimDuration: 500 //ms
};
},
inject: ['api', 'lang', 'project', 'feed', 'user', 'map', 'hash', 'consts', 'isMobile', 'getAnchor'],
computed: {
postClass() {
let sHeaderLess = this.options.headerless?' headerless':'';
@@ -55,23 +63,27 @@
drillMainIcon() {
return this.mouseOverDrill?'drill-message':'marker';
},
drillSubIcon() {
return this.mouseOverDrill?null:'footprint';
},
anchorLink() {
return '#'+[this.hash.page, this.project.currProject.codename, this.options.type, this.options.id].join(this.consts.hash_sep);
return this.getAnchor([this.hash.page, this.project.project.codename, this.options.type, this.options.id]);
},
modeHisto() {
return (this.project.currProject.mode == this.consts.modes.histo);
return (this.project?.project?.mode == this.consts.modes.histo);
},
replyVisible() {
return this.anchorVisible && !this.modeHisto;
},
relTime() {
return this.modeHisto?(this.options.formatted_time || '').substr(0, 10):this.options.relative_time;
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;
return this.map.findMarkerByMediaId(this.options.id_media);
}
else if(
['message', 'media'].includes(this.options.type)
@@ -85,10 +97,57 @@
relatedMarkerLatLng() {
let oRelatedMarker = this.relatedMarker;
return new LngLat(oRelatedMarker.longitude, oRelatedMarker.latitude);
},
messageLines() {
return (this.options.content || '').split('\n');
},
quoteRef() {
return this.options.ref_item?this.buildQuote(this.options.ref_item):null;
}
},
inject: ['api', 'lang', 'project', 'user', 'map', 'hash', 'consts', 'isMobile'],
methods: {
buildQuote(oItem) {
let sContent = '', sImage = null;
switch(oItem.type) {
case 'post':
sContent = oItem.content || '';
break;
case 'media':
sContent = oItem.comment || this.lang.get('media.posted_on', oItem.posted_on_formatted_time);
sImage = oItem.thumb_path;
break;
case 'message':
sContent = oItem.lat_dms+' '+oItem.lon_dms;
sImage = oItem.static_img_url;
break;
}
return {
type: oItem.type,
id: oItem.id,
author: oItem.formatted_name || '',
gravatar: oItem.gravatar || null,
content: sContent,
image: sImage
};
},
reply() {
this.feed.reply(this.buildQuote(this.options));
},
goToQuote() {
if(this.quoteRef) this.feed.findLinkedPost(this.quoteRef.type, this.quoteRef.id);
},
addQuote(oQuote) {
this.quote = oQuote;
this.$nextTick(() => {
this.$refs.post.focus();
});
},
formatQuote(oQuote) {
return (oQuote.author?oQuote.author+':\n':'')
+ oQuote.content.split('\n').map((sLine) => '> '+sLine).join('\n');
},
copyAnchor() {
copyTextToClipboard(this.consts.server+this.anchorLink);
this.anchorTitle = this.lang.get('post.link_copied');
@@ -98,15 +157,13 @@
this.anchorIcon = 'link';
}, 5000);
},
panMapToMarker(iAnimDuration=500) {
if(typeof iAnimDuration !== 'number') iAnimDuration = 500; //panMapToMarker will provide event on direct call in vue template
panMapToMarker(bCloseFeed=false) {
this.popupRequested = true;
if(this.isMobile()) this.project.toggleFeedPanel(false, 'panToInstant');
this.hash.items = [this.project.currProject.codename, this.options.type, this.options.id];
if(bCloseFeed===true) this.feed.toggle(false);
this.hash.items = [this.project.project.codename, this.options.type, this.options.id];
return this.map.panToBetweenPanels(this.relatedMarkerLatLng, this.focusZoomLevel, iAnimDuration).then(() => {
return this.map.panToBetweenPanels(this.relatedMarkerLatLng, this.focusZoomLevel, this.panAnimDuration).then(() => {
this.openMarkerPopup();
});
},
@@ -131,20 +188,23 @@
send() {
if(this.postMessage != '' && this.user.name != '') {
this.sending = true;
this.api.get(
this.api.post(
'add_post',
{
id_project: this.project.currProject.id,
id_project: this.project.project.id,
name: this.user.name,
content: this.postMessage
content: this.postMessage,
ref_type: this.quote?.type || '',
ref_id: this.quote?.id || 0
}
)
.then(() => {
this.postMessage = '';
this.project.checkNewFeed();
this.quote = null;
this.feed.checkNewFeed();
this.sending = false;
})
.catch((sDesc) => {
.catch(() => {
this.sending = false;
});
}
@@ -156,6 +216,7 @@
case 'media':
this.$refs.medialink.openMedia();
if(this.relatedMarker) return this.openMarkerPopup();
else return Promise.resolve();
default:
return Promise.resolve();
}
@@ -165,16 +226,19 @@
//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" />
<appIcon :icon="subType" :text="displayedId" width="auto" />
<a v-if="replyVisible" class="reply clickable" @click="reply" :title="replyTitle">
<appIcon :icon="'reply'" />
</a>
<a v-if="anchorVisible && !isMobile()" class="link" @click="copyAnchor" ref="anchor" :href="anchorLink" :title="anchorTitle">
<appIcon :icon="anchorIcon" />
</a>
</div>
<div class="time" @mouseleave="mouseOverHeader = false" @mouseover="mouseOverHeader = true" :title="timeDiff?lang.get('time.local', absTimeLocal):''">
@@ -186,48 +250,48 @@
</div>
<div class="body">
<div v-if="options.type == 'message'" class="body-box">
<div class="drill" @click.prevent="panMapToMarker" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave">
<div class="drill" @click.prevent="()=>{panMapToMarker(isMobile());}" @pointerenter="onMouseEnter" @pointerleave="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" />
<appIcon :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" />
<appIconStack mainClasses="message drill-icon" :iconMain="drillMainIcon" :iconSub="drillSubIcon" icon-sub-transform="rotate-270" />
<div class="comment" @click.stop>
<p v-if="!isMobile()">
<appIcon :icon="'coords'" margin="right" size="lg" />
<projectMapLink :options="options" />
</p>
<p v-if="timeDiff">
<p>
<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">
<div v-else-if="options.type == 'media'" class="body-box" @pointerenter="onMouseEnter" @pointerleave="onMouseLeave">
<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>
<template v-else-if="options.type == 'post'">
<projectPostQuote v-if="quoteRef" :quote="quoteRef" class="clickable" @click="goToQuote" />
<div class="message">
<p v-for="(sLine, iIndex) in messageLines" :key="iIndex">{{ sLine }}</p>
</div>
<projectPostSignature :name="options.formatted_name" :gravatar="options.gravatar" />
</template>
<div v-else-if="options.type == 'poster'" class="poster-form">
<projectPostQuote v-if="quote" :quote="quote" />
<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':''" />
<appButton 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>
<template v-else-if="options.type == 'archived'">
<p><appIcon :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>
</template>
<p v-else-if="options.type == 'loading'" class="flicker">
<appIcon :icon="'post'" />
</p>
</div>
</div>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script>
import projectPostSignature from '@components/ProjectPostSignature';
export default {
components: {
projectPostSignature
},
props: {
quote: Object
}
};
</script>
<template>
<div :class="'quote '+quote.type">
<img v-if="quote.image" class="image" :src="quote.image" />
<div class="text">
<p class="content">{{ quote.content }}</p>
<projectPostSignature v-if="quote.author" :name="quote.author" :gravatar="quote.gravatar" />
</div>
</div>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script>
export default {
props: {
name: String,
gravatar: String
}
};
</script>
<template>
<div class="signature">
<img v-if="gravatar" :src="'data:image/png;base64, '+gravatar" width="24" height="24" alt="--" />
<span v-else>-- </span>
<span>{{ name }}</span>
</div>
</template>
<style scoped>
.signature {
text-align: right;
font-style: italic;
img {
vertical-align: baseline;
margin: 0 0.2em calc((1em - 24px)/2) 0;
position: relative;
}
}
</style>
+36
View File
@@ -0,0 +1,36 @@
<script>
import appIcon from '@components/AppIcon';
export default {
components: {
appIcon
},
props: {
localTime: String,
siteTime: String,
offset: String,
classes: String,
icon: String,
titleWrapperLangId: String
},
inject: ['lang'],
computed: {
title() {
const bDifferentTimeZone = (this.localTime != this.siteTime);
const sTime =
this.lang.get('time.user', this.siteTime.slice(-5))
+ ((this.offset != '0')?' ('+this.lang.get('unit.day_short')+this.offset+')':'');
return (this.titleWrapperLangId)?
this.lang.get(this.titleWrapperLangId, bDifferentTimeZone?sTime:this.siteTime)
:
(bDifferentTimeZone?sTime:null);
}
}
};
</script>
<template>
<appIcon v-if="icon" :icon="icon" :title="title" :text="localTime" width="fixed" size="lg" />
<span v-else :class="classes" :title="title">{{ localTime }}</span>
</template>
+170
View File
@@ -0,0 +1,170 @@
<script>
import Simplebar from 'simplebar-vue';
import AppIcon from '@components/AppIcon';
import ProjectAccount from '@components/ProjectAccount';
import logoIconUrl from '@images/icons/favicon.svg';
import logoTitleUrl from '@images/logo_title.svg';
import { createSwipeToClose, initialSwipeState } from '@scripts/swipeToClose';
export default {
components: {
AppIcon,
ProjectAccount,
Simplebar
},
props: {
projects: Array,
projectCodeName: String,
baseMaps: Array,
baseMap: String,
terrainEnabled: Boolean,
mapInitializing: Boolean,
hikes: Object
},
data() {
return {
isOpen: false,
lastUpdate: {unix_time: 0, relative_time: '', formatted_time: ''},
logoIconUrl,
logoTitleUrl,
swipe: initialSwipeState()
};
},
emits: ['update:baseMap', 'update:projectCodeName', 'update:terrainEnabled', 'toggle'],
inject: ['api', 'lang', 'user', 'consts', 'isMobile', 'hash'],
computed: {
project() {
return this.projects.find((project) => project.codename == this.projectCodeName);
},
projectCodeNameModel: {
get() {
return this.projectCodeName;
},
set(sProjectCodeName) {
this.$emit('update:projectCodeName', sProjectCodeName);
}
},
baseMapModel: {
get() {
return this.baseMap;
},
set(sBaseMap) {
this.$emit('update:baseMap', sBaseMap);
}
},
terrainEnabledModel: {
get() {
return this.terrainEnabled;
},
set(bTerrainEnabled) {
this.$emit('update:terrainEnabled', bTerrainEnabled);
}
}
},
watch: {
projectCodeName() {
this.setLastUpdate();
}
},
mounted() {
this.setLastUpdate();
},
methods: {
async setLastUpdate() {
if(this.project?.mode == this.consts.modes.blog) {
this.lastUpdate = await this.api.get('last_update', {id_project: this.project.id});
}
},
toggle(bShow, iAnimDuration=500) {
this.isOpen = (typeof bShow == 'boolean')?bShow:(!this.isOpen);
this.$emit('toggle', this.isOpen, iAnimDuration);
return this.isOpen;
},
getWidth() {
return this.$el.getBoundingClientRect().width;
},
...createSwipeToClose(-1)
}
};
</script>
<template>
<div id="settings" class="panel panel-left" @touchstart.passive="onTouchStart" @touchmove.passive="onTouchMove" @touchend.passive="onTouchEnd">
<div id="settings-panel" class="panel-content">
<div class="settings-header settings-box">
<img :src="logoIconUrl" class="logo-icon clickable" :title="lang.get('project.overview')" @click="hash.items = []" />
<img :src="logoTitleUrl" class="logo-title" :alt="consts.title" />
<div class="last_update" v-if="project?.mode == consts.modes.blog && lastUpdate.unix_time > 0" :title="lastUpdate.formatted_time">
<AppIcon icon="find-me-spot" :text="lang.get('feed.last_update')+' '+lastUpdate.relative_time" />
</div>
</div>
<div class="settings-sections">
<Simplebar id="settings-sections-scrollbox">
<div class="settings-section settings-box">
<h1><AppIcon :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="projectCodeNameModel" :disabled="mapInitializing" />
<label :for="'project-'+project.id">
<span>{{ project.name }}</span>
<a v-if="!isMobile() && project.gpxfilepath" class="download" :href="project.gpxfilepath" :download="project.codename + '.gpx'" :title="lang.get('track.download')" @click.stop="()=>{}">
<AppIcon :icon="'download'" margin="left" />
</a>
</label>
</div>
</div>
</div>
<div class="settings-section settings-box">
<h1><AppIcon :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="baseMapModel" :disabled="mapInitializing" />
<label :for="'map-'+bm.id_map">{{ lang.get('map.'+bm.codename) }}</label>
</div>
<div v-if="projectCodeName != 'overview'" class="radio">
<input type="checkbox" id="map-terrain" v-model="terrainEnabledModel" :disabled="mapInitializing" />
<label for="map-terrain">{{ lang.get('map.terrain') }}</label>
</div>
</div>
</div>
<div class="settings-section settings-box account">
<h1><AppIcon :icon="'account'" width="fixed" :text="lang.get('account.title')" /></h1>
<div class="settings-section-body">
<ProjectAccount />
</div>
</div>
<div class="settings-section settings-box admin" v-if="user.hasClearance(consts.clearances.admin)">
<h1><AppIcon :icon="'admin'" width="fixed" :text="lang.get('admin.title')" /></h1>
<div class="settings-section-body admin-actions">
<a class="button" href="#admin"><AppIcon :icon="'config'" :text="lang.get('admin.config')" /></a>
<a class="button" href="#upload"><AppIcon :icon="'upload'" :text="lang.get('admin.upload')" /></a>
</div>
</div>
</Simplebar>
</div>
<div v-if="!isMobile()" class="settings-footer">
<a href="https://git.lutran.fr/franzz/livetrail" :title="lang.get('credits.git')" target="_blank" rel="noopener">
<AppIcon :icon="'credits'" width="auto" :text="lang.get('credits.project', consts.title)" />
</a>
<span>&nbsp;{{ lang.get('credits.license') }}</span>
</div>
</div>
<div :class="'panel-control panel-control-'+(isMobile()?'bottom':'top')">
<div class="panel-control-elem panel-control-icon" @click="toggle">
<AppIcon :icon="isOpen?'prev':'menu'" />
</div>
</div>
<div v-if="project?.id && !isMobile()" id="legend" class="panel-control panel-control-bottom">
<div class="panel-control-elem">
<div v-for="(color, hikeType) in hikes.colors" :key="hikeType" class="track">
<span class="line" :style="'background-color:'+color+';'"></span>
<span class="desc">{{ lang.get('track.'+hikeType) }}</span>
</div>
</div>
</div>
<div v-if="project?.id" id="title" :class="'panel-control panel-control-'+(isMobile()?'bottom':'top')">
<div class="panel-control-elem"><span>{{ project.name }}</span></div>
</div>
</div>
</template>
@@ -4,13 +4,13 @@ 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';
import AppIcon from '@components/AppIcon';
import AppButton from '@components/AppButton';
export default {
name: 'upload',
components: { SpotButton, SpotIcon },
inject: ['api', 'lang', 'projects', 'consts', 'user'],
components: { AppButton, AppIcon },
inject: ['api', 'lang', 'projects', 'consts', 'user', 'getPrevAnchor'],
data() {
return {
project: this.projects.getDefaultProject(),
@@ -22,7 +22,7 @@ export default {
},
mounted() {
if(!this.project.editable) {
this.logs = [this.lang.get('upload.mode_archived', [this.project.name])];
this.addLog('upload.mode_archived', [this.project.name]);
return;
}
@@ -35,6 +35,9 @@ export default {
}
},
methods: {
addLog(sLangId = 'upload.error', asLangParams = []) {
this.logs.push({lang_id: sLangId, lang_params: asLangParams});
},
initUploader() {
const endpoint = `${this.consts.process_page}?a=upload`;
@@ -51,6 +54,7 @@ export default {
endpoint,
fieldName: 'files[]',
formData: true,
headers: {'X-CSRF-Token': this.consts.csrf_token},
allowedMetaFields: ['t', 'name', 'type'],
getResponseData(xhr) {
return JSON.parse(xhr.responseText || '{}');
@@ -65,14 +69,14 @@ export default {
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.addLog(uploadedFile.desc_lang_id, uploadedFile.desc_lang_params);
else this.addLog('upload.success', [uploadedFile.original_name || 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.addLog(response?.body?.desc_lang_id || 'upload.error', response?.body?.desc_lang_params || []);
});
this.uppy.on('complete', () => {
@@ -85,41 +89,46 @@ export default {
event.target.value = '';
},
addComment(oFile) {
this.api.get('add_comment', {
this.api.post('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));});
.then((asData) => {this.addLog('media.comment_update', [asData.filename]);})
.catch((oError) => {this.addLog(oError.desc_lang_id, oError.desc_lang_params);});
},
addPosition() {
if(navigator.geolocation) {
this.logs.push('Determining position...');
this.addLog('upload.position.determining');
navigator.geolocation.getCurrentPosition(
(position) => {
this.logs.push('Sending position...');
this.api.get('add_position', {
this.addLog('upload.position.sending');
this.api.post('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));});
.then(() => {this.addLog('upload.position.success');})
.catch((oError) => {this.addLog(oError.desc_lang_id, oError.desc_lang_params);});
},
(error) => {
this.logs.push(error.message);
const asErrorLangIds = {
1: 'upload.position.permission_denied',
2: 'upload.position.unavailable',
3: 'upload.position.timeout'
};
this.addLog(asErrorLangIds[error.code] || 'upload.position.error');
}
);
}
else this.logs.push('This browser does not support geolocation');
else this.addLog('upload.position.unsupported');
}
}
}
};
</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>
<a name="back" class="button" :href="getPrevAnchor()"><AppIcon :icon="'back'" :text="lang.get('action.back')" /></a>
<h1>{{ this.project.name }}</h1>
</div>
<div class="section" v-if="project.editable">
@@ -135,15 +144,15 @@ export default {
<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)" />
<AppButton :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()" />
<AppButton :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>
<p class="log" v-for="(log, index) in logs" :key="index">{{ lang.get(log.lang_id, log.lang_params) }}</p>
</div>
</div>
</template>
-636
View File
@@ -1,636 +0,0 @@
<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>&nbsp;{{ 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>
-71
View File
@@ -1,71 +0,0 @@
<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
View File
@@ -1,67 +0,0 @@
<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
View File
@@ -1,85 +0,0 @@
<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>
-27
View File
@@ -1,27 +0,0 @@
<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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 10 KiB

-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/images/icons/mstile-150x150.png?v=GvmqYyKwbb"/>
<TileColor>#00a300</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 821 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

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