Compare commits

..
705 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
franzz 821b6b47f3 Merge branch 'vue' 2026-05-10 15:30:37 +02:00
franzz 1852f6640e Refresh lightbox album when getting new feed 2026-05-09 12:18:45 +02:00
franzz 3c8cdbaad6 Harmonize comment CSS 2026-05-09 07:54:50 +02:00
franzz 415bd9d0cf Improve geo precision 2026-05-09 01:11:17 +02:00
franzz aa1856acb7 Fix media marker popup 2026-05-08 18:42:22 +02:00
franzz 321e79c230 Fix comment wrap 2026-05-08 01:14:05 +02:00
franzz 614a69103b Fix hover message drill icon 2026-05-07 20:13:20 +02:00
franzz 213bd359fc Move map with lightbox 2026-05-07 19:58:48 +02:00
franzz 1cb82838b2 Fix upload progress bar 2026-05-07 19:48:57 +02:00
franzz 3f1f98f98c Fix uplaod page display 2026-05-07 18:14:45 +02:00
franzz e6d11f424d Fix lightbox multi-loading 2026-05-07 16:58:52 +02:00
franzz 7aaaff7dda Move map with media selection in feed panel 2026-05-06 21:39:40 +02:00
franzz 07a5c3baf9 Fix lightbox zooming 2026-05-05 20:54:04 +02:00
franzz 5e690e5576 Remove fat 2026-05-05 20:53:38 +02:00
franzz 12ae225773 Make newsletter own component 2026-05-05 00:16:43 +02:00
franzz 54bae3e9c9 Make subscription work 2026-05-04 23:55:41 +02:00
franzz 141618f2cd Fix post header positioning 2026-05-04 00:12:36 +02:00
franzz b759508779 Simplify lightbox 2026-05-04 00:04:07 +02:00
franzz a4e0a345d6 Use resolves 2026-05-03 18:31:53 +02:00
franzz 86082c513e Harmonize CSS 2026-05-03 16:45:21 +02:00
franzz 36aa480205 Removing initial map flickering 2026-05-03 14:39:40 +02:00
franzz 87286dc8fd Track popup 2026-05-02 17:54:34 +02:00
franzz da46106779 Simplify building script 2026-05-02 11:31:16 +02:00
franzz 0cc7fc336a Simplify webpack config 2026-05-02 11:15:56 +02:00
franzz 95ebc96484 Harmonize marker / popup rendering 2026-05-02 00:16:37 +02:00
franzz 560b22c039 Clean up popup css 2026-04-30 12:33:32 +02:00
franzz 3567f521f8 Fix icon margins 2026-04-30 11:23:52 +02:00
franzz fe2a3d91c0 Reposition medias in popup 2026-04-29 23:44:55 +02:00
franzz 15c044ac52 Force download GPX file 2026-04-29 23:20:12 +02:00
franzz e5e34676e2 Make translations multi-leveled 2026-04-29 22:41:07 +02:00
franzz c4dd938a56 Use weather icon instead of condition (more variety) 2026-04-28 22:06:57 +02:00
franzz dcb916d442 Restructure language files 2026-04-28 21:39:36 +02:00
franzz 37bfb42834 Fix video play icon size 2026-04-28 19:46:05 +02:00
franzz 13c48a559f Move message atrributes to image overlay 2026-04-27 23:59:20 +02:00
franzz c39b7705be Fix FA auto width 2026-04-27 23:15:24 +02:00
franzz 844c9c0a53 Replace lightbox icons 2026-04-26 23:21:09 +02:00
franzz f2af936e60 Replace font awesome font with svg 2026-04-26 17:01:50 +02:00
franzz dc411cc532 remove uselss dependencies and legacy code 2026-04-26 00:41:27 +02:00
franzz b339d6d068 Bye bye spot.js 2026-04-25 23:55:11 +02:00
franzz 7dc2b28c44 Get the language module out of spot.js 2026-04-25 19:36:03 +02:00
franzz ff4bc26381 Removing jQuery altogether 2026-04-25 19:07:51 +02:00
franzz 40565849c5 Swap global vars to app config as JSON 2026-04-25 18:00:19 +02:00
franzz dea14acd29 Better hash management 2026-04-25 17:44:46 +02:00
franzz c32998650f Remove vue-template-compiler 2026-04-25 16:38:27 +02:00
franzz b2b06180e6 Remove some spot.js dependencies 2026-04-25 15:49:58 +02:00
franzz 90349365f9 Merge lightbox CSS 2026-04-24 23:09:22 +02:00
franzz 24021bf60f Generate media popup on the fly 2026-04-24 18:17:55 +02:00
franzz 64cacaf16e Harmonize drill/stacked icons 2026-04-24 17:31:54 +02:00
franzz eb0ded0d26 Manage map initialization sequence better + Fix map/project radio buttons 2026-04-24 16:44:11 +02:00
franzz 635b3781e3 Fix async map ajax calls sequence 2026-04-24 08:43:41 +02:00
franzz 9e4fbe7ad4 Customize media markers 2026-04-23 00:15:16 +02:00
franzz ef88e600e3 Marker layer alternative 2026-04-20 00:42:45 +02:00
franzz bcc5e9e0cd Harmonize time fields v1 2026-04-18 08:57:32 +02:00
franzz fcbb3d9d14 Fix direct link to post 2026-04-17 23:45:17 +02:00
franzz 3416ace4ee keep flashy map icon in feed 2026-04-17 20:23:59 +02:00
franzz 932f950ed8 Remove jquery deps in projects.vue 2026-04-17 16:22:43 +02:00
franzz c4d05c297c Update npm packages 2026-04-17 11:33:02 +02:00
franzz 52316d9abb Fix color dependencies 2026-04-13 23:02:35 +02:00
franzz cb505d9092 Fix page routing 2026-04-12 00:06:08 +02:00
franzz f81fbd454e Update npm & composer packages 2026-04-11 13:20:32 +02:00
franzz 295ff0538a Fix title height 2026-04-11 13:20:09 +02:00
franzz fb26000122 update packages 2026-02-07 20:15:43 +01:00
franzz 824718fad0 Fix default text 2026-01-30 12:04:30 +01:00
franzz 77216e6c2f Fix poster box 2026-01-18 00:31:02 +01:00
franzz 28f95162aa Add auto-update 2026-01-17 20:19:01 +01:00
franzz 4f3be3342c Fix hover events on message (feed) 2026-01-17 00:57:07 +01:00
franzz e70d3ddbd3 Fix direct link to feed 2026-01-16 23:56:46 +01:00
franzz 051503cbed Upgrade to node 22 2026-01-16 21:47:07 +01:00
franzz b86d5d2cb1 Fix message event in feed 2026-01-16 21:46:51 +01:00
franzz e7d4c840c2 Fix image zoom 2026-01-16 20:21:28 +01:00
franzz da39ca6589 Fix popup medias 2026-01-14 23:00:33 +01:00
franzz 14bf9e2fc8 Remove dependency to lightbox2 2026-01-11 19:17:32 +01:00
franzz 853a9cc36f Hide missing comment 2026-01-11 17:59:24 +01:00
franzz 8ca5fa2d53 Fix lightbox comment alignment 2026-01-11 16:31:28 +01:00
franzz 975a8039b3 marker images 2026-01-10 21:09:14 +01:00
franzz 325373b5d7 Fix some sass migration regressions 2026-01-10 17:14:07 +01:00
franzz ac9fcbe0ba Migrate SASS 2026-01-09 23:41:47 +01:00
franzz d9bc89b7f6 Update SNT gpx 2025-07-19 16:22:14 +02:00
franzz 760f38374f Add email trigger to manual positioning 2025-07-19 16:21:44 +02:00
franzz 789838dd07 Fix SNT track color 2025-07-18 00:28:37 +02:00
franzz a5fe6ebf75 Update DNT GPX file 2025-07-18 00:25:07 +02:00
franzz b9a4bd6d2d Adapt manual message upload to vue 2025-05-12 19:48:49 +02:00
franzz ea14a1ef3e Add manual message upload 2025-05-11 17:38:59 +02:00
franzz d0dbb85e28 Add manual message upload 2025-05-11 13:19:59 +02:00
franzz 457bab2c18 Convert upload page to vue 2025-05-03 20:37:15 +02:00
franzz 3571f93e41 Add track popups 2025-05-03 11:56:04 +02:00
franzz e878b159bf Fix SNT track color 2025-05-02 21:27:39 +02:00
franzz db70593852 Add function to rebuild GeoJSON 2025-05-02 21:26:45 +02:00
franzz 73b8e6b04f Add Build GeoJSON Catch 2025-05-02 21:19:11 +02:00
franzz a49f73236b Update geo/snt.gpx 2025-05-02 21:18:50 +02:00
franzz c0b7ad8000 Add SNT gpx 2025-05-02 21:18:29 +02:00
franzz 83bf47287c Libs update 2025-05-02 21:17:04 +02:00
franzz 6a42494099 Fix SNT track color 2025-04-23 11:12:49 +02:00
franzz 1d90b11aef Add function to rebuild GeoJSON 2025-04-23 11:06:43 +02:00
franzz bc75cbc17d Add Build GeoJSON Catch 2025-04-23 11:02:16 +02:00
franzz 2e8372d923 Update geo/snt.gpx 2025-04-22 13:40:51 +02:00
franzz 383890a9be Add SNT gpx 2025-04-22 13:40:25 +02:00
franzz 4ce96e7192 Fix initial panel toggle 2024-02-27 23:13:10 +01:00
franzz 3169b8e83e Move geojson build to dedicated call 2024-02-27 23:03:32 +01:00
franzz 205855acd8 Split Gpx management classes 2024-02-27 20:09:58 +01:00
franzz 356d8ccd7e Update nodejs dependencies 2024-02-26 22:44:37 +01:00
franzz 25ff80ad7a Add track on project 2024-02-26 22:44:19 +01:00
franzz 0cd509a99d Fix config page buttons 2024-02-20 20:23:24 +01:00
franzz 3063f8b904 Fix feed update 2024-02-18 22:35:27 +01:00
franzz 59dea2917d Add projects to settings panel 2024-02-17 00:17:20 +01:00
franzz 6e614042d1 Replace panels movements with translateX 2024-02-13 23:33:57 +01:00
franzz 8c812f6b0a Fix lightbox comments 2024-02-11 09:43:22 +01:00
franzz abacab8206 Fix lightbox deps 2024-02-11 09:20:08 +01:00
franzz 869b084d70 Upgrade maplibre and fix goToPost 2024-02-10 23:12:57 +01:00
franzz cab899e544 Rebuild panel navigation 2024-02-06 21:22:36 +01:00
franzz b6fc305111 Fix spotIcon classes on change 2024-02-04 22:56:37 +01:00
franzz 30a81b5341 Bump vue & deps 2024-01-15 20:35:39 +01:00
franzz 683670f77a Simplify spot button & input parameters 2024-01-11 22:16:17 +01:00
franzz c2956ac373 Replace leaflet with maplibre GL 2024-01-11 21:01:21 +01:00
franzz 7853c6e285 Convert admin page to Vue 2023-12-16 09:19:40 +01:00
franzz f674b0d934 Standardize admin page 2023-12-16 09:18:28 +01:00
franzz d767e335f9 Bump font awesome to 6.5.0 2023-12-07 20:33:52 +01:00
franzz 3611f2206f Swapping to objects vue branch 2023-11-19 18:25:52 +01:00
franzz 828d32b0ef Revert changes on MainAppPage: splitting getParams 2023-11-19 18:10:00 +01:00
franzz f5d193e42b Split dependencies into modules 2023-11-19 01:03:21 +01:00
franzz c45a19e6bf Move masks 2023-11-11 17:32:47 +01:00
franzz f86dadfc7d Convert project to webpack 2023-11-11 17:23:33 +01:00
franzz 9d676c339b Admin: reject ID value 0 2023-11-11 17:18:35 +01:00
franzz 2f3a3f9561 Move php classes 2023-11-11 17:12:41 +01:00
franzz 55e40f76a1 Remove librairies css 2023-11-11 17:07:54 +01:00
franzz 4e9fb52318 Removing local librairies 2 2023-11-11 15:43:47 +01:00
franzz 850d2e7235 Removing local librairies 2023-11-11 15:43:24 +01:00
franzz 97645b3476 Move files (again) 2023-11-07 19:41:28 +01:00
franzz ab914a391f Restore language folder 2023-10-20 20:56:11 +02:00
franzz 842e02f4bb Move files to follow webpack structure 2023-10-20 20:47:26 +02:00
franzz ae8a27fa64 Fix lightbox addToAlbum external reference 2023-10-19 21:43:02 +02:00
franzz af05650443 Bump lightbox to 2.11.4 2023-10-19 21:33:59 +02:00
franzz b3cd217e28 Add altitude on image 2023-10-15 18:33:36 +02:00
franzz 5f597647b4 Support geo located images & videos 2023-08-12 23:35:40 +02:00
franzz 199e3a1269 Bump leaflet to 1.9.4 2023-08-09 23:10:04 +02:00
franzz 04f5ce8a9c Change log folder 2023-08-06 13:54:59 +02:00
franzz 402d8f271e Add KMS hitchhike 2023-08-05 22:10:32 +02:00
franzz 44dfd689b8 Add mov file handling to todo list 2023-01-08 15:17:27 +01:00
franzz 2cae5f8946 Fix spot.css.map 2023-01-02 22:33:24 +01:00
franzz 48983abfae Upgrade font awesome to 6.2.1 2023-01-02 16:50:56 +01:00
franzz 5e1507b093 Upgrade leaflet to 1.9.3 2023-01-02 15:24:09 +01:00
franzz 59e29675be Avoid loading images before popup is opened 2022-11-29 21:21:58 +01:00
franzz cee962ff50 Slide leaflet controls with panels 2022-11-24 20:03:03 +01:00
franzz 4d8b374a2f Bump jquery and mods 2022-11-13 12:45:20 +01:00
franzz 81634c0dd6 Bump PHPMailer to 6.6.4 2022-10-04 17:47:38 +02:00
franzz 35fa097b92 Bump d3 to 7.6.1 2022-09-28 13:40:30 +02:00
franzz 0c8817590a Update install steps 2022-09-28 13:32:59 +02:00
franzz f7d16330af Fix onSwipe initial positions 2022-09-28 08:01:18 +02:00
franzz 559188413e Add a transition on panel closing (swipe close) 2022-09-27 17:23:21 +02:00
franzz 303f903120 Bump leaflet to 1.9.1 2022-09-26 16:48:19 +02:00
franzz 3d67ff3103 Close menus by sliding objects on mobile 2022-09-26 16:47:36 +02:00
franzz af8d19591c Prevent feed scroll when triggered from markers 2022-09-14 16:54:11 +02:00
franzz 9055fc02f3 PCT gpx update 2022-09-14 15:04:58 +02:00
franzz c4d9db0032 Fix lightbox album scope 2 2022-09-14 08:33:16 +02:00
franzz 69ac7464d3 Fix lightbox album scope 2022-09-13 18:12:01 +02:00
franzz 86af6194a6 Sync feed scroll with lightbox 2022-09-13 09:37:21 +02:00
franzz 579a9b503a Fix vocab spot update 2022-06-29 17:33:41 -07:00
franzz 4ffc8902e1 Fix media with no taken On info 2022-04-26 07:47:20 +02:00
franzz 39fc707029 Fix image upload without exif data 2022-04-25 19:08:21 +02:00
franzz cbbbd27d83 Fix clickable image marker 2022-04-22 20:37:49 +02:00
franzz f02551eb80 Refactor pointer cursor 2022-04-22 19:11:50 +02:00
franzz a131f49b74 Upgrade to leaflet 1.8.0 2022-04-18 19:06:47 +02:00
franzz de364ea2cc Relative local time 2022-04-18 18:50:51 +02:00
franzz 7cd6ba2586 Adding North Dome hike to PCT track 2022-04-15 21:57:31 +02:00
franzz 45f6cb7670 Update PCT track 2022-04-15 21:18:03 +02:00
franzz a2b80484ef Add geonames.org timezone API 2022-04-13 22:46:25 +02:00
franzz 4b88759173 Fix update email spot position image link 2022-04-13 19:07:06 +02:00
franzz 0e353bee39 Replace lightbox grabbing cursor 2022-04-12 19:43:31 +02:00
franzz bc237845b9 Remove lightbox image number 2022-04-12 19:18:18 +02:00
franzz 405b71544e Fix lightbox close button position 2022-04-12 19:17:56 +02:00
franzz f0ae781e4d Remove outline on radio buttons 2022-04-11 19:24:17 +02:00
franzz a68f60aa92 Add panel transition on mobile 2022-04-08 20:44:13 +02:00
franzz 920e275d46 Bump leaflet to 1.8.0-beta3 2022-04-08 20:43:20 +02:00
franzz 4a7de9b955 Fix lightbox resizing 2022-04-08 20:42:24 +02:00
franzz 3b577d249a Fix delay opening panels 2022-04-06 21:15:23 +02:00
franzz 14e0831a70 update pwa description 2022-04-05 19:15:56 +02:00
franzz 2cbbb0b568 maskable icons (pwa) 2022-04-05 19:01:30 +02:00
franzz 11b6cad5c7 Remove sync media functions (glitchy) 2022-04-04 22:30:26 +02:00
franzz 5e32a73c00 Prevent closing panels on lightbox exit 2022-04-04 19:19:23 +02:00
franzz 4d3c842cd5 Add gravatar image size 2022-04-04 19:05:03 +02:00
franzz 850702c67e Add media ratio to improve page rendering 2022-04-04 18:59:54 +02:00
franzz e640b9e49f Allow media upload on previz mode 2022-02-25 14:29:36 +01:00
franzz 3ba548aa36 Bump Jquery UI to 1.13.1 2022-02-18 09:53:34 +01:00
franzz ff403d2cdb Fix Gaia updater CDATA extraction 2022-02-07 23:14:22 +01:00
franzz b9a63caa2a gaia uploader v3.1.1 2022-02-07 19:22:28 +01:00
franzz 7188174ccb php script input variables cleanup 2022-02-07 17:37:04 +01:00
franzz 638ad8c192 Gaia updater v3.1 2022-02-07 17:35:25 +01:00
franzz e36e6c768f Bump js library versions 2022-01-31 18:32:07 +01:00
franzz 6bacfdede2 Remove post anchor on mobile 2022-01-31 18:31:36 +01:00
franzz aedaad0eb0 Remove legacy cleanPost 2022-01-31 18:11:05 +01:00
franzz 434776cd38 Add spanish translation 2022-01-30 15:03:44 +01:00
franzz 6102dd2b66 updating composer 2022-01-26 21:19:52 +01:00
franzz dfbe73f238 Update objects dependencies 2022-01-24 15:24:27 +01:00
franzz d864cbbb43 Bump phpmailer 2022-01-24 14:50:43 +01:00
franzz 84acb22492 Update PCT Trace 2022-01-24 11:34:43 +01:00
franzz 469db34534 Add image repositioning feature 2022-01-23 00:03:51 +01:00
franzz 3be01ab479 Fix lightbox on resize event 2022-01-19 19:06:00 +01:00
franzz 89d352cb1f Fix lightbox prev/next buttons position 2022-01-10 17:54:56 +01:00
franzz ac7e9c15ca Fix minor fixes 2022-01-10 16:57:02 +01:00
franzz fffb09c7ca Fix lightbox prev/next buttons 2022-01-10 16:56:01 +01:00
franzz bc2295595c Pushing map parameters to DB 2021-12-20 21:27:33 +01:00
franzz 7ca66a200f Move menu icons to bottom on mobile 2021-12-19 12:52:15 +01:00
franzz cce983291e Fix post header height 2021-12-17 22:02:45 +01:00
franzz 896ee95a34 update PCT gpx 2021-12-17 22:00:25 +01:00
franzz dc761207af Adding Personal File to PCT GPX 2021-12-12 12:22:21 +01:00
franzz 7c0ef6caad noopener on external links 2021-12-01 22:01:12 +01:00
franzz cc4c2601f2 Fix accessibility 2021-12-01 21:51:49 +01:00
franzz b55926a73d Set image ratio 2021-12-01 21:38:58 +01:00
franzz 63dcff53ea Bump js libraries 2021-12-01 21:12:39 +01:00
franzz fd71c1ec71 Avoid FOIT 2021-12-01 20:56:35 +01:00
franzz 0b0261f20d Remove comment HTML entities when converting GPX 2021-11-30 19:29:49 +01:00
franzz 1f5f2e27c0 Fix tile sizes 2021-11-07 13:52:41 +01:00
franzz 6b42fbb450 Fix lightbox close button position 2021-11-07 12:46:38 +01:00
franzz af901f3c77 add picture comment on lightbox fullscreen 2021-11-07 11:00:24 +01:00
franzz 3e981ae634 Bump Font Awesome to 5.15.4 2021-11-06 19:46:53 +01:00
franzz dd04e7ee17 Navigation between admin/upload and project page 2021-11-06 19:38:51 +01:00
franzz cb8a609605 Fix submap positioning 2021-10-20 22:25:56 +02:00
franzz a4dd7a4d16 Fix button height 2021-10-20 21:01:59 +02:00
franzz 7cd9c17c8c Simplify title & panels mecanism 2021-10-18 20:54:58 +02:00
franzz 85386933ef Add an infirmation panel for archived projects 2021-10-06 22:01:30 +02:00
franzz 16d96e523e Prevent posts on archived projects 2021-10-06 20:19:44 +02:00
franzz 16c27ddb6a Add Project Title 2021-10-06 18:54:59 +02:00
franzz fc7d169759 Fix weather tag border radius 2021-09-23 21:56:06 +02:00
franzz 3479aef507 Bump simpleBar to 5.3.5 2021-09-23 21:52:29 +02:00
franzz 32cd56780c Update weather interface 2021-09-11 22:14:41 +02:00
franzz e9f7cae5f9 Optimize delta callbacks (new feed) 2021-09-05 20:07:09 +02:00
franzz 51e0565e67 Add unknown weather data flag 2021-09-05 14:17:18 +02:00
franzz 47b199702a Check weather data 2021-09-04 23:04:49 +02:00
franzz c04be09c3d Adding weather data 2021-09-04 21:37:37 +02:00
franzz b0ec36ab9c Update Todo List 2021-08-31 20:22:01 +02:00
franzz 62bc25aade Rebuild delta mode 2021-08-31 20:21:39 +02:00
franzz dbfebdf9be Redo panels animation 2021-08-31 19:55:00 +02:00
franzz a7b4fc2375 Add project local functions to main spot class 2021-08-30 23:23:57 +02:00
franzz 26b39c81db keep original message date insert 2021-08-30 21:31:30 +02:00
franzz 1b02d26fe0 Avoid PHPMailer boiler plate repetition 2021-08-30 21:27:07 +02:00
franzz 0da78509a7 simplify constraint on feed posts 2021-07-09 10:27:08 +02:00
franzz 95531b4691 feed update: introduce reference point 2021-07-06 22:48:18 +02:00
franzz af3e44e476 Update config 2021-06-28 20:51:05 +02:00
franzz 7e89816a5f Change file name to match class name 2021-06-28 20:49:17 +02:00
franzz 00b6966df8 Change file name to match class name 2021-06-28 20:49:11 +02:00
franzz 14a6727739 Change file name to match class name 2021-06-28 20:49:03 +02:00
franzz dacd8a8cca Change file name to match class name 2021-06-28 20:48:53 +02:00
franzz 2bb25347dd Update 'inc/Media.php' 2021-06-28 20:48:43 +02:00
franzz 2cb5a2c6d6 Change file name to match class name 2021-06-28 20:48:36 +02:00
franzz 49442f91f3 Change file name to match class name 2021-06-28 20:48:14 +02:00
franzz f7cd3cee2b Change file name to match class name 2021-06-28 20:48:05 +02:00
franzz 15d7efbcac composer v1 2021-06-27 20:57:10 +02:00
franzz 4f053352a3 Missing DB update 2021-06-21 21:35:08 +02:00
franzz 8a41d11e1b Bump Font Awesome v5.15.3 2021-06-21 20:18:46 +02:00
franzz 991edfe747 Enforce admin privileges 2021-06-21 20:04:16 +02:00
franzz 30a9dbc85c Fix post valifity inverval 2021-06-14 23:36:20 +02:00
franzz 167aa91d9d New Tooltip interface Fix 2021-06-13 14:34:42 +02:00
franzz c98abaaff6 New Tooltip interface 2021-06-13 14:00:12 +02:00
franzz 7240b766f9 Fix invalid last update timestamp 2021-06-10 23:46:05 +02:00
franzz 84aad67a30 Fix getTimeZoneFromDate call 2021-06-10 23:17:28 +02:00
franzz ad589a068d Clearer Tooltip: bigger titles 2021-06-03 19:28:50 +02:00
franzz 1e965bfd91 Clearer Tooltip 2021-06-03 19:17:02 +02:00
franzz bd2c7c4ce0 Remove deprecated empty project detection 2021-05-24 18:49:44 +02:00
franzz e1640ff199 Upgrade js librairies 2021-05-24 17:38:13 +02:00
franzz c88026b563 Fix DB update in strict mode 2021-05-24 01:19:15 +02:00
franzz b398480c7f Add Local Time Zone 2021-05-24 01:05:36 +02:00
franzz 6b3d637e64 Update PCT Track 2021-05-16 20:56:49 +02:00
franzz 4c4c913ed2 Animate spot logo 2021-02-18 22:12:10 +01:00
franzz 3d5450319d Store gravatar image in DB 2021-02-15 21:59:48 +01:00
franzz 1480545f1b Add Gravatar profile image 2021-01-22 15:52:43 +01:00
franzz 4eb158d60c Support update of all items 2021-01-21 21:54:52 +01:00
franzz a2faa7acf1 Add waypoint Icons & Track Colors Scripts 2021-01-17 13:09:26 +01:00
franzz a5eff5c637 Add Gaia upload progress bar 2021-01-01 17:32:41 +01:00
franzz 46d93829e2 Tracks & waypoints creation timestamp 2020-12-29 19:39:45 +01:00
franzz b597fa405a Upgrade GPXParser to v3.0.6 2020-12-29 16:35:06 +01:00
franzz 6180a04782 Gaia Updater fix (new Gaia GPS upload process) 2020-12-28 19:34:41 +01:00
franzz af5b438d64 Remove rotation fix (fixed in browser) 2020-12-04 21:13:18 +01:00
franzz 2ed8870fbe Fix popup hovering (bubbling) 2 2020-08-20 22:42:09 +02:00
franzz 3c5a83b366 Fix popup hovering (bubbling) 2020-08-20 22:02:57 +02:00
franzz b0b283cf0f Store maps (layers) settings in DB + display only project-relevant layers 2020-08-18 23:12:40 +02:00
franzz 4c4f83b6a2 Fix scroll top calculation on focus event 2020-08-17 18:18:18 +02:00
franzz 94892a2a90 Add link on feed items 2020-08-16 22:17:24 +02:00
franzz 75d64ed911 Better handling of # variables + bump simplebar to v5.2.1 2020-08-16 10:34:49 +02:00
franzz 354f824588 Adding Last Update 2020-08-14 14:58:29 +02:00
franzz 84eff9f807 Gaia GPS Upload Greasemonkey Script 2020-06-20 15:14:39 +02:00
franzz e1eda709a4 Media comment embedded in picture 2020-05-17 12:16:36 +02:00
franzz 7e21c91007 Fix lightbox close button position in vertical mode 2020-05-16 12:15:54 +02:00
franzz 3066998c72 Fix lightbox close icon position & size 2020-05-16 11:23:40 +02:00
franzz 6b023d2546 Newsletter: Add custom link to media/post 2020-05-13 19:23:39 +02:00
franzz aa9ceb151f Fix message drill-down icon position 2020-05-10 18:40:27 +02:00
franzz 35045f07a0 Fix Timezone display on media 2020-05-10 15:41:26 +02:00
franzz 9a27359359 Fix cookie typo 2020-05-10 11:38:41 +02:00
franzz f45343e47d Fix lightbox portrait mode: dynamic media data position 2020-05-09 19:46:09 +02:00
franzz 4a9c303684 Fix video fullscreen mode on mobile 2020-05-08 22:59:46 +02:00
franzz 40622de1bb Set Same Site value on Cookie (Firefox requirement) 2020-05-08 21:23:22 +02:00
franzz 8f09ad3bb6 Bump d3, jQuery & mods 2020-05-08 21:12:21 +02:00
franzz d22472722b elevation module replaced with HeightGraph (improved perfs) 2020-05-03 17:17:03 +02:00
franzz 204464d6fd Fix settings panel scrollbar on iOS safari 2020-05-02 22:16:05 +02:00
franzz f0ee1f061c Adding credits 2020-04-26 17:54:39 +02:00
franzz 33c320bf5c l10n typos & CSS fixes 2020-04-25 17:02:24 +02:00
franzz 949275f657 Fix Time Zone Name (when not using city names) 2020-04-20 15:42:42 +02:00
franzz ead9cf3a49 Replace Leaftlet Elevation with HeightGraph 2020-04-20 11:57:53 +02:00
franzz 0c5edb862b Disable elevation module on IE & Edge. 2020-04-17 21:25:56 +02:00
franzz a14b0d5dcb Remove email frequency early commit 2020-04-17 18:37:47 +02:00
franzz aad5ec0839 Email: Support multiple time zones 2020-04-17 18:32:12 +02:00
franzz 43f8b0fbf9 Add Timezone to user data 2020-04-17 11:00:11 +02:00
franzz 047b652ab7 Define user language from browser only 2020-04-14 20:43:46 +02:00
franzz f36c1cf74a Remove cookie on unsubscribe 2020-04-14 20:27:56 +02:00
franzz 73713d477b Admin page: delete feed / project 2020-04-13 22:50:37 +02:00
franzz 1d1a6f63a6 Add update emails 2020-04-13 18:06:48 +02:00
franzz 61e901b3b2 Fix auto-login (avoid multiple confirmation emails) 2020-04-11 08:56:31 +02:00
franzz 63d01a8543 Log in when subscribing an existing email 2020-04-11 08:36:37 +02:00
franzz 3b28288f0c Fix leaflet tooltip padding 2020-04-10 20:25:44 +02:00
franzz f04fe46502 cron script: add cron config in comments 2020-04-10 14:16:07 +02:00
franzz b2189fd8a1 Generate Cron File 2020-04-10 09:14:59 +02:00
franzz 9b42585c80 Fix border radius css feed panel 2 2020-04-06 21:35:57 +02:00
franzz 26f927303b Fix border radius css feed panel 2020-04-06 21:30:49 +02:00
franzz 1258a63538 Update PCT track 2020 (pctmap.net) 2020-04-06 21:23:38 +02:00
franzz 318767cab7 Fix value assignment for users.active 2020-04-06 21:21:26 +02:00
franzz 74dad7c879 Add list-unsubscribe 2020-04-06 20:59:53 +02:00
franzz 1c8f823075 New logo 2020-04-06 17:54:39 +02:00
franzz 66a9a566c0 Replace mail() with PHPMailer + new logo 2020-04-06 17:53:49 +02:00
franzz 13a9155634 New settings panel & &subscription 2020-04-05 18:56:42 +02:00
franzz 7a03fa94ab Fix feed panel display on mobile 2020-03-27 18:15:32 +01:00
franzz dae7a01221 New Key Down event catching 2020-03-27 18:09:09 +01:00
franzz 85630d2553 Auto pan map on feed panel opening/closing 2020-03-27 18:08:28 +01:00
franzz 908ef8f558 Fix leaflet elevation plugin 2020-03-27 18:07:45 +01:00
franzz 0a398b98b0 Fix Elevation Panel Width 2020-03-26 21:12:43 +01:00
franzz b9455e1c36 Update PCT track 2020-03-26 17:54:09 +01:00
franzz d81b4a2428 Improve admin page (add new projects) 2020-02-08 21:13:52 +01:00
franzz 68bdbb4014 Remove DB field geofile 2020-02-05 21:04:08 +01:00
franzz 76ee2f9ada Enlarge hover event triggering surface 2020-02-03 19:53:07 +01:00
franzz b2904b70a0 Fix Geo File Path 2020-02-02 20:08:25 +01:00
franzz 31e9ca3856 Adding markers: trail start / end 2020-01-12 21:06:13 +01:00
franzz 58f10b4126 Bump leaflet 1.6.0 2020-01-12 16:29:00 +01:00
franzz 6e12c44f54 Add GPX file download 2020-01-02 14:57:02 +01:00
franzz 1d7e9d10c0 Fix taken On / Posted On Media Dates 2019-12-13 19:46:15 +01:00
franzz 079ac221de Bump simplebar to 5.0.7 2019-12-13 18:58:16 +01:00
franzz 8c4e32d9bb Bump fileupload plugin to 10.4.0 2019-12-13 18:43:38 +01:00
franzz 8cfffb0572 Fix crash in blog mode and no message 2019-12-13 18:34:13 +01:00
franzz 77a0914248 Scale up travel times 2019-12-01 20:24:23 +01:00
franzz be50af7680 Use track's comment to store geojson generation options 2019-11-29 22:34:33 +01:00
franzz d86fcfecda Fix no post detection 2019-11-28 10:20:13 +01:00
franzz 2a4fa8b2d0 Adding elevation difference to the GPX simplification 2019-11-24 13:26:23 +01:00
franzz 5afb60da5d Add PCT tracks and update converter to include GPX simplication 2019-11-23 19:10:40 +01:00
franzz c3090f5286 update todo list 2019-11-20 19:28:56 +01:00
franzz 1f4df5b190 Change Main Constructor parameter positions 2019-11-02 19:52:41 +01:00
franzz 8496ddb278 Link pic & post 2019-11-01 23:12:32 +01:00
franzz 2af07cc656 Add zoom to lightbox 2019-10-28 19:06:22 +01:00
franzz 61084c3db9 merge lightbox calls (post / markers) 2019-10-24 21:32:05 +02:00
franzz 014ac80229 lightbox: use video object throughout 2019-10-22 20:21:33 +02:00
franzz 115c653186 Bump jquery to 3.4.1 2019-10-22 18:40:28 +02:00
franzz 44a47e5030 Bump leaflet to 1.5.1 2019-10-22 18:28:47 +02:00
franzz 2e73e72070 Fix lightbox videos 2019-10-21 20:17:11 +02:00
franzz 733f5edc62 new lightbox interface v2 2019-10-20 20:08:15 +02:00
franzz 912888814a fix lightbox video interface 2019-10-20 16:29:57 +02:00
franzz 5478f7501f new lightbox interface v1 2019-10-20 16:20:29 +02:00
franzz 9a1673e01a Bump font awesome 5.11.2 2019-10-20 16:09:57 +02:00
franzz 2af1ba755a lightbox 2.11.1 css 2019-10-20 00:13:21 +02:00
franzz 38aa0cc4ac bump lightbox to 2.11.1 2019-10-19 22:01:22 +02:00
franzz 29106b7e04 Convert all time to browser timezone 2019-10-19 21:04:22 +02:00
franzz 36dee241fa Translate object counter 2019-10-19 19:49:03 +02:00
franzz 472d07c467 Improve media retrieval performance 2019-10-19 19:47:33 +02:00
franzz 05fd7145f8 Fix lang js function 2019-10-19 19:42:57 +02:00
franzz 978313fd06 i18n 2019-10-19 18:27:26 +02:00
franzz 55f67f2a2d Fix elevation 2019-10-18 17:08:47 +02:00
franzz d8817d4f84 initialize translation 2019-10-13 18:58:56 +02:00
franzz df82b27d80 Rounded legend tracks 2019-10-13 11:22:22 +02:00
franzz 5addf8717b Update todo list 2019-09-02 22:40:37 +02:00
franzz 00bea34555 Update HRP track with actual data 2019-09-02 21:15:34 +02:00
franzz fafc377dd9 Fix thumbnail orientation 2019-09-01 21:00:06 +02:00
franzz 8f2819d4cb Fix table constraints SQL generation 2019-09-01 19:04:52 +02:00
franzz 177af8169c Idiot-proof unstable spot api parsing (messages) 2019-09-01 00:17:21 +02:00
franzz 4ec4e232c2 Update feeds once an hour (clean commit) 2019-09-01 00:17:21 +02:00
franzz eb36e4f979 Revert "Update feeds once an hour"
This reverts commit 92df6bcad7142169a3ce6b427777d5a39967dfdb.
2019-09-01 00:17:11 +02:00
franzz 2cc54862c3 Update feeds once an hour 2019-08-13 15:57:49 +02:00
julien 00720c09a3 Fix message parsing 2019-07-25 12:41:56 +02:00
franzz a3ec965b02 Use mapbox hybrid map 2019-07-13 11:34:04 +02:00
franzz be47ba6dc3 Fix message icon position 2019-07-06 23:08:36 +02:00
franzz 7462b1573b Add drill down icon on message 2019-07-06 22:58:52 +02:00
franzz c9d0462b56 Fix auto-popup of last message 2019-07-05 19:42:10 +02:00
franzz 3d0084f11f Update HRP track 2019-07-05 18:25:20 +02:00
franzz 26a04a3ada Fix drill icons on marker medias 2 2019-07-02 22:49:22 +02:00
franzz 456c45236c Fix drill icons on marker medias 2019-07-02 22:48:51 +02:00
franzz 78febf4711 Fix images / videos on markers 2019-07-01 21:34:43 +02:00
franzz 8a476b7800 Add button icons on all drill downs 2019-07-01 21:22:37 +02:00
franzz 07c328e244 Play icon on video files 2019-07-01 19:52:25 +02:00
franzz 5b2fc3f854 Add media subtype & fix resizing 2019-06-30 22:21:39 +02:00
franzz 8044d8640c Video support 2019-06-30 21:46:41 +02:00
franzz b691a32f76 Allow upload of videos 2019-06-28 22:17:46 +02:00
franzz 3a82aa90aa Update HRP track 2019-06-28 16:57:31 +02:00
franzz 7118834014 Adding readme 2019-06-28 11:44:26 +02:00
franzz 6dc3fea092 Fix file upload feedback 2019-06-28 00:06:23 +02:00
franzz 5cd187affd Bump fileuploader to v9.32 2019-06-27 23:24:06 +02:00
franzz 8fa03b2b56 Fix no messages in feed warnings 2019-06-27 20:17:24 +02:00
franzz 9f2dcc2e7c Typo upload page title 2019-06-20 16:50:56 +02:00
franzz f6025a3bdb Update upload page interface slighly 2019-06-20 11:00:39 +02:00
franzz d85ee85a83 Fix typo on quit page function 2019-06-20 09:03:43 +02:00
franzz c73c5ac6d2 unset timer on page exit 2019-06-19 20:51:44 +02:00
franzz 760d070461 set page title 2019-06-03 18:44:37 +02:00
franzz 2eaa826094 Fix image orientation 2 (from markers) 2019-06-01 22:10:23 +02:00
franzz a6b8ae9b10 Remove scale on feeds display (mobile) 2019-06-01 16:24:53 +02:00
franzz 78dd1b1f7a Leave scale on mobile 2019-06-01 15:43:15 +02:00
franzz 304b1b171d add swipe event catcher 2019-06-01 14:08:20 +02:00
franzz fceb43515a avoid message duplicates 2019-05-31 21:53:12 +02:00
franzz c9b833b898 Add total track length / elevation 2019-05-15 22:56:38 +02:00
franzz 01af4b5627 Add track step estimated duration 2019-05-11 18:51:53 +02:00
franzz d147f19e57 fix elevation chart icon 2019-05-11 16:31:29 +02:00
franzz 57a6689f16 add distance & elevation on tracks 2019-05-11 01:12:24 +02:00
franzz d33fa4a732 fix picture orientation 2019-05-09 19:40:45 +02:00
franzz af71aaffa3 add lightbox unminified library 2019-05-09 19:33:16 +02:00
franzz 3de177f75c Revert "add lightbox unminified library"
This reverts commit f321929de87a7bb720e7f51dfcf52abb77670914.
2019-05-09 19:29:30 +02:00
franzz 686b89d2db add lightbox unminified library 2019-05-09 19:27:04 +02:00
franzz d5b0c000ca Update lightbox & set options 2019-05-03 23:34:10 +02:00
franzz 205d065606 fix pictures with no "taken on" info 2019-05-03 23:18:48 +02:00
franzz 2dd5e08259 fix picture numbering 2019-05-03 22:51:09 +02:00
franzz e64fac898f open latest marker on mobile devices 2019-05-03 22:39:06 +02:00
franzz 73a7322a6e On resize management 2019-04-09 22:34:56 +02:00
franzz bf62cdeb25 fix time on project active period 2019-04-01 18:31:35 +02:00
franzz 5fe925a473 remove debug 2019-04-01 18:25:38 +02:00
franzz ecf47b1a7e admin page v1 2019-03-31 22:07:21 +02:00
franzz cf91719dba index cleanup 2019-03-27 22:04:10 +01:00
franzz 596dbfc40f update HRP track 2019-03-24 23:02:27 +01:00
franzz b4b6cea6eb use new geo server for tiles 2019-03-24 00:05:14 +01:00
franzz 1dea80d6a1 403 http request wrapping 2019-03-23 22:56:38 +01:00
franzz 6acfbc7cdc no need for Geo API keys anymore 2019-03-23 18:08:27 +01:00
franzz 7004934d54 Push Cacher to Objects (WMTS Class) 2019-03-23 14:43:50 +01:00
franzz 5779feb68b new OGP image 2019-03-15 20:14:20 +01:00
franzz 5a0706f8f8 OGP support 2019-03-15 19:42:16 +01:00
franzz 0c8bd785e5 add prod server as settings param 2019-03-15 18:58:23 +01:00
franzz 2b170d5255 fix offset on mobile devices 2019-03-11 22:07:38 +01:00
franzz 5f040b113d fix feed panel top margin 2019-03-11 18:57:45 +01:00
franzz 3e4e5ae4d9 fix feed panel display on mobile 2019-03-11 13:18:15 +01:00
franzz 900619f8e2 fix auto update timer on histo projects 2019-03-11 12:05:17 +01:00
franzz 34ad20f572 fix elevation chart size 2019-03-10 19:27:29 +01:00
franzz 4c9738924c foldable feed panel 2019-03-10 15:50:58 +01:00
franzz 7abce20d9f backup v5 2019-03-07 20:51:13 +01:00
franzz eb0cf07bb6 update hrp path 2019-03-07 20:37:06 +01:00
franzz dc9a5c82d8 automatic geojson file generation 2019-03-07 20:36:23 +01:00
franzz 49090d1416 Update HRP geo file 2019-03-06 22:27:32 +01:00
franzz 2edba945c9 feed class 2019-02-28 22:29:09 +01:00
franzz 70489bd680 add feed auto update 2019-02-25 21:20:40 +01:00
franzz c30cd4d8d4 page split width variables 2019-02-25 20:07:30 +01:00
franzz 5aec9d361e fix IE11 bug (not supporting ES6) 2019-02-25 19:55:31 +01:00
franzz 202ac44759 sass color variables 2019-02-24 00:07:05 +01:00
franzz eb5006f610 get rid of google fonts dependency 2019-02-23 21:04:13 +01:00
franzz 41ee601770 change favicon 2019-02-23 20:50:36 +01:00
franzz f60550b813 new icon as png 2019-02-23 19:33:33 +01:00
franzz bd269e1ffd new svg icon 2019-02-23 19:20:39 +01:00
franzz aba4d77ac6 on hover, display absolute time 2019-02-22 22:06:55 +01:00
franzz 43f0d99bc4 reorganize leaflet plugin css 2019-02-21 21:33:21 +01:00
franzz a5bcfa25aa fix map loading icon position 2019-02-21 20:31:03 +01:00
franzz 98bb82d22a fix lightbox loading icon color 2019-02-21 19:20:18 +01:00
franzz 6489a0ad73 replacing icons with font awesome v2 2019-02-20 23:07:26 +01:00
franzz 044adc651e replacing icons with font awesome 2019-02-20 23:06:43 +01:00
franzz c39bb5a4fc fix message displayed ID 2019-02-20 22:27:29 +01:00
franzz 28f80dbcee update HRP track 2019-02-20 21:42:19 +01:00
franzz 0617aac091 Adding elevation chart 2019-02-14 22:59:32 +01:00
franzz 1b4f0f5b17 updating GPX files 2019-02-14 22:56:55 +01:00
franzz d5b448b733 ignoring geojson files 2019-02-14 22:56:16 +01:00
franzz 6a126ab470 untrack geojson files 2019-02-14 22:54:46 +01:00
franzz 935edac509 Split CSS by page 2019-02-13 21:01:15 +01:00
franzz 50878503e5 GPX to GeoJson converter 2019-02-12 20:00:59 +01:00
franzz b49445a85e add scale 2019-02-10 18:51:44 +01:00
franzz 85774935c6 hover on message (feed) triggers marker's popup 2019-02-07 19:35:03 +01:00
franzz 0a9188ca2a backup v4 2019-02-06 22:54:05 +01:00
franzz 4bfea69374 files2 folder removed 2019-02-06 22:43:21 +01:00
franzz 036e2d3fa7 Replace 'heure française' with actual site timezone 2019-02-06 22:43:06 +01:00
franzz 89b9145bf6 fix wrong commit 2019-02-06 22:33:25 +01:00
franzz d4b846088d add mapbox marker 2019-02-06 22:23:45 +01:00
franzz f961384376 fix marker position on static maps 2019-02-06 22:23:06 +01:00
franzz 29d39a9d0a fix picture assignment to spot message 2019-02-06 21:08:53 +01:00
franzz 05bcbfc251 fix SQL upgrade (v4) 2019-02-06 18:57:58 +01:00
franzz 883aea494d Harmonize timezones 2019-02-06 18:56:08 +01:00
franzz 6a22bd98f2 update todo list 2019-02-03 21:01:14 +01:00
franzz 2d3a0d2076 fix picture numbering 2019-02-03 19:59:43 +01:00
franzz dbc747e096 update backup v3 2019-02-03 19:23:26 +01:00
franzz 4e761a898f sort feed ascending on histo mode 2019-02-03 19:00:23 +01:00
franzz bab12f64b0 typo 2019-02-03 15:03:40 +01:00
franzz 12b796d83a back up v3 2019-02-03 14:44:34 +01:00
franzz 471d771972 fix feed max width (images are 400px) 2019-02-03 14:44:20 +01:00
franzz bfd3531362 fix picture width 2019-02-03 14:26:45 +01:00
franzz 2b915e9b87 extra info on picture selection 2019-02-03 12:32:42 +01:00
franzz 3c4bafec64 pictures table 2019-02-03 01:26:56 +01:00
franzz 0239bb0551 Add DMS value on Spot Messages 2019-01-30 22:39:28 +01:00
franzz ead0a42ae4 translate relative time to french 2019-01-27 23:10:34 +01:00
franzz 05c9214b59 replace leaflet images with font awesome icons 2019-01-27 21:15:33 +01:00
franzz fc948163ff update backup 2019-01-27 21:15:03 +01:00
franzz 29e3e232ac Pushed static maps retrieval to backend 2019-01-27 20:11:09 +01:00
franzz 7e715518db set a first_exec var for project detection 2019-01-27 17:15:32 +01:00
franzz b4457a1931 fix first project selection 2019-01-27 16:56:46 +01:00
franzz 7da90d4ed1 Fix poster display 2019-01-27 16:47:41 +01:00
franzz ba1deee754 Add project radio buttons 2019-01-27 16:42:15 +01:00
franzz e603c069a6 Move legend to left 2019-01-27 13:02:49 +01:00
franzz 445f1a144a Hover opening popup on tracks 2019-01-27 00:28:24 +01:00
franzz 5659c0c848 Add track icons 2019-01-26 20:33:10 +01:00
franzz e74973933c Upgrade Font Awesome to 5.6.3 2019-01-26 18:52:13 +01:00
franzz 58174a7a5c add timestamp on all css/js files 2019-01-26 18:05:17 +01:00
franzz d074fb31ec SQL backup v2 2019-01-26 17:54:55 +01:00
franzz 02d6822d05 fix first execution of project finder 2019-01-26 12:17:11 +01:00
franzz a538357aba Spot v2: Project Management 2019-01-26 10:13:06 +01:00
franzz 55bb972c5b fix zoom offset 2018-10-21 17:25:39 +02:00
franzz adb173cd2f remove offset centering (causing infinite loops) 2018-10-21 12:32:13 +02:00
franzz 2a7049638f replace marker tooltip with popup + update jquery 2018-10-17 20:54:14 +02:00
franzz bc56f5af40 replace tracks tooltip with popup 2018-10-17 20:01:04 +02:00
franzz a90b5bb469 add LINZ API key to settings 2018-10-17 18:26:49 +02:00
franzz 28a7693c4d add cache folder 2018-10-17 18:23:04 +02:00
franzz 8ceacaf175 add tile api 2018-10-17 18:05:10 +02:00
franzz abaf459017 add 30% x-offset on zoom 2018-10-10 22:48:27 +02:00
franzz 3b7a92d44a adding track names 2018-10-09 20:33:12 +02:00
franzz b3eb748a90 swap open topo map with LINZ 2018-10-08 22:12:57 +02:00
franzz cca50b20be add Open Topo Map 2018-10-07 22:43:05 +02:00
franzz a14b53b6bc convert to leaflet 2018-10-07 11:46:50 +02:00
franzz d6af05c8c0 add pictures to messages 2018-04-22 16:21:31 +02:00
franzz dda622e870 simplify minified CSS 2018-04-19 22:08:23 +02:00
franzz f0e7cda755 update fileUploader to 9.21.0 2018-04-19 21:03:46 +02:00
franzz 8f7dc7e75a upgrade lightbox + add thumbnails 2018-04-18 22:54:00 +02:00
franzz 893e27cb5f add auto post loader 2018-04-15 14:59:39 +02:00
franzz 588f9bb102 code cleaning 2018-04-15 13:14:37 +02:00
franzz ffc94cb85a adding customer sidebar + posts over map + add validity interval (histo) 2018-04-14 16:16:25 +02:00
franzz 1c8c9a2175 remove dependence on gmap3 2018-04-12 22:08:51 +02:00
franzz aa4393746a update settings sample 2018-04-11 20:37:32 +02:00
franzz 2af6f5d2a2 add message minimaps 2018-04-11 20:26:33 +02:00
franzz efb62fc02c remove message form on histo mode 2016-09-24 23:47:03 +02:00
franzz b957d92747 update link + remove extra photo 2016-06-30 20:12:12 +02:00
franzz 050bbd68d9 adding hitchhike to Te Anau 2016-06-30 20:04:04 +02:00
franzz 06f751ee0b changing map center 2016-06-29 21:07:26 +02:00
franzz 6387aa4686 force kmz refresh 2016-06-28 00:20:39 +02:00
franzz 030460509c adding water taxi to ship cove 2016-06-28 00:19:47 +02:00
franzz 07969161bb test kmz 2016-06-27 23:47:22 +02:00
franzz f1392fafb9 adding hitchhike to Methven 2016-06-27 23:40:50 +02:00
franzz 04811d796f changing legend position 2016-06-27 21:44:48 +02:00
franzz 2bc7487f7e fixing kml URL 2016-06-27 21:26:19 +02:00
franzz bc6addd0e9 Adding Queenstown bus ride + map legend 2016-06-27 21:25:27 +02:00
franzz 0ac2868c79 adding greenstone track 2016-06-23 23:02:33 +02:00
franzz 16362e186d sort messages by timestamp 2016-06-23 21:11:24 +02:00
franzz 55f518b120 upgrade to gmap3 6.1.0 2016-06-23 20:48:01 +02:00
franzz a6b83ab0da upgrade to jQuery 3.0.0 and google API key added 2016-06-23 20:43:43 +02:00
franzz 6f161757cf adding settings sample file 2016-06-22 19:43:31 +02:00
franzz 428ffc2cb0 remove settings file from repo 2016-06-22 19:36:34 +02:00
franzz 9f1daf956c fix message box (again) 2016-06-22 19:28:57 +02:00
franzz ccf1924e47 fix message box width 2016-06-19 20:21:01 +02:00
franzz c0fef4512e fixing poster panel 2016-06-13 23:35:30 +02:00
franzz 7ee14d7d1a adding news feed chunks 2016-06-13 22:17:46 +02:00
franzz 68d7711186 Post trek update: recentering map + style minor changes 2016-06-09 23:11:10 +02:00
franzz 4d480a6bca removing lightbox quick fix 2016-03-26 14:15:54 +01:00
franzz 78016d2cc6 fixing lightbox 2016-03-26 14:13:33 +01:00
franzz 71e3cb3603 prefix fo lightbox 2016-03-26 11:43:30 +01:00
franzz 1e4d5eb2d6 adding Rangitata crossing and removing Wakatipu crossing 2016-03-20 22:30:45 +01:00
franzz 938fd40e8e remove post number 2016-03-15 21:13:50 +01:00
franzz 0e97cbcc72 fix feed sorting and image numbering 2016-03-15 20:57:26 +01:00
franzz 1683c35c4f remove test image 2016-03-09 17:54:02 +01:00
franzz 11c7e4a957 adding routeburn 2016-03-06 17:12:54 +01:00
franzz fd3b489b7d auto refresh 2015-12-27 23:00:33 +01:00
franzz 2f42d3f287 init pictures folder 2015-12-27 13:19:16 +01:00
franzz d8e3fe4127 comments + images 2015-12-27 13:08:01 +01:00
franzz 8887e888b7 images for lightbox 2015-12-27 13:06:23 +01:00
franzz 1cba2a5831 no htaccess 2015-12-27 13:06:02 +01:00
franzz 672625ea34 accept only images (front end) 2015-12-25 21:32:24 +01:00
franzz b215911433 Accept only images 2015-12-25 21:30:14 +01:00
franzz c5b003cc62 upload fixes 2015-12-25 21:07:55 +01:00
franzz ceca030fe5 replacing 32x32 favicon 2015-12-25 20:22:54 +01:00
franzz 795f3e9016 replacing png favicon 2015-12-25 20:18:46 +01:00
franzz c281248a69 new foot print icons + favicon 2015-12-25 20:16:05 +01:00
franzz 20829ff39a closer zoom level + fixing loader position 2015-12-20 12:32:57 +01:00
franzz 31103414e1 add footprint alternative image 2015-12-18 11:46:04 +01:00
franzz 99af3d4d13 alt foot print 2015-12-18 11:45:47 +01:00
franzz 30f359b9b5 remove force refresh 2015-12-18 11:35:48 +01:00
franzz 7bee863adc force update 2015-12-18 11:35:15 +01:00
franzz cb8160e076 init uploader (v0.1) + go live spot feed 2015-12-18 11:33:53 +01:00
franzz 7e2cdb9861 uploader v0.1 2015-12-16 22:52:17 +01:00
franzz 8d60f8a20c fix infowindow focus/blur event 2015-12-16 20:44:34 +01:00
franzz 5673df69f0 remove centering on KML layer 2015-12-16 20:40:04 +01:00
franzz beb1617a6e removing recenter 2015-12-11 19:35:16 +01:00
franzz ea449335e5 recenter kml 2015-12-11 00:28:57 +01:00
franzz c96e0e5748 adding kayak section in Queenstown 2015-12-11 00:24:52 +01:00
franzz ef99709980 removing north island 2015-12-11 00:14:57 +01:00
franzz 711289ca57 removing colors 2015-12-11 00:12:57 +01:00
franzz 562fafd5fb all green 2015-12-10 23:51:10 +01:00
franzz bf7024b57a missed before, adding kml file (simplified) 2015-12-10 23:35:26 +01:00
franzz d450dee81d simplifying kml file 2015-12-10 23:34:08 +01:00
franzz 80bc3551f7 testing kml 2015-12-10 23:26:09 +01:00
franzz 947ef6e881 gitignore 2015-12-10 23:23:32 +01:00
franzz 47ef0bae24 switching to gmap3 2015-12-10 23:12:50 +01:00
franzz a52307af80 v0.1 with spot app 2015-12-01 23:54:46 +01:00
franzz 94370ae57e repo init 2015-12-01 23:22:23 +01:00
208 changed files with 642433 additions and 147692 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/"
+19 -17
View File
@@ -1,17 +1,19 @@
/build.xml
/settings.php
/.externalToolBuilders/
/.sass-cache/
/.buildpath
/.project
/style/.sass-cache/
/.settings/
/files/**/*.jpg
/files/**/*.JPG
/files/**/*.jpeg
/files/**/*.JPEG
/files/**/*.png
/files/**/*.PNG
/files/**/*.mov
/files/**/*.MOV
/geo/*.geojson
# App config files
/config/settings.php
/log.html
# 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);
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
cd "$(dirname "$0")/../public" || exit 1 #Execute from public folder
php -f index.php a=update_project > /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"
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "franzz/livetrail",
"description": "LiveTrail",
"type": "project",
"license": "GPL-3.0-or-later",
"repositories": [
{
"type": "git",
"url": "https://git.lutran.fr/franzz/objects"
}
],
"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/"
},
"files": [
"config/settings.php"
]
},
"scripts": {
"lint": "php-cs-fixer fix --dry-run --diff",
"lint-fix": "php-cs-fixer fix"
}
}
Generated
+2959
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>
+19
View File
@@ -0,0 +1,19 @@
<?php
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/**']
}
];
-340
View File
@@ -1,340 +0,0 @@
-- MySQL dump 10.16 Distrib 10.1.32-MariaDB, for debian-linux-gnu (x86_64)
--
-- Host: localhost Database: spot
-- ------------------------------------------------------
-- Server version 10.1.32-MariaDB-1~stretch
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `feeds`
--
DROP TABLE IF EXISTS `feeds`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `feeds` (
`id_feed` int(10) unsigned NOT NULL AUTO_INCREMENT,
`ref_feed_id` varchar(40) DEFAULT NULL,
`id_spot` int(10) unsigned DEFAULT NULL,
`name` varchar(100) DEFAULT NULL,
`description` varchar(100) DEFAULT NULL,
`status` varchar(10) DEFAULT NULL,
`led` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id_feed`),
KEY `ref_feed_id` (`ref_feed_id`),
KEY `id_spot` (`id_spot`),
CONSTRAINT `feeds_ibfk_1` FOREIGN KEY (`id_spot`) REFERENCES `spots` (`id_spot`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `feeds`
--
LOCK TABLES `feeds` WRITE;
/*!40000 ALTER TABLE `feeds` DISABLE KEYS */;
INSERT INTO `feeds` VALUES (1,'0Y5LrvigElWeAieBGnFol0KBEuOTkFJmm',1,'Te Araroa','Te Araroa','ACTIVE','2016-03-15 17:04:16');
/*!40000 ALTER TABLE `feeds` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `messages`
--
DROP TABLE IF EXISTS `messages`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `messages` (
`id_message` int(10) unsigned NOT NULL AUTO_INCREMENT,
`ref_msg_id` int(11) DEFAULT NULL,
`id_feed` int(10) unsigned DEFAULT NULL,
`type` varchar(20) DEFAULT NULL,
`latitude` decimal(7,5) DEFAULT NULL,
`longitude` decimal(8,5) DEFAULT NULL,
`timestamp` datetime DEFAULT NULL,
`unix_timestamp` int(11) DEFAULT NULL,
`content` longtext,
`battery_state` varchar(10) DEFAULT NULL,
`led` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id_message`),
KEY `ref_msg_id` (`ref_msg_id`),
KEY `id_feed` (`id_feed`),
CONSTRAINT `messages_ibfk_1` FOREIGN KEY (`id_feed`) REFERENCES `feeds` (`id_feed`)
) ENGINE=InnoDB AUTO_INCREMENT=59 DEFAULT CHARSET=utf8mb4;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `messages`
--
LOCK TABLES `messages` WRITE;
/*!40000 ALTER TABLE `messages` DISABLE KEYS */;
INSERT INTO `messages` VALUES
(1,477259564,1,'OK',48.85129,2.40491,'2015-12-01 21:39:05',1449002345,'Jusque là , tout va bien ! Click sur le lien en dessous pour voir où je suis :)\r\n\r\n@Clara: <3','GOOD','2015-12-16 14:23:03'),
(2,482984605,1,'OK',48.84101,2.41755,'2015-12-17 23:10:42',1450390242,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2015-12-18 10:42:15'),
(3,486423333,1,'OK',-43.53616,172.61385,'2015-12-29 22:22:24',1451424144,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2015-12-30 09:58:24'),
(5,487401670,1,'OK',-41.22218,173.97224,'2016-01-02 06:27:37',1451712457,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-02 14:31:28'),
(6,487063089,1,'OK',-41.09460,174.23471,'2015-12-31 22:33:10',1451597590,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-02 14:31:28'),
(7,486718912,1,'OK',-41.29022,174.00610,'2015-12-30 20:40:41',1451504441,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-02 14:31:28'),
(8,487814727,1,'OK',-41.28061,173.76732,'2016-01-03 19:43:24',1451846604,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-03 23:52:58'),
(9,488100295,1,'OK',-41.29897,173.57143,'2016-01-04 19:23:08',1451931788,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-04 23:22:18'),
(10,488411186,1,'OK',-41.36731,173.35481,'2016-01-05 18:53:01',1452016381,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-05 23:34:03'),
(11,488730447,1,'OK',-41.45784,173.24123,'2016-01-06 18:16:24',1452100584,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-07 08:24:06'),
(12,489245530,1,'OK',-41.57080,173.12152,'2016-01-08 06:23:38',1452230618,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-08 07:20:03'),
(13,489146979,1,'OK',-41.55176,173.16811,'2016-01-07 22:57:15',1452203835,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-08 07:20:03'),
(14,489746661,1,'OK',-41.65356,172.99733,'2016-01-09 19:04:08',1452362648,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-09 23:54:03'),
(15,490046880,1,'OK',-41.80673,172.84689,'2016-01-10 18:14:09',1452446049,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-11 00:09:23'),
(16,490876985,1,'OK',-42.05820,172.65526,'2016-01-13 04:48:01',1452656881,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-13 06:13:14'),
(17,491128561,1,'OK',-42.10017,172.65326,'2016-01-13 21:54:26',1452718466,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-13 23:14:01'),
(18,491946127,1,'OK',-42.64189,172.23726,'2016-01-16 04:09:41',1452913781,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-16 09:35:19'),
(19,491768993,1,'OK',-42.51692,172.38441,'2016-01-15 18:25:04',1452878704,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-16 09:35:20'),
(20,492464284,1,'OK',-42.70105,171.98822,'2016-01-17 20:12:02',1453057922,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-18 05:15:04'),
(21,492745154,1,'OK',-42.75708,171.74355,'2016-01-18 18:23:18',1453137798,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-18 23:09:18'),
(22,493320430,1,'OK',-43.12927,171.63002,'2016-01-20 06:51:33',1453269093,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-20 07:21:38'),
(23,493141638,1,'OK',-42.93994,171.56100,'2016-01-19 20:03:17',1453230197,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-20 07:21:38'),
(24,493677290,1,'OK',-43.34627,171.59125,'2016-01-21 03:21:00',1453342860,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-21 08:47:20'),
(25,493994325,1,'OK',-43.63405,171.64557,'2016-01-21 21:41:26',1453408886,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-22 00:40:30'),
(26,494457231,1,'OK',-43.31882,171.38515,'2016-01-22 21:40:07',1453495207,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-23 07:44:42'),
(27,495055818,1,'OK',-43.58426,171.13054,'2016-01-24 07:26:12',1453616772,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-24 10:08:24'),
(28,494821469,1,'OK',-43.42873,171.36708,'2016-01-23 18:51:37',1453571497,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-24 10:08:24'),
(29,495887402,1,'OK',-43.72580,170.73425,'2016-01-26 06:09:39',1453784979,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-26 07:10:34'),
(30,495669215,1,'OK',-43.62390,170.78990,'2016-01-25 19:22:36',1453746156,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-26 07:10:34'),
(31,496179329,1,'OK',-43.72072,170.68646,'2016-01-26 20:53:15',1453837995,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-27 00:36:13'),
(32,496419143,1,'OK',-44.00206,170.47495,'2016-01-27 09:50:39',1453884639,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-27 23:12:38'),
(33,496883508,1,'OK',-44.06305,170.21727,'2016-01-28 07:41:29',1453963289,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-28 23:27:14'),
(34,497400375,1,'OK',-44.25226,170.10001,'2016-01-29 09:16:52',1454055412,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-01-30 01:42:55'),
(35,498866253,1,'OK',-44.42783,169.66644,'2016-02-01 06:24:29',1454304269,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-01 08:11:21'),
(36,498385881,1,'OK',-44.27385,169.85075,'2016-01-31 07:37:20',1454222240,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-01 08:11:21'),
(37,499962038,1,'OK',-44.58177,169.35648,'2016-02-03 07:05:25',1454479525,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-03 07:43:40'),
(38,499395797,1,'OK',-44.47073,169.50117,'2016-02-02 06:18:37',1454390317,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-03 07:43:40'),
(39,500503692,1,'OK',-44.69841,169.13498,'2016-02-04 06:10:58',1454562658,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-04 05:52:17'),
(40,501657088,1,'OK',-44.73988,168.98436,'2016-02-06 05:50:00',1454734200,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-06 08:23:11'),
(41,502236112,1,'OK',-44.86626,168.82047,'2016-02-07 08:37:03',1454830623,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-07 23:47:20'),
(42,502771262,1,'OK',-44.94405,168.83736,'2016-02-08 07:48:00',1454914080,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-08 23:52:07'),
(43,503972978,1,'OK',-44.87169,168.14522,'2016-02-10 07:09:30',1455084570,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-10 08:53:11'),
(44,503837599,1,'OK',-44.72940,168.16869,'2016-02-10 01:08:16',1455062896,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-10 08:53:11'),
(45,503401885,1,'OK',-45.03299,168.66650,'2016-02-09 09:22:58',1455006178,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-10 08:53:11'),
(46,504583136,1,'OK',-45.05038,168.22240,'2016-02-11 06:34:06',1455168846,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-11 06:39:45'),
(47,505126467,1,'OK',-45.27615,168.17433,'2016-02-12 02:46:09',1455241569,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-12 07:50:08'),
(48,505916901,1,'OK',-45.41464,167.71832,'2016-02-13 11:25:30',1455359130,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-14 11:05:46'),
(49,506909922,1,'OK',-45.42007,167.71593,'2016-02-15 05:59:23',1455512363,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-15 05:13:33'),
(50,507277335,1,'OK',-45.55125,167.93690,'2016-02-15 21:53:00',1455569580,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-15 23:44:19'),
(51,507750070,1,'OK',-45.70488,167.98070,'2016-02-16 20:11:06',1455649866,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-17 07:35:12'),
(52,508565100,1,'OK',-45.93181,167.96127,'2016-02-18 07:50:51',1455778251,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-18 07:01:47'),
(53,509084605,1,'OK',-46.10100,167.83972,'2016-02-19 04:50:57',1455853857,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-19 06:37:36'),
(54,509610359,1,'OK',-46.26309,167.85985,'2016-02-20 04:26:29',1455938789,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-20 05:52:45'),
(55,510254362,1,'OK',-46.34938,168.01643,'2016-02-21 09:25:41',1456043141,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-21 23:44:49'),
(56,511262804,1,'OK',-46.60947,168.35690,'2016-02-23 04:23:06',1456197786,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-23 04:00:04'),
(57,511034202,1,'OK',-46.41151,168.34714,'2016-02-22 20:10:35',1456168235,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-23 04:00:04'),
(58,511682504,1,'OK',-46.61339,168.35718,'2016-02-23 22:01:37',1456261297,'Jusque là, tout va bien ! Va sur spot.lutran.fr pour voir ma progression :)\r\n\r\n@Clara: <3','GOOD','2016-02-23 23:13:21');
/*!40000 ALTER TABLE `messages` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `posts`
--
DROP TABLE IF EXISTS `posts`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `posts` (
`id_post` int(10) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(100) DEFAULT NULL,
`content` longtext,
`led` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id_post`)
) ENGINE=InnoDB AUTO_INCREMENT=143 DEFAULT CHARSET=utf8mb4;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `posts`
--
LOCK TABLES `posts` WRITE;
/*!40000 ALTER TABLE `posts` DISABLE KEYS */;
INSERT INTO `posts` VALUES (1,'françois','Message test','2015-12-27 12:29:05'),
(2,'le viking','Merry Christmas, Joyeux Noël & Feliz Navidad .. ;-)','2015-12-27 17:38:28'),
(3,'françois','C\'est parti !!','2015-12-28 08:09:24'),
(4,'clarita <3','Nice :) Have a good trip carino','2015-12-28 20:57:00'),
(5,'fais \"gaffe\" on te surveille sur notre écran de contrôle','fais \"gaffe\" on te surveille sur notre écran de contrôle','2015-12-30 21:06:56'),
(6,'françois','Le bateau devrait me déposer derrière les montagnes au fond de la photo. Ensuite, il y a plus qu\'a marcher !','2015-12-31 05:29:25'),
(7,'dany','Happy new year! Joyeuse nouvelle année. Affute tes crampons, remonte tes chaussettes, remplis ta gourde, ferme bien ton sacverifie une dernière fois ta check list et bonne route !,','2015-12-31 12:41:54'),
(8,'dominique et alain','Bonne année et bonne randonnée. Tu vas mettre en pratique les consignes pour bien faire PIPI dans la nature sans arroser tes chaussures, attention au sens du vent. Gros bisous','2015-12-31 14:11:52'),
(9,'martin','Bonne route mec et meilleurs voeux pour 2016 ;)','2015-12-31 14:41:48'),
(10,'clara','Disfrutarlo cariño - forget about everything el de, only your feet and the way. I love you','2015-12-31 22:15:45'),
(11,'clara','Wow......','2016-01-01 04:01:40'),
(12,'el peruano :)','Bonne année et bonne marche veinard ! Disfruta tu caminata !','2016-01-01 10:04:44'),
(13,'alain','Coucou françois, pour garder la forme tu dois réduire les 2 ou 3 prochaines étapes. Ca soulagera le dos et les pieds ! Bon courage','2016-01-03 19:31:45'),
(14,'dominique','Pour les pieds mouillés et les ampoules, mettre de l\'huile + qques goutte de huile de lavande; attention ne pas en mettre près des ampoules car les pansements ne colleront pas .On va prier pour que le soleil revienne vite.bisous mon chéri.','2016-01-03 19:32:24'),
(15,'dany','Tu as des ampoules François ??? ça m\'étonne pas de toi, tu as tjs été une lumière :) Profites-en pour prendre de belles photos bien éclairées','2016-01-03 20:13:01'),
(16,'françois','Coucou ! Le soleil est revenu apres 2 jours de pluiern continue. J\'ai compris pourquoi ils appelaient ça \"rain forest\" ici...petite journée aujourd\'hui avant d\'attaquer le dur. Ah, j\'oubliais: Bonne année à tout le monde :)','2016-01-03 23:56:54'),
(17,'françois','Voila, ca ressemble à ça de l\'intérieur. Dany, t\'inquiète je flashouille :p','2016-01-04 00:00:13'),
(18,'plateau givaudan','Tout le monde est en train de baver ... Amuse toi bien ;)','2016-01-04 11:06:35'),
(19,'clara','That picture brings me a lot of memories :-) <3','2016-01-04 20:51:22'),
(20,'françois','La montagne arrive à grand pas :)','2016-01-06 04:09:19'),
(21,'clara','Wow I am impressed cariño... Ca a pousse beaucoup la barbe!!! Tu es trop beau <3','2016-01-06 07:09:28'),
(22,'dany','Tu voulais dire la montagne arrive à \"Aragorn\", non ?','2016-01-06 07:12:16'),
(23,'dany','la barbe pousse mais le bronzage c\'est pas encore çà :)','2016-01-06 07:13:54'),
(24,'alain','Quel paysage ! Tu dois te régaler sur ce parcours en crête, avec le beau temps : Veinard !','2016-01-06 17:36:52'),
(25,'christophe','Tu n\'étais pas censé te raser la tête avant ton départ ?','2016-01-07 09:29:20'),
(26,'yann','J\'ai pensé à toi ce matin quand j\'étais sous des trombes d\'eau en meule sur l\'A15.. J\'aurai préféré me faire un selfie avec un bob sous ton beau soleil!! Enjoy!','2016-01-07 12:16:30'),
(27,'clara','hehehe Christophe, je pense qu\'il n\'avait pas le courage de laisser mon péré lui raser la tete completement... :p','2016-01-07 13:01:40'),
(28,'dominique','je me regale des paysages que tu traverses, pas trop de moustiques?rencontres tu du monde?','2016-01-08 11:01:11'),
(29,'martin','Pendant ce temps, à Verá Cruz...;) bonne continuation, j\'espère que la cheville va mieux et que le temps se maintient. Bonne route!','2016-01-09 18:47:26'),
(30,'dany','Ca y est, la première semaine est passée. Y a plus qu\'à dérouler tranquille maintenant. :)','2016-01-09 21:09:42'),
(31,'françois','Coucou ! Ca y est, je viens de finir la première partie du trek (spécial montagne : pas de réseau / douche, mais de super paysages !). Je n\'ai pas de problème de cheville mais de chaussures lol, la chaussures droite se décolle. Je tente la super glue ce soir :) sinon niveau temps c\'est tres mitigé. Environ 2 jours de beaux pour un de pluie. Et ici la pluie c\'est pas de la tarte ! Mais bon il fait bien 28C aujourd\'hui donc ca va, je me plains pas. Retour à la civilisation à St Arnaud (ils prononcent le \"d\" ici, c\'est bizare) avant d\'attaquer la prochaine étape. Je vous upload quelques photos :) bisous','2016-01-10 04:33:14'),
(32,'françois','Et pour le bronzage, ouais bah j\'y travaille ! Avec ma creme 50+, ca va pas aller très vite !','2016-01-10 04:34:14'),
(33,'dany','Clara, c\'était une excellente idée de lui offrir un appareil photo! Dire qu\'il hésitait à en prendre un.','2016-01-10 09:22:21'),
(34,'clara','Lol oue!! :-D là je suis vraiment content qu\'il aie amené l\'appareil... Des qu\'il s\'approche plus au sud de l\'île on va tous baver avec les passages qu\'il va rencontrer ... :-)','2016-01-10 11:03:45'),
(35,'clara','Cariño, dont worry too much about your shoes. Worst case scenario you\'ll find for sure new ones at Queenstown :-) Enjoy your night in a warm confy bed and stay as long as you can in the shower!! I love you <3','2016-01-10 11:08:32'),
(36,'alain','J\'espère que tu as bien profité de ta nuit confortable dans la civilisation. C\'était une semaine \"costaud\" avec de bons dénivellés mais de beaux paysages de montagne. la semaine prochaine a l\'air plus \"cool\", profites-en ! Bisous.','2016-01-10 17:09:27'),
(37,'françois','Oui c\'est la relâche aujourd\'hui ^^. J\'ai trouvé des édelweiss (en tout cas elle ressemblait beaucoup). Hier soir, un hotel a eu la bonne idée de proposer un bbq illimité, ah, l\'erreur !','2016-01-11 00:15:29'),
(38,'dany','Bonne progression dis donc, surtout à cloche pied avec une chaussure décollée !','2016-01-13 08:44:56'),
(39,'dominique','Jolies les Edelweiss ! j\'en ai observée une seule fois en Savoie.Les chaussures tiennent elles après réparations?s','2016-01-13 08:57:49'),
(40,'geoff','Tu n\'as pas croisé avec des gens style \"Deliverance\"? Cet parti de NZ est reputé :-) Courage Francois, tu fais du progrés impressionant - plus de photos stp!','2016-01-13 17:13:27'),
(41,'stéphane','Hello, ben tu avances bien ! Si tu sens des autochtones dans le trip \"Delivrance\" ... évite d\'imiter le cochon ... Bah de notre côté c\'est moins exotique : on est allé vérifier que le Komptoir était tjs ouvert ! Bonne marche ! PS : et Georges, il est impressionné du coup ??','2016-01-14 09:19:59'),
(42,'guillaume','Coucou mon poulet ! on a pense à toi hier au Komptoir (moins exotique que les kiwis!!) T\'as l\'air d\'avancer assez vite, c\'est trop cool. Bon courage on pense à toi. bises','2016-01-14 20:36:51'),
(43,'alain','Quelle étape de \"OUF\" ! Aujourd\'hui dernière étape avant \"la grande bouffe\"! J\'espère que le soleil est au rdv. Bisous','2016-01-15 18:06:25'),
(44,'doby','Salut le Franz, ca a l\'air cool ton trip, tu as l\'air de prendre beaucoup de plaisir. Contine a envoyer des news, c\'est cool de pouvoir suivre ton periple ! La bise mon pote!!!','2016-01-17 17:22:10'),
(45,'françois','Coucou ! De retour à la civilisation après 8 jours (dont 5 sous la pluie). Heureusement la partie la plus haute etait au début et avec du beau temps. De jolis dénivelés et des belles photos. Je devrais aoot du free wifi cet aprèm donc je rajouterais des photos :) @Stéphane: très bonne question ! Tiens, P\'pa, tu peux montrer le site à Canta steup ? Bon aller je vais chercher une brasserie pour me rengraisser avant la prochaine étape, et boire une petite chopine !','2016-01-18 23:17:16'),
(46,'françois','Évidemment je ne mets que les photos des jours où il faisait beau ... Ce qui n\'est pas réellement représentatif !','2016-01-19 04:00:18'),
(47,'françois','Petite sélection des \"ponts\" suspendus le long du trek :p','2016-01-19 04:12:54'),
(48,'françois','Ce que l\'on voit pas sur la dernière photo, c\'est que l\'eau est à 35 degrés. J\'ai fais un petit plouf dansce spa naturel ^^','2016-01-19 06:51:11'),
(49,'tacou','Hey Franz ! Visiblement, tu es sur le bon chemin. On dit que c\'est le voyage qui fait l\'homme et pas l\'inverse (c\'est pas de moi !). Bonne route et bravo pour ton site aussi, tu devrais le packager (en Open Source :) ), ça intéresserait plein de gens. Biz','2016-01-19 07:56:32'),
(50,'dany','A la vitesse à laquelle tu vas, N\'oublie pas de bifurguer vers le sud sinon tu vas te retrouver à la baille à la Kumara Junction!','2016-01-19 10:13:28'),
(51,'alain','Super les ponts suspendus, tu as pu les tester en les secouant dans tous les sens comme tu aime bien ! Un spa pour toi tout seul, quel privilège. Profite bien de ces beaux paysages et j\'espère que le beau temps va s\'installer. Bisous','2016-01-19 19:56:12'),
(52,'françois','De nouveau dans une ville avec du wifi :) je suis bien loin du chemin mais c\'est la ville la plus proche pour faire les courses, et aussi éviter de traverser la Rakaia River à la nage... Journée \"zéro\" comme ils appelent ça ici (zero day), donc je passe mon temps à manger et dormir (principalement pour digérer). Ce soir (vers 7h du mat vendredi matin chez vous), je teste le fish & chips local, comme je le fais dans chaque ville ^^. Demain matin, il faudra que je trouve une voiture allant vers l\'endroit ou j\'ai laissé le chemin, ce qui est bien plus dur que d\'allr en ville vu que c\'est souvent des culs-de-sac. Sur la prochaine section, jusqu\'à Tekapo, je devrais passer par le col le plus haut du trek, je croise les doigts pour le temps ! Bon aller, à dans 5/6 jours !','2016-01-22 04:14:49'),
(53,'dany','Tu sais que tu as un mode panorama sur ton appareil photo ? Profite de ton point de vue élevé pour nous faire de belles pix. Tu avances vraiment à un bon rythme, faudrait pas que la partie en voiture te ralentisse.! Bon courage.','2016-01-22 06:46:17'),
(54,'jul.','Ah justement depuis le début je me demandais comment t\'allais faire pour passer la rivière, j\'ai eu ma réponse ^^ Biz !','2016-01-23 16:02:28'),
(55,'dominique','Alors t\'es au milieu de nul part pour camper au 4358426? bon nous espérons que tout va bien et que le bonhomme et les chaussures tiennent le coup.Nous avons reçu José Manuel et Maria en grande forme , Clara te racontera.On t\'embrasse bien fort.p','2016-01-24 11:27:29'),
(56,'geoff','Le fish/chips NZ est excellent, c\'est du requin, de memoire! Rare de trouver un Francais qui l\'apprecie! Comment t\'as passé la riviere Rangitata? Je commence d\'etre jaloux de ta jeunesse et liberté de faire ces exploits! :-) Bravo!','2016-01-26 16:18:18'),
(57,'françois','Coucou ! Je suis à la stag saddle, où il y a (je ne sais pas comment du signal 3G !). Grosse déception, je n\'y vois absolument rien. Je suis dans un brouillard bien humide depuis 2h, c\'est pas la joie :D mais bon, si je vais assez vite, j\'aurais les pieds au chaud ce soir. Sinon, j\'ai traversé la rivière rangitata à pieds, c\'était pas facile(environ 3h30 pour traverser les 10km de rivière, par petits bouts). Dany, oui j\'ai vu le mode panorama, ça marche bien :) bon la je vais pas en avoir besoin avec ce temps pourri ! Bon aller je vous laisse, je me gèle les doigts :p','2016-01-26 20:12:36'),
(58,'alain','Tu n\'oublies pas que le Mont Cook arrive à grand pas ! L\'alignement du lac, du glacier et du sommet, c\'est une image à ne pas rater. j\'espére que le beau temps sera au rendez vous, sinon tu peut patienter quelques jours avec l\'avance que tu as. Gros Bisous','2016-01-26 20:20:13'),
(59,'françois','Pour l\'instant, ni d\'avance ni de retard, juste dans les temps. Normalement, j\'aurais droit à la super vue dans 2 jours. Mais il pleut pour l\'instant, mais bon je mange une part monstrueuse de gateau au chocolat donc ça va :p sur la photo d\'en dessous, la petite coline ridicule au fond, c\'est Edoras. Ils ont malheureusement viré les décors :/ j\'ai pas pris de photoplus proche car j\'étais occupé à traverser la rivière ^^ (ps: Edoras = capitale du Rohan dans le seigneur des anneaux, il y a le roi Theoden)','2016-01-27 04:51:09'),
(60,'françois','C\'est paq gagné niveau temps. L\'equivalent de \"va-t-il pleuvoir dans l\'heure ?\" de Météo France en NZ: \"rain radar\" sur le site metservice.com','2016-01-27 04:58:50'),
(61,'françois','Mais la pluie fait des photos sympas aussi :)','2016-01-27 05:07:34'),
(62,'françois','Heureusement, il y a la bouffe :p (et oui je n\'avais demandé qu\'une seule part de frite dans le pub, mais ils ont du me voir venir avec mon gros sac et ils se sont dit: \"lui, il a faim !\").Cce soir je teste le fish & chips de Tekapo !!','2016-01-27 05:15:54'),
(63,'françois','Mon campement il y a 3 jours. Entre Rakaia et Rangitata :) pour ceux que ça intéresse, je recommande chaudement (ahah) le réchaud Jetboil Minimo. C\'est super rapide et ca consomme très peu de gaz.','2016-01-27 05:28:31'),
(64,'françois','Il y a quelques améliorations côté météo : je vois le ciel ! Petite photo du lac Pukaki du coup. Si les nuages partent je tenterais de faire un petit à côté : un petit coup de stop pour aller voir le Mount Cook de près.','2016-01-28 05:47:18'),
(65,'françois','Le temps était encore bien nuageux ce matin jusqu\'à ce qu\'un petit trou se crée, juste sur le Mount Cook ! Ensuite, ça a été de mieux en mieux :) La dernière photo est le Tasman glacier, le plus long de Nouvelle Zélande : 27km ! Il se finie sur un lac avec des petits icebergs ^^','2016-01-29 08:25:41'),
(66,'dany','Whaouuuu ! Magnifique !','2016-01-29 10:13:29'),
(67,'dominique','Quelle chance pour toi d\'avoir pu voir le mont Cook mon coco!bisous','2016-01-29 14:45:51'),
(68,'mimine','des énormes bisous de ta little sis\' qui vient de comprendre comment ton site marche! ;) ça fait rêver!! keep going keep smiling','2016-01-30 20:58:28'),
(69,'mimine','des énormes bisous de ta little sis\' qui vient de comprendre comment ton site marche! ;) ça fait rêver!! keep going keep smiling','2016-01-30 20:58:28'),
(70,'clara <3','Hey!!! When you get to lake wanaka think of meeee!!! I loved that lake! (Though I was doing most of the driving, it was an amazing view! Enjoy it!! - I love you','2016-02-03 07:48:57'),
(71,'adrien','Chouettes Photos François ! Je pense bien à toi, bon courage pour ton périple !','2016-02-03 09:37:44'),
(72,'yann','Magnifiques, les paysages, ça change de notre vue imprenable sur le smog au-dessus de La Défense.. Profite bien!','2016-02-03 10:14:59'),
(73,'un ch\'ti voyageur amateur anonyme','Salut François, Comme tu t\'approches à grand-pas de Queenstown, capital mondial du sport extrême, et que tu ne peux pas te contenter de traverser le lac en canoé, le moment est venu de tinformer que, après avoir longuement hésité avec la « Thrillogy Bungy Jumping AJ Hacket », tu tes inscrit à l « Aérobatic stunt flight » ! N\'oublies pas ton \"vomit bag\" (amis de la poésie ...), il arrive que çà soit utile ! ;)','2016-02-03 12:32:36'),
(74,'françois','Petite pause dans un café equipé d\'un wifi ^^ super rando ceq derniers jours. Plus que 30km jusqu\'à Wanaka (mais j\'ai le temps de me motiver pour les sports extrêmes de queenstown Mr. Anonymous :p). J\'ai pu faire un détour par Mount Cook village il y a quelques jours grâce à un couple d\'Estoniens. J\'ai d\'ailleur eu droit à quelques photos sur leur blog : http://www.leegi.jassu.ee et une photo sympa de moi que je vais uploader. Bon aller, la plus dur partie du chemin arrive maintenant : arriver a ne pas recommander un truc à manger et me lever de ma banquette ! C\'est pas gagné.','2016-02-03 21:46:02'),
(75,'françois','Et pour Papa qui veut savoir quelle rype de vaches ils ont là-bas. Ben ils ont des vaches... Euh... noires et blanches ? Avec une photo ça sera plus simple :)','2016-02-03 21:56:59'),
(76,'gc3','Bisoux ma biche','2016-02-04 09:55:18'),
(77,'alain','ça y est, tu es arrivé dans la ville aux bons resto ! un bon lit un bon repas et c\'est reparti, il ne faut pas trop t\'habituer au confort. Je vois que tu fais de belles rencontres : des estoniens; des vaches. Pour la race de la vache il me manque des infos, peux tu revenir en arrière pour faire quelques photos plus précises ! et en plus elle n\'a pas de corne ? Bon ceci dit les étapes me paraissaient bien \"costaux\" Un peu de repos sera le bien venu. A+x','2016-02-04 17:57:36'),
(78,'françois','Petit groupe de photo depuis Breast Hill, qui surplombe le lac Haweā (et au fond le lac Wanaka). Une petite photo du lever de soleil au moment je partais hier matin de la Pakituhi Hut. Ca y est Dany, j\'ai utilisé le mode \"pano\" (Obééé, Astérix). Les 2 tests sont séparés par 2 autres photos mais elles se suivent. Je n\'ai pas pu avoir tout le paysage sur une seule photo. Je n\'ai pas de grand écran pour voir le résultat mais ça à l\'air pas mal :)','2016-02-04 23:24:02'),
(79,'françois','Je suis dans la région des lacs (comme on peut le voir ^^) mais paradoxalement la natire devient très sèche. Heureusement les fjords arrivent à grand et le grand retour de la pluie avec !','2016-02-04 23:40:33'),
(80,'clarita','bon avec ces photos du lac... c\'est la que je regrete pas avoir quitte WD pour aller avec toi... ce lac a quelque chose... lucky bastard :) -- I love you carino :p','2016-02-06 18:53:22'),
(81,'alain','Tu vas bien dormir cette nuit avec plus de 2000m de dénivellé que tu t\'es \"tapé\". Demain bon repas! bon lit ! le paradis ! Profites en bien avant le dernier parcours montagneux. Bon courage !','2016-02-07 10:26:28'),
(82,'françois','Petit challenge hier ^^','2016-02-07 20:26:10'),
(83,'françois','En effet, plus de 2000m au final. J\'ai changé les points se chutes, et finie avec 2300m de dénivelé positif. Je suis un peu mort aujourd\'hui mais c\'est une petite journèe jusqu\'à Arrowtown. Il fallait être à l\'heure pour le Super Bowl ! Je n\'ai pas oublié de prendre quelque photos tout de même :)','2016-02-08 01:42:11'),
(84,'dany','YBon ben maintenant y\'a plus qu\'à faire du tout schuss jusqu\'à l\'arrivée! Y\'a plus vraiment de dénivelés sur le reste du trajet. Bon courage','2016-02-08 10:54:31'),
(85,'dominique','Beau temps et photos extras ! cool raoul maintenant et retour à la civilisation...Attention à l\'indigestion ...plein de bisous','2016-02-08 14:07:13'),
(86,'françois','Meri pour tous vos messgaes. Ca me fait super plaisir de les lire lorsque je reviens en ville. Aujourd\'hui, direction Queenstown. Je ne vais pas me jeter dans le vide suspendu à un fil comme cela a été gentiment suggeré, en revanche, je vais faire un petit détour pour restr le plus longtemps possible dans les montagnes : le Routeburn Track. Une trentainede kilomètres au nord du lac Wakatipu. Rando facile \"manucuré\" pour les touristes avec des ponts au dessus des rivières et tout et tout. Donc pas d\'inquiétude si je sors du chemin vert, j\'y reviens très vite ^^','2016-02-08 21:58:46'),
(87,'françois','Ah, et j\'ai oublié de faire les présentations : la photo 48 est le mont Aspiring (3000+) et 49 est le lac Wanaka vu à plus de 30 bornes (photo prise hier).','2016-02-08 21:59:46'),
(88,'françois','J\'ai trouvé de nouvelles vaches sur mon chemin l\'autre jour, avec des demi-cornes. C\'est ce que j\'ai trouvé de mieux :D','2016-02-08 22:07:49'),
(89,'françois','Depuis quelques jours, j\'ai un co-randonneur avec moi : Stephan (Australien). On rigole bien','2016-02-08 22:13:44'),
(90,'alain','C\'est super si tu as un coéquipier, tu vas pouvoir parler \"engliche\" à ton aise. J\'espère que le temps est correct et que tu vas pouvoir profiter de la traversée du lac \"Wak...\" Bisous PS Y a t il des vaches qui ne sont pas noires? et avec des cornes type Salers ou Aubrac ?','2016-02-08 23:08:11'),
(91,'georges & janine','Nous découvrons ton parcours François, cest impressionnant et les paysages sont magnifiques (super photos). Tu as vraiment bien marché et tu nes plus très loin de larrivée (mais il en reste un bout tout de même), bravo …','2016-02-09 07:26:20'),
(92,'georges & janine','Bon courage pour les jours à venir et terminer à deux cest plus sympa, mais jespère que ton coéquipier est de ton niveau … ? Grosses bises','2016-02-09 07:27:56'),
(93,'cricri','Un début de Brokeback Mountain en perspective ? Plein de bisous de Pomponne','2016-02-09 08:20:53'),
(94,'dany','On voit le gars qui veut profiter de dénivelés jusqu\'à la dernière minute avant de passer en plaine.','2016-02-10 17:21:32'),
(95,'yann','je viens juse de decouvrirle chat!! c\'est top qu\'on puisse t\'envoyer des messages! en tout cas c\'est magnifique tout ce que tu nous envois! tu as chassé combien de lapins jusque ? mangé combien d\'escargots? ?','2016-02-11 06:44:54'),
(101,'dominique','Alors cette petite escapade en dehors de ton chemin valait le coup?pont de singe ou pas? coequipier toujours avec toi ou bien seulement les meumeu? tu pourrais dire aux agriculteurs du coin qu\'ils pourraient faire un peu varier la couleur de leur bestiaux, je sais pas violette comme celles des suisses mais emmes ne savent peut être pas enlever le papier du chocolat..au fait en manges tu au pays des kiwi toi qui aime tant le chocolat! plein de bisous','2016-02-11 08:05:28'),
(102,'jul.','Ah ça y est, ya plus que du plat maintenant :p Tu va faire un tour sur Stewart Island si t\'arrives en avance ? :D','2016-02-12 15:19:26'),
(103,'françois','Petites photos de mon détour par Routeburn Track :) du coup j\'ai du laisser Stephan à Queenstown pour aller prendre mon bus mais je vais le retrouver à Te Anau dans 1 jour ou 2. On fera surement le finish ensemble :) et peut etre en effet la Stewart Island. Il faut que je renseigne car mon hut passn\'est pas valide sur l\'ile, et puis ily a le bateau aussi. Il faut voir si ca vaut le coup','2016-02-12 22:12:11'),
(104,'mimine','Merci mon frérot d\'amour!! tes photos font rêver...mais je trouve ue ta barbe est bien raisonnable pour un aventurier. :)','2016-02-13 17:24:03'),
(105,'dominique','Clara est venu manger aujourd\'hui Samedi et nous à fait un petit condensé de tes \"aventures\"; on pense bien à toi! et ton père continu de baver...bisouss','2016-02-13 18:33:07'),
(106,'françois','Je fais ce que je peux avec la barbe ! Mais j\'ai du \"égaliser\" plusieurs fois car ça ressemblait à rien. Mais il y a des champions du monde sur le trail...','2016-02-13 19:49:26'),
(108,'françois','Je suis tombé sur un fin connaisseur de vaches en faisant du stop en direction de Te Anau : il m\'a dit qu\'il y avait 2 types de vaches pour le lait (les 2 sont noires et blanches) : les Friesian et les Galloway. Pour la viande, ils sont connus pour les Angus mais ils ont aussi des charolais. Voila !','2016-02-13 22:09:18'),
(109,'alain','Merci pour les infos, maintenant on va pouvoir aller \"frimer\" au salon de l\'agriculture ! J\'espère que ton petit WE de repos va te permettre de reprendre la dernière partie de ta progression. Il n\'y aplus qu\'à croiser les doigts (sans les écraser !) pour que le beau temps soit de la partie. J\'espère que ton coéquipier sera au rdv et bon courage !','2016-02-14 16:32:08'),
(110,'martin','Bonne route François ;) superbes photos','2016-02-16 21:40:50'),
(111,'dany','Encore un écart par rapport à la route prévue ? T\'es en train d\'inventer une nouvelle piste: la \"Te Lutaranau\" peut-être ?','2016-02-18 07:30:55'),
(112,'ludivine','Salut François. Tu a l\'air d\'avancer super vite. Les paysages donnent envie de s\'exiler :) Bon courage pour la dernière ligne droite','2016-02-18 14:47:41'),
(113,'georges','Courage François, bientôt c\'est la quille... ! J\'espère que tu profites un max et que tu termineras avec du grand beau temps ...','2016-02-20 06:14:35'),
(114,'dominique','Trop rapide notre baroudeur..bientôt le bord de mer , la paille dans le verre et les doigts de pieds en éventail , comment vas tu? mercredi soir nous dormons chez toi et mangeons avec Clara ; le lendemain tgv pour Grenoble et peut être la neige..Plein de bisous toi','2016-02-20 18:18:34'),
(115,'georges de samoreau','Bonjour François, quel cheminement sur cette ile lointaine ! Ton raid pédestre se déroule dans des paysages superbes. Profite pleinement de tes derniers jours de randonnée. Bisous .','2016-02-20 19:01:57'),
(116,'françois','Ma nouvelle équipe pour la fin du voyage :) De gauche à droite : Mike de Wellington, Raphael de Suisse et Ann d\'Allemagne. Nous sommes dans le dernier refuge du trek. Et un des plus pourris :p','2016-02-22 03:14:26'),
(117,'françois','J\'ai oublié de mettre à jour, mais depuis un mois les forêts ressemblent plutôt à ça. Un gros tas de mousse!','2016-02-22 03:17:57'),
(118,'françois','L\'océan, enfin. Et le confort des villes côtières va avec : douches régulières, supermarchés... Le luxe !','2016-02-22 03:19:59'),
(119,'françois','Le chemin est carrément sur la plage. Demain est mon dernier jour ! Je devrais enfin arriverà Bluff !!','2016-02-22 04:05:45'),
(120,'abdoul','Best of luck François! Encore qq milliers de km à pied et tu arriveras enfin...à Argenteuil ;)','2016-02-22 14:50:44'),
(121,'ahmed','Félicitations François, une très bonne performance par rapport à ton planning de départ Bravo :) Profite bien de cette Excellente et Relaxante étape','2016-02-22 17:16:29'),
(122,'yann','Ben, tu vas pouvoir te piquer une petite tête dans le Pacifique Sud, , non? Tu ne pourras pas faire bain plus austral pour le coup sauf à faire ton prochain trip en Terre Adélie! Profite bien de la dernière ligne droite.','2016-02-22 17:16:57'),
(123,'dominique','bluff , quel nom pour finir un tel parcours ! bon on attend que tu fasses un plouf à bluff ou bien un pluff à blouff...','2016-02-22 21:14:35'),
(124,'françois','La fin !!','2016-02-23 04:30:46'),
(125,'abdoul','Hey, nous fais pas le coup de blues à Bluff maintenant que c\'est la fin ;)','2016-02-23 08:31:27'),
(126,'yann','Well done, Callaghan! Profite bien des derniers jours pour un peu de farniente! Pour la bronzette, vu ta dernière photo, tu ne risques pas trop les coups de soleil a priori..','2016-02-23 08:32:14'),
(127,'dominique','bravo , you are the best! bisous','2016-02-23 08:39:04'),
(128,'georges','Hello François, j\'étais bien loin de penser que tu aurais réalisé un pareil trek à l\'autre bout de la terre et je ne dois pas être le seul ... ! Bravo, profite bien de tes derniers jours en NZS. Bon retour et j\'espère à bientôt pour voir d\'autres photos de ton superbe séjour ...','2016-02-23 10:09:41'),
(129,'alain','Ca y est tu est au bout ! Quel périple mais quelle volonté ! (tu es le digne fils de ton père! et de ta mère!) Bisous et va tremper un peu les pieds dans l\'eau salée et peu-être plus ?','2016-02-23 10:38:21'),
(130,'jul.','gg bro :p','2016-02-23 12:40:59'),
(131,'françois','En effet, une petite semaine à glandouiller avant de reprendre l\'avion :) Je voulais continuer le périple sur Stewart Island (l\'ile au sud de Bluff) mais le temps est pourri, et j\'ai assez marché dans la boue (ça, c\'est la partie que l\'on ne voit pas sur les photos). Je vais profiter de mes derniers pour visiter le sud est et aller au cinéma (films en VO sans les sous-titres, le pied). Pour la trempette, c\'est pas exactement tentant : l\'eau est bien froide ici et la plage est pleine de galets, mais je pourrais remonter un peu vers le nord pour récupérer le sable fin :) je suis revenu à Invercargill en stop ce matin. Environ 20 minutes de voiture contre 9 heures de marche hier. D\'ici j\'étudie mes possibilités pour la suite !','2016-02-24 01:01:16'),
(132,'dominique','Après l\'effort le réconfort, tu as raison.Profites car tu vas trouver le début du printemps chez nous! Un film à aller voir\" the revenant\" l\'histoire d\'un trappeur (ça va te changer) dur, mais magnifiquement filmé dans la nature et qui defend la cause des indiens....bisous t','2016-02-24 09:51:05'),
(133,'marion','you are the best my dear Bro!! des bisous','2016-02-24 20:15:17'),
(135,'geoff','Man, that is an impressive achievement, well done! Bad planning to finish in the south though, chilling out down there means exactly that- it\'s bl*ody chilly!','2016-02-26 12:57:32'),
(136,'clarita','I love you carino, cant wait to see you','2016-02-27 12:33:04'),
(137,'guillaume&arnaud','Felicitations ! onboirra des coups pour fêter ca !','2016-02-28 13:04:48'),
(138,'françois','That\'s right! I\'m was freezing my ass off in Bluff so i took a bus north stopping a few times along the coast (Dunedin/Oamaru/Cheistchurch). I am no heading to Arthur\'s Pass for a last off-track hike before taking off on Friday.','2016-02-29 21:38:00'),
(139,'alain','Qu\'ils sont beaux tous les deux ! Profitez bien de votre semaine \"aux petits soins\" Bisous','2016-03-09 17:20:09'),
(140,'dany','Qui a rejoint l\'autre? et finalement ?','2016-03-08 09:37:38'),
(141,'françois','Petit coucou de Madère :) Derniers instants de repos avant la reprise !','2016-03-09 17:23:24'),
(142,'françois & clara','Ah oui Dany, désolé, j\'ai sauté une étape : Après 31h d\'avion et 8 films, je suis arrivé à CDG samedi midi ^^ Nous sommes reparti lundi matin pour une petite semaine à la plage en amoureux :)','2016-03-09 17:41:18');
/*!40000 ALTER TABLE `posts` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `spots`
--
DROP TABLE IF EXISTS `spots`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `spots` (
`id_spot` int(10) unsigned NOT NULL AUTO_INCREMENT,
`ref_spot_id` varchar(10) DEFAULT NULL,
`name` varchar(100) DEFAULT NULL,
`model` varchar(20) DEFAULT NULL,
`led` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id_spot`),
KEY `ref_spot_id` (`ref_spot_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `spots`
--
LOCK TABLES `spots` WRITE;
/*!40000 ALTER TABLE `spots` DISABLE KEYS */;
INSERT INTO `spots` VALUES (1,'0-2489517','Francois','SPOT3','2015-12-16 14:23:02');
/*!40000 ALTER TABLE `spots` ENABLE KEYS */;
UNLOCK TABLES;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2018-04-10 21:27:41
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-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;
-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';
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

-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.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

-32
View File
@@ -1,32 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M5041 5784 c366 -384 572 -817 630 -1323 17 -156 7 -495 -20 -641
-78 -415 -250 -717 -1091 -1915 -108 -154 -338 -482 -510 -730 -172 -247 -328
-469 -346 -492 -21 -25 -56 -52 -91 -68 -48 -23 -66 -27 -127 -23 -46 2 -86
11 -113 24 l-42 21 36 -44 c47 -58 122 -116 191 -150 50 -25 68 -28 152 -28
88 0 100 2 163 34 80 40 167 121 254 236 102 137 967 1375 1158 1660 429 639
578 959 636 1365 20 140 17 485 -5 630 -37 237 -101 440 -205 655 -162 331
-404 620 -696 830 -33 24 -28 16 26 -41z"/>
<path d="M2750 5673 c-63 -22 -143 -75 -197 -131 -192 -196 -318 -616 -298
-990 9 -174 36 -314 118 -607 l34 -120 316 -3 317 -2 0 62 c1 81 22 199 49
265 11 29 42 87 69 130 143 230 186 366 185 588 -1 420 -140 736 -356 809 -60
20 -177 20 -237 -1z"/>
<path d="M4017 5056 c-174 -62 -301 -280 -348 -591 -19 -129 -17 -337 4 -425
32 -136 85 -257 159 -368 81 -119 127 -268 128 -410 l0 -62 317 2 316 3 34
120 c106 374 136 590 115 816 -22 225 -66 391 -153 566 -48 97 -74 138 -131
198 -39 42 -90 87 -115 102 -99 58 -241 79 -326 49z"/>
<path d="M2412 3548 c3 -109 7 -129 30 -173 120 -232 445 -231 559 0 28 58 32
76 37 180 l5 115 -318 0 -317 0 4 -122z"/>
<path d="M3960 2962 c0 -234 106 -368 300 -380 98 -5 168 20 233 83 75 75 91
119 95 263 l4 122 -316 0 -316 0 0 -88z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

-19
View File
@@ -1,19 +0,0 @@
{
"name": "Spot",
"short_name": "Spot",
"icons": [
{
"src": "/images/icons/android-chrome-192x192.png?v=GvmqYyKwbb",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/images/icons/android-chrome-512x512.png?v=GvmqYyKwbb",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.
-142
View File
@@ -1,142 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:osb="http://www.openswatchbook.org/uri/2009/osb"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 512 512"
version="1.1"
id="svg4"
sodipodi:docname="spot.svg"
width="512"
height="512"
inkscape:version="0.92.2 (5c3e80d, 2017-08-06)"
inkscape:export-filename="C:\Users\francois\Downloads\footprint_mapbox.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96">
<metadata
id="metadata10">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs8">
<linearGradient
id="linearGradient4520"
osb:paint="solid">
<stop
style="stop-color:#6dff58;stop-opacity:1;"
offset="0"
id="stop4518" />
</linearGradient>
<filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter4696">
<feFlood
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4686" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4688" />
<feGaussianBlur
in="composite1"
stdDeviation="3"
result="blur"
id="feGaussianBlur4690" />
<feOffset
dx="3"
dy="3"
result="offset"
id="feOffset4692" />
<feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4694" />
</filter>
<filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter5223">
<feFlood
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood5213" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite5215" />
<feGaussianBlur
in="composite1"
stdDeviation="1.2"
result="blur"
id="feGaussianBlur5217" />
<feOffset
dx="1.2"
dy="1.2"
result="offset"
id="feOffset5219" />
<feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite5221" />
</filter>
</defs>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1017"
id="namedview6"
showgrid="false"
inkscape:snap-grids="true"
inkscape:zoom="1.3037281"
inkscape:cx="248.69883"
inkscape:cy="256.06919"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg4"
units="px" />
<path
id="path4698"
d="M 14.766749,479.35437 C 5.685625,466.18943 4,464.83831 4,460 c 0,-6.62744 5.372562,-12 11.999999,-12 6.627437,0 11.999998,5.37256 11.999998,12 0,4.83831 -1.685625,6.18943 -10.766748,19.35437 -0.595937,0.86087 -1.870625,0.86081 -2.4665,0 z"
inkscape:connector-curvature="0"
style="fill:#6dff58;fill-opacity:1;stroke:none;stroke-width:0.06249999;stroke-opacity:1;filter:url(#filter5223)"
inkscape:label="marker"
transform="matrix(13.333333,0,0,13.333333,42.666662,-5930.6664)" />
<path
id="shoes"
d="m 221.87402,255.01429 v -11.375 h -45.49995 v 11.375 c 0,12.56578 10.18419,22.74983 22.74997,22.74983 12.56579,0 22.74998,-10.18405 22.74998,-22.74983 z m 90.99975,68.24979 c 12.56579,0 22.74982,-10.18419 22.74982,-22.74998 v -11.37499 h -45.4998 v 11.37499 c 0,12.56579 10.18419,22.74998 22.74998,22.74998 z M 267.37383,203.30789 c 0,12.40941 4.66373,27.07248 11.37498,37.22819 5.82257,8.81203 11.37498,15.8254 11.37498,37.22804 h 45.4998 l 5.67685,-20.45001 c 2.58791,-9.31681 4.66375,-18.84333 5.34988,-28.54756 0.8216,-11.6203 0.23791,-23.31873 -2.20387,-34.68656 -8.36069,-38.91663 -26.87344,-52.81544 -42.94766,-52.81544 -22.74998,0 -34.12496,29.92325 -34.12496,62.04334 z m -99.81881,-54.72772 c -2.44205,11.36784 -3.02861,23.06626 -2.20402,34.68644 0.68639,9.70437 2.7621,19.23086 5.34624,28.54769 l 5.67683,20.45002 h 45.49995 c 0,-21.39914 5.55243,-28.41615 11.37498,-37.22818 6.71124,-10.15572 11.37499,-24.81865 11.37499,-37.22807 0,-32.12008 -11.37499,-62.043305 -34.12495,-62.043305 -16.07422,0 -34.58699,13.898815 -42.94402,52.815405 z"
inkscape:connector-curvature="0"
style="fill:#326526;fill-opacity:1;stroke-width:0.35546762" />
</svg>

Before

Width:  |  Height:  |  Size: 5.2 KiB

-137
View File
@@ -1,137 +0,0 @@
<?php
/**
* To convert a gpx file:
* 1. Add file <file_name>.gpx to geo/ folder
* 2. Assign file to project: UPDATE projects SET geofile = '<file_name>' WHERE id_project = <id_project>;
*/
class Converter extends PhpObject {
public function __construct() {
parent::__construct(__CLASS__, Settings::DEBUG);
}
public function convertToGeoJson($sFileName) {
$oGpx = new Gpx($sFileName);
$oGeoJson = new GeoJson($sFileName);
$oGeoJson->setTracks($oGpx->getTracks());
$oGeoJson->saveFile();
}
public static function isGeoJsonValid($sFileName) {
$bResult = false;
$sGeoJsonFilePath = Geo::getFilePath($sFileName, GeoJson::EXT);
if(file_exists($sGeoJsonFilePath)) {
if(filemtime($sGeoJsonFilePath) > filemtime(Geo::getFilePath($sFileName, Gpx::EXT))) $bResult = true;
}
return $bResult;
}
}
class Geo extends PhpObject {
const GEO_FOLDER = 'geo/';
protected $asTracks;
protected $sFilePath;
public function __construct($sFileName) {
parent::__construct(__CLASS__, Settings::DEBUG);
$this->sFilePath = self::getFilePath($sFileName, static::EXT);
$this->asTracks = array();
}
public static function getFilePath($sFileName, $sExt) {
return self::GEO_FOLDER.$sFileName.$sExt;
}
}
class Gpx extends Geo {
const EXT = '.gpx';
public function __construct($sFileName) {
parent::__construct($sFileName);
$this->parseFile();
}
public function getTracks() {
return $this->asTracks;
}
private function parseFile() {
$oXml = simplexml_load_file($this->sFilePath);
foreach($oXml->trk as $aoTrack) {
$asTrack = array(
'name' => (string) $aoTrack->name,
'desc' => str_replace("\n", '', ToolBox::fixEOL((strip_tags($aoTrack->desc)))),
'color' => (string) $aoTrack->extensions->children('gpxx', true)->TrackExtension->DisplayColor,
'points'=> array()
);
foreach($aoTrack->trkseg as $asSegment) {
foreach($asSegment as $asPoint) {
$asTrack['points'][] = array(
'lon' => (float) $asPoint['lon'],
'lat' => (float) $asPoint['lat'],
'ele' => (int) $asPoint->ele
);
}
}
$this->asTracks[] = $asTrack;
}
}
}
class GeoJson extends Geo {
const EXT = '.geojson';
public function __construct($sFileName) {
parent::__construct($sFileName);
}
public function setTracks($asTracks) {
$this->asTracks = $asTracks;
}
public function saveFile() {
$sContent = json_encode($this->getGeoJson());
file_put_contents($this->sFilePath, $sContent);
}
private function getGeoJson() {
$asTracks = array('features'=>array());
foreach($this->asTracks as $asTrackProps) {
//Color mapping
switch($asTrackProps['color']) {
case 'DarkBlue': $sType = 'main'; break;
case 'Magenta': $sType = 'off-track'; break;
case 'Red': $sType = 'hitchhiking'; break;
default: continue 2; //discard tracks
}
$asTrack = array(
'type' => 'Feature',
'properties' => array(
'name' => $asTrackProps['name'],
'type' => $sType,
'description' => $asTrackProps['desc']
),
'geometry' => array(
'type' => 'LineString',
'coordinates' => array()
)
);
foreach($asTrackProps['points'] as $asPoint) {
$asTrack['geometry']['coordinates'][] = array_values($asPoint);
}
$asTracks['features'][] = $asTrack;
}
return $asTracks;
}
}
-125
View File
@@ -1,125 +0,0 @@
<?php
/**
* Feed Class
* Also takes care of spot (device) & messages
*
* To add a new feed, create a new records in the feed table:
* INSERT INTO feeds (ref_feed_id, id_spot, id_project) VALUES ('<feed_id>', '<related_spot_id>', '<related_project_id>');
*/
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';
//DB Tables
const SPOT_TABLE = 'spots';
const FEED_TABLE = 'feeds';
const MSG_TABLE = 'messages';
/**
* Database Handle
* @var Db
*/
private $oDb;
private $iFeedId;
private $sRefFeedId;
private $iLastUpdate;
public function __construct(Db &$oDb, $iFeedId) {
parent::__construct(__CLASS__, Settings::DEBUG);
$this->oDb = &$oDb;
$this->setFeedId($iFeedId);
}
public function setFeedId($iFeedId) {
$this->iFeedId = $iFeedId;
$asFeed = $this->oDb->selectRow(self::FEED_TABLE, $this->iFeedId);
$this->sRefFeedId = $asFeed['ref_feed_id'];
$this->iLastUpdate = strtotime($asFeed['last_update']);
}
public function getMessages($asActivePeriod = array()) {
$asInfo = array(
'select' => array('id_message', 'ref_msg_id', 'type', 'latitude', 'longitude', 'site_time', 'unix_time'),
'from' => self::MSG_TABLE,
'constraint'=> array(Db::getId(self::FEED_TABLE) => $this->iFeedId),
'constOpe' => array(Db::getId(self::FEED_TABLE) => "="),
'orderBy' => array('site_time'=>'ASC')
);
if(!empty($asActivePeriod)) {
$asInfo['constraint']['site_time'] = $asActivePeriod;
$asInfo['constOpe']['site_time'] = "BETWEEN";
}
return $this->oDb->selectRows($asInfo);
}
public function checkUpdateFeed($sProjectMode) {
//Feed updated once a day in Blog Mode
if($sProjectMode == Project::MODE_BLOG && date('Y-m-d-H', $this->iLastUpdate) != date('Y-m-d-H')) $this->updateFeed();
}
private function updateFeed() {
$asData = $this->retrieveFeed();
$sLastUpdate = date(Db::TIMESTAMP_FORMAT);
if(!isset($asData['response']['errors']) || empty($asData['response']['errors'])) {
$asMsgs = $asData['response']['feedMessageResponse']['messages'];
if(array_key_exists('message', $asMsgs)) $asMsgs = $asMsgs['message'];
$asFeed = $asData['response']['feedMessageResponse']['feed'];
if(!empty($asMsgs))
{
//Update Spot Info from the first message
$asFirstMsg = array_values($asMsgs)[0];
$asSpotInfo = array(
'ref_spot_id' => $asFirstMsg['messengerId'],
'name' => $asFirstMsg['messengerName'],
'model' => $asFirstMsg['modelId']
);
$iSpotId = $this->oDb->insertUpdateRow(self::SPOT_TABLE, $asSpotInfo, array('ref_spot_id'));
//Update Feed Info and last update date
$asFeedInfo = array(
'ref_feed_id' => $asFeed['id'],
Db::getId(self::SPOT_TABLE) => $iSpotId,
'name' => $asFeed['name'],
'description' => $asFeed['description'],
'status' => $asFeed['status'],
'last_update' => $sLastUpdate
);
$iFeedId = $this->oDb->insertUpdateRow(self::FEED_TABLE, $asFeedInfo, array('ref_feed_id'));
//Update Messages
foreach($asMsgs as $asMsg)
{
$asMsg = array(
'ref_msg_id' => $asMsg['id'],
Db::getId(self::FEED_TABLE) => $iFeedId,
'type' => $asMsg['messageType'],
'latitude' => $asMsg['latitude'],
'longitude' => $asMsg['longitude'],
'iso_time' => $asMsg['dateTime'], //ISO 8601 time (backup)
'site_time' => date(Db::TIMESTAMP_FORMAT, $asMsg['unixTime']), //Conversion to Site Time (see Settings::TIMEZONE)
'unix_time' => $asMsg['unixTime'], //UNIX Time (backup)
'content' => $asMsg['messageContent'],
'battery_state' => $asMsg['batteryState']
);
$this->oDb->insertUpdateRow(self::MSG_TABLE, $asMsg, array('ref_msg_id'));
}
}
}
else $this->oDb->updateRow(self::FEED_TABLE, $this->iFeedId, array('last_update'=>$sLastUpdate));
}
private function retrieveFeed() {
$sUrl = self::FEED_HOOK.$this->sRefFeedId.self::FEED_TYPE_JSON;
$sContent = file_get_contents($sUrl);
return json_decode($sContent, true);
}
}
-228
View File
@@ -1,228 +0,0 @@
<?php
class Media extends PhpObject {
//DB Tables
const MEDIA_TABLE = 'medias';
//Media folders
const MEDIA_FOLDER = 'files/';
const THUMB_FOLDER = self::MEDIA_FOLDER.'thumbs/';
const THUMB_MAX_WIDTH = 400;
/**
* Database Handle
* @var Db
*/
private $oDb;
/**
* Media Project
* @var Project
*/
private $oProject;
private $asMedias;
private $sSystemType;
public function __construct(Db &$oDb, &$oProject) {
parent::__construct(__CLASS__, Settings::DEBUG);
$this->oDb = &$oDb;
$this->oProject = &$oProject;
$this->sSystemType = (substr(php_uname(), 0, 7) == "Windows")?'win':'unix';
}
public function getProjectCodeName() {
return $this->oProject->getProjectCodeName();
}
public function getMediasInfo() {
if(empty($this->asMedias)) {
if($this->oProject->getProjectId()) {
$asMedias = $this->oDb->selectRows(array(
'select' => array(Db::getId(self::MEDIA_TABLE), 'filename', 'taken_on', 'posted_on', 'rotate', 'type AS subtype'),
'from' => self::MEDIA_TABLE,
'constraint'=> array(Db::getId(Project::PROJ_TABLE) => $this->oProject->getProjectId())
));
foreach($asMedias as &$asMedia) {
$asMedia['media_path'] = self::getMediaPath($asMedia['filename']);
$asMedia['thumb_path'] = $this->getMediaThumbnail($asMedia['filename']);
}
$this->asMedias = $asMedias;
}
}
return $this->asMedias;
}
public function isProjectModeValid() {
return ($this->oProject->getMode() == Project::MODE_BLOG);
}
public function addMedia($sMediaName, $sMethod='upload') {
$sError = '';
$asErrorParams = array();
if(!$this->isProjectModeValid() && $sMethod!='sync') {
$sError = 'upload_wrong_mode';
$asErrorParams[] = $this->oProject->getProjectCodeName();
}
elseif($this->oDb->pingValue(self::MEDIA_TABLE, array('filename'=>$sMediaName)) && $sMethod!='sync') {
$sError = 'upload_media_exist';
$asErrorParams[] = $sMediaName;
}
else {
//Add media to DB
$asMediaInfo = $this->getMediaInfoFromFile($sMediaName);
$asDbInfo = array(
Db::getId(Project::PROJ_TABLE) => $this->oProject->getProjectId(),
'filename' => $sMediaName,
'taken_on' => ($asMediaInfo['taken_ts'] > 0)?date(Db::TIMESTAMP_FORMAT, $asMediaInfo['taken_ts']):0, //Site Time (Settings::TIMEZONE)
'posted_on' => date(Db::TIMESTAMP_FORMAT, $asMediaInfo['file_ts']), //Site Time
'rotate' => $asMediaInfo['rotate'],
'type' => $asMediaInfo['type']
);
if($sMethod=='sync') $iMediaId = $this->oDb->insertUpdateRow(self::MEDIA_TABLE, $asDbInfo, array(Db::getId(Project::PROJ_TABLE), 'filename'));
else $iMediaId = $this->oDb->insertRow(self::MEDIA_TABLE, $asDbInfo);
if(!$iMediaId) $sError = 'error_commit_db';
else {
//Create thumbnail
$this->getMediaThumbnail($sMediaName);
}
}
return Spot::getResult(($sError==''), $sError, $asErrorParams);
}
/**
* One-shot function to initialize DB with existing images
*/
public function syncFileFolder() {
$asMediaPaths = glob(self::getMediaPath('*.{jpg,JPG,jpeg,JPEG,png,PNG,mov,MOV}'), GLOB_BRACE);
foreach($asMediaPaths as $sMediaPath)
{
$sMediaName = pathinfo($sMediaPath, PATHINFO_BASENAME);
$this->addMedia($sMediaName, 'sync');
}
$this->setExtractMode(PhpObject::MODE_HTML);
return $this->getCleanMessageStack();
}
private function getMediaInfoFromFile($sMediaName)
{
$sMediaPath = self::getMediaPath($sMediaName);
$sType = self::getMediaType($sMediaName);
$iTimeStamp = $iTakenOn = $iPostedOn = 0;
$sRotate = '0';
$sTakenOn = '';
switch($sType) {
case 'video':
$asResult = array();
$sParams = implode(' ', array(
'-loglevel error', //Remove comments
'-select_streams v:0', //First video channel
'-show_entries stream_tags=rotate,creation_time', //filter tags :rotation & creation time only
'-print_format json', //output format: json
'-i' //input file
));
exec('ffprobe '.$sParams.' "'.$sMediaPath.'"', $asResult);
$asResult = json_decode(implode('', $asResult), true);
//Timestamps
$sTakenOn = date(Db::TIMESTAMP_FORMAT, strtotime($asResult['streams'][0]['tags']['creation_time']));
$iPostedOn = filemtime($sMediaPath);
//Orientation
if(isset($asResult['streams'][0]['tags']['rotate'])) $sRotate = $asResult['streams'][0]['tags']['rotate'];
break;
case 'image':
$asExif = @exif_read_data($sMediaPath, 0, true);
if(!$asExif) $asExif['FILE']['FileDateTime'] = filemtime($sMediaPath);
//Timestamps
if(array_key_exists('EXIF', $asExif) && array_key_exists('DateTimeOriginal', $asExif['EXIF'])) $sTakenOn = $asExif['EXIF']['DateTimeOriginal'];
if(array_key_exists('FILE', $asExif) && array_key_exists('FileDateTime', $asExif['FILE'])) $iPostedOn = $asExif['FILE']['FileDateTime'];
//Orientation
if(array_key_exists('IFD0', $asExif) && array_key_exists('Orientation', $asExif['IFD0'])) {
switch($asExif['IFD0']['Orientation'])
{
case 1: $sRotate = '0'; break; //None
case 3: $sRotate = '180'; break; //Flip over
case 6: $sRotate = '90'; break; //Clockwise
case 8: $sRotate = '-90'; break; //Trigo
}
}
break;
}
//Media info do not have any TZ: Interpreting date time using project timezone (assuming all medias have been taken in this time zone)
if($sTakenOn != '') {
$oTakenOn = new DateTime($sTakenOn, new DateTimeZone($this->oProject->getTimeZone()));
$iTakenOn = $oTakenOn->format('U');
}
//Merge timestamps
$iTimeStamp = ($iTakenOn > 0)?$iTakenOn:$iPostedOn;
return array(
'timestamp' => $iTimeStamp,
'taken_ts' => $iTakenOn,
'file_ts' => $iPostedOn,
'rotate' => $sRotate,
'type' => $sType
);
}
private function getMediaThumbnail($sMediaName)
{
$sMediaPath = self::getMediaPath($sMediaName);
$sThumbPath = self::getMediaPath($sMediaName, 'thumbnail');
$sType = self::getMediaType($sMediaName);
if(!file_exists($sThumbPath)) {
switch($sType) {
case 'image':
$asThumbInfo = ToolBox::createThumbnail($sMediaPath, self::THUMB_MAX_WIDTH, 0, $sThumbPath);
break;
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
'-ss 00:00:01.000', //Image taken after x seconds
'-vframes 1', //number of video frames to output
'"'.$sTempPath.'"', //output file
));
exec('ffmpeg '.$sParams, $asResult);
//Resize
$asThumbInfo = ToolBox::createThumbnail($sTempPath, self::THUMB_MAX_WIDTH, 0, $sThumbPath, true);
break;
}
}
else $asThumbInfo = array('error'=>'', 'out'=>$sThumbPath);
return ($asThumbInfo['error']=='')?$asThumbInfo['out']:$sMediaPath;
}
private static function getMediaPath($sMediaName, $sFileType='media') {
if($sFileType=='thumbnail') return self::THUMB_FOLDER.$sMediaName.(self::getMediaType($sMediaName)=='video'?'.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;
default: $sType = 'image'; break;
}
return $sType;
}
}
-160
View File
@@ -1,160 +0,0 @@
<?php
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);
//DB Tables
const PROJ_TABLE = 'projects';
/**
* Database Handle
* @var Db
*/
private $oDb;
private $iProjectId;
private $sMode;
private $sName;
private $sCodeName;
private $asActive;
private $asGeo;
public function __construct(Db &$oDb) {
parent::__construct(__CLASS__, Settings::DEBUG);
$this->oDb = &$oDb;
}
public function getProjectId() {
return $this->iProjectId;
}
public function setProjectId($iProjectId=0) {
if($iProjectId > 0) {
$this->iProjectId = $iProjectId;
}
else {
/**
* Project 1 [-----------------]
* Project 2 [---------------------------]
* Project 3 [-----------]
* Selected Project [-------Project 1-------][------------Project 2-------------][---------------Project 3------------------
* Mode --P--][--------B--------][--P--][-----------B---------------][---P---][-----B-----][---------H----------
*/
$sQuery = "SELECT MAX(id_project) FROM projects WHERE active_to = (SELECT MIN(active_to) FROM projects WHERE active_to > NOW() OR active_to = (SELECT MAX(active_to) FROM projects))";
$asResult = $this->oDb->getArrayQuery($sQuery, true);
$this->iProjectId = array_shift($asResult);
}
$this->setProjectInfo();
}
public function getMode() {
return $this->sMode;
}
public function getProjectCodeName() {
return $this->sCodeName;
}
public function setProjectCodeName($sCodeName) {
return $this->updateField('codename', $sCodeName);
}
public function getActivePeriod($sFromTo='') {
return ($sFromTo=='')?$this->asActive:$this->asActive[$sFromTo];
}
public function setActivePeriod($oValue, $sFromTo='') {
if($sFromTo=='') {
$this->updateField('active_from', $oValue['from']);
return $this->updateField('active_to', $oValue['to']);
}
else {
return $this->updateField('active_'.$sFromTo, $oValue);
}
}
public function getTimeZone() {
return $this->asGeo['timezone'];
}
public function setTimeZone($sTimeZone) {
return $this->updateField('timezone', $sTimeZone);
}
public function getGeoFile() {
return $this->asGeo['file'];
}
public function getFeedIds() {
return $this->oDb->selectColumn(
Feed::FEED_TABLE,
Db::getId(Feed::FEED_TABLE),
array(Db::getId(self::PROJ_TABLE) => $this->getProjectId())
);
}
public function getProjects($iProjectId=0) {
$bSpecificProj = ($iProjectId > 0);
$asInfo = array(
'select'=> array(
Db::getId(self::PROJ_TABLE)." AS id",
'codename',
'name',
'active_from',
'active_to',
"IF(NOW() BETWEEN active_from AND active_to, 1, IF(NOW() < active_from, 0, 2)) AS mode",
'geofile',
'timezone'
),
'from' => self::PROJ_TABLE
);
if($bSpecificProj) $asInfo['constraint'] = array(Db::getId(self::PROJ_TABLE)=>$iProjectId);
$asProjects = $this->oDb->selectRows($asInfo, 'codename');
foreach($asProjects as $sCodeName=>&$asProject) {
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;
}
if(!Converter::isGeoJsonValid($asProject['geofile'])) {
(new Converter())->convertToGeoJson($asProject['geofile']);
}
$asProject['geofile'] = Spot::addTimestampToFilePath(Geo::getFilePath($asProject['geofile'], GeoJson::EXT));
$asProject['codename'] = $sCodeName;
}
return $bSpecificProj?$asProject:$asProjects;
}
public function getProject() {
return $this->getProjects($this->getProjectId());
}
private function setProjectInfo() {
if($this->getProjectId() > 0) {
$asProject = $this->getProject();
$this->sMode = $asProject['mode'];
$this->asActive = array('from'=>$asProject['active_from'], 'to'=>$asProject['active_to']);
$this->sCodeName = $asProject['codename'];
$this->sName = $asProject['name'];
$this->asGeo = array('file'=>$asProject['geofile'], 'timezone'=>$asProject['timezone']);
}
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);
$this->setProjectInfo();
return $bResult;
}
}
-384
View File
@@ -1,384 +0,0 @@
<?php
/* Timezones
* - Feeds (table `feeds`):
* - last_update: timestamp in site time (see Settings::TIMEZONE)
* - Spot Messages (table `messages`):
* - unix_time: UNIX (int) in UTC
* - site_time: timestamp in site time (see Settings::TIMEZONE)
* - iso_time: raw ISO 8601 in local timezone
* - Medias (table `medias`):
* - posted_on: timestamp in site time (see Settings::TIMEZONE)
* - taken_on: timestamp in site time (see Settings::TIMEZONE)
* - Posts (table `posts`):
* - site_time: timestamp in site time (see Settings::TIMEZONE)
*/
class Spot extends Main
{
//Database
const POST_TABLE = 'posts';
const FEED_CHUNK_SIZE = 15;
/**
* Active Project
* @var Project
*/
private $oProject;
/**
* Media Class
* @var Media
*/
private $oMedia;
public function __construct($oClassManagement, $sProcessPage)
{
$asClasses = array(
array('name'=>'feed', 'project'=>true),
array('name'=>'project', 'project'=>true),
array('name'=>'media', 'project'=>true),
array('name'=>'converter', 'project'=>true)
);
parent::__construct($oClassManagement, $sProcessPage, $asClasses);
$this->oClassManagement->incClass('translator');
$this->oLang = new Translator('', 'en');
$this->oProject = new Project($this->oDb);
$this->oMedia = new Media($this->oDb, $this->oProject);
}
protected function install()
{
//Install DB
$this->oDb->install();
}
protected function getSqlOptions()
{
return array
(
'tables' => array
(
Feed::MSG_TABLE => array('ref_msg_id', Db::getId(Feed::FEED_TABLE), 'type', 'latitude', 'longitude', 'iso_time', 'site_time', 'unix_time', 'content', 'battery_state'),
Feed::FEED_TABLE => array('ref_feed_id', Db::getId(Feed::SPOT_TABLE), Db::getId(Project::PROJ_TABLE), 'name', 'description', 'status', 'last_update'),
Feed::SPOT_TABLE => array('ref_spot_id', 'name', 'model'),
Project::PROJ_TABLE => array('name', 'codename', 'active_from', 'active_to', 'geofile', 'timezone'),
self::POST_TABLE => array(Db::getId(Project::PROJ_TABLE), 'name', 'content', 'site_time'),
Media::MEDIA_TABLE => array(Db::getId(Project::PROJ_TABLE), 'filename', 'type', 'taken_on', 'posted_on', 'rotate')
),
'types' => array
(
'ref_msg_id' => "INT",
'type' => "VARCHAR(20)",
'latitude' => "DECIMAL(7,5)",
'longitude' => "DECIMAL(8,5)",
'iso_time' => "VARCHAR(24)",
'site_time' => "TIMESTAMP DEFAULT 0", //DEFAULT 0 removes auto-set to current time
'unix_time' => "INT",
'content' => "LONGTEXT",
'battery_state' => "VARCHAR(10)",
'ref_spot_id' => "VARCHAR(10)",
'name' => "VARCHAR(100)",
'codename' => "VARCHAR(100)",
'model' => "VARCHAR(20)",
'ref_feed_id' => "VARCHAR(40)",
'description' => "VARCHAR(100)",
'status' => "VARCHAR(10)",
'active_from' => "TIMESTAMP DEFAULT 0",
'active_to' => "TIMESTAMP DEFAULT 0",
'geofile' => "VARCHAR(50)",
'timezone' => "VARCHAR(100)",
'last_update' => "TIMESTAMP DEFAULT 0",
'filename' => "VARCHAR(100) NOT NULL",
'taken_on' => "TIMESTAMP DEFAULT 0",
'posted_on' => "TIMESTAMP DEFAULT 0",
'rotate' => "SMALLINT"
),
'constraints' => array
(
Feed::MSG_TABLE => array("UNIQUE KEY `uni_ref_msg_id` (`ref_msg_id`)", "INDEX(`ref_msg_id`)"),
Feed::FEED_TABLE => array("UNIQUE KEY `uni_ref_feed_id` (`ref_feed_id`)", "INDEX(`ref_feed_id`)"),
Feed::SPOT_TABLE => array("UNIQUE KEY `uni_ref_spot_id` (`ref_spot_id`)", "INDEX(`ref_spot_id`)"),
Project::PROJ_TABLE => "UNIQUE KEY `uni_proj_name` (`codename`)",
Media::MEDIA_TABLE => "UNIQUE KEY `uni_file_name` (`filename`)"
),
'cascading_delete' => array
(
Feed::SPOT_TABLE=>array(Feed::MSG_TABLE)
)
);
}
public function getMainPage($asGlobalVars = array(), $sMainPage = 'index', $asMainPageTags=array())
{
return parent::getMainPage(
array(
'vars' => array(
'chunk_size' => self::FEED_CHUNK_SIZE,
'default_project_codename' => $this->oProject->getProjectCodeName(),
'projects' => $this->oProject->getProjects()
),
'consts' => array(
'geo_server' => Settings::GEO_SERVER,
'modes' => Project::MODES,
'site_timezone' => Settings::TIMEZONE
)
),
'index',
array(
'host_url' => $this->asContext['serv_name'],
'filepath_css' => self::addTimestampToFilePath('style/spot.css'),
'filepath_js_d3' => self::addTimestampToFilePath('script/d3.min.js'),
'filepath_js_leaflet' => self::addTimestampToFilePath('script/leaflet.min.js'),
'filepath_js_jquery' => self::addTimestampToFilePath('script/jquery.min.js'),
'filepath_js_jquery_mods' => self::addTimestampToFilePath('script/jquery.mods.js'),
'filepath_js_spot' => self::addTimestampToFilePath('script/spot.js')
)
);
}
/* Managing projects */
public function setProjectId($iProjectId=0) {
$this->oProject->setProjectId($iProjectId);
//Update all feeds belonging to the project
$asFeeds = $this->oProject->getFeedIds();
foreach($asFeeds as $iFeedId) {
$oFeed = new Feed($this->oDb, $iFeedId);
$oFeed->checkUpdateFeed($this->oProject->getMode());
}
}
public function getMarkers()
{
$asMessages = $this->getSpotMessages();
$bSuccess = !empty($asMessages);
$sDesc = $bSuccess?'':self::NO_DATA;
//Add medias
if($bSuccess) {
$asMedias = $this->getMedias('taken_on');
//Assign medias to closest message
$iIndex = 0;
$iMaxIndex = count($asMessages) - 1;
foreach($asMedias as $asMedia) {
while($iIndex <= $iMaxIndex && $asMedia['unix_time'] > $asMessages[$iIndex]['unix_time']) {
$iIndex++;
}
if($iIndex == 0) $iMsgIndex = $iIndex;
elseif($iIndex > $iMaxIndex) $iMsgIndex = $iMaxIndex;
else {
$iHalfWayPoint = ($asMessages[$iIndex - 1]['unix_time'] + $asMessages[$iIndex]['unix_time'])/2;
$iMsgIndex = ($asMedia['unix_time'] >= $iHalfWayPoint)?$iIndex:($iIndex - 1);
}
$asMessages[$iMsgIndex]['medias'][] = $asMedia;
}
}
return self::getJsonResult($bSuccess, $sDesc, $asMessages);
}
private function getSpotMessages()
{
//Get messages from all feeds belonging to the project
$asFeeds = $this->oProject->getFeedIds();
foreach($asFeeds as $iFeedId) {
$oFeed = new Feed($this->oDb, $iFeedId);
$asMessages = $oFeed->getMessages($this->oProject->getActivePeriod());
foreach($asMessages as $iIndex=>&$asMessage)
{
$asMessage['latitude'] = floatval($asMessage['latitude']);
$asMessage['longitude'] = floatval($asMessage['longitude']);
$asMessage['lat_dms'] = self::decToDms($asMessage['latitude'], 'lat');
$asMessage['lon_dms'] = self::decToDms($asMessage['longitude'], 'lon');
$asMessage['displayed_id'] = 'N°'.($iIndex + 1);
$this->addTimeStamp($asMessage, $asMessage['unix_time']);
}
}
usort($asMessages, function($a, $b){return $a['unix_time'] > $b['unix_time'];});
return $asMessages;
}
/**
* Get valid medias based on $sTimeRefField (both are on site time):
* - taken_on: Date/time on which the media was taken
* - posted_on: Date/time on which the media was uploaded
* @param String $sTimeRefField Field to calculate relative times
* @return Array Medias info
*/
private function getMedias($sTimeRefField)
{
$asMedias = $this->oMedia->getMediasInfo();
$asValidMedias = array();
foreach($asMedias as $iIndex=>$asMedia) {
$sTimeRef = $asMedia[$sTimeRefField];
if($sTimeRef >= $this->oProject->getActivePeriod('from') && $sTimeRef <= $this->oProject->getActivePeriod('to')) {
$asMedia['taken_on_formatted'] = $this->getTimeFormat(strtotime($asMedia['taken_on']));
$asMedia['displayed_id'] = 'N°'.($iIndex + 1);
$this->addTimeStamp($asMedia, strtotime($sTimeRef));
$asValidMedias[] = $asMedia;
}
}
usort($asValidMedias, function($a, $b){return $a['unix_time'] > $b['unix_time'];});
return $asValidMedias;
}
private function getPosts()
{
$asInfo = array(
'from' => self::POST_TABLE,
'constraint'=> array(Db::getId(Project::PROJ_TABLE) => $this->oProject->getProjectId()),
'constOpe' => array(Db::getId(Project::PROJ_TABLE) => "=")
);
if($this->oProject->getMode()==Project::MODE_HISTO) {
$asInfo['constraint']['site_time'] = $this->oProject->getActivePeriod();
$asInfo['constOpe']['site_time'] = "BETWEEN";
}
$asPosts = $this->oDb->selectRows($asInfo);
foreach($asPosts as &$asPost) {
$iUnixTimeStamp = strtotime($asPost['site_time']); //assumes site timezone (Settings::TIMEZONE)
$asPost['formatted_name'] = Toolbox::mb_ucwords($asPost['name']);
$this->addTimeStamp($asPost, $iUnixTimeStamp);
}
usort($asPosts, function($a, $b){return $a['unix_time'] > $b['unix_time'];});
return $asPosts;
}
private function addTimeStamp(&$asData, $iTime) {
$asData['unix_time'] = (int) $iTime;
$asData['relative_time'] = Toolbox::getDateTimeDesc($iTime, $this->oLang->getLanguage());
$asData['formatted_time'] = $this->getTimeFormat($iTime);
}
public function getNewsFeed($iChunk=0)
{
$bHistoMode = ($this->oProject->getMode() == Project::MODE_HISTO);
$asFeeds = array();
$asFeedTypes = array(
'message' => array(
'table' => Feed::MSG_TABLE,
'feed' => $this->getSpotMessages(),
'priority' => 0
),
'media' => array(
'table' => Media::MEDIA_TABLE,
'feed' => $this->getMedias('posted_on'),
'priority' => 1
),
'post' => array(
'table' => self::POST_TABLE,
'feed' => $this->getPosts(),
'priority' => 2
)
);
foreach($asFeedTypes as $sFeedType=>$asFeedTypeInfo) {
foreach($asFeedTypeInfo['feed'] as $asFeed) {
$sPriority = $asFeedTypeInfo['priority'];
if($bHistoMode) $sPriority = count($asFeedTypes) - 1 - $asFeedTypeInfo['priority'];
$iId = ($asFeed['unix_time'] * -1).'.'.$sPriority.$asFeed[Db::getId($asFeedTypeInfo['table'])];
$asFeeds[$iId] = $asFeed;
$asFeeds[$iId]['type'] = $sFeedType;
}
}
//Sort by key
if($bHistoMode) krsort($asFeeds);
else ksort($asFeeds);
//Split chunks
$asFeeds = array_slice($asFeeds, $iChunk*self::FEED_CHUNK_SIZE, self::FEED_CHUNK_SIZE);
return self::getJsonResult(true, '', $asFeeds);
}
public function syncMedias() {
return $this->oMedia->syncFileFolder();
}
public function addPost($sName, $sPost)
{
$asData = array(
Db::getId(Project::PROJ_TABLE) => $this->oProject->getProjectId(),
'name' => mb_strtolower(trim($sName)),
'content' => trim($sPost),
'site_time' => date(Db::TIMESTAMP_FORMAT) //site time (Settings::TIMEZONE)
);
$iPostId = $this->oDb->insertRow(self::POST_TABLE, $asData);
return self::getJsonResult(($iPostId > 0), '');
}
public function upload()
{
$this->oClassManagement->incClass('uploader', true);
$oUploader = new Uploader($this->oMedia, $this->oLang);
return $oUploader->sBody;
}
public function getAdminSettings() {
return self::getJsonResult(true, '', array('projects'=>$this->oProject->getProjects()));
}
public function setAdminSettings($sField, $sValue) {
$bSuccess = false;
$sDesc = '';
switch($sField) {
case 'codename':
$bSuccess = $this->oProject->setProjectCodeName($sValue);
break;
case 'active_from':
$bSuccess = $this->oProject->setActivePeriod($sValue.' 00:00:00', 'from');
break;
case 'active_to':
$bSuccess = $this->oProject->setActivePeriod($sValue.' 23:59:59', 'to');
break;
case 'timezone':
$bSuccess = $this->oProject->setTimeZone($sValue);
break;
}
if(!$bSuccess) $sDesc = Mask::LANG_PREFIX.'error_commit_db';
return self::getJsonResult($bSuccess, $sDesc, array('values'=>$this->oProject->getProject()));
}
public static function decToDms($dValue, $sType) {
if($sType=='lat') $sDirection = ($dValue >= 0)?'N':'S'; //Latitude
else $sDirection = ($dValue >= 0)?'E':'W'; //Longitude
$dLeft = abs($dValue);
//Degrees
$iDegree = floor($dLeft);
$dLeft -= $iDegree;
//Minutes
$iMinute = floor($dLeft * 60);
$dLeft -= $iMinute / 60;
//Seconds
$fSecond = round($dLeft * 3600, 1);
return $iDegree.'°'.$iMinute.'\''.$fSecond.'"'.$sDirection;
}
public function getTimeFormat($iTime) {
$sDate = date('d/m/Y', $iTime);
$sTime = date('H:i', $iTime);
return $this->oLang->getTranslation('date_time', array($sDate, $sTime));
}
}
-1523
View File
File diff suppressed because it is too large Load Diff
-67
View File
@@ -1,67 +0,0 @@
<?php
/* Requests Handler */
//Start buffering
ob_start();
require_once '../objects/class_management.php';
$oClassManagement = new ClassManagement('spot');
ToolBox::cleanPost($_POST);
ToolBox::cleanPost($_GET);
ToolBox::cleanPost($_REQUEST);
ToolBox::fixGlobalVars(isset($argv)?$argv:array());
//Available variables
$sAction = isset($_REQUEST['a'])?$_REQUEST['a']:'';
$sName = isset($_GET['name'])?$_GET['name']:'';
$sContent = isset($_GET['content'])?$_GET['content']:'';
$iChunk = isset($_GET['chunk'])?$_GET['chunk']:0;
$iProjectId = isset($_REQUEST['project_id'])?$_REQUEST['project_id']:0;
$sField = isset($_REQUEST['field'])?$_REQUEST['field']:'';
$oValue = isset($_REQUEST['value'])?$_REQUEST['value']:'';
//Initiate class
$oSpot = new Spot($oClassManagement, __FILE__);
$oSpot->setProjectId($iProjectId);
$sResult = '';
if($sAction!='')
{
switch($sAction)
{
case 'markers':
$sResult = $oSpot->getMarkers();
break;
case 'feed':
$sResult = $oSpot->getNewsFeed($iChunk);
break;
case 'upload':
$sResult = $oSpot->upload();
break;
case 'add_post':
$sResult = $oSpot->addPost($sName, $sContent);
break;
/*case 'sql':
$sResult = $oSpot->getDbBuildScript();
break;*/
case 'sync_pics':
$sResult = $oSpot->syncPics();
break;
case 'get_admin':
$sResult = $oSpot->getAdminSettings();
break;
case 'set_admin':
$sResult = $oSpot->setAdminSettings($sField, $oValue);
break;
default:
$sResult = Main::getJsonResult(false, Main::NOT_FOUND);
}
}
else $sResult = $oSpot->getMainPage();
$sDebug = ob_get_clean();
if(Settings::DEBUG && $sDebug!='') $oSpot->addUncaughtError($sDebug);
echo $sResult;
?>
-46
View File
@@ -1,46 +0,0 @@
locale = en_NZ
page_og_desc = Keep contact with François when he is off hiking
error_commit_db = Issue committing to DB
admin_save_success = Saved
track_main = Main track
track_offtrack = Off-track
track_hitchhiking = Hitchhiking
upload_title = Picture & Video Uploads
upload_wrong_mode = Project "$0" is not in blog mode. No upload allowed
upload_success = $0 uploaded successfully
upload_media_exist = Picture $0 already exists
post_message = Message
post_name = Name
post_new_message = New message
and = and
map_satellite = Satellite
map_otm = Open Topo Map
map_ign_france = IGN (France)
map_ign_spain = IGN (Spain)
map_linz = LINZ (New Zealand)
pic = Picture
pics = Pictures
pic_taken = taken on $0
video = Video
video_taken = shot on $0
add_on = added on $0
click_watch = Click to watch video
click_zoom = Click to zoom
media_count = Media $0 / $1
city_time = $0 time
project = Project
mode = Mode
code_name = Code name
start = Start
end = End
date_time = $0 at $1
time_zone = Time zone
-46
View File
@@ -1,46 +0,0 @@
locale = fr_FR
page_og_desc = Gardez le contact avec François lorsqu'il part sur les chemins
error_commit_db = Error lors de la requête SQL
admin_save_success = Sauvegardé
track_main = Trajet principal
track_offtrack = Variante
track_hitchhiking = Hors rando
upload_title = Uploader photos & vidéos
upload_wrong_mode = Le projet "$0" n'est pas en mode blog. Aucun upload possible
upload_success = $0 a été uploadé
upload_media_exist = l'image $0 existe déjà
post_message = Message
post_name = Nom
post_new_message = Nouveau message
and = et
map_satellite = Satellite
map_otm = Open Topo Map
map_ign_france = IGN (France)
map_ign_spain = IGN (Espagne)
map_linz = LINZ (Nouvelle-Zélande)
pic = Photo
pics = Photos
pic_taken = prise le $0
video = Vidéo
video_taken = filmée le $0
add_on = ajoutée le $0
click_watch = Click pour voir la vidéo
click_zoom = Click pour zoomer
media_count = Média $0 sur $1
city_time = heure de $0
project = Projet
mode = Mode
code_name = Nom de code
start = Départ
end = Arrivée
date_time = $0 à $1
time_zone = Fuseau horaire
+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]])
};
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
/**
* GPX to GeoJSON Converter
*
* To convert a gpx file:
* 1. Add file <file_name>.gpx to geo/ folder
* 2. Assign file to project: UPDATE projects SET codename = '<file_name>' WHERE id_project = <id_project>;
* 3. Load any page
*/
class Converter extends PhpObject {
public function __construct() {
parent::__construct(__CLASS__);
}
public static function convertToGeoJson(string $sCodeName) {
$oGpx = new Gpx($sCodeName);
$oGeoJson = new GeoJson($sCodeName);
$oGeoJson->buildTracks($oGpx->getTracks());
if($oGeoJson->isSimplicationRequired()) $oGeoJson->buildTracks($oGpx->getTracks(), true);
$oGeoJson->sortOffTracks();
$oGeoJson->saveFile();
return [
'logs' => $oGpx->getLog().'<br />'.$oGeoJson->getLog(),
'center' => $oGeoJson->getCenter()
];
}
public static function hasGpxFile(string $sCodeName) {
return ($sCodeName != '' && file_exists(Gpx::getBackendFilePath($sCodeName)));
}
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
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use Franzz\Objects\Mask;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;
use \Settings;
class Email extends PhpObject {
private $sServName;
private $sTemplateName;
/**
* Email Template
* @var Mask
*/
public $oTemplate;
private $asDests;
public function __construct($sServName, $sTemplateName='') {
parent::__construct(__CLASS__);
$this->sServName = $sServName;
$this->setTemplate($sTemplateName);
$this->asDests = [];
}
public function setTemplate($sTemplateName) {
$this->sTemplateName = $sTemplateName;
$this->oTemplate = new Mask($this->sTemplateName);
$this->oTemplate->setTag('local_server', $this->sServName);
}
/**
* Set Target User Info
* @param array $asDests Contains: id_user, name, email, language, timezone, active
*/
public function setDestInfo($asDests) {
if(array_key_exists('email', $asDests)) $asDests = [$asDests];
$this->asDests = $asDests;
}
public function send() {
$oPHPMailer = new PHPMailer(true);
//Server settings
if(Settings::DEBUG) $oPHPMailer->SMTPDebug = SMTP::DEBUG_SERVER;//Enable verbose debug output
$oPHPMailer->isSMTP(); //Send using SMTP
$oPHPMailer->CharSet = Settings::TEXT_ENC; //Mail Character Set
$oPHPMailer->Encoding = 'base64'; //Base 64 Character Encoding
$oPHPMailer->Host = Settings::MAIL_SERVER; //Set the SMTP server to send through
$oPHPMailer->SMTPAuth = true; //Enable SMTP authentication
$oPHPMailer->Username = Settings::MAIL_USER; //SMTP username
$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, Livetrail::PROJECT_NAME);
$oPHPMailer->addReplyTo(Settings::MAIL_FROM, Livetrail::PROJECT_NAME);
$bSuccess = true;
foreach($this->asDests as $asDest) {
//Message
$this->oTemplate->setLanguage($asDest['language'], Livetrail::DEFAULT_LANG);
$this->oTemplate->setTimezone($asDest['timezone']);
//Unsubscribe Link
$sUnsubLink = $this->sServName.'?a=unsubscribe_email&id='.$asDest['id_user'];
$this->oTemplate->setTag('unsubscribe_link', htmlspecialchars($sUnsubLink));
$oPHPMailer->addCustomHeader('List-Unsubscribe','<mailto:'.Settings::MAIL_FROM.'?subject=unsubscribe>, <'.$sUnsubLink.'>');
$oPHPMailer->addCustomHeader('List-Unsubscribe-Post','List-Unsubscribe=One-Click');
//Email Content
$this->oTemplate->setTag('timezone', 'lang:time.city', self::getTimeZoneCity($asDest['timezone']));
$sHtmlMessage = $this->oTemplate->getMask();
$sPlainMessage = strip_tags(str_replace('<br />', "\n", $sHtmlMessage));
//Recipients
try {
$oPHPMailer->addAddress($asDest['email'], $asDest['name']);
} catch (Exception $oError) {
$this->addError('Invalid address skipped: '.$asDest['email'].' ('.$asDest['name'].')');
continue;
}
//Content
$oPHPMailer->isHTML(true);
$oPHPMailer->Subject = $this->oTemplate->getTranslator()->getTranslation($this->sTemplateName.'.subject');
$oPHPMailer->Body = $sHtmlMessage;
$oPHPMailer->AltBody = $sPlainMessage;
try {
$bSuccess = $bSuccess && $oPHPMailer->send();
}
catch (Exception $oError) {
$this->addError('Message could not be sent to "'.$asDest['email'].'". Mailer Error: '.$oPHPMailer->ErrorInfo);
$oPHPMailer->getSMTPInstance()->reset();
}
$oPHPMailer->clearAddresses();
$oPHPMailer->clearCustomHeaders();
}
return $bSuccess;
}
private static function getTimeZoneCity($sTimeZone) {
return (strpos($sTimeZone, '/')!==false)?str_replace('_', ' ', explode('/', $sTimeZone)[1]):$sTimeZone;
}
}
+341
View File
@@ -0,0 +1,341 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use Franzz\Objects\Db;
use Franzz\Objects\Translator;
use \Settings;
/**
* Feed Class
* Also manages spots (devices) & messages
*/
class Feed extends PhpObject {
//Spot feed
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
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
private const TIMEZONE_HOOK = 'http://api.geonames.org/timezoneJSON';
//DB Tables
public const SPOT_TABLE = 'spots';
public const FEED_TABLE = 'feeds';
public const MSG_TABLE = 'messages';
//Hide/Display values
public const MSG_HIDDEN = 0;
public const MSG_DISPLAYED = 1;
/**
* Database Handle
* @var Db
*/
private $oDb;
private $iFeedId;
private $sRefFeedId;
private $iLastUpdate;
public function __construct(Db &$oDb, $iFeedId=0) {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
if($iFeedId > 0) $this->setFeedId($iFeedId);
}
public function getFeedId() {
return $this->iFeedId;
}
public function getLastUpdate(): int {
return $this->iLastUpdate;
}
public function setFeedId($iFeedId) {
$this->iFeedId = $iFeedId;
$asFeed = $this->getFeed();
$this->sRefFeedId = $asFeed['ref_feed_id'];
$this->iLastUpdate = $asFeed['last_update']=='0000-00-00 00:00:00'?0:strtotime($asFeed['last_update']);
}
public function createFeedId($oProjectId) {
$this->setFeedId($this->oDb->insertRow(self::FEED_TABLE, [
Db::getId(Project::PROJ_TABLE) => $oProjectId,
'status' => 'INACTIVE'
]));
return $this->getFeedId();
}
public function setRefFeedId($sRefFeedId) {
return $this->updateField('ref_feed_id', $sRefFeedId);
}
public function setSpotId($iSpotId) {
return $this->updateField(Db::getId(self::SPOT_TABLE), $iSpotId);
}
public function setProjectId($iProjectId) {
return $this->updateField(Db::getId(Project::PROJ_TABLE), $iProjectId);
}
public function getSpots() {
$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 = ['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)];
return $asFeeds;
}
public function getFeed() {
$asFeeds = $this->getFeeds($this->getFeedId());
return array_shift($asFeeds);
}
public function getMessages($asConstraints=[]) {
$sFeedIdCol = Db::getId(self::FEED_TABLE, true);
$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' => [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);
/* Temporary lookup - Start */
$iCount = 0;
foreach($asResult as &$asMsg) {
if($asMsg['weather_icon'] == '' && $iCount < 3) {
$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++;
}
}
/* Temporary lookup - End */
return $asResult;
}
public function getLastMessageId($asConstraints=[]) {
$asMessages = $this->getMessages($asConstraints);
return end($asMessages)[Db::getId(self::MSG_TABLE)] ?? 0;
}
public function checkUpdateFeed($sProjectMode) {
$bNewMsg = false;
//Spam Check: no more than 1 API request per 5 minutes
if($sProjectMode == Project::MODE_BLOG) {
$oLastUpdate = new \DateTime('@'.$this->iLastUpdate);
$oNow = new \DateTime('now');
$iSecDiff = $oNow->getTimestamp() - $oLastUpdate->getTimestamp();
if($iSecDiff > self::FEED_MAX_REFRESH) $bNewMsg = $this->updateFeed();
}
return $bNewMsg;
}
public function addManualPosition($sLat, $sLng, $iTimestamp) {
$sTimeZone = date_default_timezone_get();
$oDateTime = new \DateTime('@'.$iTimestamp);
$oDateTime->setTimezone(new \DateTimeZone($sTimeZone));
$asWeather = $this->getWeather([$sLat, $sLng], $iTimestamp);
$asMsg = [
'ref_msg_id' => $iTimestamp.'/man',
'id_feed' => $this->getFeedId(),
'type' => 'OK',
'latitude' => $sLat,
'longitude' => $sLng,
'iso_time' => $oDateTime->format("Y-m-d\TH:i:sO"), //Incorrect ISO 8601 format, but compliant with Spot data
'site_time' => $oDateTime->format(Db::TIMESTAMP_FORMAT),
'timezone' => $sTimeZone,
'unix_time' => $iTimestamp,
'content' => '',
'battery_state' => '',
'posted_on' => date(Db::TIMESTAMP_FORMAT),
];
$iMessageId = $this->oDb->insertRow(self::MSG_TABLE, array_merge($asMsg, $asWeather));
return $iMessageId;
}
private function updateFeed() {
$bNewMsg = false;
$asData = $this->retrieveFeed();
$sNow = date(Db::TIMESTAMP_FORMAT);
if(!isset($asData['response']['errors']) || empty($asData['response']['errors'])) {
$asMsgs = $asData['response']['feedMessageResponse']['messages'];
$asFeed = $asData['response']['feedMessageResponse']['feed'];
//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 = [$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 = [
'ref_spot_id' => $asMsgs[0]['messengerId'],
'name' => $asMsgs[0]['messengerName'],
'model' => $asMsgs[0]['modelId']
];
$iSpotId = $this->oDb->insertUpdateRow(self::SPOT_TABLE, $asSpotInfo, ['ref_spot_id']);
//Update Feed Info and last update date
$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, ['ref_feed_id']);
//Update Messages
foreach($asMsgs as $asMsg) {
$asMsg = [
'ref_msg_id' => $asMsg['id'],
Db::getId(self::FEED_TABLE) => $iFeedId,
'type' => $asMsg['messageType'],
'latitude' => $asMsg['latitude'],
'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($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, ['ref_msg_id'=>$asMsg['ref_msg_id']]);
if(!$iMsgId) {
//First Catch
$asMsg['posted_on'] = $sNow;
//Weather Data
$asMsg = array_merge($asMsg, $this->getWeather([$asMsg['latitude'], $asMsg['longitude']], $asMsg['unix_time']));
$this->oDb->insertRow(self::MSG_TABLE, $asMsg);
$bNewMsg = true;
}
else $this->oDb->updateRow(self::MSG_TABLE, $iMsgId, $asMsg);
}
}
}
else $this->oDb->updateRow(self::FEED_TABLE, $this->getFeedId(), ['last_update'=>$sNow]);
return $bNewMsg;
}
private function getWeather($asLatLng, $iTimeStamp) {
$sApiUrl = self::WEATHER_HOOK.'/'.$asLatLng[0].','.$asLatLng[1].'/'.$iTimeStamp.'?'.http_build_query(self::WEATHER_PARAM);
$asWeather = json_decode(file_get_contents($sApiUrl), true);
if(array_key_exists('currentConditions', $asWeather)) { //Current conditions
$sWeatherIcon = $asWeather['currentConditions']['icon'];
$sWeatherCond = $asWeather['currentConditions']['conditions'];
$sWeatherTemp = $asWeather['currentConditions']['temp'];
}
elseif($asWeather['days'][0]['icon'] != '') { //Daily Conditions
$sWeatherIcon = $asWeather['days'][0]['icon'];
$sWeatherCond = $asWeather['days'][0]['conditions'];
$sWeatherTemp = $asWeather['days'][0]['temp'];
}
else {
$sWeatherIcon = 'unknown';
}
//Get Condition Language ID
$sCondLangId = (new Translator(self::WEATHER_PARAM['lang']))->getTranslationKey($sWeatherCond);
return [
'weather_icon' => $sWeatherIcon,
'weather_cond' => $sCondLangId,
'weather_temp' => floatval($sWeatherTemp)
];
}
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);
return $asTimeZone['timezoneId'] ?? Settings::TIMEZONE;
}
private function retrieveFeed() {
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(), [$sField=>$oValue]) > 0);
$this->setFeedId($this->getFeedId());
return $bResult;
}
public function delete() {
$bSuccess = false;
$sLangId = '';
$asLangParams = [];
$asData = [];
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);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use \Settings;
abstract class Geo extends PhpObject {
protected const EXT = '';
protected const GEO_FOLDER = 'geo';
protected const OPT_SIMPLE = 'simplification';
protected array $asTracks;
protected string $sFilePath;
public function __construct(string $sCodeName) {
parent::__construct(get_class($this), Settings::DEBUG, PhpObject::MODE_HTML);
$this->sFilePath = self::getBackEndFilePath($sCodeName);
$this->asTracks = [];
}
//Access from backend
public static function getBackendFilePath(string $sCodeName) {
return '../resources/'.self::GEO_FOLDER.'/'.$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() {
return $this->getCleanMessageStack(PhpObject::NOTICE_TAB);
}
}
+230
View File
@@ -0,0 +1,230 @@
<?php
namespace Franzz\Livetrail;
class GeoJson extends Geo {
protected const EXT = '.geojson';
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);
}
public function saveFile() {
$this->addNotice('Saving '.$this->sFilePath);
file_put_contents($this->sFilePath, $this->buildGeoJson());
}
public function isSimplicationRequired() {
//Size in bytes
$iFileSize = strlen($this->buildGeoJson());
//Convert to MB
$iFileSize = round($iFileSize / pow(1024, 2), 2);
//Compare with max allowed size
$bFileTooLarge = ($iFileSize > self::MAX_FILESIZE);
if($bFileTooLarge) $this->addNotice('Output file is too large ('.$iFileSize.'MB > '.self::MAX_FILESIZE.'MB)');
return $bFileTooLarge;
}
public function buildTracks($asTracks, $bSimplify=false) {
$this->addNotice('Creating '.($bSimplify?'Simplified ':'').'GeoJson Tracks');
$iGlobalInvalidPointCount = 0;
$iGlobalPointCount = 0;
$this->asTracks = [];
foreach($asTracks as $asTrackProps) {
$asOptions = $this->parseOptions($asTrackProps['cmt']);
//Color mapping
switch($asTrackProps['color']) {
case 'DarkBlue':
$sType = 'main';
break;
case 'Magenta':
if($bSimplify && $asOptions[self::OPT_SIMPLE]!='keep') {
$this->addNotice('Ignoring Track "'.$asTrackProps['name'].' (off-track)');
continue 2; //discard tracks
}
else {
$sType = 'off-track';
break;
}
case 'Red':
$sType = 'hitchhiking';
break;
default:
$this->addNotice('Ignoring Track "'.$asTrackProps['name'].' (unknown color "'.$asTrackProps['color'].'")');
continue 2; //discard tracks
}
$asTrack = [
'type' => 'Feature',
'properties' => [
'name' => $asTrackProps['name'],
'type' => $sType,
'description' => $asTrackProps['desc']
],
'geometry' => [
'type' => 'LineString',
'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 = [];
foreach($asTrackPoints as $iIndex=>$asPoint) {
$asNextPoint = ($iIndex < ($iPointCount - 1))?$asTrackPoints[$iIndex + 1]:[];
if($bSimplify && !empty($asPrevPoint) && !empty($asNextPoint)) {
if(!$this->isPointValid($asPrevPoint, $asPoint, $asNextPoint)) {
$iInvalidPointCount++;
continue;
}
}
$asTrack['geometry']['coordinates'][] = array_values($asPoint);
$asPrevPoint = $asPoint;
}
$this->asTracks[] = $asTrack;
$iGlobalInvalidPointCount += $iInvalidPointCount;
$iGlobalPointCount += $iPointCount;
if($iInvalidPointCount > 0) $this->addNotice('Removing '.$iInvalidPointCount.'/'.$iPointCount.' points ('.round($iInvalidPointCount / $iPointCount * 100, 1).'%) from '.$asTrackProps['name']);
}
if($bSimplify) $this->addNotice('Total: '.$iGlobalInvalidPointCount.'/'.$iGlobalPointCount.' points removed ('.round($iGlobalInvalidPointCount / $iGlobalPointCount * 100, 1).'%)');
}
public function sortOffTracks() {
$this->addNotice('Sorting off-tracks');
//Find first & last track points
$asTracksEnds = [];
$asTracks = [];
foreach($this->asTracks as $iTrackId=>$asTrack) {
$sTrackId = 't'.$iTrackId;
$asTracksEnds[$sTrackId] = ['first'=>reset($asTrack['geometry']['coordinates']), 'last'=>end($asTrack['geometry']['coordinates'])];
$asTracks[$sTrackId] = $asTrack;
}
//Find variants close-by tracks
$asClonedTracks = $asTracks;
foreach($asClonedTracks as $sTrackId=>$asTrack) {
if($asTrack['properties']['type'] != 'off-track') continue;
$iMinDistance = INF;
$sConnectedTrackId = 0;
$iPosition = 0;
//Test all track ending points to find the closest
foreach($asTracksEnds as $sTrackEndId=>$asTrackEnds) {
if($sTrackEndId != $sTrackId) {
//Calculate distance between the last point of the track and every starting point of other tracks
$iDistance = self::getDistance($asTracksEnds[$sTrackId]['last'], $asTrackEnds['first']);
if($iDistance < $iMinDistance) {
$sConnectedTrackId = $sTrackEndId;
$iPosition = 0; //Track before the Connected Track
$iMinDistance = $iDistance;
}
//Calculate distance between the first point of the track and every ending point of other tracks
$iDistance = self::getDistance($asTracksEnds[$sTrackId]['first'], $asTrackEnds['last']);
if($iDistance < $iMinDistance) {
$sConnectedTrackId = $sTrackEndId;
$iPosition = +1; //Track after the Connected Track
$iMinDistance = $iDistance;
}
}
}
//Move track
unset($asTracks[$sTrackId]);
$iOffset = array_search($sConnectedTrackId, array_keys($asTracks)) + $iPosition;
$asTracks = array_slice($asTracks, 0, $iOffset) + [$sTrackId => $asTrack] + array_slice($asTracks, $iOffset);
}
$this->asTracks = array_values($asTracks);
}
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 = [self::OPT_SIMPLE=>''];
foreach(explode("\n", $sComment) as $sLine) {
$asOptions[mb_strtolower(trim(mb_strstr($sLine, ':', true)))] = mb_strtolower(trim(mb_substr(mb_strstr($sLine, ':'), 1)));
}
return $asOptions;
}
private function isPointValid($asPointA, $asPointO, $asPointB) {
/* A----O Calculate angle AO^OB
* \ If angle is within [90% Pi ; 110% Pi], O can be discarded
* \ O is valid otherwise
* B
*/
//Path Turn Check -> -> -> ->
//Law of Cosines (vector): angle = arccos(OA.OB / ||OA||.||OB||)
$fVectorOA = ['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));
$fVectorOAxOB = $fVectorOA['lon'] * $fVectorOB['lon'] + $fVectorOA['lat'] * $fVectorOB['lat'];
$fAngleAOB = ($fLengthOA != 0 && $fLengthOB != 0) ? acos($fVectorOAxOB/($fLengthOA * $fLengthOB)) : 0;
//Elevation Check
//Law of Cosines: angle = arccos((OB² + AO² - AB²) / (2*OB*AO))
$fLengthAB = sqrt(pow($asPointB['ele'] - $asPointA['ele'], 2) + pow($fLengthOA + $fLengthOB, 2));
$fLengthAO = sqrt(pow($asPointO['ele'] - $asPointA['ele'], 2) + pow($fLengthOA, 2));
$fLengthOB = sqrt(pow($asPointB['ele'] - $asPointO['ele'], 2) + pow($fLengthOB, 2));
$fAngleAOBElev = ($fLengthOB != 0 && $fLengthAO != 0) ? (acos((pow($fLengthOB, 2) + pow($fLengthAO, 2) - pow($fLengthAB, 2)) / (2 * $fLengthOB * $fLengthAO))) : 0;
return ($fAngleAOB <= (1 - self::MAX_DEVIATION_FLAT) * M_PI || $fAngleAOB >= (1 + self::MAX_DEVIATION_FLAT) * M_PI ||
$fAngleAOBElev <= (1 - self::MAX_DEVIATION_ELEV) * M_PI || $fAngleAOBElev >= (1 + self::MAX_DEVIATION_ELEV) * M_PI);
}
private function buildGeoJson() {
return json_encode(['type'=>'FeatureCollection', 'features'=>$this->asTracks]);
}
private static function getDistance($asPointA, $asPointB) {
$fLatFrom = $asPointA[1];
$fLonFrom = $asPointA[0];
$fLatTo = $asPointB[1];
$fLonTo = $asPointB[0];
$fRad = M_PI / 180;
//Calculate distance from latitude and longitude
$fTheta = $fLonFrom - $fLonTo;
$fDistance = sin($fLatFrom * $fRad) * sin($fLatTo * $fRad) + cos($fLatFrom * $fRad) * cos($fLatTo * $fRad) * cos($fTheta * $fRad);
return acos($fDistance) / $fRad * 60 * 1.853;
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\ToolBox;
class Gpx extends Geo {
public const EXT = '.gpx';
public function __construct($sCodeName) {
parent::__construct($sCodeName);
$this->parseFile();
}
public function getTracks() {
return $this->asTracks;
}
private function parseFile() {
$this->addNotice('Parsing: '.$this->sFilePath);
if(!file_exists($this->sFilePath)) $this->addError($this->sFilePath.' file missing');
else {
$oXml = simplexml_load_file($this->sFilePath);
//Tracks
$this->addNotice('Converting '.count($oXml->trk).' tracks');
foreach($oXml->trk as $aoTrack) {
$asTrack = [
'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'=> []
];
foreach($aoTrack->trkseg as $asSegment) {
foreach($asSegment as $asPoint) {
$asTrack['points'][] = [
'lon' => (float) $asPoint['lon'],
'lat' => (float) $asPoint['lat'],
'ele' => (int) $asPoint->ele
];
}
}
$this->asTracks[] = $asTrack;
}
//Waypoints
$this->addNotice('Ignoring '.count($oXml->wpt).' waypoints');
}
}
}
+952
View File
@@ -0,0 +1,952 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\Db;
use Franzz\Objects\Main;
use Franzz\Objects\Translator;
use Franzz\Objects\ToolBox;
use \Settings;
/* Timezones
* ---------
* Site Time: Timestamp converted to the Timezone from which the user is viewing the Site (default PHP/SQL Timezone)
* Local Time: Timestamp converted to the Timezone from which the content (media/post/message) has been sent (Local Timezone stored in timezone field)
*
* - Feeds (table `feeds`):
* - last_update: timestamp in Site Time
* - Spot Messages (table `messages`):
* - unix_time: UNIX (int) in UTC
* - site_time: timestamp in Site Time
* - iso_time: raw ISO 8601 in UTC or Local Time (spot messages are unreliable, timezone is then calculated from GPS coordinates)
* - posted_on: timestamp in Site Time
* - timezone: Local Timezone
* - Medias (table `medias`):
* - posted_on: timestamp in Site Time
* - taken_on: timestamp in Site Time
* - timezone: Local Timezone
* - Posts (table `posts`):
* - site_time: timestamp in Site Time
* - timezone: Local Timezone
* - Users (table `users`):
* - timezone: Site Timezone (stored user's timezone for emails)
*/
class Livetrail extends Main {
//Database
public const POST_TABLE = 'posts';
private const REF_TYPES = ['post', 'media', 'message'];
private const FEED_CHUNK_SIZE = 15;
private const MAIL_CHUNK_SIZE = 5;
public const DEFAULT_LANG = 'en';
public const PROJECT_NAME = 'LiveTrail';
private const MAIN_PAGE = 'index';
private const VITE_APP = 'src/app.js';
private Project $oProject;
private Media $oMedia;
private User $oUser;
private Map $oMap;
public function __construct($sProcessPage, $sTimezone) {
parent::__construct($sProcessPage, true, $sTimezone);
$this->oUser = new User($this->oDb);
$this->oLang = new Translator('', self::DEFAULT_LANG);
$this->oProject = new Project($this->oDb);
$this->oMedia = new Media($this->oDb, $this->oProject);
$this->oMap = new Map($this->oDb);
}
protected function install() {
//Install DB
$this->oDb->install();
//Add first user
$iUserId = $this->oDb->insertRow(User::USER_TABLE, [
'name' => 'Admin',
'email' => 'admin@admin.com',
'language' => self::DEFAULT_LANG,
'timezone' => date_default_timezone_get(),
'subscribed'=> User::USER_SUBSCRIBED,
'clearance' => User::CLEARANCE_ADMIN
]);
$this->oUser->setUserId($iUserId);
}
protected function getSqlOptions() {
return
[
'tables' =>
[
Feed::MSG_TABLE => ['ref_msg_id', Db::getId(Feed::FEED_TABLE), 'type', 'latitude', 'longitude', 'iso_time', 'site_time', 'timezone', 'unix_time', 'content', 'battery_state', 'posted_on', 'weather_icon', 'weather_cond', 'weather_temp', 'display'],
Feed::FEED_TABLE => ['ref_feed_id', Db::getId(Feed::SPOT_TABLE), Db::getId(Project::PROJ_TABLE), 'name', 'description', 'status', 'last_update'],
Feed::SPOT_TABLE => ['ref_spot_id', 'name', 'model'],
Project::PROJ_TABLE => ['name', 'codename', 'active_from', 'active_to'],
self::POST_TABLE => [Db::getId(Project::PROJ_TABLE), Db::getId(User::USER_TABLE), 'name', 'content', 'site_time', 'timezone', 'ref_id', 'ref_type'],
Media::MEDIA_TABLE => [Db::getId(Project::PROJ_TABLE), 'filename', 'type', 'taken_on', 'posted_on', 'timezone', 'latitude', 'longitude', 'altitude', 'width', 'height', 'rotate', 'comment'],
User::USER_TABLE => ['name', 'email', 'password', 'token', 'token_exp', 'gravatar', 'language', 'timezone', 'subscribed', 'clearance'],
Map::MAP_TABLE => ['codename', 'pattern', 'token', 'tile_size', 'min_zoom', 'max_zoom', 'attribution'],
Map::MAPPING_TABLE => [Db::getId(Map::MAP_TABLE) , Db::getId(Project::PROJ_TABLE)]
],
'types' =>
[
'clearance' => 'TINYINT(1) DEFAULT '.User::CLEARANCE_USER,
'active_from' => 'TIMESTAMP DEFAULT 0',
'active_to' => 'TIMESTAMP DEFAULT 0',
'battery_state' => 'VARCHAR(10)',
'codename' => 'VARCHAR(100)',
'content' => 'LONGTEXT',
'comment' => 'LONGTEXT',
'description' => 'VARCHAR(100)',
'email' => 'VARCHAR(320) NOT NULL',
'filename' => 'VARCHAR(100) NOT NULL',
'iso_time' => 'VARCHAR(24)',
'language' => 'VARCHAR(2)',
'last_update' => 'TIMESTAMP DEFAULT 0',
'latitude' => 'DECIMAL(8,6)',
'longitude' => 'DECIMAL(9,6)',
'altitude' => 'SMALLINT',
'model' => 'VARCHAR(20)',
'name' => 'VARCHAR(100)',
'pattern' => 'VARCHAR(200) NOT NULL',
'password' => "VARCHAR(255) NOT NULL DEFAULT ''",
'posted_on' => 'TIMESTAMP DEFAULT 0',
'ref_feed_id' => 'VARCHAR(40)',
'ref_msg_id' => 'VARCHAR(15)',
'ref_spot_id' => 'VARCHAR(10)',
'ref_id' => 'INT UNSIGNED',
'ref_type' => 'VARCHAR(20)',
'rotate' => 'SMALLINT',
'site_time' => 'TIMESTAMP DEFAULT 0', //DEFAULT 0 removes auto-set to current time
'status' => 'VARCHAR(10)',
'subscribed' => 'BOOLEAN DEFAULT '.User::USER_UNSUBSCRIBED,
'taken_on' => 'TIMESTAMP DEFAULT 0',
'timezone' => 'CHAR(64) NOT NULL', //see mysql.time_zone_name
'token' => 'VARCHAR(4096)',
'token_exp' => 'TIMESTAMP DEFAULT 0',
'type' => 'VARCHAR(20)',
'unix_time' => 'INT',
'min_zoom' => 'TINYINT UNSIGNED',
'max_zoom' => 'TINYINT UNSIGNED',
'attribution' => 'VARCHAR(100)',
'gravatar' => 'LONGTEXT',
'weather_icon' => 'VARCHAR(30)',
'weather_cond' => 'VARCHAR(30)',
'weather_temp' => 'DECIMAL(3,1)',
'tile_size' => 'SMALLINT UNSIGNED DEFAULT 256',
'width' => 'INT',
'height' => 'INT',
'display' => 'BOOLEAN DEFAULT '.Feed::MSG_DISPLAYED
],
'constraints' =>
[
Feed::MSG_TABLE => ['UNIQUE KEY `uni_ref_msg_id` (`ref_msg_id`)', 'INDEX(`ref_msg_id`)'],
Feed::FEED_TABLE => ['UNIQUE KEY `uni_ref_feed_id` (`ref_feed_id`)', 'INDEX(`ref_feed_id`)'],
Feed::SPOT_TABLE => ['UNIQUE KEY `uni_ref_spot_id` (`ref_spot_id`)', 'INDEX(`ref_spot_id`)'],
Project::PROJ_TABLE => 'UNIQUE KEY `uni_proj_name` (`codename`)',
Media::MEDIA_TABLE => 'UNIQUE KEY `uni_file_name` (`filename`)',
User::USER_TABLE => 'UNIQUE KEY `uni_email` (`email`)',
Map::MAP_TABLE => 'UNIQUE KEY `uni_map_name` (`codename`)',
Map::MAPPING_TABLE => 'default_on_generic_map_only CHECK (`default_map` = 0 OR `id_project` IS NULL)'
],
'cascading_delete' =>
[
Feed::SPOT_TABLE => [Feed::FEED_TABLE],
Feed::FEED_TABLE => [Feed::MSG_TABLE],
Project::PROJ_TABLE => [Feed::FEED_TABLE, Media::MEDIA_TABLE, self::POST_TABLE, Map::MAPPING_TABLE],
Map::MAP_TABLE => [Map::MAPPING_TABLE]
]
];
}
public function getAppMainPage(string $sCsrfToken='') {
$asViteAssets = $this->getViteAssets();
return parent::getMainPage(
[
'projects' => $this->oProject->getProjects(),
'user' => $this->oUser->getUserInfo(),
'consts' => [
'modes' => Project::MODES,
'clearances' => User::CLEARANCES,
'default_timezone' => Settings::TIMEZONE,
'default_maps' => $this->oMap->getProjectMaps(-1),
'chunk_size' => self::FEED_CHUNK_SIZE,
'hash_sep' => '-',
'title' => self::PROJECT_NAME,
'default_page' => 'project',
'csrf_token' => $sCsrfToken
]
],
self::MAIN_PAGE,
[
'tags' => [
'language' => $this->oLang->getLanguage(),
'title' => self::PROJECT_NAME,
'app_entry' => $asViteAssets['app']
],
'instances' => [
'css' => $asViteAssets['css'],
'module' => $asViteAssets['module']
]
]
);
}
private function getViteAssets() {
$asManifest = json_decode(file_get_contents(__DIR__.'/../public/.vite/manifest.json'), true);
$asAppImport = $asManifest[self::VITE_APP];
//Recursive search for chunk imports
$asImports = [];
$asSeenImports = [self::VITE_APP => true];
$this->appendViteImportedChunks($asManifest, $asAppImport, $asSeenImports, $asImports);
//CSS
$asCssFiles = [];
foreach(array_merge([$asAppImport], $asImports) as $asChunk) {
foreach($asChunk['css'] ?? [] as $sCssFile) $asCssFiles[] = $sCssFile;
}
//Modules
$asModuleFiles = [];
foreach($asImports as $asImport) {
if(str_ends_with($asImport['file'] ?? '', '.js')) $asModuleFiles[] = $asImport['file'];
}
return [
'app' => $asAppImport['file'],
'css' => $this->getViteAssetInstances($asCssFiles),
'module' => $this->getViteAssetInstances($asModuleFiles)
];
}
private function appendViteImportedChunks($asManifest, $asChunk, &$asSeenImports, &$asImports) {
foreach($asChunk['imports'] ?? [] as $sImport) {
if(isset($asSeenImports[$sImport]) || !isset($asManifest[$sImport])) continue;
$asSeenImports[$sImport] = true;
$this->appendViteImportedChunks($asManifest, $asManifest[$sImport], $asSeenImports, $asImports);
$asImports[] = $asManifest[$sImport];
}
}
private function getViteAssetInstances($asFilePaths) {
return array_map(
function($sFilePath) { return ['filename' => $sFilePath]; },
$asFilePaths
);
}
public function checkUserClearance($iClearance) {
return $this->oUser->checkUserClearance($iClearance);
}
/* Managing projects */
public function setProjectId($iProjectId=0) {
$this->oProject->setProjectId($iProjectId);
}
public function updateProject() {
$bNewMsg = false;
$bSuccess = true;
$sLangId = '';
//Update all feeds belonging to the project
$asFeeds = $this->oProject->getFeedIds();
foreach($asFeeds as $iFeedId) {
$oFeed = new Feed($this->oDb, $iFeedId);
$bNewMsg = $bNewMsg || $oFeed->checkUpdateFeed($this->oProject->getMode());
}
//Send Update Email
if($bNewMsg) {
$bSuccess = $this->sendEmail();
$sLangId = $bSuccess?'email.sent':'email.failure';
}
else $sLangId = 'spot.no_new_msg';
return self::getJsonResult($bSuccess, $sLangId);
}
private function sendEmail() {
$oEmail = new Email($this->asContext['serv_name'], 'email.update');
$oEmail->setDestInfo($this->oUser->getSubscribedUsersInfo());
//Add Position
$asSpotMessages = $this->getSpotMessages([$this->oProject->getLastMessageId($this->getFeedConstraints(Feed::MSG_TABLE))]);
$asLastMessage = array_shift($asSpotMessages);
$oEmail->oTemplate->setTags($asLastMessage);
$oEmail->oTemplate->setTag('date_time', 'time:'.$asLastMessage['unix_time'], 'd/m/Y, H:i');
//Add latest news feed
$asNews = $this->getNextFeed(0, true);
$iPostCount = 0;
foreach($asNews as $asPost) {
if($asPost['type'] != 'message') {
$oEmail->oTemplate->newInstance('news');
$oEmail->oTemplate->setInstanceTags('news', [
'local_server' => $this->asContext['serv_name'],
'project' => $this->oProject->getProjectCodeName(),
'type' => $asPost['type'],
'id' => $asPost['id_'.$asPost['type']]]
);
$oEmail->oTemplate->addInstance($asPost['type'], $asPost);
$oEmail->oTemplate->setInstanceTag($asPost['type'], 'local_server', $this->asContext['serv_name']);
$iPostCount++;
}
if($iPostCount == self::MAIL_CHUNK_SIZE) break;
}
return $oEmail->send();
}
public function getMarkers($asMessageIds=[], $asMediaIds=[], $bInternal=false) {
//Get messages
$asMessages = $this->getSpotMessages($asMessageIds);
foreach($asMessages as &$asMessage) {
$asMessage['id'] = $asMessage[Db::getId(Feed::MSG_TABLE)];
$asMessage['type'] = 'message';
$asMessage['subtype'] = 'message';
}
//Get Geo-positioned Medias
//FIXME Make more efficient than requesting images twice from DB
$asMedias = $this->getMedias('taken_on', $asMediaIds);
$asGeoMedias = $this->getMedias('posted_on', $asMediaIds, true);
foreach($asGeoMedias as &$asGeoMedia) {
$iId = $asGeoMedia[Db::getId(Media::MEDIA_TABLE)];
unset($asGeoMedia[Db::getId(Media::MEDIA_TABLE)]);
$asGeoMedia['id'] = $iId;
$asGeoMedia['type'] = 'media';
$asGeoMedia['medias'] = array_values(array_filter($asMedias, function($asMedia) use ($iId) {
return $asMedia['id_media'] == $iId;
}));
}
//Assign medias to closest message
if(!empty($asMessages)) {
usort($asMessages, function($a, $b) {return (int) $a['unix_time'] <=> (int) $b['unix_time'];});
usort($asMedias, function($a, $b) {return (int) $a['unix_time'] <=> (int) $b['unix_time'];});
$iIndex = 0;
$iMaxIndex = count($asMessages) - 1;
foreach($asMedias as $asMedia) {
while($iIndex <= $iMaxIndex && $asMedia['unix_time'] > $asMessages[$iIndex]['unix_time']) $iIndex++;
//All medias before first message or after last message are assigned to first/last message respectively
if($iIndex == 0) $iMsgIndex = $iIndex;
elseif($iIndex > $iMaxIndex) $iMsgIndex = $iMaxIndex;
else {
$iHalfWayPoint = ($asMessages[$iIndex - 1]['unix_time'] + $asMessages[$iIndex]['unix_time'])/2;
$iMsgIndex = ($asMedia['unix_time'] >= $iHalfWayPoint)?$iIndex:($iIndex - 1);
}
$asMessages[$iMsgIndex]['medias'][] = $asMedia;
}
}
//Combine markers
$asMarkers = [...$asMessages, ...$asGeoMedias];
usort($asMarkers, function($a, $b) {return (int) $a['unix_time'] <=> (int) $b['unix_time'];});
$asResult = [
'markers' => $asMarkers,
'maps' => $this->oMap->getProjectMaps($this->oProject->getProjectId())
];
return $bInternal?$asResult:self::getJsonResult(true, '', $asResult);
}
public function getLastUpdate() {
$asLastUpdate = [];
$this->addTimeStamp($asLastUpdate, $this->oProject->getLastUpdate());
return self::getJsonResult(true, '', $asLastUpdate);
}
public function login($sEmail, $sPassword, $sNickName) {
$asResult = $this->oUser->login($sEmail, $sPassword, $this->oLang->getLanguage(), date_default_timezone_get(), $sNickName);
if($asResult['result'] && $asResult['data']['subscribe']) return $this->subscribe();
else return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $this->oUser->getUserInfo(), $asResult['desc_lang_params']);
}
public function logout() {
$asResult = $this->oUser->logout();
return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], User::DEFAULT_USER, $asResult['desc_lang_params']);
}
public function subscribe() {
$asResult = $this->oUser->setSubscription(true);
$asUserInfo = $this->oUser->getUserInfo();
if($asResult['result'] && !Settings::DEBUG) {
$oConfEmail = new Email($this->asContext['serv_name'], 'email.confirmation');
$oConfEmail->setDestInfo($asUserInfo);
$oConfEmail->send();
}
return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $asUserInfo, $asResult['desc_lang_params']);
}
public function unsubscribe() {
$asResult = $this->oUser->setSubscription(false);
return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $this->oUser->getUserInfo(), $asResult['desc_lang_params']);
}
private function getSpotMessages($asMsgIds=[]) {
$asConstraints = $this->getFeedConstraints(Feed::MSG_TABLE);
if(!empty($asMsgIds)) {
$asConstraints['constraint'][Db::getId(Feed::MSG_TABLE)] = $asMsgIds;
$asConstraints['constOpe'][Db::getId(Feed::MSG_TABLE)] = 'IN';
}
$asCombinedMessages = [];
//Get messages from all feeds belonging to the project
$asFeeds = $this->oProject->getFeedIds();
foreach($asFeeds as $iFeedId) {
$oFeed = new Feed($this->oDb, $iFeedId);
$asMessages = $oFeed->getMessages($asConstraints);
foreach($asMessages as $asMessage) {
$asMessage['latitude'] = floatval($asMessage['latitude']);
$asMessage['longitude'] = floatval($asMessage['longitude']);
$asMessage['lat_dms'] = self::decToDms($asMessage['latitude'], 'lat');
$asMessage['lon_dms'] = self::decToDms($asMessage['longitude'], 'lon');
$asMessage['displayed_id'] = $asMessage[Db::getId(Feed::MSG_TABLE)];
$asMessage['static_img_url'] = $this->oMap->getMapUrl('static', ['x'=>$asMessage['longitude'], 'y'=>$asMessage['latitude']]);
$asMessage['marker_img_url'] = $this->oMap->getMapUrl('static_marker', ['x'=>$asMessage['longitude'], 'y'=>$asMessage['latitude']]);
$this->addTimeStamp($asMessage, $asMessage['unix_time'], $asMessage['timezone']);
$asCombinedMessages[] = $asMessage;
}
}
return $asCombinedMessages;
}
/**
* Get valid medias based on $sTimeRefField:
* - taken_on: Date/time on which the media was taken
* - posted_on: Date/time on which the media was uploaded
* @param String $sTimeRefField Field to calculate relative times: 'taken_on' or 'posted_on'
* @return Array Medias info
*/
private function getMedias($sTimeRefField, $asMediaIds=[], $bOnlyGeoMedia=false) {
//Constraints
$asConstraints = $this->getFeedConstraints(Media::MEDIA_TABLE, $sTimeRefField);
if(!empty($asMediaIds)) {
$asConstraints['constOpe'][Db::getId(Media::MEDIA_TABLE)] = 'IN';
$asConstraints['constraint'][Db::getId(Media::MEDIA_TABLE)] = $asMediaIds;
}
if($bOnlyGeoMedia) {
$asConstraints['constOpe']['latitude'] = ' IS NOT ';
$asConstraints['constraint']['latitude'] = 'NULL';
$asConstraints['constOpe']['longitude'] = ' IS NOT ';
$asConstraints['constraint']['longitude'] = 'NULL';
}
$asMedias = $this->oMedia->getMediasInfo($asConstraints);
foreach($asMedias as &$asMedia) {
$asMedia['displayed_id'] = $asMedia[Db::getId(Media::MEDIA_TABLE)];
$this->addTimeStamp($asMedia, strtotime($asMedia[$sTimeRefField]), $asMedia['timezone']);
$this->addTimeStamp($asMedia, strtotime($asMedia['taken_on']), $asMedia['timezone'], 'taken_on');
$this->addTimeStamp($asMedia, strtotime($asMedia['posted_on']), $asMedia['timezone'], 'posted_on');
if($asMedia['latitude'] != '' && $asMedia['longitude'] != '') {
$asMedia['lat_dms'] = self::decToDms($asMedia['latitude'], 'lat');
$asMedia['lon_dms'] = self::decToDms($asMedia['longitude'], 'lon');
}
unset($asMedia['taken_on']);
unset($asMedia['posted_on']);
}
return $asMedias;
}
private function getPosts($asPostIds=[], $bResolveRef=true) {
$asInfo = [
'select' => [Db::getFullColumnName(self::POST_TABLE, '*'), 'gravatar'],
'from' => self::POST_TABLE,
'join' => [User::USER_TABLE => Db::getId(User::USER_TABLE)]
];
$asInfo = array_merge($asInfo, $this->getFeedConstraints(self::POST_TABLE));
if(!empty($asPostIds)) {
$asInfo['constraint'][Db::getId(self::POST_TABLE)] = $asPostIds;
$asInfo['constOpe'][Db::getId(self::POST_TABLE)] = 'IN';
}
$asPosts = $this->oDb->selectRows($asInfo);
foreach($asPosts as &$asPost) {
$iUnixTimeStamp = strtotime($asPost['site_time']); //assumes site timezone
$asPost['formatted_name'] = Toolbox::mb_ucwords($asPost['name']);
unset($asPost[Db::getId(User::USER_TABLE)]);
$this->addTimeStamp($asPost, $iUnixTimeStamp, $asPost['timezone']);
if($bResolveRef && !empty($asPost['ref_type']) && !empty($asPost['ref_id'])) {
switch($asPost['ref_type']) {
case 'post': $asRefs = $this->getPosts([$asPost['ref_id']], false); break;
case 'media': $asRefs = $this->getMedias('posted_on', [$asPost['ref_id']]); break;
case 'message': $asRefs = $this->getSpotMessages([$asPost['ref_id']]); break;
default: $asRefs = [];
}
$asPost['ref_item'] = reset($asRefs);
$asPost['ref_item']['type'] = $asPost['ref_type'];
$asPost['ref_item']['id'] = $asPost['ref_id'];
}
unset($asPost['ref_id']);
unset($asPost['ref_type']);
}
return $asPosts;
}
private function addTimeStamp(&$asData, $iTime, $sTimeZone='', $sPrefix='') {
if($sPrefix != '') $sPrefix = $sPrefix.'_';
$asData[$sPrefix.'unix_time'] = (int) $iTime;
$asData[$sPrefix.'relative_time'] = Toolbox::getDateTimeDesc($iTime, $this->oLang->getLanguage());
$asData[$sPrefix.'formatted_time'] = $this->getTimeFormat($iTime);
if($sTimeZone != '') {
$asData[$sPrefix.'formatted_time_local'] = $this->getTimeFormat($iTime, $sTimeZone);
$asData[$sPrefix.'day_offset'] = self::getTimeZoneDayOffset($iTime, $sTimeZone);
}
}
private function getFeedConstraints($sType, $sTimeField='site_time', $sReturnFormat='array') {
$asConsArray = [];
$sConsSql = '';
$asActPeriod = $this->oProject->getActivePeriod();
//Filter on Project ID
$sConsSql = 'WHERE '.Db::getId(Project::PROJ_TABLE).' = '.$this->oProject->getProjectId();
$asConsArray = [
'constraint'=> [Db::getId(Project::PROJ_TABLE) => $this->oProject->getProjectId()],
'constOpe' => [Db::getId(Project::PROJ_TABLE) => '=']
];
//Time Filter
switch($sType) {
case Feed::MSG_TABLE:
$asConsArray['constraint'][$sTimeField] = $asActPeriod;
$asConsArray['constOpe'][$sTimeField] = 'BETWEEN';
$asConsArray['constraint']['display'] = Feed::MSG_DISPLAYED;
$asConsArray['constOpe']['display'] = '=';
$sConsSql .= ' AND '.$sTimeField." BETWEEN '".$asActPeriod['from']."' AND '".$asActPeriod['to']."' AND display = ".Feed::MSG_DISPLAYED;
break;
case Media::MEDIA_TABLE:
$asConsArray['constraint'][$sTimeField] = $asActPeriod['to'];
$asConsArray['constOpe'][$sTimeField] = '<=';
$sConsSql .= ' AND '.$sTimeField." <= '".$asActPeriod['to']."'";
break;
case self::POST_TABLE:
$asConsArray['constraint'][$sTimeField] = $asActPeriod['to'];
$asConsArray['constOpe'][$sTimeField] = '<=';
$sConsSql .= ' AND '.$sTimeField." <= '".$asActPeriod['to']."'";
break;
}
return ($sReturnFormat=='array')?$asConsArray:$sConsSql;
}
public function getNewFeed($iRefIdFirst) {
$asResult = [];
$sLangId = '';
if($this->oProject->isEditable()) {
$asMessageIds = $asMediaIds = [];
//New Feed Items
$asResult = $this->getFeed($iRefIdFirst, '>', 'DESC');
foreach($asResult['feed'] as $asItem) {
switch($asItem['type']) {
case 'message':
$asMessageIds[] = $asItem['id'];
break;
case 'media':
$asMediaIds[] = $asItem['id'];
break;
}
}
//New Markers
$asMarkers = $this->getMarkers(
empty($asMessageIds)?[0]:$asMessageIds,
empty($asMediaIds)?[0]:$asMediaIds,
true
);
$asResult = array_merge($asResult, $asMarkers);
}
else $sLangId = 'project.modes.histo';
return self::getJsonResult(true, $sLangId, $asResult);
}
public function getNextFeed($iRefIdLast=0, $bInternal=false) {
if($this->oProject->getMode() == Project::MODE_HISTO) {
$sDirection = '>';
$sSort = 'ASC';
}
else {
$sDirection = '<';
$sSort = 'DESC';
}
$asResult = $this->getFeed($iRefIdLast, $sDirection, $sSort);
return $bInternal?$asResult['feed']:self::getJsonResult(true, '', $asResult);
}
private function getFeed($iRefId, $sDirection, $sSort) {
$sRefId = is_scalar($iRefId) && preg_match('/^\d+(?:\.\d+)?$/D', (string) $iRefId) ? (string) $iRefId : '0';
$sDirection = ($sDirection === '>')?'>':'<';
$sSort = ($sSort === 'ASC')?'ASC':'DESC';
$sProjectIdField = Db::getId(Project::PROJ_TABLE);
$sMsgIdField = Db::getId(Feed::MSG_TABLE);
$sMediaIdField = Db::getId(Media::MEDIA_TABLE);
$sPostIdField = Db::getId(self::POST_TABLE);
$sFeedIdField = Db::getId(Feed::FEED_TABLE);
$sQuery = implode(' ', [
'SELECT type, id, ref',
'FROM (',
"SELECT {$sProjectIdField}, {$sMsgIdField} AS id, 'message' AS type, CONCAT(UNIX_TIMESTAMP(site_time), '.0', {$sMsgIdField}) AS ref",
'FROM '.Feed::MSG_TABLE,
'INNER JOIN '.Feed::FEED_TABLE." USING({$sFeedIdField})",
$this->getFeedConstraints(Feed::MSG_TABLE, 'site_time', 'sql'),
'UNION',
"SELECT {$sProjectIdField}, {$sMediaIdField} AS id, 'media' AS type, CONCAT(UNIX_TIMESTAMP(posted_on), '.1', {$sMediaIdField}) AS ref",
'FROM '.Media::MEDIA_TABLE,
$this->getFeedConstraints(Media::MEDIA_TABLE, 'posted_on', 'sql'),
'UNION',
"SELECT {$sProjectIdField}, {$sPostIdField} AS id, 'post' AS type, CONCAT(UNIX_TIMESTAMP(site_time), '.2', {$sPostIdField}) AS ref",
'FROM '.self::POST_TABLE,
$this->getFeedConstraints(self::POST_TABLE, 'site_time', 'sql'),
') AS items',
($sRefId !== '0')?('WHERE ref '.$sDirection.' '.$sRefId):'',
'ORDER BY ref '.$sSort,
'LIMIT '.self::FEED_CHUNK_SIZE
]);
//Get new chunk
$asItems = $this->oDb->getArrayQuery($sQuery, true);
//Update Reference Point with latest/earliest value
$iRefIdFirst = $iRefIdLast = 0;
if(!empty($asItems)) {
$iRefIdLast = end($asItems)['ref'];
$iRefIdFirst = reset($asItems)['ref'];
}
//Sort Table IDs by type & Get attributes
$asFeedIds = ['message'=>[], 'media'=>[], 'post'=>[]];
foreach($asItems as $asItem) {
$asFeedIds[$asItem['type']][$asItem['id']] = $asItem;
}
$asFeedAttrs = [
'message' => empty($asFeedIds['message'])?[]:$this->getSpotMessages(array_keys($asFeedIds['message'])),
'media' => empty($asFeedIds['media'])?[]:$this->getMedias('posted_on', array_keys($asFeedIds['media'])),
'post' => empty($asFeedIds['post'])?[]:$this->getPosts(array_keys($asFeedIds['post']))
];
//Replace Array Key with Item ID
$asFeeds = [];
foreach($asFeedAttrs as $sType=>$asFeedAttr) {
foreach($asFeedAttr as $asFeed) {
$asFeeds[$sType][$asFeed['id_'.$sType]] = $asFeed;
}
}
//Assign
foreach($asItems as &$asItem) {
$asItem = array_merge($asFeeds[$asItem['type']][$asItem['id']], $asItem);
}
return ['ref_id_last'=>$iRefIdLast, 'ref_id_first'=>$iRefIdFirst, 'sort'=>$sSort, 'feed'=>$asItems];
}
public function addPost($sName, $sPost, $sRefType='', $iRefId=0) {
$iPostId = 0;
$sLangId = '';
if($this->oProject->isEditable()) {
$bHasRef = ($iRefId > 0) && in_array($sRefType, self::REF_TYPES, true);
$asData = [
Db::getId(Project::PROJ_TABLE) => $this->oProject->getProjectId(),
'name' => mb_strtolower(trim($sName)),
'content' => trim($sPost),
'site_time' => date(Db::TIMESTAMP_FORMAT), //Now in Site Time
'timezone' => date_default_timezone_get(), //Site Time Zone
'ref_id' => $bHasRef?$iRefId:'NULL',
'ref_type' => $bHasRef?$sRefType:'NULL'
];
if($this->oUser->getUserId() > 0) $asData[Db::getId(User::USER_TABLE)] = $this->oUser->getUserId();
$iPostId = $this->oDb->insertRow(self::POST_TABLE, $asData);
if($iPostId == 0) $sLangId = 'error.commit_db';
$this->oUser->updateNickname($sName);
}
else $sLangId = 'project.modes.histo';
return self::getJsonResult(($iPostId > 0), $sLangId);
}
public function upload() {
$oUploader = new Uploader($this->oMedia);
return $oUploader->sBody;
}
public function addComment($iMediaId, $sComment) {
$oMedia = new Media($this->oDb, $this->oProject, $iMediaId);
$asResult = $oMedia->setComment($sComment);
return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $asResult['data'], $asResult['desc_lang_params']);
}
public function addPosition($sLat, $sLng, $iTimestamp) {
$oFeed = new Feed($this->oDb, $this->oProject->getFeedIds()[0]);
$bSuccess = ($oFeed->addManualPosition($sLat, $sLng, $iTimestamp) > 0);
if($bSuccess) {
$bSuccess = $this->sendEmail();
$sLangId = $bSuccess?'email.sent':'email.failure';
}
else $sLangId = 'error.commit_db';
return self::getJsonResult($bSuccess, $sLangId);
}
public function getAdminSettings() {
$oFeed = new Feed($this->oDb);
$asData = [
'project' => $this->oProject->getProjects(),
'feed' => $oFeed->getFeeds(),
'spot' => $oFeed->getSpots(),
'user' => $this->oUser->getSubscribedUsersInfo()
];
foreach($asData['project'] as &$asProject) {
$asProject['active_from'] = substr($asProject['active_from'], 0, 10);
$asProject['active_to'] = substr($asProject['active_to'], 0, 10);
}
return self::getJsonResult(true, '', $asData);
}
public function setAdminSettings($sType, $iId, $sField, $sValue) {
$bSuccess = false;
$sLangId = '';
$asLangParams = [];
$asResult = [];
if($this->oDb->isId($sField) && $sValue <= 0) return self::getJsonResult(false, 'error.impossible_value', [], [$sValue, $sField]);
switch($sType) {
case 'project':
$oProject = new Project($this->oDb, $iId);
switch($sField) {
case 'name':
$bSuccess = $oProject->setProjectName($sValue);
break;
case 'codename':
$bSuccess = $oProject->setProjectCodeName($sValue);
break;
case 'active_from':
$bSuccess = $oProject->setActivePeriod($sValue.' 00:00:00', 'from');
break;
case 'active_to':
$bSuccess = $oProject->setActivePeriod($sValue.' 23:59:59', 'to');
break;
default:
$sLangId = 'error.unknown_field';
$asLangParams = [$sField];
}
//Identify missing GPX file
$sProjectCodeName = ($sField == 'codename')?$sValue:$oProject->getProjectCodeName();
if(!Converter::hasGpxFile($sProjectCodeName)) {
$bSuccess = true;
$sLangId = 'error.file_missing';
$asLangParams = ['GPX', $sProjectCodeName.Gpx::EXT];
}
$asResult = $oProject->getProject();
$asResult['active_from'] = substr($asResult['active_from'], 0, 10);
$asResult['active_to'] = substr($asResult['active_to'], 0, 10);
break;
case 'feed':
$oFeed = new Feed($this->oDb, $iId);
switch($sField) {
case 'ref_feed_id':
$bSuccess = $oFeed->setRefFeedId($sValue);
break;
case 'id_spot':
$bSuccess = $oFeed->setSpotId($sValue);
break;
case 'id_project':
$bSuccess = $oFeed->setProjectId($sValue);
break;
default:
$sLangId = 'error.unknown_field';
$asLangParams = [$sField];
}
$asResult = $oFeed->getFeed();
break;
case 'user':
switch($sField) {
case 'clearance':
$asReturnCode = $this->oUser->setUserClearance($iId, $sValue);
$bSuccess = $asReturnCode['result'];
$sLangId = $asReturnCode['desc_lang_id'];
$asLangParams = $asReturnCode['desc_lang_params'];
break;
default:
$sLangId = 'error.unknown_field';
$asLangParams = [$sField];
}
$asResult = $this->oUser->getUserById($iId);
break;
}
if(!$bSuccess && $sLangId=='') $sLangId = 'error.commit_db';
return self::getJsonResult($bSuccess, $sLangId, [$sType=>[$asResult]], $asLangParams);
}
public function createAdminSettings($sType) {
$bSuccess = false;
$sLangId = '';
$asResult = [];
switch($sType) {
case 'project':
$oProject = new Project($this->oDb);
$iNewProjectId = $oProject->createProjectId();
$oFeed = new Feed($this->oDb);
$oFeed->createFeedId($iNewProjectId);
$bSuccess = $iNewProjectId > 0;
$asResult = [
'project' => [$oProject->getProject()],
'feed' => [$oFeed->getFeed()]
];
break;
case 'feed':
$oFeed = new Feed($this->oDb);
$iNewFeedId = $oFeed->createFeedId($this->oProject->getProjectId());
$bSuccess = $iNewFeedId > 0;
$asResult = [
'feed' => [$oFeed->getFeed()]
];
break;
}
if(!$bSuccess && $sLangId=='') $sLangId = 'error.commit_db';
return self::getJsonResult($bSuccess, $sLangId, $asResult);
}
public function deleteAdminSettings($sType, $iId) {
$bSuccess = false;
$sLangId = '';
$asLangParams = [];
$asResult = [];
switch($sType) {
case 'project':
$oProject = new Project($this->oDb, $iId);
$asResult = $oProject->delete();
$sLangId = $asResult['project'][0]['desc_lang_id'];
$asLangParams = $asResult['project'][0]['desc_lang_params'];
$bSuccess = $asResult['project'][0]['del'];
break;
case 'feed':
$oFeed = new Feed($this->oDb, $iId);
$asResult = ['feed' => [$oFeed->delete()]];
$sLangId = $asResult['feed'][0]['desc_lang_id'];
$asLangParams = $asResult['feed'][0]['desc_lang_params'];
$bSuccess = $asResult['feed'][0]['result'];
break;
}
if(!$bSuccess && $sLangId=='') $sLangId = 'error.commit_db';
return self::getJsonResult($bSuccess, $sLangId, $asResult, $asLangParams);
}
public static function decToDms($dValue, $sType) {
if($sType=='lat') $sDirection = ($dValue >= 0)?'N':'S'; //Latitude
else $sDirection = ($dValue >= 0)?'E':'W'; //Longitude
$dLeft = abs($dValue);
//Degrees
$iDegree = floor($dLeft);
$dLeft -= $iDegree;
//Minutes
$iMinute = floor($dLeft * 60);
$dLeft -= $iMinute / 60;
//Seconds
$fSecond = round($dLeft * 3600, 1);
return
$iDegree.'°'.
self::getNumberWithLeadingZeros($iMinute, 2, 0)."'".
self::getNumberWithLeadingZeros($fSecond, 2, 1).'"'.
$sDirection;
}
public static function getNumberWithLeadingZeros($fValue, $iNbLeadingZeros, $iNbDigits) {
$sDecimalSeparator = '.';
if($iNbDigits > 0) $iNbLeadingZeros += mb_strlen($sDecimalSeparator) + $iNbDigits;
$sPattern = '%0'.$iNbLeadingZeros.$sDecimalSeparator.$iNbDigits.'f';
return sprintf($sPattern, $fValue);
}
public function getTimeFormat($iTime, $sTimeZone='') {
if($sTimeZone == '') $sTimeZone = date_default_timezone_get();
$oDate = new \DateTime('@'.$iTime);
$oDate->setTimezone(new \DateTimeZone($sTimeZone));
$sDate = $oDate->format('d/m/Y');
$sTime = $oDate->format('H:i');
return $this->oLang->getTranslation('time.date_time', [$sDate, $sTime]);
}
public static function getTimeZoneDayOffset($iTime, $sLocalTimeZone) {
$sSiteTimeZone = date_default_timezone_get();
$iLocalDate = (int) (new \DateTime('@'.$iTime))->setTimezone(new \DateTimeZone($sLocalTimeZone))->format('Ymd');
$iSiteDate = (int) (new \DateTime('@'.$iTime))->setTimezone(new \DateTimeZone($sSiteTimeZone ))->format('Ymd');
return ($iLocalDate == $iSiteDate)?'0':(($iLocalDate < $iSiteDate)?'+1':'-1');
}
public static function getTimeZoneFromDate($sDate) {
$sTimeZone = null;
preg_match('/(?<timezone>(\+|\-)\d{2}:?(\d{2}|))$/', $sDate, $asMatch);
if(array_key_exists('timezone', $asMatch)) {
$sTimeZone = $asMatch['timezone'];
//Complete short form: +12 => +1200
if(strlen($sTimeZone) == 3) $sTimeZone .= '00';
//Add colon: +1200 => +12:00
if(!strpos($sTimeZone, ':')) $sTimeZone = substr_replace($sTimeZone, ':', 3, 0);
}
return $sTimeZone;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use Franzz\Objects\Db;
class Map extends PhpObject {
public const MAP_TABLE = 'maps';
public const MAPPING_TABLE = 'mappings';
private Db $oDb;
private $asMaps;
public function __construct(Db &$oDb) {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
$this->asMaps = [];
}
private function setMaps() {
$asMaps = $this->oDb->selectRows(['from'=>self::MAP_TABLE]);
foreach($asMaps as $asMap) $this->asMaps[$asMap['codename']] = $asMap;
}
private function getMaps($sCodeName='') {
if(empty($this->asMaps)) $this->setMaps();
return ($sCodeName=='')?$this->asMaps:$this->asMaps[$sCodeName];
}
public function getProjectMaps($iProjectId) {
$asMappings = $this->oDb->selectRows(
[
'select' => [Db::getId(self::MAP_TABLE), 'default_map'],
'from' => self::MAPPING_TABLE,
'constraint'=> ["IFNULL(id_project, {$iProjectId})" => $iProjectId]
],
Db::getId(self::MAP_TABLE)
);
$asProjectMaps = [];
foreach($this->getMaps() as $asMap) {
if(array_key_exists($asMap['id_map'], $asMappings)) {
$asMap['default_map'] = $asMappings[$asMap['id_map']];
$asProjectMaps[] = $asMap;
}
}
return $asProjectMaps;
}
public function getMapUrl($sCodeName, $asParams) {
$asMap = $this->getMaps($sCodeName);
$asParams['token'] = $asMap['token'];
return self::populateParams($asMap['pattern'], $asParams);
}
private static function populateParams($sUrl, $asParams) {
foreach($asParams as $sParam=>$sValue) {
$sUrl = str_replace('{'.$sParam.'}', $sValue, $sUrl);
}
return $sUrl;
}
}
+327
View File
@@ -0,0 +1,327 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use Franzz\Objects\Db;
use Franzz\Objects\ToolBox;
class Media extends PhpObject {
//DB Tables
public const MEDIA_TABLE = 'medias';
//Media folders (works because /public/files is a symlink of /files)
public const MEDIA_FOLDER = 'files';
public const THUMB_FOLDER = self::MEDIA_FOLDER.'/thumbs';
private const THUMB_MAX_WIDTH = 400;
private Db $oDb;
private Project $oProject;
private $asMedia;
private $asMedias;
private $iMediaId;
public function __construct(Db &$oDb, Project &$oProject, $iMediaId=0) {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
$this->oProject = &$oProject;
$this->asMedia = [];
$this->asMedias = [];
$this->setMediaId($iMediaId);
}
public function setMediaId($iMediaId) {
$this->iMediaId = $iMediaId;
}
public function getMediaId() {
return $this->iMediaId;
}
public function getProjectCodeName() {
return $this->oProject->getProjectCodeName();
}
public function setComment($sComment) {
$sLangId = '';
$asData = [];
if($this->iMediaId > 0) {
$bResult = $this->oDb->updateRow(self::MEDIA_TABLE, $this->iMediaId, ['comment'=>$sComment]);
if(!$bResult) $sLangId = 'error.commit_db';
else $asData = $this->getInfo();
}
else $sLangId = 'media.no_id';
return Livetrail::getResult(($sLangId==''), $sLangId, $asData);
}
public function getMediasInfo($oMediaIds=null) {
$bOwnMedia = is_numeric($oMediaIds); //1 value
$bConstraintArray = is_array($oMediaIds); //Custom Constraints
if($bOwnMedia && empty($this->asMedia) || !$bOwnMedia && empty($this->asMedias) || $bConstraintArray) {
if($this->oProject->getProjectId()) {
$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'=> [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);
$asMedias = $this->oDb->selectRows($asParams);
foreach($asMedias as &$asMedia) {
$asMedia['media_path'] = self::getMediaPath($asMedia['filename']);
$asMedia['thumb_path'] = $this->getMediaThumbnail($asMedia['filename']);
}
if(!empty($asMedias) && !$bConstraintArray) {
if($bOwnMedia) $this->asMedia = array_shift($asMedias);
else $this->asMedias = $asMedias;
}
}
}
return $bOwnMedia?$this->asMedia:$asMedias ?? [];
}
public function getInfo() {
return $this->getMediasInfo($this->iMediaId);
}
public function isProjectEditable() {
return $this->oProject->isEditable();
}
public function addMedia($sMediaName, $sMethod='upload') {
$sLangId = '';
$asParams = [];
if(!$this->isProjectEditable() && $sMethod!='sync') {
$sLangId = 'upload.mode_archived';
$asParams[] = $this->oProject->getProjectCodeName();
}
elseif($this->oDb->pingValue(self::MEDIA_TABLE, ['filename'=>$sMediaName]) && $sMethod!='sync') {
$sLangId = 'upload.media.exists';
$asParams[] = $sMediaName;
}
else {
$asMediaInfo = $this->getMediaInfoFromFile($sMediaName);
//Converting times to Site Time Zone, by using date()
//Media Timezone is kept in a separate field for later conversion to Local Time
$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']),
'posted_on' => date(Db::TIMESTAMP_FORMAT, $asMediaInfo['file_ts']),
'timezone' => $asMediaInfo['timezone'],
'latitude' => is_null($asMediaInfo['latitude'])?'NULL':$asMediaInfo['latitude'],
'longitude' => is_null($asMediaInfo['longitude'])?'NULL':$asMediaInfo['longitude'],
'altitude' => is_null($asMediaInfo['altitude'])?'NULL':$asMediaInfo['altitude'],
'width' => $asMediaInfo['width'],
'height' => $asMediaInfo['height'],
'rotate' => $asMediaInfo['rotate'],
'type' => $asMediaInfo['type']
];
if($sMethod=='sync') $iMediaId = $this->oDb->insertUpdateRow(self::MEDIA_TABLE, $asDbInfo, ['filename']);
else $iMediaId = $this->oDb->insertRow(self::MEDIA_TABLE, $asDbInfo);
if(!$iMediaId) $sLangId = 'error.commit_db';
else {
$this->setMediaId($iMediaId);
$asParams = $this->getInfo(); //Creates thumbnail
}
}
return Livetrail::getResult(($sLangId==''), $sLangId, $asParams);
}
private function getMediaInfoFromFile($sMediaName) {
$sMediaPath = self::getMediaPath($sMediaName);
$sType = self::getMediaType($sMediaName);
$iPostedOn = filemtime($sMediaPath);
$sTimeZone = date_default_timezone_get();
$iWidth = 0;
$iHeight = 0;
$sRotate = '0';
$sTakenOn = '';
$fLat = null;
$fLng = null;
$iAlt = null;
switch($sType) {
case 'video':
$asResult = [];
$sParams = implode(' ', [
'-loglevel error', //Remove comments
'-select_streams v:0', //First video channel
'-show_entries '. //filter tags : Width, Height, Creation Time, Location & Rotation
'format_tags=creation_time,com.apple.quicktime.creationdate,com.apple.quicktime.location.ISO6709:'.
'stream_tags=rotate,creation_time:'.
'stream=width,height',
'-print_format json', //output format: json
'-i' //input file
]);
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 = Livetrail::getTimeZoneFromDate($sTakenOn) ?? $sTimeZone;
}
else $sTakenOn = $asExif['format']['tags']['creation_time'] ?? $asExif['streams'][0]['tags']['creation_time'];
//Location
if(isset($asExif['format']['tags']['com.apple.quicktime.location.ISO6709'])) {
list($fLat, $fLng, $iAlt) = self::getLatLngAltFromISO6709($asExif['format']['tags']['com.apple.quicktime.location.ISO6709']);
}
//Width & Height
$iWidth = $asExif['streams'][0]['width'];
$iHeight = $asExif['streams'][0]['height'];
//Orientation
if(isset($asExif['streams'][0]['tags']['rotate'])) $sRotate = $asExif['streams'][0]['tags']['rotate'];
break;
case 'image':
$asExif = @exif_read_data($sMediaPath, 0, true);
if($asExif === false) $asExif = [];
list($iWidth, $iHeight) = getimagesize($sMediaPath);
//Posted On
if(array_key_exists('FILE', $asExif) && array_key_exists('FileDateTime', $asExif['FILE'])) $iPostedOn = $asExif['FILE']['FileDateTime'];
//Taken On & Timezone
if(array_key_exists('EXIF', $asExif)) {
if(array_key_exists('DateTimeOriginal', $asExif['EXIF'])) $sTakenOn = $asExif['EXIF']['DateTimeOriginal'];
/* Priorities:
* 1. OffsetTimeOriginal: timezone for DateTimeOriginal (exif version >= 2.31)
* 2. 0x9011: same as above, but unidentified
* 3. Timezone extracted from DateTimeOriginal
* 4. Uploader Browser Time Zone (PHP Default Time Zone)
*/
$sTimeZone = $asExif['EXIF']['OffsetTimeOriginal'] ?? $asExif['EXIF']['UndefinedTag:0x9011'] ?? Livetrail::getTimeZoneFromDate($sTakenOn) ?? $sTimeZone;
}
//Location
if(array_key_exists('GPS', $asExif)) {
$asGps = $asExif['GPS'];
$fLat = self::getLatLngFromExif($asGps['GPSLatitudeRef'] ?? $asGps['UndefinedTag:0x0001'], $asGps['GPSLatitude'] ?? $asGps['UndefinedTag:0x0002']);
$fLng = self::getLatLngFromExif($asGps['GPSLongitudeRef'] ?? $asGps['UndefinedTag:0x0003'], $asGps['GPSLongitude'] ?? $$asGps['UndefinedTag:0x0004']);
$iAlt = (($asGps['GPSAltitudeRef'] ?? $asGps['UndefinedTag:0x0005'] ?? '0') == '1'?-1:1) * ($asGps['GPSAltitude'] ?? $asGps['UndefinedTag:0x0006'] ?? 0);
}
//Orientation
if(array_key_exists('IFD0', $asExif) && array_key_exists('Orientation', $asExif['IFD0'])) {
switch($asExif['IFD0']['Orientation']) {
case 1: $sRotate = '0'; break; //None
case 3: $sRotate = '180'; break; //Flip over
case 6: $sRotate = '90'; break; //Clockwise
case 8: $sRotate = '-90'; break; //Trigo
}
}
break;
}
//Assign the correct Time Zone to $sTakenOn if it is not already contained in it. Then get Unix Timestamp
//Time Zone (2nd parameter) will be ignored if already contained in $sTakenOn
$iTakenOn = 0;
if($sTakenOn != '') {
$oTakenOn = new \DateTime($sTakenOn, new \DateTimeZone($sTimeZone));
$iTakenOn = $oTakenOn->format('U');
}
return [
'timezone' => $sTimeZone,
'latitude' => $fLat,
'longitude' => $fLng,
'altitude' => $iAlt,
'taken_ts' => $iTakenOn,
'file_ts' => $iPostedOn,
'width' => $iWidth,
'height' => $iHeight,
'rotate' => $sRotate,
'type' => $sType
];
}
private function getMediaThumbnail($sMediaName) {
$sMediaPath = self::getMediaPath($sMediaName);
$sThumbPath = self::getMediaPath($sMediaName, 'thumbnail');
if(!file_exists($sThumbPath)) {
$sType = self::getMediaType($sMediaName);
switch($sType) {
case 'image':
$asThumbInfo = ToolBox::createThumbnail($sMediaPath, self::THUMB_MAX_WIDTH, 0, $sThumbPath);
break;
case 'video':
//Get a screenshot of the video 1 second in
$sTempPath = self::getMediaPath(uniqid('temp_').'.png');
$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
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 = ['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;
}
private static function getMediaType($sMediaName) {
$sMediaPath = self::getMediaPath($sMediaName);
$sMediaMime = mime_content_type($sMediaPath);
switch($sMediaMime) {
case 'video/quicktime':
case 'video/mp4': $sType = 'video'; break;
default: $sType = 'image'; break;
}
return $sType;
}
private static function getLatLngFromExif($sDirection, $asCoords) {
$fCoord = 0;
foreach($asCoords as $iIndex=>$sCoord) {
$fValue = 0;
$asCoordParts = explode('/', $sCoord);
switch(count($asCoordParts)) {
case 1:
$fValue = $asCoordParts[0];
break;
case 2:
$fValue = floatval($asCoordParts[0]) / floatval($asCoordParts[1]);
break;
}
$fCoord += $fValue / pow(60, $iIndex);
}
return $fCoord * (($sDirection == 'W' || $sDirection == 'S')?-1:1);
}
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 [floatval($asMatches['lat']), floatval($asMatches['lng']), floatval($asMatches['alt'] ?? 0)];
}
}
+234
View File
@@ -0,0 +1,234 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use Franzz\Objects\Db;
class Project extends PhpObject {
//Spot Mode
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
public const PROJ_TABLE = 'projects';
/**
* Database Handle
* @var Db
*/
private $oDb;
private $iProjectId;
private $sName;
private $sCodeName;
private $sMode;
private $asActive;
public function __construct(Db &$oDb, $iProjectId=0) {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
if($iProjectId > 0) $this->setProjectId($iProjectId);
}
public function getProjectId() {
return $this->iProjectId;
}
public function setProjectId($iProjectId=0) {
if($iProjectId > 0) {
$this->iProjectId = $iProjectId;
}
else {
/**
* Project 1 [-----------------]
* Project 2 [---------------------------]
* Project 3 [-----------]
* Selected Project [-------Project 1-------][------------Project 2-------------][---------------Project 3------------------
* 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'.
')'.
')';
$asResult = $this->oDb->getArrayQuery($sQuery, true);
$this->iProjectId = array_shift($asResult);
}
$this->setProjectInfo();
}
public function createProjectId() {
$this->setProjectId($this->oDb->insertRow(self::PROJ_TABLE, ['codename'=>'']));
return $this->getProjectId();
}
public function getMode() {
return $this->sMode;
}
public function getProjectName() {
return $this->sName;
}
public function setProjectName($sName) {
return $this->updateField('name', $sName);
}
public function getProjectCodeName() {
return $this->sCodeName;
}
public function setProjectCodeName($sCodeName) {
return $this->updateField('codename', $sCodeName);
}
public function getActivePeriod($sFromTo='') {
return ($sFromTo=='')?$this->asActive:$this->asActive[$sFromTo];
}
public function setActivePeriod($oValue, $sFromTo='') {
if($sFromTo=='') {
$this->updateField('active_from', $oValue['from']);
return $this->updateField('active_to', $oValue['to']);
}
else {
return $this->updateField('active_'.$sFromTo, $oValue);
}
}
public function getFeedIds() {
return $this->oDb->selectColumn(
Feed::FEED_TABLE,
Db::getId(Feed::FEED_TABLE),
[Db::getId(self::PROJ_TABLE) => $this->getProjectId()]
);
}
public function getProjects($iProjectId=0) {
$bSpecificProj = ($iProjectId > 0);
$sDefaultProjectCodeName = $this->getProjectCodeName();
$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,
'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) {
//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['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 getProject() {
return $this->getProjects($this->getProjectId());
}
public function getLastUpdate(): int {
$iLastUpdate = INF;
$asFeedIds = $this->getFeedIds();
foreach($asFeedIds as $iFeedId) {
$iLastUpdate = min($iLastUpdate, (new Feed($this->oDb, $iFeedId))->getLastUpdate());
}
return $iLastUpdate;
}
public function getLastMessageId($asConstraints=[]): int {
$iLastMsg = 0;
$asFeedIds = $this->getFeedIds();
foreach($asFeedIds as $iFeedId) {
$iLastMsg = max($iLastMsg, (new Feed($this->oDb, $iFeedId))->getLastMessageId($asConstraints));
}
return $iLastMsg;
}
private function setProjectInfo() {
if($this->getProjectId() > 0) {
$asProject = $this->getProject();
$this->sName = $asProject['name'];
$this->sCodeName = $asProject['codename'];
$this->sMode = $asProject['mode'];
$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(), [$sField=>$oValue]) > 0);
$this->setProjectInfo();
return $bResult;
}
public function delete() {
$asResult = [];
if($this->getProjectId() > 0) {
$asFeedIds = $this->getFeedIds();
foreach($asFeedIds as $iFeedId) {
$asResult['feed'][] = (new Feed($this->oDb, $iFeedId))->delete();
}
$bDeleted = $this->oDb->deleteRow(self::PROJ_TABLE, $this->getProjectId());
$asResult['project'][] = [
'id' => $this->getProjectId(),
'del' => $bDeleted,
'desc_lang_id' => $bDeleted?'':'error.commit_db',
'desc_lang_params' => []
];
}
else $asResult['project'][] = ['del'=>false, 'desc_lang_id'=>'error.impossible_value', 'desc_lang_params'=>[$this->getProjectId(), 'project ID']];
return $asResult;
}
public function isEditable() {
return self::isModeEditable($this->getMode());
}
public static function isModeEditable($sMode) {
return ($sMode != self::MODE_HISTO);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\UploadHandler;
class Uploader extends UploadHandler {
private Media $oMedia;
public string $sBody;
public function __construct(Media &$oMedia) {
$this->oMedia = &$oMedia;
$this->sBody = '';
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) {
$bResult = parent::validate($uploaded_file, $file, $error, $index, $content_range);
//Check project mode
if(!$this->oMedia->isProjectEditable()) {
$file->error = true;
$file->desc_lang_id = 'upload.mode_archived';
$file->desc_lang_params = [$this->oMedia->getProjectCodeName()];
$bResult = false;
}
return $bResult;
}
protected function handle_file_upload($uploaded_file, $name, $size, $type, $error, $index = null, $content_range = null) {
$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 = 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;
}
protected function body($sBodyPart) {
$this->sBody .= $sBodyPart;
}
protected function get_error_message($sLangId, $asParams=[]) {
return array_key_exists($sLangId, $this->error_messages)?'upload.error':$sLangId;
}
}
+341
View File
@@ -0,0 +1,341 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use Franzz\Objects\Db;
class User extends PhpObject {
//DB Tables
public const USER_TABLE = 'users';
//Clearance Levels
public const CLEARANCE_USER = 0;
public const CLEARANCE_ADMIN = 9;
public const CLEARANCES = ['user'=>self::CLEARANCE_USER, 'admin'=>self::CLEARANCE_ADMIN];
public const USER_SUBSCRIBED = 1;
public const USER_UNSUBSCRIBED = 0;
public const DEFAULT_USER = [
'id' => 0,
'id_user' => 0,
'name' => '',
'email' => '',
'language' => '',
'timezone' => '',
'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
* @var Db
*/
private $oDb;
//User Info
private $iUserId;
private $asUserInfo;
public function __construct(Db &$oDb) {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
$this->setUserId(0);
$this->checkSession();
}
public function getUserId() {
return $this->iUserId;
}
public function setUserId($iUserId) {
$this->iUserId = 0;
$this->asUserInfo = self::DEFAULT_USER;
if($iUserId > 0) {
$asUser = $this->getUserById($iUserId);
if(!empty($asUser)) {
$this->iUserId = $iUserId;
$this->asUserInfo = $asUser;
}
}
}
public function getUserInfo() {
return $this->asUserInfo;
}
public function getUserById($iUserId) {
$asUsersInfo = [];
if($iUserId > 0) $asUsersInfo = $this->getUsersInfo($iUserId);
return empty($asUsersInfo)?[]:array_shift($asUsersInfo);
}
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';
$asInfo = [
'select' => $asSelect,
'from' => self::USER_TABLE
];
if($iUserId != -1) $asInfo['constraint'] = [Db::getId(self::USER_TABLE) => $iUserId];
return $this->oDb->selectRows($asInfo);
}
public function getLang() {
return $this->asUserInfo['language'];
}
private function addUser($sEmail, $sLang, $sTimezone, $sNickName='') {
$bSuccess = false;
$sLangId = '';
$iUserId = $this->oDb->insertRow(
self::USER_TABLE,
['email'=>$sEmail, 'language'=>$sLang, 'timezone'=>$sTimezone]
);
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 {
//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);
$this->setSession();
$this->setTokenCookie();
}
return Livetrail::getResult($bSuccess, $sLangId, ['subscribe'=>$bSubscribe]);
}
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(), ['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, ['gravatar' => base64_encode($sImage)]);
}
public function checkUserClearance($iClearance) {
return ($this->asUserInfo['clearance'] >= $iClearance);
}
public function setUserClearance($iUserId, $iClearance) {
$bSuccess = false;
$sLangId = '';
$asLangParams = [];
if(!$this->checkUserClearance(self::CLEARANCE_ADMIN)) $sLangId = 'error.no_auth';
else {
if(!in_array($iClearance, self::CLEARANCES)) {
$sLangId = 'error.impossible_value';
$asLangParams = [$iClearance, 'clearance'];
}
else {
$iUserId = $this->oDb->updateRow(self::USER_TABLE, $iUserId, ['clearance'=>$iClearance]);
if(!$iUserId) $sLangId = 'error.commit_db';
else $bSuccess = true;
}
}
return Livetrail::getResult($bSuccess, $sLangId, [], $asLangParams);
}
/* 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]);
}
}
-84
View File
@@ -1,84 +0,0 @@
<div id="admin">
<div id="projects">
<table>
<tr>
<th>[#]lang:project[#]</th>
<th>[#]lang:mode[#]</th>
<th>[#]lang:code_name[#]</th>
<th>[#]lang:start[#]</th>
<th>[#]lang:end[#]</th>
<th>[#]lang:time_zone[#]</th>
</tr>
</table>
</div>
<div id="feedback"></div>
</div>
<script type="text/javascript">
oSpot.pageInit = function(asHash) {
self.get(
'get_admin',
function(asData){
setProjects(asData.projects);
}
);
};
oSpot.onFeedback = function(sType, sMsg, asContext) {
delete asContext.a;
sMsg += ' (';
$.each(asContext, function(sKey, sElem) {
sMsg += sKey+'='+sElem+' / ' ;
});
sMsg = sMsg.slice(0, -3)+')';
$('#feedback').append($('<p>', {'class': sType}).text(sMsg));
};
function setProjects(asProjects) {
$.each(asProjects, function(iKey, oProject) {
var sElemId = 'proj_'+oProject.id;
var $Project = ($('#'+sElemId).length==0)?$('<tr>', {'id': sElemId}):$('#'+sElemId).empty();
$Project
.data('project_id', oProject.id)
.append($('<td>', {'class': 'name'}).text(oProject.name))
.append($('<td>', {'class': 'mode'}).text(oProject.mode))
.append($('<td>').addInput('text', 'codename', oProject.codename))
.append($('<td>').addInput('date', 'active_from', oProject.active_from.substr(0, 10)))
.append($('<td>').addInput('date', 'active_to', oProject.active_to.substr(0, 10)))
.append($('<td>').addInput('text', 'timezone', oProject.timezone))
.appendTo($('#projects').find('table'));
});
$('input')
.change(commit)
.keyup(waitAndCommit);
}
function commit(event, $This) {
$This = $This || $(this);
var sOldVal = $This.data('old_value');
var sNewVal = $This.val();
if(sOldVal!=sNewVal) {
$This.data('old_value', sNewVal);
var asInputs = {project_id: $This.closest('tr').data('project_id'), field: $This.attr('name'), value: sNewVal};
self.get(
'set_admin',
function(asData){
oSpot.onFeedback('success', self.lang('admin_save_success'), asInputs);
setProjects([asData.values]);
},
asInputs,
function(sError){
$This.data('old_value', sOldVal);
oSpot.onFeedback('error', sError, asInputs);
}
);
}
}
function waitAndCommit(event) {
if(typeof self.tmp('wait') != 'undefined') clearTimeout(self.tmp('wait'));
self.tmp('wait', setTimeout(()=>{commit(event,$(this));}, 2000));
}
</script>
-41
View File
@@ -1,41 +0,0 @@
<!DOCTYPE html>
<html>
<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:page_og_desc[#]">
<meta property="og:title" content="Spotty" />
<meta property="og:description" content="[#]lang:page_og_desc[#]" />
<meta property="og:type" content="website" />
<meta property="og:url" content="[#]host_url[#]" />
<meta property="og:image" content="images/ogp.png" />
<meta property="og:locale" content="[#]lang:locale[#]" />
<link rel="apple-touch-icon" sizes="180x180" href="images/icons/apple-touch-icon.png?v=GvmqYyKwbb">
<link rel="icon" type="image/png" sizes="32x32" href="images/icons/favicon-32x32.png?v=GvmqYyKwbb">
<link rel="icon" type="image/png" sizes="16x16" href="images/icons/favicon-16x16.png?v=GvmqYyKwbb">
<link rel="manifest" href="images/icons/site.webmanifest?v=GvmqYyKwbb">
<link rel="mask-icon" href="images/icons/safari-pinned-tab.svg?v=GvmqYyKwbb" color="#44d15a">
<link rel="shortcut icon" href="images/icons/favicon.ico?v=GvmqYyKwbb">
<meta name="msapplication-TileColor" content="#00a300">
<meta name="msapplication-config" content="images/icons/browserconfig.xml?v=GvmqYyKwbb">
<meta name="theme-color" content="#ffffff">
<link type="text/css" href="[#]filepath_css[#]" rel="stylesheet" media="all" />
<script type="text/javascript" src="[#]filepath_js_d3[#]"></script>
<script type="text/javascript" src="[#]filepath_js_leaflet[#]"></script>
<script type="text/javascript" src="[#]filepath_js_jquery[#]"></script>
<script type="text/javascript" src="[#]filepath_js_jquery_mods[#]"></script>
<script type="text/javascript" src="[#]filepath_js_spot[#]"></script>
<script type="text/javascript">
var oSpot = new Spot([#]GLOBAL_VARS[#]);
$(document).ready(oSpot.init);
</script>
<title></title>
</head>
<body>
<div id="container">
<header></header>
<div id="main"></div>
<footer></footer>
</div>
</body>
</html>
-552
View File
@@ -1,552 +0,0 @@
<div id="projects">
<div id="submap">
<div class="loader fa fa-fw fa-map flicker" id="map_loading"></div>
</div>
<div id="map"></div>
<div id="feed">
<div id="posts">
<div id="poster"></div>
<div id="posts_list"></div>
<div id="loading"></div>
</div>
</div>
<div id="elems">
<div id="post-button"><i class="fa fa-fw"></i></div>
<div id="legend" class="leaflet-control-layers leaflet-control leaflet-control-layers-expanded">
<div class="track"><span class="line main"></span><span class="desc">[#]lang:track_main[#]</span></div>
<div class="track"><span class="line off-track"></span><span class="desc">[#]lang:track_offtrack[#]</span></div>
<div class="track"><span class="line hitchhiking"></span><span class="desc">[#]lang:track_hitchhiking[#]</span></div>
</div>
</div>
</div>
<div id="mobile" class="mobile"></div>
<script type="text/javascript" src="script/lightbox.js"></script>
<script type="text/javascript">
oSpot.onSamePageMove = function(asHash) {
if(self.tmp('first_exec')) initPage(asHash);
else {
self.tmp('map').remove();
self.tmp('$Map').empty();
self.tmp('map', null);
self.tmp('$PostList').empty();
setFeedUpdateTimer(false);
initProject(asHash.items[0]);
}
return false;
};
oSpot.pageInit = function(asHash) {
self.tmp('first_exec', true);
initPage(asHash);
};
self.onResize = function() {
self.tmp('mobile', $('#mobile').is(':visible'));
self.tmp('feed_width', self.tmp('with_feed')?$('#feed').outerWidth(true):0);
self.tmp('map_offset', -1 * self.tmp('feed_width') / $('body').outerWidth(true));
if(typeof self.tmp('elev') != 'undefined') {
self.tmp('elev').remove();
self.tmp('elev').options.width = getElevWidth();
self.tmp('elev').addTo(self.tmp('map'));
}
};
self.onQuitPage = function() {
setFeedUpdateTimer(false);
return true;
}
function toggleFeedPanel(bShow) {
var $Container = $('#projects');
if(typeof bShow === 'undefined') $Container.toggleClass('with-feed');
else $Container.toggleClass('with-feed', bShow);
oSpot.tmp('with_feed', $Container.hasClass('with-feed'));
}
function isMobile() {
self.tmp('mobile', $('#mobile').is(':visible'));
return self.tmp('mobile');
}
function initPage(asHash) {
if(asHash.items.length==0) self.setHash(asHash.page, [self.vars('default_project_codename')]);
else {
self.tmp('$Map', $('#map'));
self.tmp('$PostList', $('#posts_list'));
self.tmp('updatable', true);
self.tmp('out-of-data', false);
self.tmp('tile_api', getWmtsApiUrl('{id}', '{y}', '{x}', '{z}'));
self.tmp('markers', 'object');
self.tmp('marker_size', {width: 32, height: 32});
toggleFeedPanel(!isMobile());
oSpot.onResize();
//Lightbox options
lightbox.option({
alwaysShowNavOnTouchDevices: true,
albumLabel: oSpot.lang('media_count', ['%1', '%2']),
fadeDuration: 300,
imageFadeDuration: 400,
resizeDuration: 600,
hasVideo: true
});
//Assign Track Type Colors
self.tmp('track-type-styles', 'object');
$('#legend').find('.line').each(function(iKey, oLegend){
var $Legend = $(oLegend);
var sTrackType = $Legend.attr('class').replace('line', '').trim();
self.tmp(['track-type-styles', sTrackType], {weight: parseInt($Legend.css('height')), color: $Legend.css('background-color'), opacity: 1});
});
//Scrollbar
self.tmp('simple-bar', new SimpleBar($('#posts')[0]));
self.tmp('simple-bar').getScrollElement().addEventListener('scroll', onFeedScroll);
//Add "Loading" Post
getPost({type: 'loading', headerless: true, formatted_time: '', relative_time: ''}).appendTo($('#loading'));
//Mobile events
$("#feed").onSwipe(function(aiDelta){
if(aiDelta.x > self.tmp('feed_width')/3 && aiDelta.x > Math.abs(aiDelta.y)) toggleFeedPanel(false);
});
//project Bootstrap
initProject(asHash.items[0]);
}
}
function initProject(sProjectCodeName){
self.tmp('first_exec', false);
self.vars('project', self.vars(['projects', sProjectCodeName]));
//Page Title
self.setPageTitle(oSpot.vars(['project', 'name']));
//Timezone difference notice
var bSameTZ = (oSpot.consts.site_timezone == oSpot.vars(['project', 'timezone']));
self.tmp('site_tz_notice', bSameTZ?'':' ('+getTimeZoneDesc(oSpot.consts.site_timezone)+')');
self.tmp('proj_tz_notice', bSameTZ?'':' ('+getTimeZoneDesc(self.vars(['project', 'timezone']))+')');
$.when(
//Markers: Spot Messages & Medias
self.get(
'markers',
function(){},
{project_id: self.vars(['project', 'id'])},
function(e){console.log(e);}
),
//Project Geojson: Hike track
$.ajax({
dataType: 'json',
url: self.vars(['project', 'geofile']),
mimeType: 'application/json'
})
).done(function(aoMessages, aoTracks) {
initSpotMessages(aoMessages[0]['data'], aoTracks[0]);
});
//Posts
initPosts();
}
function initPosts() {
var $Poster = $('#poster').empty();
if(self.vars(['project', 'mode'])==self.consts.modes.histo) $Poster.hide();
else {
var asPoster = {
type: 'poster',
formatted_time: '',
relative_time: oSpot.lang('post_new_message')
};
getPost(asPoster).appendTo($Poster.show());
autosize($('#post'));
$('#submit').click(function(){
if($Poster.checkForm())
{
self.get(
'add_post',
function()
{
$('#name').val('');
$('#post').val('');
updateFeed(true);
},
{
project_id: self.vars(['project', 'id']),
name: $('#name').val(),
content: $('#post').val()
}
);
}
});
}
//Feed auto-update
self.tmp('simple-bar').getScrollElement().scrollTop = 0;
if(self.vars(['project', 'mode']) != self.consts.modes.histo) onAutoUpdate(true);
else updateFeed(true);
}
function onAutoUpdate(bFirstExec) {
bFirstExec = bFirstExec || false;
if(bFirstExec) updateFeed(true);
else if(self.tmp('simple-bar').getScrollElement().scrollTop == 0) updateFeed(true, true);
setFeedUpdateTimer(60, onAutoUpdate);
}
function getElevWidth() {
return $('#projects').width() - oSpot.tmp('feed_width') - $('.leaflet-bottom.leaflet-left').outerWidth(true) - 6;
}
function initSpotMessages(aoMessages, aoTracks) {
//Tile layers
aoLayers = {};
aoLayers[oSpot.lang('map_satellite')] = L.tileLayer(self.tmp('tile_api'), {id: 'mapbox.satellite-streets', minZoom: 0, maxZoom: 19});
aoLayers[oSpot.lang('map_otm')] = L.tileLayer(self.tmp('tile_api'), {id: 'opentopomap', minZoom: 2, maxZoom: 19});
aoLayers[oSpot.lang('map_ign_france')] = L.tileLayer(self.tmp('tile_api'), {id: 'ign.fr', minZoom: 0, maxZoom: 18, tileSize: 256});
aoLayers[oSpot.lang('map_ign_spain')] = L.tileLayer(self.tmp('tile_api'), {id: 'ign.es', minZoom: 1, maxZoom: 20});
aoLayers[oSpot.lang('map_linz')] = L.tileLayer(self.tmp('tile_api'), {id: 'linz', maxZoom: 17, continuousWorld: true, attribution: 'Sourced from LINZ. CC BY 4.0'});
//Map
var oMap = L.map(self.tmp('$Map')[0], {
layers: [aoLayers[oSpot.lang('map_satellite')]],
attributionControl: false,
zoomControl: false
});
self.tmp('map', oMap);
//Controls: Feed Panel
var oFeedPanel = L.control({position: 'topright'});
var $PostButton = $('#post-button').clone();
$PostButton.click(toggleFeedPanel);
oFeedPanel.onAdd = function(oMap) {return $PostButton[0];};
oFeedPanel.addTo(oMap);
//Controls: Legend
var oLegend = L.control({position: 'bottomleft'});
oLegend.onAdd = function(oMap) {return $('#legend').clone()[0];};
oLegend.addTo(oMap);
//Controls: Projects
var oProjects = L.control({position: 'bottomleft'});
oProjects.onAdd = function(oMap) {
var $Labels = $('<div>', {'class': 'leaflet-control-layers-base'});
$.each(self.vars('projects'), function(sCodeName, asProject){
var asRadioAttrs = {'type': 'radio', 'class': 'leaflet-control-layers-selector', 'name':'project', 'value': sCodeName};
if(asProject.id == self.vars(['project', 'id'])) asRadioAttrs.checked = 'checked';
var $Radio =$('<input>', asRadioAttrs).change(function(){
self.setHash(self.vars('page'), [$(this).val()]);
});
var $Label = $('<label>').append($('<div>')
.append($Radio)
.append($('<span>').text(' '+asProject.name))
);
$Labels.append($Label);
});
return $('<div>', {'class':'leaflet-control-layers leaflet-control leaflet-control-layers-expanded'}).append($('<section>').append($Labels))[0];
};
oProjects.addTo(oMap);
//Controls: Scale
oScale = L.control.scale({imperial: false, 'position':'bottomright'}).addTo(oMap);
//Controls: Elevation
var oElev = L.control.elevation({
collapsed: true,
position: "bottomright",
width: getElevWidth(),
height: 129,
hoverNumber: {
decimalsX: 0, //distance (km)
decimalsY: 0 //elevation (m)
},
theme: 'spot-theme',
onExpand: function(){$('.leaflet-control-scale').hide();},
onCollapse: function(){$('.leaflet-control-scale').show();}
}).addTo(oMap);
self.tmp('elev', oElev);
//Controls: Tiles (layers)
L.control.layers(aoLayers, null, {position: 'topleft'}).addTo(oMap);
//Tracks, colors & popup
var oTracks = L.geoJson(aoTracks, {
style: function(oTrack) {
return self.tmp(['track-type-styles', oTrack.properties.type]);
},
onEachFeature: function(feature, oLayer) {
var asProperties = feature.properties;
var $Tooltip = $('<div>', {'class':'track_tooltip'});
$('<p>', {'class':'name'}).addIcon('fa-track-'+asProperties.type, true).append(asProperties.name).appendTo($Tooltip);
if(asProperties.Name != asProperties.description) $('<p>', {'class':'description'}).text(asProperties.description).appendTo($Tooltip);
var aiCoords = feature.geometry.coordinates;
if(aiCoords && asProperties.type != 'hitchhiking') {
var iDistance = 0;
var iElevDrop = 0;
var iElevGain = 0;
var iTime = 0;
for (var i = 1; i < aiCoords.length; i++) {
var oCurrPoint = new L.LatLng(aiCoords[i][1], aiCoords[i][0]);
var oPrevPoint = new L.LatLng(aiCoords[i - 1][1], aiCoords[i - 1][0]);
var iElevDelta = aiCoords[i][2] - aiCoords[i - 1][2];
var iSegDistance = oCurrPoint.distanceTo(oPrevPoint);
iDistance += iSegDistance;
iElevDrop += Math.min(iElevDelta, 0);
iElevGain += Math.max(iElevDelta, 0);
var iSpeedCorrecRatio = 0;
var iAngle = iElevDelta / iSegDistance;
if(iAngle < -1) iSpeedCorrecRatio = 0.5;
else if(iAngle < -0.2) iSpeedCorrecRatio = 1.25;
else if(iAngle < 0.1) iSpeedCorrecRatio = 1;
else if(iAngle < 0.25) iSpeedCorrecRatio = 0.85;
else if(iAngle < 0.5) iSpeedCorrecRatio = 0.6;
else if(iAngle < 1) iSpeedCorrecRatio = 0.5;
else iSpeedCorrecRatio = 0.25;
iTime += iSegDistance / 1000 * iSpeedCorrecRatio / 3.5 ; //default speed: 3.5km/h
}
var iTimeHours = Math.floor(iTime);
var iTimeMinutes = Math.round((iTime - iTimeHours) * 4) * 15; //Approx 15 minutes
if(iTimeMinutes == 60) {
iTimeMinutes = 0;
iTimeHours += 1;
}
if(iTimeMinutes==0) iTimeMinutes = '';
$('<p>', {'class':'detail'}).addIcon('fa-distance fa-fw', true).append(Math.round(iDistance/1000)+'km').appendTo($Tooltip);
$('<p>', {'class':'detail'}).addIcon('fa-time fa-fw', true).append('~ '+iTimeHours+'h'+iTimeMinutes).appendTo($Tooltip);
$('<p>', {'class':'detail'}).addIcon('fa-elev-gain fa-fw', true).append(iElevGain+'m').appendTo($Tooltip);
$('<p>', {'class':'detail'}).addIcon('fa-elev-drop fa-fw', true).append(iElevDrop+'m').appendTo($Tooltip);
(oElev.addData.bind(oElev))(feature, oLayer);
}
oLayer
.bindPopup($Tooltip[0])
.on('mouseover', function(e) {
var popup = e.target.getPopup();
popup.setLatLng(e.latlng).openOn(self.tmp('map'));
})
.on('mouseout', function(e) {
e.target.closePopup();
});
}
}).addTo(oMap);
//Centering map
if(self.vars(['project', 'mode'])==self.consts.modes.blog && aoMessages.length > 0)
{
//Zoom on last message
var oLastMsg = aoMessages[aoMessages.length-1];
oMap.setView(L.latLng(oLastMsg.latitude, oLastMsg.longitude), 15);
//Recenter map to be at the center of 70% (map_offset) of the page, 30% being used by posts
oMap.setOffsetView(self.tmp('map_offset'));
}
else oMap.fitBounds(oTracks.getBounds(), {paddingTopLeft: L.point(5, self.tmp('marker_size').height + 5), paddingBottomRight: L.point(self.tmp('feed_width') + 5, 5)});
//Spot Messages
$.each(aoMessages, function(iKey, oMsg){
//Marker
var oMarker = L.marker(L.latLng(oMsg.latitude, oMsg.longitude), {
id: oMsg.id_message,
riseOnHover: true,
icon: L.divIcon({
className: '',
html: '<span class="fa-stack"><i class="fa fa-message fa-stack-2x"></i><i class="fa fa-message-in fa-stack-1x fa-rotate-270"></i></span>',
iconSize: [self.tmp('marker_size').width, self.tmp('marker_size').height],
iconAnchor: [self.tmp('marker_size').width / 2, self.tmp('marker_size').height] //position from icon's top left corner (iconAnchor = marker's position)
})
}).addTo(oMap);
//Tooltip
$Tooltip = $('<div>', {'class':'info-window'})
.append($('<h1>')
.addIcon('fa-message fa-fw fa-lg')
.append('Message '+oMsg.type+' '+oMsg.displayed_id))
.append($('<p>', {'class':'time'})
.addIcon('fa-time fa-fw fa-lg')
.append(oMsg.formatted_time+(self.vars(['project', 'mode'])==self.consts.modes.blog?' ('+oMsg.relative_time+')':'')+self.tmp('site_tz_notice')))
.append($('<p>', {'class':'coordinates'})
.addIcon('fa-coords fa-fw fa-lg', false)
.append('Lat : '+oMsg.latitude+', Lng : '+oMsg.longitude));
//Tooltip medias
if(oMsg.medias) {
var $Medias = $('<div>', {'class':'medias'});
$.each(oMsg.medias, function(iKey, asMedia) {
var bVideo = (asMedia.subtype == 'video');
$Medias
.append($('<a>', {
'class': 'drill',
href: asMedia.media_path,
'data-lightbox': 'marker-medias',
'data-video': (bVideo?'true':'false'),
'data-title': oSpot.lang(bVideo?'video':'pic')+' '+oSpot.lang(bVideo?'video_taken':'pic_taken', [asMedia.formatted_time+self.tmp('site_tz_notice')]),
'data-orientation': asMedia.rotate})
.append($('<img>', {'src': asMedia.thumb_path}))
.append($('<span>', {'class': 'drill-icon'}).addIcon('fa-drill-'+(bVideo?'video':'picture')))
);
});
$Tooltip
.append($('<p>').addIcon('fa-media fa-fw fa-lg').append(oSpot.lang('pics')))
.append($Medias);
}
oMarker.bindPopup($Tooltip[0], {
maxWidth: 1000,
autoPan: false,
closeOnClick: true,
offset: new L.Point(0, -30)
});
//Open tooltip on latest message in mobile mode
if(iKey === (aoMessages.length - 1) && self.vars(['project', 'mode']) == self.consts.modes.blog && (!oMsg.medias || oMsg.medias.length < 3) && self.tmp('mobile')) oMarker.openPopup();
oSpot.tmp(['markers', oMsg.id_message], oMarker);
});
}
function onFeedScroll() {
var $Box = $(this);
var $BoxContent = $Box.find('.simplebar-content');
if(($Box.scrollTop() + $(window).height()) / $BoxContent.height() >= 0.8) updateFeed();
}
function updateFeed(bFirstChunk, bDiscrete) {
bFirstChunk = bFirstChunk || false;
bDiscrete = bDiscrete || false;
if(self.tmp('updatable')) {
if(!self.tmp('out-of-data') || bFirstChunk) {
self.tmp('updatable', false);
if(!bDiscrete) $('#loading').show();
var $Posts = $('<div>');
if(bFirstChunk===true) {
self.tmp('news_chunk', 0);
}
self.get('feed', function(asData) {
$('#loading').hide();
$.each(asData, function(iKey, asPost){
$Posts.append(getPost(asPost));
});
self.tmp('news_chunk', self.tmp('news_chunk') + 1);
self.tmp('out-of-data', Object.keys(asData).length != self.vars('chunk_size'));
if(bFirstChunk===true) self.tmp('$PostList').empty();
self.tmp('$PostList').append($Posts.children());
self.tmp('updatable', true);
}, {
'project_id': self.vars(['project', 'id']),
'chunk': self.tmp('news_chunk')
});
}
}
else if(bFirstChunk) setFeedUpdateTimer(0.2);
}
function setFeedUpdateTimer(iSeconds, fCallback) {
fCallback = fCallback || function(){updateFeed(true);};
if(typeof self.tmp('update_timer') != 'undefined') clearTimeout(self.tmp('update_timer'));
if(iSeconds > 0) self.tmp('update_timer', setTimeout(fCallback, iSeconds * 1000));
}
function getPost(asPost) {
asPost.headerless = asPost.headerless || false;
var $Post = $('<div>', {'class':'post '+asPost.type+(asPost.headerless?' headerless':'')});
var sRelTime = (asPost.relative_time!='')?((self.vars(['project', 'mode'])==self.consts.modes.histo)?asPost.formatted_time.substr(0, 10):asPost.relative_time):'';
var sAbsTime = asPost.formatted_time;
var $Body = {};
switch(asPost.type) {
case 'message':
$Body = $('<div>')
.data('id', asPost.id_message)
.append($('<p>').addIcon('fa-coords', true).append(asPost.lat_dms+' '+asPost.lon_dms))
.append($('<p>').addIcon('fa-time', true).append(sAbsTime+self.tmp('site_tz_notice')))
.append($('<a>', {'class':'drill'})
.append($('<img>', {'class':'staticmap', title: oSpot.lang('click_zoom'), src: getWmtsApiUrl('static', asPost.latitude, asPost.longitude, 13)}))
.append($('<span>', {'class': 'drill-icon fa-stack'})
.addIcon('fa-message-in fa-stack-1x fa-rotate-270')
.addIcon('fa-message fa-stack-2x')
)
.click(function(){
var oMarker = oSpot.tmp(['markers', $(this).parent().data('id')]);
self.tmp('map').setOffsetView(self.tmp('map_offset'), oMarker.getLatLng(), 13);
if(!oMarker.isPopupOpen()) oMarker.openPopup();
if(self.tmp('mobile')) toggleFeedPanel(false);
})
)
.hover(
function(){
var oMarker = oSpot.tmp(['markers', $(this).data('id')]);
if(oSpot.tmp('map').getBounds().contains(oMarker.getLatLng()) && !oMarker.isPopupOpen()) oMarker.openPopup();
},
function(){
var oMarker = oSpot.tmp(['markers', $(this).data('id')]);
if(oMarker.isPopupOpen()) oMarker.closePopup();
}
);
break;
case 'media':
var bVideo = (asPost.subtype == 'video');
var sTakenOn = (asPost.taken_on == '0000-00-00 00:00:00')?'':(' '+oSpot.lang('and')+' '+oSpot.lang(bVideo?'video_taken':'pic_taken', [asPost.taken_on_formatted+self.tmp('site_tz_notice')]));
var $Image = $('<img>', {'src': asPost.thumb_path, title: oSpot.lang(bVideo?'click_watch':'click_zoom')});
$Body = $('<a>', {
'class': 'drill',
href: asPost.media_path,
'data-video': (bVideo?'true':'false'),
'data-lightbox': 'post-medias',
'data-title': oSpot.lang(bVideo?'video':'pic')+' '+oSpot.lang('add_on', [sAbsTime+sTakenOn]), 'data-orientation': asPost.rotate
}).append($Image).append($('<span>', {'class': 'drill-icon'}).addIcon('fa-drill-'+(bVideo?'video':'picture')));
break;
case 'post':
$Body = $('<div>')
.append($('<p>', {'class':'message'}).text(asPost.content))
.append($('<p>', {'class':'signature'}).text('-- '+asPost.formatted_name));
break;
case 'poster':
$Body = $('<p>', {'class':'message'})
.append($('<textarea>', {id:'post', name:'post', placeholder:oSpot.lang('post_message'), 'class':'autoExpand', rows:'1'}))
.append($('<input>', {type:'text', id:'name', name:'name', placeholder:oSpot.lang('post_name')}))
.append($('<button>', {type:'button', id:'submit', name:'submit'}).addIcon('fa-send'));
break;
case 'loading':
$Body = $('<p>', {'class':'flicker'}).addIcon('fa-post');
break;
}
$Post
.append($('<div>', {'class':'header'})
.append($('<span>', {'class':'index'}).addIcon('fa-'+asPost.type))
.append($('<span>', {'class':'time'}).hoverSwap(sRelTime, sAbsTime+self.tmp('site_tz_notice'))))
.append($('<div>', {'class':'body'}).append($Body));
if(asPost.displayed_id) $Post.find('.index').append(' '+asPost.displayed_id);
return $Post;
}
function getWmtsApiUrl(sMapId, iLat, iLng, iZoom) {
return self.consts.geo_server+'/?a=tile&id='+sMapId+'&z='+iZoom+'&x='+iLng+'&y='+iLat;
}
</script>
-37
View File
@@ -1,37 +0,0 @@
<div id="upload">
<h1>[#]lang:upload_title[#]</h1>
<input id="fileupload" type="file" name="files[]" multiple>
<div id="progress">
<div class="bar" style="width: 0%;"></div>
</div>
<div id="status"></div>
</div>
<script type="text/javascript">
oSpot.pageInit = function(asHash)
{
var asProject = self.vars(['projects', self.vars('default_project_codename')]);
if(asProject.mode == self.consts.modes.blog) {
$('#fileupload')
.attr('data-url', self.getActionLink('upload'))
.fileupload({
dataType: 'json',
acceptFileTypes: /(\.|\/)(gif|jpe?g|png|mov)$/i,
done: function (e, asData) {
var $Status = $('#status');
console.log(asData);
$.each(asData.result.files, function(iKey, oFile) {
var sMsg = ('error' in oFile)?oFile.error:(self.lang('upload_success', [oFile.name]));
$Status.append($('<p>').text(sMsg));
});
},
progressall: function (e, data) {
var progress = parseInt(data.loaded / data.total * 100, 10);
$('#progress .bar').css('width', progress+'%');
}
});
}
else $('#status').empty().append(self.lang('upload_wrong_mode', [asProject.name]));
};
</script>
+3034
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"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": "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": "^6.0.0",
"@uppy/xhr-upload": "^6.0.0",
"autosize": "^6.0.1",
"maplibre-gl": "^6.0.0",
"sass": "^1.97.2",
"simplebar-vue": "^2.3.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 ?? []);
+63 -8
View File
@@ -1,12 +1,67 @@
# 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 24+
* composer
* php-mbstring
* php-imagick
* ffprobe (ffmpeg)
* php-gd
* php-mysql
* php-exif
* ffprobe & ffmpeg
* STARTTLS Email Server (use Gmail if none available)
* Optional: Geo Caching Server (WMTS Caching Service)
## PHP Configuration
* max_execution_time = 300
* memory_limit = 500M
* post_max_size = 4G
* upload_max_filesize = 4G
* max_file_uploads = 50
## Getting started
1. Clone Git onto web server
2. 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. 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).
## To Do List
* import/export
* Convert all time to browser timezone
* Reset zoom on image closing (lightbox)
* Optimize mobile image size (lightbox)
* Link pic & post
* Add mail frequency slider
* Use WMTS servers directly when not using Geo Caching Server
* Allow HEIF picture format
* Garmin InReach Integration
+215376
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+219079
View File
File diff suppressed because it is too large Load Diff
+69644
View File
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)"
}
}
+237
View File
@@ -0,0 +1,237 @@
{
"action": {
"back": "Atrás",
"delete": "Borrar",
"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",
"delete_success": "Eliminado",
"save_success": "Guardado",
"title": "Administración",
"toolbox": "Herramientas",
"upload": "Cargar"
},
"credits": {
"git": "Repositorio de Git",
"license": "bajo licencia GPLv3",
"project": "Proyecto $0"
},
"email": {
"confirmation": {
"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": "Darse de baja",
"update": {
"latest_news": "Últimas noticias:",
"preheader": "¡Nueva posición!",
"subject": "Nueva posición recibida",
"title": "Mensaje"
}
},
"error": {
"commit_db": "Error SQL",
"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": "N.º $0",
"id": "ID Feed",
"last_update": "Última actualización de SPOT",
"name": "Descripción",
"new": "Nuevo feed",
"plural": "Feeds",
"ref_id": "ID Feed ref.",
"status": "Estado"
},
"map": {
"ign_france": "IGN (Francia)",
"ign_spain": "IGN (España)",
"linz": "LINZ",
"natgeo": "National Geographic",
"otm": "Open Topo Map",
"outdoors": "Mapbox Topo",
"satellite": "Satélite",
"see_on_google": "Ver la posición en Google Maps",
"terrain": "Relieve 3D",
"title": "Mapas de base",
"usgs": "USGS"
},
"media": {
"click_watch": "Haz clic para ver el vídeo",
"click_zoom": "Haz clic para ampliar",
"comment_update": "Comentario \"$0\" actualizado",
"image": "Foto",
"image_taken_on": "Foto tomada el $0",
"images": "Fotos",
"nearby": "Fotos cercanas",
"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 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",
"reply": "Responder a este mensaje"
},
"project": {
"wip": "En curso",
"code_name": "Nombre clave",
"end": "Fin",
"hikes": "Senderos",
"id": "ID del proyecto",
"mode": "Modo",
"modes": {
"blog": "Proyecto activo",
"histo": "Proyecto archivado",
"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 de Spot",
"model": "Modelo",
"name": "Spot",
"no_new_msg": "No hay mensajes nuevos",
"plural": "Spots",
"ref_id": "ID de referencia de Spot"
},
"stats": {
"duration": "Duración",
"distance": "Distancia",
"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 en $0",
"date_time": "$0 a las $1",
"local": "$0 hora local",
"user": "$0 en tu zona horaria",
"zone": "Huso horario"
},
"track": {
"download": "Descargar la ruta GPX",
"hitchhiking": "Autostop",
"main": "Camino principal",
"off-track": "Variante"
},
"unit": {
"day": "día",
"day_short": "D",
"days": "días",
"hour": "h"
},
"upload": {
"error": "Error al subir el archivo",
"media": {
"exists": "La imagen $0 ya existe",
"title": "Cargar fotos y vídeos"
},
"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",
"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 se ha subido correctamente."
},
"user": {
"subscribed": "Usuarios suscritos",
"clearance": "Nivel de autorización",
"email": "Correo electrónico",
"id": "ID del usuario",
"language": "Idioma",
"name": "Nombre"
},
"weather": {
"clear-day": "La nubosidad es inferior al 20 % durante el día",
"clear-night": "La nubosidad es inferior al 20 % durante la noche",
"cloudy": "La nubosidad es superior al 90 %",
"fog": "La visibilidad es baja (menos de 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",
"rain": "La cantidad de lluvia es superior a cero",
"rain-snow": "Chubascos de nieve y lluvia",
"rain-snow-showers-day": "Posible lluvia/nieve durante todo el día",
"rain-snow-showers-night": "Posible lluvia/nieve durante toda la noche",
"showers-day": "Chubascos de lluvia durante el día",
"showers-night": "Chubascos de lluvia durante la noche",
"sleet": "Aguanieve",
"snow": "La cantidad de nieve es superior a cero",
"snow-showers-day": "Periodos de nieve durante el día",
"snow-showers-night": "Periodos de nieve durante la noche",
"thunder": "Tormentas",
"thunder-rain": "Tormentas durante el día o la noche",
"thunder-showers-day": "Posibles tormentas durante todo el día",
"thunder-showers-night": "Posibles tormentas durante toda la noche",
"wind": "La velocidad del viento es alta (más de 30 km/h)"
}
}
+237
View File
@@ -0,0 +1,237 @@
{
"action": {
"back": "Retour",
"delete": "Supprimer",
"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": "Boîte à outils",
"upload": "Téléverser"
},
"credits": {
"git": "Dépôt Git",
"license": "sous licence GPLv3",
"project": "Projet $0"
},
"email": {
"confirmation": {
"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 !"
},
"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 :",
"preheader": "Nouvelle position !",
"subject": "Nouvelle position reçue",
"title": "Message"
}
},
"error": {
"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'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 de SPOT",
"name": "Description",
"new": "Nouveau feed",
"plural": "Feeds",
"ref_id": "ID Feed ref.",
"status": "Statut"
},
"map": {
"ign_france": "IGN (France)",
"ign_spain": "IGN (Espagne)",
"linz": "LINZ",
"natgeo": "National Geographic",
"otm": "Open Topo Map",
"outdoors": "Mapbox Topo",
"satellite": "Satellite",
"see_on_google": "Voir la position sur Google Maps",
"terrain": "Relief 3D",
"title": "Fonds de carte",
"usgs": "USGS"
},
"media": {
"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_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_on": "Filmé le $0"
},
"meta": {
"locale": "fr_CH",
"page_og_desc": "Garde le contact lorsque je suis sur les chemins."
},
"post": {
"copy_to_clipboard": "Copier le lien dans le presse-papiers",
"link_copied": "Lien copié !",
"message": "Message",
"name": "Nom",
"new_message": "Nouveau message",
"reply": "Répondre à ce message"
},
"project": {
"wip": "En cours",
"code_name": "Nom de code",
"end": "Arrivée",
"hikes": "Randonnées",
"id": "ID projet",
"mode": "Mode",
"modes": {
"blog": "Projet actif",
"histo": "Projet archivé",
"previz": "Projet en cours de préparation"
},
"new": "Nouveau projet",
"overview": "Vue d'ensemble",
"plural": "Projets",
"single": "Projet",
"start": "Départ",
"update_messages": "Mettre à jour les messages du projet"
},
"spot": {
"id": "ID Spot",
"model": "Modèle",
"name": "Spot",
"no_new_msg": "Aucun nouveau message",
"plural": "Spots",
"ref_id": "ID Spot ref."
},
"stats": {
"duration": "Durée",
"distance": "Distance",
"elevation": "Dénivelé",
"elevation_gain": "Dénivelé positif",
"elevation_loss": "Dénivelé négatif",
"from": "Départ",
"legend": "Légende",
"segment_length": "Taille du segment",
"to": "Arrivée",
"type": "Type de rando"
},
"time": {
"city": "Heure à $0",
"date_time": "$0 à $1",
"local": "$0 heure locale",
"user": "$0 dans ton fuseau horaire",
"zone": "Fuseau horaire"
},
"track": {
"download": "Télécharger la trace GPX",
"hitchhiking": "Auto-stop",
"main": "Itinéraire",
"off-track": "Variante"
},
"unit": {
"day": "jour",
"day_short": "J",
"days": "jours",
"hour": "h"
},
"upload": {
"error": "Erreur lors du téléversement",
"media": {
"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 téléversement possible.",
"position": {
"determining": "Détermination de la position…",
"error": "Impossible de déterminer la position",
"new": "Nouvelle position",
"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é téléversé"
},
"user": {
"subscribed": "Utilisateurs abonnés",
"clearance": "Niveau d'autorisation",
"email": "E-mail",
"id": "ID utilisateur",
"language": "Langue",
"name": "Nom"
},
"weather": {
"clear-day": "La couverture nuageuse est inférieure à 20 % pendant la journée",
"clear-night": "La couverture nuageuse est inférieure à 20 % pendant la nuit",
"cloudy": "La couverture nuageuse est supérieure à 90 %",
"fog": "La visibilité est faible (moins 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",
"rain": "La quantité de pluie est supérieure à zéro",
"rain-snow": "Averses de neige et de pluie",
"rain-snow-showers-day": "Pluie/neige possible tout au long de la journée",
"rain-snow-showers-night": "Pluie/neige possible tout au long de la nuit",
"showers-day": "Averses de pluie pendant la journée",
"showers-night": "Averses de pluie pendant la nuit",
"sleet": "Grésil",
"snow": "La quantité de neige est supérieure à zéro",
"snow-showers-day": "Périodes de neige pendant la journée",
"snow-showers-night": "Périodes de neige pendant la nuit",
"thunder": "Orages",
"thunder-rain": "Orages tout au long de la journée ou de la nuit",
"thunder-showers-day": "Orages possibles tout au long de la journée",
"thunder-showers-night": "Orages possibles tout au long de la nuit",
"wind": "La vitesse du vent est élevée (plus de 30 km/h)"
}
}
+33
View File
@@ -0,0 +1,33 @@
<!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[#]assets/images/icons/favicon-96x96.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>
+47
View File
@@ -0,0 +1,47 @@
<!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.update.subject[#]</title>
</head>
<body>
<span style="color: transparent; display: none !important; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">[#]lang: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[#]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>
<td colspan="2">
<div style="background-color:#6dff58;color:#326526;border-radius:3px;padding:1rem;margin-top:1rem;display:inline-block;box-shadow: 2px 2px 3px 0px rgba(0,0,0,.5);">
<a href="[#]local_server[#]" target="_blank" rel="noopener"><img style="border-radius:3px;" src="[#]marker_img_url[#]" alt="position" /></a>
<br />[#]lat_dms[#] [#]lon_dms[#]
<br />[#]date_time[#] ([#]timezone[#])
</div>
</td>
</tr>
<tr>
<td colspan="2">
<h2>[#]lang:email.update.latest_news[#]</h2>
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<!-- [PART] news [START] -->
<tr>
<td>
<a href="[#]local_server[#]#project-[#]project[#]-[#]type[#]-[#]id[#]" target="_blank" rel="noopener" style="text-decoration:none;background-color:#EEE;color:#333;margin-bottom:1rem;border-radius:3px;padding:5%;display:inline-block;width:90%;box-shadow: 2px 2px 3px 0px rgba(0,0,0,.5);">
<!-- [PART] media [START] --><img src="[#]local_server[#][#]thumb_path[#]" style="max-height:200px;image-orientation:from-image;" /><br /><span>[#]comment[#]</span><!-- [PART] media [END] -->
<!-- [PART] post [START] --><span>[#]content[#]</span><br /><span style="margin-top:0.5em;float:right;">--[#]formatted_name[#]</span><!-- [PART] post [END] -->
</a>
</td>
</tr>
<!-- [PART] news [END] -->
</table>
</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>
+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>
-2
View File
File diff suppressed because one or more lines are too long
-2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-838
View File
File diff suppressed because one or more lines are too long
-634
View File
@@ -1,634 +0,0 @@
/*!
* Lightbox v2.11.0
* by Lokesh Dhakar
*
* More info:
* http://lokeshdhakar.com/projects/lightbox2/
*
* Copyright 2007, 2018, 2019 Lokesh Dhakar
* Released under the MIT license
* https://github.com/lokesh/lightbox2/blob/master/LICENSE
*
* @preserve
*/
// Uses Node, AMD or browser globals to create a module.
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['jquery'], factory);
} else if (typeof exports === 'object') {
// Node. Does not work with strict CommonJS, but
// only CommonJS-like environments that support module.exports,
// like Node.
module.exports = factory(require('jquery'));
} else {
// Browser globals (root is window)
root.lightbox = factory(root.jQuery);
}
}(this, function ($) {
function Lightbox(options) {
this.album = [];
this.currentImageIndex = void 0;
this.init();
// options
this.options = $.extend({}, this.constructor.defaults);
this.option(options);
}
// Descriptions of all options available on the demo site:
// http://lokeshdhakar.com/projects/lightbox2/index.html#options
Lightbox.defaults = {
albumLabel: 'Image %1 of %2',
alwaysShowNavOnTouchDevices: false,
fadeDuration: 600,
fitImagesInViewport: true,
imageFadeDuration: 600,
// maxWidth: 800,
// maxHeight: 600,
positionFromTop: 50,
resizeDuration: 700,
showImageNumberLabel: true,
wrapAround: false,
disableScrolling: false,
/*
Sanitize Title
If the caption data is trusted, for example you are hardcoding it in, then leave this to false.
This will free you to add html tags, such as links, in the caption.
If the caption data is user submitted or from some other untrusted source, then set this to true
to prevent xss and other injection attacks.
*/
sanitizeTitle: false
//ADDED-START
, hasVideo: false
//ADDED-END
};
Lightbox.prototype.option = function(options) {
$.extend(this.options, options);
};
Lightbox.prototype.imageCountLabel = function(currentImageNum, totalImages) {
return this.options.albumLabel.replace(/%1/g, currentImageNum).replace(/%2/g, totalImages);
};
Lightbox.prototype.init = function() {
var self = this;
// Both enable and build methods require the body tag to be in the DOM.
$(document).ready(function() {
self.enable();
self.build();
});
};
// Loop through anchors and areamaps looking for either data-lightbox attributes or rel attributes
// that contain 'lightbox'. When these are clicked, start lightbox.
Lightbox.prototype.enable = function() {
var self = this;
$('body').on('click', 'a[rel^=lightbox], area[rel^=lightbox], a[data-lightbox], area[data-lightbox]', function(event) {
self.start($(event.currentTarget));
return false;
});
};
// Build html for the lightbox and the overlay.
// Attach event handlers to the new DOM elements. click click click
Lightbox.prototype.build = function() {
if ($('#lightbox').length > 0) {
return;
}
var self = this;
$('<div id="lightboxOverlay" class="lightboxOverlay"></div><div id="lightbox" class="lightbox"><div class="lb-outerContainer"><div class="lb-container"><img class="lb-image" src="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==" /><div class="lb-nav"><a class="lb-prev" href="" ></a><a class="lb-next" href="" ></a></div><div class="lb-loader"><a class="lb-cancel"></a></div></div></div><div class="lb-dataContainer"><div class="lb-data"><div class="lb-details"><span class="lb-caption"></span><span class="lb-number"></span></div><div class="lb-closeContainer"><a class="lb-close"></a></div></div></div></div>').appendTo($('body'));
// Cache jQuery objects
this.$lightbox = $('#lightbox');
this.$overlay = $('#lightboxOverlay');
this.$outerContainer = this.$lightbox.find('.lb-outerContainer');
this.$container = this.$lightbox.find('.lb-container');
this.$image = this.$lightbox.find('.lb-image');
this.$nav = this.$lightbox.find('.lb-nav');
//ADDED-START
if(self.options.hasVideo) {
this.$video = $('<video class="lb-video" src="" controls autoplay></video>');
this.$image.after(this.$video);
this.videoBorderWidth = {
top: parseInt(this.$video.css('border-top-width'), 10),
right: parseInt(this.$video.css('border-right-width'), 10),
bottom: parseInt(this.$video.css('border-bottom-width'), 10),
left: parseInt(this.$video.css('border-left-width'), 10)
};
}
//ADDED-END
// Store css values for future lookup
this.containerPadding = {
top: parseInt(this.$container.css('padding-top'), 10),
right: parseInt(this.$container.css('padding-right'), 10),
bottom: parseInt(this.$container.css('padding-bottom'), 10),
left: parseInt(this.$container.css('padding-left'), 10)
};
this.imageBorderWidth = {
top: parseInt(this.$image.css('border-top-width'), 10),
right: parseInt(this.$image.css('border-right-width'), 10),
bottom: parseInt(this.$image.css('border-bottom-width'), 10),
left: parseInt(this.$image.css('border-left-width'), 10)
};
// Attach event handlers to the newly minted DOM elements
this.$overlay.hide().on('click', function() {
self.end();
return false;
});
this.$lightbox.hide().on('click', function(event) {
if ($(event.target).attr('id') === 'lightbox') {
self.end();
}
return false;
});
this.$outerContainer.on('click', function(event) {
if ($(event.target).attr('id') === 'lightbox') {
self.end();
}
return false;
});
this.$lightbox.find('.lb-prev').on('click', function() {
if (self.currentImageIndex === 0) {
self.changeImage(self.album.length - 1);
} else {
self.changeImage(self.currentImageIndex - 1);
}
return false;
});
this.$lightbox.find('.lb-next').on('click', function() {
if (self.currentImageIndex === self.album.length - 1) {
self.changeImage(0);
} else {
self.changeImage(self.currentImageIndex + 1);
}
return false;
});
/*
Show context menu for image on right-click
There is a div containing the navigation that spans the entire image and lives above of it. If
you right-click, you are right clicking this div and not the image. This prevents users from
saving the image or using other context menu actions with the image.
To fix this, when we detect the right mouse button is pressed down, but not yet clicked, we
set pointer-events to none on the nav div. This is so that the upcoming right-click event on
the next mouseup will bubble down to the image. Once the right-click/contextmenu event occurs
we set the pointer events back to auto for the nav div so it can capture hover and left-click
events as usual.
*/
this.$nav.on('mousedown', function(event) {
if (event.which === 3) {
self.$nav.css('pointer-events', 'none');
self.$lightbox.one('contextmenu', function() {
setTimeout(function() {
this.$nav.css('pointer-events', 'auto');
}.bind(self), 0);
});
}
});
this.$lightbox.find('.lb-loader, .lb-close').on('click', function() {
self.end();
return false;
});
};
// Show overlay and lightbox. If the image is part of a set, add siblings to album array.
Lightbox.prototype.start = function($link) {
var self = this;
var $window = $(window);
$window.on('resize', $.proxy(this.sizeOverlay, this));
$('select, object, embed').css({
visibility: 'hidden'
});
this.sizeOverlay();
this.album = [];
var imageNumber = 0;
function addToAlbum($link) {
self.album.push({
alt: $link.attr('data-alt'),
link: $link.attr('href'),
title: $link.attr('data-title') || $link.attr('title')
//ADDED-START
, orientation: $link.attr('data-orientation')
, video: (self.options.hasVideo && typeof $link.attr('data-video') !== 'undefined' && $link.attr('data-video') === 'true')
//ADDED-END
});
}
// Support both data-lightbox attribute and rel attribute implementations
var dataLightboxValue = $link.attr('data-lightbox');
var $links;
if (dataLightboxValue) {
$links = $($link.prop('tagName') + '[data-lightbox="' + dataLightboxValue + '"]');
for (var i = 0; i < $links.length; i = ++i) {
addToAlbum($($links[i]));
if ($links[i] === $link[0]) {
imageNumber = i;
}
}
} else {
if ($link.attr('rel') === 'lightbox') {
// If image is not part of a set
addToAlbum($link);
} else {
// If image is part of a set
$links = $($link.prop('tagName') + '[rel="' + $link.attr('rel') + '"]');
for (var j = 0; j < $links.length; j = ++j) {
addToAlbum($($links[j]));
if ($links[j] === $link[0]) {
imageNumber = j;
}
}
}
}
// Position Lightbox
var top = $window.scrollTop() + this.options.positionFromTop;
var left = $window.scrollLeft();
this.$lightbox.css({
top: top + 'px',
left: left + 'px'
}).fadeIn(this.options.fadeDuration);
// Disable scrolling of the page while open
if (this.options.disableScrolling) {
$('html').addClass('lb-disable-scrolling');
}
this.changeImage(imageNumber);
};
// Hide most UI elements in preparation for the animated resizing of the lightbox.
Lightbox.prototype.changeImage = function(imageNumber) {
var self = this;
this.disableKeyboardNav();
var $image = this.$lightbox.find('.lb-image');
this.$overlay.fadeIn(this.options.fadeDuration);
$('.lb-loader').fadeIn('slow');
//ADDED-START
//this.$lightbox.find('.lb-image, .lb-nav, .lb-prev, .lb-next, .lb-dataContainer, .lb-numbers, .lb-caption').hide();
this.$lightbox.find('.lb-image, .lb-video, .lb-nav, .lb-prev, .lb-next, .lb-dataContainer, .lb-numbers, .lb-caption').hide();
//ADDED-END
this.$outerContainer.addClass('animating');
//ADDED-START
if(self.options.hasVideo) {
var $video = this.$lightbox.find('.lb-video');
var $lbContainer = this.$lightbox.find('.lb-container');
var $hasVideoNav = $lbContainer.hasClass('lb-video-nav');
if(self.album[imageNumber].video) {
$video.on('loadedmetadata', function(){
var $This = $(this);
//TODO merge with image
if(self.options.fitImagesInViewport) {
windowWidth = $(window).width();
windowHeight = $(window).height();
maxVideoWidth = windowWidth - self.containerPadding.left - self.containerPadding.right - self.videoBorderWidth.left - self.videoBorderWidth.right - 20;
maxVideoHeight = windowHeight - self.containerPadding.top - self.containerPadding.bottom - self.videoBorderWidth.top - self.videoBorderWidth.bottom - 120;
//Check if image size is larger then maxWidth|maxHeight in settings
if(self.options.maxWidth && self.options.maxWidth < maxVideoWidth) maxVideoWidth = self.options.maxWidth;
if(self.options.maxHeight && self.options.maxHeight < maxVideoWidth) maxVideoHeight = self.options.maxHeight;
//Is the current image's width or height is greater than the maxImageWidth or maxImageHeight
//option than we need to size down while maintaining the aspect ratio.
if((this.videoWidth > maxVideoWidth) || (this.videoHeight > maxVideoHeight)) {
if ((this.videoWidth / maxVideoWidth) > (this.videoHeight / maxVideoHeight)) {
videoWidth = maxVideoWidth;
videoHeight = Math.round(this.videoHeight / (this.videoWidth / videoWidth));
$This.width(videoWidth);
$This.height(videoHeight);
} else {
videoHeight = maxVideoHeight;
videoWidth = Math.round(this.videoWidth / (this.videoHeight / videoHeight));
$This.width(videoWidth);
$This.height(videoHeight);
}
}
}
self.sizeContainer($This.width(), $This.height(), 'video');
$This.off('loadedmetadata');
});
this.currentImageIndex = imageNumber;
$video.attr('src', self.album[imageNumber].link);
if(!$hasVideoNav) $lbContainer.addClass('lb-video-nav');
return;
}
else {
$video.attr('src', '');
if($hasVideoNav) $lbContainer.removeClass('lb-video-nav');
}
}
//ADDED-END
// When image to show is preloaded, we send the width and height to sizeContainer()
var preloader = new Image();
preloader.onload = function() {
var $preloader;
var imageHeight;
var imageWidth;
var maxImageHeight;
var maxImageWidth;
var windowHeight;
var windowWidth;
$image.attr({
'alt': self.album[imageNumber].alt,
'src': self.album[imageNumber].link
});
$preloader = $(preloader);
//ADDED-START
if(Math.abs(self.album[imageNumber].orientation) == 90) {
var sWidth = preloader.width;
preloader.width = preloader.height;
preloader.height = sWidth;
}
//ADDED-END
$image.width(preloader.width);
$image.height(preloader.height);
if (self.options.fitImagesInViewport) {
// Fit image inside the viewport.
// Take into account the border around the image and an additional 10px gutter on each side.
windowWidth = $(window).width();
windowHeight = $(window).height();
maxImageWidth = windowWidth - self.containerPadding.left - self.containerPadding.right - self.imageBorderWidth.left - self.imageBorderWidth.right - 20;
maxImageHeight = windowHeight - self.containerPadding.top - self.containerPadding.bottom - self.imageBorderWidth.top - self.imageBorderWidth.bottom - 120;
// Check if image size is larger then maxWidth|maxHeight in settings
if (self.options.maxWidth && self.options.maxWidth < maxImageWidth) {
maxImageWidth = self.options.maxWidth;
}
if (self.options.maxHeight && self.options.maxHeight < maxImageWidth) {
maxImageHeight = self.options.maxHeight;
}
// Is the current image's width or height is greater than the maxImageWidth or maxImageHeight
// option than we need to size down while maintaining the aspect ratio.
if ((preloader.width > maxImageWidth) || (preloader.height > maxImageHeight)) {
if ((preloader.width / maxImageWidth) > (preloader.height / maxImageHeight)) {
imageWidth = maxImageWidth;
imageHeight = parseInt(preloader.height / (preloader.width / imageWidth), 10);
$image.width(imageWidth);
$image.height(imageHeight);
} else {
imageHeight = maxImageHeight;
imageWidth = parseInt(preloader.width / (preloader.height / imageHeight), 10);
$image.width(imageWidth);
$image.height(imageHeight);
}
}
}
//ADDED-START
//self.sizeContainer($image.width(), $image.height());
self.sizeContainer($image.width(), $image.height(), 'image');
//ADDED-END
};
preloader.src = this.album[imageNumber].link;
this.currentImageIndex = imageNumber;
};
// Stretch overlay to fit the viewport
Lightbox.prototype.sizeOverlay = function() {
this.$overlay
.width($(document).width())
.height($(document).height());
};
// Animate the size of the lightbox to fit the image we are showing
//ADDED-START
//Lightbox.prototype.sizeContainer = function(imageWidth, imageHeight) {
Lightbox.prototype.sizeContainer = function(imageWidth, imageHeight, media) {
media = media || 'image';
//ADDED-END
var self = this;
var oldWidth = this.$outerContainer.outerWidth();
var oldHeight = this.$outerContainer.outerHeight();
//ADDED-START
//var newWidth = imageWidth + this.containerPadding.left + this.containerPadding.right + this.imageBorderWidth.left + this.imageBorderWidth.right;
//var newHeight = imageHeight + this.containerPadding.top + this.containerPadding.bottom + this.imageBorderWidth.top + this.imageBorderWidth.bottom;
var mediaBorderWidth = (media=='image')?this.imageBorderWidth:this.videoBorderWidth;
var newWidth = imageWidth + this.containerPadding.left + this.containerPadding.right + mediaBorderWidth.left + mediaBorderWidth.right;
var newHeight = imageHeight + this.containerPadding.top + this.containerPadding.bottom + mediaBorderWidth.top + mediaBorderWidth.bottom;
//ADDED-END
function postResize() {
self.$lightbox.find('.lb-dataContainer').width(newWidth);
self.$lightbox.find('.lb-prevLink').height(newHeight);
self.$lightbox.find('.lb-nextLink').height(newHeight);
self.showImage();
}
if (oldWidth !== newWidth || oldHeight !== newHeight) {
this.$outerContainer.animate({
width: newWidth,
height: newHeight
}, this.options.resizeDuration, 'swing', function() {
postResize();
});
} else {
postResize();
}
};
// Display the image and its details and begin preload neighboring images.
Lightbox.prototype.showImage = function() {
this.$lightbox.find('.lb-loader').stop(true).hide();
//ADDED-START
//this.$lightbox.find('.lb-image').fadeIn(this.options.imageFadeDuration);
if(this.options.hasVideo && this.album[this.currentImageIndex].video) this.$lightbox.find('.lb-video').fadeIn(this.options.imageFadeDuration);
else this.$lightbox.find('.lb-image').fadeIn(this.options.imageFadeDuration);
//ADDED-END
this.updateNav();
this.updateDetails();
this.preloadNeighboringImages();
this.enableKeyboardNav();
};
// Display previous and next navigation if appropriate.
Lightbox.prototype.updateNav = function() {
// Check to see if the browser supports touch events. If so, we take the conservative approach
// and assume that mouse hover events are not supported and always show prev/next navigation
// arrows in image sets.
var alwaysShowNav = false;
try {
document.createEvent('TouchEvent');
alwaysShowNav = (this.options.alwaysShowNavOnTouchDevices) ? true : false;
} catch (e) {}
this.$lightbox.find('.lb-nav').show();
if (this.album.length > 1) {
if (this.options.wrapAround) {
if (alwaysShowNav) {
this.$lightbox.find('.lb-prev, .lb-next').css('opacity', '1');
}
this.$lightbox.find('.lb-prev, .lb-next').show();
} else {
if (this.currentImageIndex > 0) {
this.$lightbox.find('.lb-prev').show();
if (alwaysShowNav) {
this.$lightbox.find('.lb-prev').css('opacity', '1');
}
}
if (this.currentImageIndex < this.album.length - 1) {
this.$lightbox.find('.lb-next').show();
if (alwaysShowNav) {
this.$lightbox.find('.lb-next').css('opacity', '1');
}
}
}
}
};
// Display caption, image number, and closing button.
Lightbox.prototype.updateDetails = function() {
var self = this;
// Enable anchor clicks in the injected caption html.
// Thanks Nate Wright for the fix. @https://github.com/NateWr
if (typeof this.album[this.currentImageIndex].title !== 'undefined' &&
this.album[this.currentImageIndex].title !== '') {
var $caption = this.$lightbox.find('.lb-caption');
if (this.options.sanitizeTitle) {
$caption.text(this.album[this.currentImageIndex].title);
} else {
$caption.html(this.album[this.currentImageIndex].title);
}
$caption.fadeIn('fast')
.find('a').on('click', function(event) {
if ($(this).attr('target') !== undefined) {
window.open($(this).attr('href'), $(this).attr('target'));
} else {
location.href = $(this).attr('href');
}
});
}
if (this.album.length > 1 && this.options.showImageNumberLabel) {
var labelText = this.imageCountLabel(this.currentImageIndex + 1, this.album.length);
this.$lightbox.find('.lb-number').text(labelText).fadeIn('fast');
} else {
this.$lightbox.find('.lb-number').hide();
}
this.$outerContainer.removeClass('animating');
this.$lightbox.find('.lb-dataContainer').fadeIn(this.options.resizeDuration, function() {
return self.sizeOverlay();
});
};
// Preload previous and next images in set.
Lightbox.prototype.preloadNeighboringImages = function() {
if (this.album.length > this.currentImageIndex + 1) {
var preloadNext = new Image();
preloadNext.src = this.album[this.currentImageIndex + 1].link;
}
if (this.currentImageIndex > 0) {
var preloadPrev = new Image();
preloadPrev.src = this.album[this.currentImageIndex - 1].link;
}
};
Lightbox.prototype.enableKeyboardNav = function() {
$(document).on('keyup.keyboard', $.proxy(this.keyboardAction, this));
};
Lightbox.prototype.disableKeyboardNav = function() {
$(document).off('.keyboard');
};
Lightbox.prototype.keyboardAction = function(event) {
var KEYCODE_ESC = 27;
var KEYCODE_LEFTARROW = 37;
var KEYCODE_RIGHTARROW = 39;
var keycode = event.keyCode;
var key = String.fromCharCode(keycode).toLowerCase();
if (keycode === KEYCODE_ESC || key.match(/x|o|c/)) {
this.end();
} else if (key === 'p' || keycode === KEYCODE_LEFTARROW) {
if (this.currentImageIndex !== 0) {
this.changeImage(this.currentImageIndex - 1);
} else if (this.options.wrapAround && this.album.length > 1) {
this.changeImage(this.album.length - 1);
}
} else if (key === 'n' || keycode === KEYCODE_RIGHTARROW) {
if (this.currentImageIndex !== this.album.length - 1) {
this.changeImage(this.currentImageIndex + 1);
} else if (this.options.wrapAround && this.album.length > 1) {
this.changeImage(0);
}
}
};
// Closing time. :-(
Lightbox.prototype.end = function() {
this.disableKeyboardNav();
//ADDED-START
if(this.options.hasVideo) {
var $video = this.$lightbox.find('.lb-video');
var $lbContainer = this.$lightbox.find('.lb-container');
var $hasVideoNav = $lbContainer.hasClass('lb-video-nav');
$video.attr('src', '');
if($hasVideoNav) $lbContainer.removeClass('lb-video-nav');
}
//ADDED-END
$(window).off('resize', this.sizeOverlay);
this.$lightbox.fadeOut(this.options.fadeDuration);
this.$overlay.fadeOut(this.options.fadeDuration);
$('select, object, embed').css({
visibility: 'visible'
});
if (this.options.disableScrolling) {
$('html').removeClass('lb-disable-scrolling');
}
};
return new Lightbox();
}));
-15
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-337
View File
@@ -1,337 +0,0 @@
function Spot(asGlobals)
{
self = this;
this.consts = asGlobals.consts;
this.consts.hash_sep = '-';
this.consts.title = 'Spotty';
this.consts.default_page = 'project';
//this.consts.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
/* Initialization */
this.init = function()
{
//Variables & constants from php
self.vars('tmp', 'object');
self.vars('page', 'string');
self.updateVars(asGlobals.vars);
//page elem
self.elem = {};
self.elem.container = $('#container');
self.elem.main = $('#main');
self.resetTmpFunctions();
//on window resize
$(window).on('resize', function(){self.onResize();});
//Setup menu
//self.initMenu();
//Hash management
$(window)
.bind('hashchange', self.onHashChange)
.trigger('hashchange');
};
this.updateVars = function(asVars)
{
$.each(asVars, function(sKey, oValue){self.vars(sKey, oValue)});
};
/* Variable Management */
this.vars = function(oVarName, oValue)
{
var asVarName = (typeof oVarName == 'object')?oVarName:[oVarName];
//Set, name & type / default value (init)
if(typeof oValue !== 'undefined') setElem(self.vars, copyArray(asVarName), oValue);
//Get, only name parameter
return getElem(self.vars, asVarName);
};
this.tmp = function(sVarName, oValue)
{
var asVarName = (typeof sVarName == 'object')?sVarName:[sVarName];
asVarName.unshift('tmp');
return self.vars(asVarName, oValue);
};
/* Interface with server */
this.get = function(sAction, fOnSuccess, oVars, fOnError, bProcessIcon)
{
if(!oVars) oVars = {};
fOnError = fOnError || function(sError) {console.log(sError);};
bProcessIcon = bProcessIcon || false;
//if(bProcessIcon) self.addBufferIcon();
oVars['a'] = sAction;
return $.ajax(
{
url: self.consts.process_page,
data: oVars,
dataType: 'json'
})
.done(function(oData)
{
if(oData.desc.substr(0, self.consts.lang_prefix.length)==self.consts.lang_prefix) oData.desc = self.lang(oData.desc.substr(5));
if(oData.result==self.consts.error) fOnError(oData.desc);
else
{
//if(bProcessIcon) self.resetIcon();
fOnSuccess(oData.data);
}
})
.fail(function(jqXHR, textStatus, errorThrown)
{
//if(bProcessIcon) self.resetIcon();
fOnError(textStatus+' '+errorThrown);
});
};
this.lang = function(sKey, asParams) {
asParams = asParams || [];
if($.type(asParams) == 'string') asParams = [asParams];
var sLang = '';
if(sKey in self.consts.lang) {
sLang = self.consts.lang[sKey];
for(i in asParams) sLang = sLang.replace('$'+i, asParams[i]);
}
else console.log('missing translation: '+sKey);
return sLang;
};
/* Page Switch - Trigger & Event catching */
this.onHashChange = function()
{
var asHash = self.getHash();
if(asHash.hash !='' && asHash.page != '') self.switchPage(asHash); //page switching
else if(self.vars('page')=='') self.setHash(self.consts.default_page); //first page
};
this.getHash = function()
{
var sHash = self.hash();
var asHash = sHash.split(self.consts.hash_sep);
var sPage = asHash.shift() || '';
return {hash:sHash, page:sPage, items:asHash};
};
this.setHash = function(sPage, asItems, bReboot)
{
bReboot = bReboot || false;
sPage = sPage || '';
asItems = asItems || [];
if(typeof asItems == 'string') asItems = [asItems];
if(sPage != '')
{
var sItems = (asItems.length > 0)?self.consts.hash_sep+asItems.join(self.consts.hash_sep):'';
self.hash(sPage+sItems, bReboot);
}
};
this.hash = function(hash, bReboot)
{
bReboot = bReboot || false;
if(!hash) return window.location.hash.slice(1);
else window.location.hash = '#'+hash;
if(bReboot) location.reload();
};
/* Page Switch - DOM Replacement */
this.getActionLink = function(sAction, oVars)
{
if(!oVars) oVars = {};
sVars = '';
for(i in oVars)
{
sVars += '&'+i+'='+oVars[i];
}
return self.consts.process_page+'?a='+sAction+sVars;
};
this.resetTmpFunctions = function()
{
self.pageInit = function(asHash){console.log('no init for the page: '+asHash.page)};
self.onSamePageMove = function(asHash){return false};
self.onQuitPage = function(){return true};
self.onResize = function(){};
self.onFeedback = function(sType, sMsg){feedback(sType, sMsg);};
};
this.switchPage = function(asHash)
{
var sPageName = asHash.page;
var bSamePage = self.vars('page')==sPageName;
if(self.onQuitPage(bSamePage) && !bSamePage || self.onSamePageMove(asHash))
{
//Delete tmp variables
self.vars('tmp', {});
//disable tmp functions
self.resetTmpFunctions();
//Officially a new page
var bFirstPage = self.vars('page')=='';
self.vars('page', sPageName);
//Update Page Title
this.setPageTitle(sPageName+' '+(asHash.items[0] || ''));
//Replacing DOM
var $Dom = $(self.consts.pages[sPageName]);
if(bFirstPage)
{
self.splash($Dom, asHash, bFirstPage); //first page
}
else
{
self.elem.main.stop().fadeTo('fast', 0, function(){self.splash($Dom, asHash, bFirstPage);}); //Switching page
}
}
};
this.setPageTitle = function(sTitle) {
document.title = self.consts.title+' - '+sTitle;
};
this.splash = function($Dom, asHash, bFirstPage)
{
//Switch main content
self.elem.main.empty().html($Dom);
//Page Bootstrap
self.pageInit(asHash, bFirstPage);
//Show main
var $FadeInElem = bFirstPage?self.elem.container:self.elem.main;
$FadeInElem.hide().fadeTo('slow', 1);
};
}
/* Common Functions */
function copyArray(asArray)
{
return asArray.slice(0); //trick to copy array
}
function getElem(aoAnchor, asPath)
{
return (typeof asPath == 'object' && asPath.length > 1)?getElem(aoAnchor[asPath.shift()], asPath):aoAnchor[(typeof asPath == 'object')?asPath.shift():asPath];
}
function setElem(aoAnchor, asPath, oValue)
{
var asTypes = {boolean:false, string:'', integer:0, int:0, array:[], object:{}};
if(typeof asPath == 'object' && asPath.length > 1)
{
var nextlevel = asPath.shift();
if(!(nextlevel in aoAnchor)) aoAnchor[nextlevel] = {}; //Creating a new level
if(typeof aoAnchor[nextlevel] !== 'object') debug('Error - setElem() : Already existing path at level "'+nextlevel+'". Cancelling setElem() action');
return setElem(aoAnchor[nextlevel], asPath, oValue);
}
else
{
var sKey = (typeof asPath == 'object')?asPath.shift():asPath;
return aoAnchor[sKey] = (!(sKey in aoAnchor) && (oValue in asTypes))?asTypes[oValue]:oValue;
}
}
function getTimeZoneDesc(sTimeZoneName) {
var sCity = sTimeZoneName.split('/')[1] || '';
return oSpot.lang('city_time', sCity);
}
$.prototype.addInput = function(sType, sName, sValue)
{
return $(this).append($('<input>', {type: sType, name: sName, value: sValue}).data('old_value', sValue));
};
$.prototype.addIcon = function(sIcon, bMargin, sStyle)
{
bMargin = bMargin || false;
sStyle = sStyle || '';
return $(this).prepend($('<i>', {'class':'fa'+sStyle+' '+sIcon+(bMargin?' push':'')}));
};
$.prototype.defaultVal = function(sDefaultValue)
{
$(this)
.data('default_value', sDefaultValue)
.val(sDefaultValue)
.addClass('defaultText')
.focus(function()
{
var $This = $(this);
if($This.val() == $This.data('default_value')) $This.val('').removeClass('defaultText');
})
.blur(function()
{
var $This = $(this);
if($This.val() == '') $This.val($This.data('default_value')).addClass('defaultText');
});
};
$.prototype.checkForm = function(sSelector)
{
sSelector = sSelector || 'input[type="text"], textarea';
var $This = $(this);
var bOk = true;
$This.find(sSelector).each(function()
{
$This = $(this);
bOk = bOk && $This.val()!='' && $This.val()!=$This.data('default_value');
});
return bOk;
};
$.prototype.cascadingDown = function(sDuration)
{
return $(this).slideDown(sDuration, function(){$(this).next().cascadingDown(sDuration);});
};
$.prototype.hoverSwap = function(sDefault, sHover)
{
return $(this)
.data('default', sDefault)
.data('hover', sHover)
.hover(function(){
var $This = $(this),
sHover = $This.data('hover');
sDefault = $This.data('default');
if(sDefault!='' && sHover != '') {
$This.fadeOut('fast', function() {
var $This = $(this);
$This.text((sDefault==$This.text())?sHover:sDefault).fadeIn('fast');
});
}
})
.text(sDefault);
};
$.prototype.onSwipe = function(fCallBack){
return $(this)
.on('mousedown touchstart', function(e) {
var $This = $(this);
$This.data('x-down', e.pageX);
$This.data('y-down', e.pageY);
})
.on('mouseup touchend',function (e) {
var $This = $(this);
var iDeltaX = e.pageX - $This.data('x-down');
var iDeltaY = e.pageY - $This.data('y-down');
fCallBack({x:iDeltaX, y:iDeltaY});
});
};
-14
View File
@@ -1,14 +0,0 @@
<?php
class Settings
{
const GEO_SERVER = 'http(s)://geo.server.tld';
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/Paris';
const DEBUG = true;
}
+114
View File
@@ -0,0 +1,114 @@
<script>
import { defineAsyncComponent } from 'vue';
import Project from '@components/Project';
const aoRoutes = {
'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: [], prev: {}},
consts: this.appConfig.consts,
mobile: false
};
},
provide() {
return {
hash: this.hash,
consts: this.consts,
isMobile: () => this.isMobile(),
getAnchor: this.getAnchor,
getPrevAnchor: () => this.getPrevAnchor(),
};
},
computed: {
route() {
return aoRoutes[this.hash.page];
},
hashSnapshot() {
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
this.setVarHash(this.validateRoute(this.getBrowserHash()));
},
mounted() {
//Catch browser hash change
window.addEventListener('hashchange', this.onBrowserHashChange);
},
watch: {
hashSnapshot(jNewHash, jOldHash) {
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);
}
},
methods: {
isMobile() {
return this.mobile;
},
updateMobile() {
this.mobile = this.mobileMediaQuery.matches;
},
setPageTitle(sTitle) {
document.title = this.consts.title + ' - ' + sTitle.trim();
},
setVarHash(asHash) {
this.hash.page = asHash.page || '';
this.hash.items = Array.isArray(asHash.items) ? [...asHash.items.filter(n => n)] : [];
},
onBrowserHashChange() { //Sync #hash -> variable
let asHash = this.getBrowserHash();
if(asHash != this.hash) this.setVarHash(asHash);
},
getBrowserHash() {
let sHash = window.location.hash.slice(1);
let asHash = sHash.split(this.consts.hash_sep).filter(n => n);
let sPage = asHash.shift() || '';
return {page: sPage, items: asHash};
},
setBrowserHash(sPage = '', asItems = []) {
if(typeof asItems == 'string' && asItems != '') asItems = [asItems];
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);
this.mobileMediaQuery.removeEventListener('change', this.updateMobile);
}
};
</script>
<template>
<div id="main">
<component :is="route" />
</div>
</template>
+38
View File
@@ -0,0 +1,38 @@
//Librairies
import 'vite/modulepreload-polyfill';
import Api from '@scripts/api';
import Lang from '@scripts/lang';
import Projects from '@scripts/projects';
import User from '@scripts/user';
import { createApp, reactive } from 'vue';
//Main template
import App from './App.vue';
//Style
import '@styles/livetrail.scss';
//App Configuration from PHP
const appConfig = JSON.parse(document.getElementById('app-config').textContent);
//Instances
const oProjects = new Projects(appConfig.projects);
const oUser = reactive(new User(appConfig.user, appConfig.consts.default_timezone));
const oLang = new Lang({translations: appConfig.consts.lang, prefix: appConfig.consts.lang_prefix});
const oApi = new Api({
server: appConfig.consts.server,
processPage: appConfig.consts.process_page,
timezone: oUser.timezone,
csrfToken: appConfig.consts.csrf_token,
errorCode: appConfig.consts.error,
lang: oLang
});
//Mount app
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');
+234
View File
@@ -0,0 +1,234 @@
<script>
import AppIcon from '@components/AppIcon';
import AppButton from '@components/AppButton';
import AdminInput from '@components/AdminInput';
export default {
components: {
AppIcon,
AppButton,
AdminInput
},
inject: ['api', 'lang', 'getPrevAnchor'],
data() {
return {
elems: {},
feedbacks: [],
saveTimer: null
};
},
beforeUnmount() {
if(this.saveTimer) clearTimeout(this.saveTimer);
},
mounted() {
this.setProjects();
},
methods: {
l(sLangId, asLangParams=[]) {
return this.lang.get(sLangId, asLangParams);
},
addFeedback(sType, sMsg, asContext = {}) {
delete asContext.a;
delete asContext.t;
sMsg += ' (';
for(const [sKey, sElem] of Object.entries(asContext)) {
sMsg += sKey+'='+sElem+' / ' ;
}
sMsg = sMsg.slice(0, -3)+')';
this.feedbacks.push({type:sType, msg:sMsg});
},
async setProjects() {
let aoElemTypes = await this.api.get('admin_get');
for(const [sType, aoElems] of Object.entries(aoElemTypes)) {
this.elems[sType] = {};
for(const oElem of Object.values(aoElems)) {
oElem.type = sType;
this.elems[sType][oElem.id] = oElem;
}
}
},
createElem(sType) {
this.api.post('admin_create', {type: sType})
.then((aoNewElemTypes) => {
for(const [sType, aoNewElems] of Object.entries(aoNewElemTypes)) {
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((oError) => {this.addFeedback('error', oError.desc_lang_text, {'create':sType});});
},
updateElem(oElem, oEvent) {
if(this.saveTimer) clearTimeout(this.saveTimer);
let sOldVal = this.elems[oElem.type][oElem.id][oEvent.target.name];
let sNewVal = oEvent.target.value;
if(sOldVal != sNewVal) {
let asInputs = {
type: oElem.type,
id: oElem.id,
field: oEvent.target.name,
value: sNewVal
};
this.api.request('admin_set', asInputs, 'POST')
.then((oResponse) => {
this.elems[oElem.type][oElem.id][oEvent.target.name] = sNewVal;
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((oError) => {
oEvent.target.value = sOldVal;
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.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="getPrevAnchor()"><AppIcon :icon="'back'" :text="l('action.back')" /></a>
<h1>{{ l('project.plural') }}</h1>
<div>
<table>
<thead>
<tr>
<th>{{ l('project.id') }}</th>
<th>{{ l('project.single') }}</th>
<th>{{ l('project.mode') }}</th>
<th>{{ l('project.code_name') }}</th>
<th>{{ l('project.start') }}</th>
<th>{{ l('project.end') }}</th>
<th>{{ l('action.delete') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="project in elems.project" :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><AppButton :icon="'close'" iconSize="lg" @click="deleteElem(project)" /></td>
</tr>
</tbody>
</table>
<AppButton :classes="'new'" :text="l('project.new')" :icon="'new'" @click="createElem('project')" />
</div>
<h1>{{ l('feed.plural') }}</h1>
<div>
<table>
<thead>
<tr>
<th>{{ l('feed.id') }}</th>
<th>{{ l('feed.ref_id') }}</th>
<th>{{ l('spot.id') }}</th>
<th>{{ l('project.id') }}</th>
<th>{{ l('feed.name') }}</th>
<th>{{ l('feed.status') }}</th>
<th>{{ l('feed.last_update') }}</th>
<th>{{ l('action.delete') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="feed in elems.feed" :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>
<td><AdminInput :type="'number'" :name="'id_project'" :elem="feed" /></td>
<td>{{ feed.name }}</td>
<td>{{ feed.status }}</td>
<td>{{ feed.last_update }}</td>
<td><AppButton :icon="'close'" iconSize="lg" @click="deleteElem(feed)" /></td>
</tr>
</tbody>
</table>
<AppButton :classes="'new'" :text="l('feed.new')" :icon="'new'" @click="createElem('feed')" />
</div>
<h1>{{ l('spot.plural') }}</h1>
<div>
<table>
<thead>
<tr>
<th>{{ l('spot.id') }}</th>
<th>{{ l('spot.ref_id') }}</th>
<th>{{ l('spot.name') }}</th>
<th>{{ l('spot.model') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="spot in elems.spot" :key="spot.id">
<td>{{ spot.id }}</td>
<td>{{ spot.ref_spot_id }}</td>
<td>{{ spot.name }}</td>
<td>{{ spot.model }}</td>
</tr>
</tbody>
</table>
</div>
<h1>{{ l('user.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>
</tr>
</thead>
<tbody>
<tr v-for="user in elems.user" :key="user.id">
<td>{{ user.id }}</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>
</tr>
</tbody>
</table>
</div>
<h1>{{ l('admin.toolbox') }}</h1>
<div id="toolbox">
<AppButton :classes="'refresh'" :text="l('project.update_messages')" :icon="'refresh'" @click="updateProject" />
</div>
<div id="feedback" class="feedback">
<p v-for="(feedback, index) in feedbacks" :key="index" :class="feedback.type">{{ feedback.msg }}</p>
</div>
</div>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script>
export default {
props: {
type: String,
name: String,
elem: Object
},
computed: {
value() {
return this.elem[this.name];
}
}
};
</script>
<template>
<input :type="type" :name="name" :value="value" @change="$parent.updateElem(elem, $event)" @keyup="$parent.queue(elem, $event)" />
</template>
+19
View File
@@ -0,0 +1,19 @@
<script>
import AppIcon from '@components/AppIcon';
export default {
components: {
AppIcon
},
props: {
classes: String,
text: String,
icon: String,
iconClasses: String,
iconSize: String
}
};
</script>
<template>
<button :class="classes"><AppIcon :icon="icon" :text="text" :classes="iconClasses" :size="iconSize" /></button>
</template>
+88
View File
@@ -0,0 +1,88 @@
<script>
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import { getIcon } from '@scripts/icons';
export default {
components: {
FontAwesomeIcon
},
props: {
icon: String,
width: String,
size: String,
classes: String,
title: String,
text: String,
margin: String,
textClasses: String,
transform: String
},
computed: {
resolvedFixedWidth() {
return (this.width == 'fixed') || null;
},
resolvedAutoWidth() {
return (this.width == 'auto') || null;
},
hasText() {
return this.text && this.text != '';
},
iconDefinition() {
return getIcon(this.icon);
},
resolvedSize() {
return this.size || null;
},
resolvedTransform() {
return this.transform || null;
}
}
};
</script>
<template>
<span :title="title" :class="hasText?'app-icon-with-text':null">
<span class="app-icon-symbol">
<FontAwesomeIcon
:icon="iconDefinition"
: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="['app-icon-text', textClasses]">{{ text }}</span>
</span>
</template>
<style lang="scss">
@use "@styles/var";
.app-icon-with-text {
display: inline-flex;
align-items: flex-start;
gap: var.$text-spacing;
.app-icon-symbol {
flex: 0 0 auto;
align-self: center;
}
.app-icon-text {
flex: 1 1 auto;
min-width: 0;
align-self: center;
}
}
.app-icon {
&.margin-right {
margin-right: var.$text-spacing;
}
&.margin-left {
margin-left: var.$text-spacing;
}
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<script>
import { FontAwesomeLayers } from '@fortawesome/vue-fontawesome';
import appIcon from '@components/AppIcon';
export default {
components: {
FontAwesomeLayers,
appIcon
},
props: {
mainClasses: String,
iconMain: String,
iconMainClasses: String,
iconMainTransform: String,
iconSub: String,
iconSubClasses: String,
iconSubTransform: String
},
computed: {
mainClass() {
return 'app-icon-stack clickable'+(this.mainClasses?' '+this.mainClasses:'');
},
iconMainClass() {
return this.mergeClasses('main clickable', this.iconMainClasses);
},
iconSubClass() {
return this.mergeClasses('sub', this.iconSubClasses);
},
iconSubTransformValue() {
return this.mergeClasses('shrink-9 up-2', this.iconSubTransform);
}
},
methods: {
mergeClasses(sClassesA, sClassesB) {
return [
...(sClassesA || '').split(/\s+/),
...(sClassesB || '').split(/\s+/)
].filter(Boolean).join(' ');
}
}
};
</script>
<template>
<span :class="mainClass">
<FontAwesomeLayers>
<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>

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