├── .eslintrc.cjs ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── .prettierrc.json ├── .vscode └── extensions.json ├── README.md ├── index.html ├── jsconfig.json ├── package-lock.json ├── package.json ├── patches └── @mapbox+mapbox-gl-directions+4.3.1.patch ├── postcss.config.js ├── public ├── apple-touch-icon.png ├── favicon.ico ├── favicon_Vue.ico ├── img │ ├── Berlin.jpg │ ├── Rome.jpg │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ └── marking_point.jpg └── mapbox_routes.geojson ├── src ├── App.vue ├── api │ ├── authService │ │ └── index.js │ ├── clientFetch.js │ ├── favorite-places │ │ └── index.js │ ├── routesService.js │ └── user │ │ └── index.js ├── assets │ ├── base.css │ ├── img │ │ ├── 404.jpg │ │ ├── Alexanderplatz.jpg │ │ ├── Berlin.jpg │ │ ├── BlankMap-World-min.png │ │ ├── BlankMap-World.svg │ │ ├── Rome.jpg │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── map-pin.svg │ │ ├── marking_point.jpg │ │ └── static-map.png │ ├── logo.svg │ └── main.css ├── components │ ├── Auth │ │ ├── LoginForm │ │ │ └── LoginForm.vue │ │ └── RegistrationForm │ │ │ └── RegistrationForm.vue │ ├── ConfirmationModal │ │ └── ConfirmationModal.vue │ ├── CreateNewPlaceModal │ │ └── CreateNewPlaceModal.vue │ ├── EditPlaceModal │ │ └── EditPlaceModal.vue │ ├── FavoritePlace │ │ ├── DeleteIcon.vue │ │ ├── EditIcon.vue │ │ ├── FavoritePlace.vue │ │ └── FavoritePlaceIconButton.vue │ ├── FavoritePlaces │ │ └── FavoritePlaces.vue │ ├── IButton │ │ ├── FullScreenButton.vue │ │ ├── IButton.vue │ │ ├── ResetZoomButton.vue │ │ ├── RoutesButton.vue │ │ └── Toggle3DButton.vue │ ├── IInput │ │ └── IInput.vue │ ├── IModal │ │ └── IModal.vue │ ├── ISpinner │ │ └── ISpinner.vue │ ├── InputImage │ │ ├── InputImage.vue │ │ └── UploadIcon.vue │ ├── LogoutButton │ │ ├── LogoutButton.vue │ │ └── LogoutIcon.vue │ ├── RouteStatusBar │ │ └── RouteStatusBar.vue │ ├── SwiperSlider │ │ ├── SwiperSlider.vue │ │ └── VerticalSliderCard.vue │ ├── UserInfo │ │ ├── UserIcon.vue │ │ └── UserInfo.vue │ ├── __tests__ │ │ └── VueTests.spec.js │ └── icons │ │ ├── AddPoint.vue │ │ ├── CrossIcon.vue │ │ ├── EyeIcon.vue │ │ ├── EyeOffIcon.vue │ │ └── MarkerIcon.vue ├── composables │ ├── useModal.js │ └── useMutation.js ├── layouts │ └── BaseLayout.vue ├── main.js ├── map │ └── settings.js ├── router │ └── index.js ├── services │ ├── mapService.js │ ├── routeService.js │ └── routeTransformService.js ├── stores │ └── routeStore.js ├── utils │ └── spinnerControl.js └── views │ ├── AuthView.vue │ ├── GreetingView.vue │ ├── HomepageView.vue │ ├── LoginView.vue │ ├── NotFoundView.vue │ └── Registration.vue ├── tailwind.config.js ├── vite.config.js └── vitest.config.js /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require('@rushstack/eslint-patch/modern-module-resolution') 3 | 4 | module.exports = { 5 | root: true, 6 | 'extends': [ 7 | 'plugin:vue/vue3-essential', 8 | 'eslint:recommended', 9 | '@vue/eslint-config-prettier/skip-formatting' 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 'latest' 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build and deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | build-and-deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 🛎️ 12 | uses: actions/checkout@v3 13 | 14 | - name: Install and build 🔧 15 | env: 16 | VITE_TOKEN_MAPBOX: ${{ secrets.VITE_TOKEN_MAPBOX }} 17 | VITE_BASE_URL: ${{ secrets.VITE_BASE_URL }} 18 | VITE_API_SERVER_URL: ${{ secrets.VITE_API_SERVER_URL }} 19 | run: | 20 | npm install 21 | npm run build 22 | 23 | - name: Deploy 🚀 24 | uses: JamesIves/github-pages-deploy-action@4.1.0 25 | with: 26 | branch: gh-pages 27 | folder: dist 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | .env 25 | .env.production 26 | *.suo 27 | *.ntvs* 28 | *.njsproj 29 | *.sln 30 | *.sw? 31 | 32 | *.tsbuildinfo 33 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": false, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "printWidth": 100, 7 | "trailingComma": "none" 8 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "Vue.volar", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VueMap-Explorer 2 | 3 | Interactive 3D map platform 4 | **Designed for professional, production-level use** – from small personal trips to large-scale route management, with an **intuitive design** and **Mobile-First** approach. 5 | 6 | > **An advanced Vue 3 + Mapbox application for exploring, annotating, and sharing geospatial data** 7 | > Complete with 3D routes, Swiper-based UIs, iOS optimizations, PWA offline capabilities, and a Node/Express backend with Swagger. 8 | 9 | [![Vue 3](https://img.shields.io/badge/Vue-3.x-42b883.svg?logo=vue.js&logoColor=white)](https://vuejs.org/) 10 | [![Vite](https://img.shields.io/badge/Vite-^4.x-646CFF.svg?logo=vite&logoColor=white)](https://vitejs.dev/) 11 | [![Mapbox GL](https://img.shields.io/badge/Mapbox%20GL-Latest-blue.svg)](https://www.mapbox.com/) 12 | [![PWA Support](https://img.shields.io/badge/PWA-Supported-brightgreen.svg?logo=pwa&logoColor=white)](#pwa-installation-instructions) 13 | [![License](https://img.shields.io/badge/License-MIT-blue.svg)](#license) 14 | 15 | ## Table of Contents 16 | 17 | - [Demo Links](#demo-links) 18 | - [Open API Swagger Documentation](#swagger-api-documentation) 19 | - [Features](#features) 20 | - [Installation](#installation) 21 | - [Project Setup](#project-setup) 22 | - [Compile and Hot-Reload for Development](#compile-and-hot-reload-for-development) 23 | - [Compile and Minify for Production](#compile-and-minify-for-production) 24 | - [Run Unit Tests](#run-unit-tests) 25 | - [Lint with ESLint](#lint-with-eslint) 26 | - [PWA Installation Instructions](#pwa-installation-instructions) 27 | - [Tech Stack & Highlights](#tech-stack--highlights) 28 | - [Roadmap](#roadmap) 29 | - [License](#license) 30 | - [Contact & Feedback](#contact--feedback) 31 | 32 | --- 33 | 34 | ## Demo Links 35 | 36 | - **Live Frontend (Vercel)**: 37 | [https://vue-map-explorer.vercel.app](https://vue-map-explorer.vercel.app) 🗺 38 | 39 | --- 40 | 41 | ## Open API Swagger Documentation 42 | 43 | This project integrated with a **Node/Express** backend providing **Open API** includes **Swagger** UI documentation. 44 | 45 | - **Open API (Swagger) on Render:** 46 | [https://backend-vue-map-explorer.onrender.com/api-docs](https://backend-vue-map-explorer.onrender.com/api-docs) ✔ 47 | 48 | - **Production Server (Render):** 49 | [https://backend-vue-map-explorer.onrender.com](https://backend-vue-map-explorer.onrender.com) 🌍 50 | 51 | All endpoints are described in `swagger.json`, covering 52 | **User, Points, Routes,** and more. You can test requests directly in the 53 | browser using the “Try it out” button, but note that you must provide a valid 54 | **Bearer token** for protected routes. 55 | 56 | **Important**: If you’re dealing with very large data (e.g. tens of thousands of 57 | coordinates), Swagger UI may lag while rendering the JSON response. In such 58 | cases, Postman or cURL might be more responsive for heavy payloads. 59 | 60 | --- 61 | 62 | ## Features 63 | 64 | - **3D Routes & Custom Markers**: 65 | Powered by [Mapbox GL](https://www.mapbox.com/). Easily add, edit, or remove markers and see 3D transitions for routes. 66 | 67 | - **Mobile First and Adaptive UI**: 68 | Tailored for phones and tablets – the app also runs great on desktops/laptops for advanced tasks. 69 | Designed based on real-world user experiences on mobile/tablet devices. 70 | 71 | - **Responsive & iOS-Optimized**: 72 | Special care for iPhone/iPad (safe-area insets, orientation fix, etc.). PWA with black-translucent status bar. 73 | 74 | - **Pinia State Management**: 75 | Coordinates user sessions, active route data, and iOS detection (`isIOS`) for dynamic UI. 76 | 77 | - **Swiper Cards**: 78 | Swipe through points of interest in a sliding gallery. Overcame iOS quirks for clickable side slides. 79 | 80 | - **Secure Auth (JWT)**: 81 | Connects to a Node/Express backend using Bearer tokens. Protected routes for user-based markers. 82 | 83 | - **Offline-Ready**: 84 | Deployed as a PWA. Install on mobile devices and continue exploring even without network. 85 | 86 | --- 87 | 88 | ## Installation 89 | 90 | ## Project Setup 91 | 92 | ```sh 93 | npm install 94 | ``` 95 | 96 | ### Compile and Hot-Reload for Development 97 | 98 | ```sh 99 | npm run dev 100 | ``` 101 | 102 | ### Compile and Minify for Production 103 | 104 | ```sh 105 | npm run build 106 | ``` 107 | 108 | ### Run Unit Tests with [Vitest](https://vitest.dev/) 109 | 110 | ```sh 111 | npm run test:unit 112 | ``` 113 | 114 | ### Lint with [ESLint](https://eslint.org/) 115 | 116 | ```sh 117 | npm run lint 118 | ``` 119 | 120 | ### PWA Installation Instructions: 121 | 122 | ### Android 123 | 124 | 1. Open the app in your browser (Chrome etc.) on your Android phone. 125 | 2. You will see a banner prompting to **Add to Home Screen**. Follow the instructions to install. 126 | 3. Launch from your home screen as if it’s a native app. 127 | 128 | ### iPhone/iPad (Safari) 129 | 130 | 1. Open the app in **Safari**. 131 | 2. Tap the **Share** button (the icon with an arrow pointing out of a box). 132 | 3. Scroll down and select **Add to Home Screen**. 133 | 4. Confirm by tapping **Add** in the top-right corner. 134 | 135 | After installation, the app will appear on your home screen like a native app! 136 | 137 | --- 138 | 139 | ## Tech Stack & Highlights 140 | 141 | - **Vue 3 + Vite** 142 | Lightning-fast development, modern ES builds. 143 | 144 | - **Pinia** 145 | Simple yet powerful state management. 146 | 147 | - **Mapbox GL** 148 | Real-time 2D/3D maps, custom markers & route layers. 149 | 150 | - **Swiper.js** 151 | For slick marker/route card slideshows with iOS fixes. 152 | 153 | - **Tailwind CSS** 154 | Utility-first styling, responsive breakpoints. 155 | 156 | - **Node/Express backend (separate repo)** 157 | JWT auth, user route sharing, image upload, etc. 158 | 159 | - **Deployed** to Vercel (frontend) + Render (backend) 160 | 161 | --- 162 | 163 | ## iOS & iPad Adaptations 164 | 165 | - `viewport-fit=cover`, handling safe-area insets. 166 | 167 | - Orientation & scale fixes to prevent auto-zoom. 168 | 169 | - PWA `apple-mobile-web-app-capable=YES` with `black-translucent` status bar. 170 | 171 | --- 172 | 173 | ## Roadmap 174 | 175 | - **Enhanced Route Editing:** Multi-waypoints, partial updates, color-coded lines. 176 | 177 | - **Sharing & Collaboration:** Invites, rating system, real-time comment threads. 178 | 179 | - **Offline Caching:** Full offline usage for markers & routes. 180 | 181 | - **TypeScript:** Migrate backend to TS for end-to-end typed dev. 182 | 183 | - **CI/CD:** Automated tests & deploy on merges. 184 | 185 | --- 186 | 187 | ## License 188 | 189 | [MIT License]. 190 | Feel free to use or modify. If you make improvements, consider a pull request. 191 | 192 | --- 193 | 194 | ## Contact & Feedback 195 | 196 | - **Author:** [@maxexc](https://github.com/maxexc) 197 | 198 | - **Contributions:** PRs/issues are welcome here. 199 | 200 | - **Questions?** Feel free to open a discussion or contact me directly. 201 | 202 | --- 203 | 204 | **Enjoy exploring your geospatial data with VueMap-Explorer!** 205 | Feel free to star this repo if you find it helpful. 206 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | VueMap-Explorer 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | } 6 | }, 7 | "exclude": ["node_modules", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vuemap-explorer", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "test:unit": "vitest", 11 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore", 12 | "format": "prettier --write src/", 13 | "postinstall": "patch-package" 14 | }, 15 | "dependencies": { 16 | "@mapbox/mapbox-gl-directions": "^4.3.1", 17 | "@mapbox/polyline": "^1.2.1", 18 | "@studiometa/vue-mapbox-gl": "^2.5.0", 19 | "axios": "^1.7.7", 20 | "mapbox-gl": "^3.7.0", 21 | "pinia": "^2.1.7", 22 | "swiper": "^11.1.15", 23 | "vue": "^3.4.29", 24 | "vue-router": "^4.3.3" 25 | }, 26 | "devDependencies": { 27 | "@rushstack/eslint-patch": "^1.8.0", 28 | "@vitejs/plugin-vue": "^5.0.5", 29 | "@vue/eslint-config-prettier": "^9.0.0", 30 | "@vue/test-utils": "^2.4.6", 31 | "autoprefixer": "^10.4.20", 32 | "eslint": "^8.57.0", 33 | "eslint-plugin-vue": "^9.23.0", 34 | "jsdom": "^24.1.0", 35 | "patch-package": "^8.0.0", 36 | "postcss": "^8.4.47", 37 | "prettier": "^3.2.5", 38 | "tailwindcss": "^3.4.13", 39 | "vite": "^5.3.1", 40 | "vite-plugin-pwa": "^0.21.1", 41 | "vite-plugin-vue-devtools": "^7.3.1", 42 | "vitest": "^1.6.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxexc/VueMap-Explorer/f8000f68de55158f59205493590d7ac1c20bc603/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxexc/VueMap-Explorer/f8000f68de55158f59205493590d7ac1c20bc603/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon_Vue.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxexc/VueMap-Explorer/f8000f68de55158f59205493590d7ac1c20bc603/public/favicon_Vue.ico -------------------------------------------------------------------------------- /public/img/Berlin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxexc/VueMap-Explorer/f8000f68de55158f59205493590d7ac1c20bc603/public/img/Berlin.jpg -------------------------------------------------------------------------------- /public/img/Rome.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxexc/VueMap-Explorer/f8000f68de55158f59205493590d7ac1c20bc603/public/img/Rome.jpg -------------------------------------------------------------------------------- /public/img/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxexc/VueMap-Explorer/f8000f68de55158f59205493590d7ac1c20bc603/public/img/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/img/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxexc/VueMap-Explorer/f8000f68de55158f59205493590d7ac1c20bc603/public/img/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/img/marking_point.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxexc/VueMap-Explorer/f8000f68de55158f59205493590d7ac1c20bc603/public/img/marking_point.jpg -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 56 | 57 | 66 | -------------------------------------------------------------------------------- /src/api/authService/index.js: -------------------------------------------------------------------------------- 1 | import { router } from "@/router"; 2 | import { clientFetch } from "../clientFetch"; 3 | 4 | export const TOKEN_KEY = 'token'; 5 | 6 | class AuthService { 7 | #token = null; 8 | 9 | constructor() { 10 | this.#token = localStorage.getItem(TOKEN_KEY) || null; 11 | } 12 | 13 | isLoggedIn() { 14 | return Boolean(this.#token); 15 | } 16 | 17 | setToken(token) { 18 | localStorage.setItem(TOKEN_KEY, token); 19 | this.#token = token; 20 | } 21 | 22 | getToken() { 23 | return this.#token 24 | } 25 | 26 | clearToken() { 27 | this.#token = null; 28 | localStorage.removeItem(TOKEN_KEY); 29 | clientFetch.defaults.headers.common = {} 30 | } 31 | 32 | async login(body) { 33 | const { data } = await clientFetch.post('user/login', body); 34 | const { accessToken } = data; 35 | 36 | this.setToken(accessToken); 37 | } 38 | 39 | async register(body) { 40 | const { data } = await clientFetch.post('user/register', body); 41 | const { accessToken } = data; 42 | 43 | this.setToken(accessToken); 44 | } 45 | 46 | async logout() { 47 | const { data } = await clientFetch.get('user/logout'); 48 | 49 | this.clearToken(); 50 | return data 51 | } 52 | 53 | async refresh() { 54 | const { data } = await clientFetch.post('user/refresh'); 55 | const { accessToken } = data; 56 | 57 | this.setToken(accessToken); 58 | } 59 | }; 60 | 61 | export const authService = new AuthService; 62 | 63 | clientFetch.interceptors.request.use((request) => { 64 | const token = authService.getToken(); 65 | if (token) { 66 | request.headers = { 67 | ...request.headers, 68 | Authorization: `Bearer ${token}`, 69 | } 70 | }; 71 | 72 | return request; 73 | }); 74 | 75 | 76 | // need Cookie 77 | 78 | clientFetch.interceptors.response.use( 79 | (response) => response, 80 | async (error) => { 81 | 82 | const errorCode = error.response?.status 83 | const reqUrl = error.config?.url || ''; 84 | 85 | if (errorCode === 401 && !error.config._retry) { 86 | error.config._retry = true; 87 | if (!(reqUrl.includes('/login') || reqUrl.includes('/register') || reqUrl.includes('/refresh'))) { 88 | try { 89 | await authService.refresh(); 90 | return clientFetch(error.config); 91 | } catch (e) { 92 | authService.clearToken(); 93 | router.push('/auth/login'); 94 | return Promise.reject(error); 95 | } 96 | } 97 | router.push('/auth/login'); 98 | return Promise.reject(error); 99 | } 100 | return Promise.reject(error); 101 | } 102 | ); 103 | -------------------------------------------------------------------------------- /src/api/clientFetch.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const SERVER_URL = import.meta.env.VITE_API_SERVER_URL 4 | 5 | export const clientFetch = axios.create({ 6 | baseURL: SERVER_URL, 7 | // withCredentials: true // for Cookie 8 | }) 9 | -------------------------------------------------------------------------------- /src/api/favorite-places/index.js: -------------------------------------------------------------------------------- 1 | import { clientFetch } from "../clientFetch"; 2 | 3 | const BASE_PLACES_URL = 'points'; 4 | 5 | export const getFavoritePlaces = () => { 6 | return clientFetch.get(BASE_PLACES_URL).then(({ data }) => 7 | data.map((place) => ({ 8 | ...place, 9 | id: place._id 10 | })) 11 | ) 12 | } 13 | 14 | export const addFavoritePlaces = (body) => { 15 | return clientFetch.post(BASE_PLACES_URL, body) 16 | } 17 | 18 | export const updateFavoritePlaces = (body) => { 19 | return clientFetch.put(BASE_PLACES_URL, body) 20 | } 21 | 22 | export const deleteFavoritePlaces = (id) => { 23 | return clientFetch.delete(`${BASE_PLACES_URL}/${id}`) 24 | } -------------------------------------------------------------------------------- /src/api/routesService.js: -------------------------------------------------------------------------------- 1 | 2 | // *** TO DO *** 3 | 4 | // import axios from 'axios'; 5 | 6 | // const BASE_URL = import.meta.env.VITE_API_SERVER_URL || 'http://localhost:3001'; 7 | 8 | // export const routesService = { 9 | // async createRoute(routeData) { 10 | // // routeData — the object with {name, routeType, geometry, points} 11 | // // need POST /routes, c headers: Authorization: Bearer ... 12 | // try { 13 | // const token = localStorage.getItem('token') 14 | 15 | // const response = await axios.post( 16 | // `${BASE_URL}routes`, 17 | // routeData, 18 | // { 19 | // headers: { 20 | // 'Content-Type': 'application/json', 21 | // Authorization: `Bearer ${token}` 22 | // }, 23 | // // withCredentials: true // if required 24 | // } 25 | // ); 26 | // console.log("SAVE ROTE", response.data); 27 | // return response.data; 28 | // } catch (error) { 29 | // console.error('createRoute error', error); 30 | // throw error; 31 | // } 32 | // }, 33 | 34 | // // Next getAllRoutes(), deleteRoute() и т.д. 35 | // // ... 36 | // }; 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | // import axios from 'axios'; 48 | 49 | // const API_URL = import.meta.env.VITE_API_SERVER_URL; 50 | // // или process.env.VUE_APP_API_SERVER_URL, если у тебя Vue CLI. 51 | 52 | // export async function addRoute(routeData, token) { 53 | // const res = await axios.post(`${API_URL}/routes`, routeData, { 54 | // headers: { 55 | // 'Content-Type': 'application/json', 56 | // Authorization: `Bearer ${token}` 57 | // } 58 | // }); 59 | // return res.data; 60 | // } 61 | 62 | 63 | 64 | 65 | // import { api } from './apiBase' 66 | // // (if apiBase.js is already configured axios with baseURL and intersectors) 67 | 68 | // export async function createRoute(routeData) { 69 | // // Example POST request to the backend /api/routes 70 | // // routeData - is an object with fields: name, routeType, geometry, points, isShared... 71 | // const { data } = await api.post('/routes', routeData) 72 | // return data 73 | // } 74 | 75 | // export async function getUserRoutes() { 76 | // // Example GET request to the backend /api/routes 77 | // const { data } = await api.get('/routes') 78 | // return data 79 | // } 80 | 81 | // export async function getRouteById(routeId) { 82 | // const { data } = await api.get(`/routes/${routeId}`) 83 | // return data 84 | // } 85 | 86 | // и т.д. — updateRoute, deleteRoute, if required -------------------------------------------------------------------------------- /src/api/user/index.js: -------------------------------------------------------------------------------- 1 | import { clientFetch } from "../clientFetch"; 2 | 3 | export const login = (body) => { 4 | return clientFetch.post('user/login', body); 5 | }; 6 | 7 | export const register = (body) => { 8 | return clientFetch.post('user/register', body); 9 | }; 10 | 11 | export const logout = () => { 12 | return clientFetch.get('user/logout'); 13 | }; 14 | 15 | export const refresh = () => { 16 | return clientFetch.post('user/refresh'); 17 | }; 18 | 19 | export const getUserInfo = () => { 20 | return clientFetch.get('user/me'); 21 | }; 22 | -------------------------------------------------------------------------------- /src/assets/base.css: -------------------------------------------------------------------------------- 1 | /* color palette from */ 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | :root { 8 | --vt-c-white: #ffffff; 9 | --vt-c-white-soft: #f8f8f8; 10 | --vt-c-white-mute: #f2f2f2; 11 | 12 | --vt-c-black: #181818; 13 | --vt-c-black-soft: #222222; 14 | --vt-c-black-mute: #282828; 15 | 16 | --vt-c-indigo: #2c3e50; 17 | 18 | --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); 19 | --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); 20 | --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); 21 | --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); 22 | 23 | --vt-c-text-light-1: var(--vt-c-indigo); 24 | --vt-c-text-light-2: rgba(60, 60, 60, 0.66); 25 | --vt-c-text-dark-1: var(--vt-c-white); 26 | --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); 27 | } 28 | 29 | /* semantic color variables for this project */ 30 | :root { 31 | --color-background: var(--vt-c-white); 32 | --color-background-soft: var(--vt-c-white-soft); 33 | --color-background-mute: var(--vt-c-white-mute); 34 | 35 | --color-border: var(--vt-c-divider-light-2); 36 | --color-border-hover: var(--vt-c-divider-light-1); 37 | 38 | --color-heading: var(--vt-c-text-light-1); 39 | --color-text: var(--vt-c-text-light-1); 40 | 41 | --section-gap: 160px; 42 | } 43 | 44 | @media (prefers-color-scheme: dark) { 45 | :root { 46 | --color-background: var(--vt-c-black); 47 | --color-background-soft: var(--vt-c-black-soft); 48 | --color-background-mute: var(--vt-c-black-mute); 49 | 50 | --color-border: var(--vt-c-divider-dark-2); 51 | --color-border-hover: var(--vt-c-divider-dark-1); 52 | 53 | --color-heading: var(--vt-c-text-dark-1); 54 | --color-text: var(--vt-c-text-dark-2); 55 | } 56 | } 57 | 58 | *, 59 | *::before, 60 | *::after { 61 | box-sizing: border-box; 62 | margin: 0; 63 | font-weight: normal; 64 | } 65 | 66 | /* html, 67 | body { 68 | touch-action: auto; 69 | overscroll-behavior: auto; 70 | height: 100%; 71 | } */ 72 | 73 | body { 74 | min-height: 100vh; 75 | color: var(--color-text); 76 | background: var(--color-background); 77 | transition: 78 | color 0.5s, 79 | background-color 0.5s; 80 | line-height: 1.6; 81 | font-family: 82 | Inter, 83 | -apple-system, 84 | BlinkMacSystemFont, 85 | 'Segoe UI', 86 | Roboto, 87 | Oxygen, 88 | Ubuntu, 89 | Cantarell, 90 | 'Fira Sans', 91 | 'Droid Sans', 92 | 'Helvetica Neue', 93 | sans-serif; 94 | font-size: 15px; 95 | text-rendering: optimizeLegibility; 96 | -webkit-font-smoothing: antialiased; 97 | -moz-osx-font-smoothing: grayscale; 98 | } 99 | 100 | html, 101 | body { 102 | height: 100%; 103 | margin: 0; 104 | padding: 0; 105 | } 106 | 107 | /* Hack for iOS Safari: */ 108 | @supports (-webkit-touch-callout: none) { 109 | html, 110 | body { 111 | /* min-height: 100vh; */ 112 | min-height: -webkit-fill-available; 113 | } 114 | } 115 | 116 | .section-android-desktop { 117 | height: 100vh; 118 | overflow: auto; 119 | } 120 | .section-ios { 121 | height: -webkit-fill-available; 122 | min-height: -webkit-fill-available; 123 | } 124 | 125 | @supports (-webkit-touch-callout: none) { 126 | .h-screen, 127 | .min-h-screen { 128 | height: -webkit-fill-available !important; 129 | min-height: -webkit-fill-available !important; 130 | } 131 | } 132 | 133 | /* compass buttom scale */ 134 | .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl-compass { 135 | width: 40px; 136 | height: 40px; 137 | } 138 | 139 | .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl-compass-icon { 140 | width: 100%; 141 | height: 100%; 142 | } 143 | 144 | /* logo */ 145 | .mapboxgl-ctrl-bottom-left { 146 | scale: 80%; 147 | left: -8px !important; 148 | } 149 | 150 | /* compass */ 151 | .mapboxgl-ctrl-group { 152 | scale: 90%; 153 | } 154 | 155 | /*** CUSTOME STYLES FOR MAPBOX PLUGIN Mapbox GL Directions ***/ 156 | 157 | /* hide panel mapbox-gl-directions */ 158 | .mapbox-directions-step { 159 | display: none !important; 160 | } 161 | 162 | @media (max-width: 639px) { 163 | .mapboxgl-ctrl-left { 164 | top: unset !important; 165 | bottom: -52px !important; 166 | left: -26px !important; 167 | scale: 80%; 168 | } 169 | } 170 | 171 | @media (min-width: 640px) and (max-width: 1279px) { 172 | .mapboxgl-ctrl-left { 173 | position: absolute !important; 174 | top: 52px !important; 175 | left: -26px !important; 176 | scale: 80%; 177 | } 178 | } 179 | 180 | .mapboxgl-ctrl-geocoder input[type='text'] { 181 | font-size: 16px !important; 182 | } 183 | 184 | .mapbox-directions-profile label { 185 | font-size: 16px !important; 186 | color: #333333 !important; 187 | } 188 | 189 | @media (min-width: 1280px) { 190 | .mapboxgl-ctrl-left { 191 | top: 84px !important; 192 | left: 4px !important; 193 | } 194 | .mapboxgl-ctrl-geocoder input[type='text'], 195 | .mapbox-directions-profile label { 196 | font-size: 14px !important; 197 | } 198 | } 199 | 200 | .mapbox-directions-profile { 201 | margin: 7px 0 0 0 !important; 202 | } 203 | 204 | .mapboxgl-ctrl-directions { 205 | margin: 0 !important; 206 | } 207 | 208 | .mapbox-directions-instructions { 209 | overflow-x: hidden !important; 210 | } 211 | 212 | .directions-control-instructions { 213 | margin-bottom: -20px; 214 | } 215 | 216 | .mapbox-directions-error { 217 | padding: 4px 20px !important; 218 | } 219 | 220 | .mapbox-directions-profile { 221 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2) !important; 222 | } 223 | .mapbox-directions-component-keyline { 224 | box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1) !important; 225 | } 226 | 227 | /* Injected "Save route" button */ 228 | .my-save-route-btn-class { 229 | position: absolute; 230 | display: flex; 231 | align-items: center; 232 | height: 80%; 233 | margin: 2px 0; 234 | top: 2px; 235 | right: 4px; 236 | z-index: 10; 237 | font-size: 16px; 238 | font-weight: bold; 239 | padding: 1px 8px; 240 | border-radius: 10px; 241 | background-color: #a7fcd1; 242 | color: #333333; 243 | transition: all 0.2s ease-in-out; 244 | } 245 | 246 | .my-save-route-btn-class:hover { 247 | color: #232323; 248 | align-items: center; 249 | 250 | background-color: #91ffc8 !important; 251 | transform: scale(0.85); 252 | } 253 | 254 | .my-addpoint-btn-class { 255 | position: absolute; 256 | display: flex; 257 | align-items: center; 258 | justify-content: center; 259 | border-radius: 50%; 260 | top: 28px; 261 | right: 41px; 262 | z-index: 10; 263 | background: #a7fcd1 !important; 264 | width: 24px; 265 | height: 24px; 266 | cursor: pointer; 267 | transition: all 0.2s ease-in-out; 268 | } 269 | .my-addpoint-btn-class:hover { 270 | transform: scale(0.85) !important; 271 | background: #91ffc8 !important; 272 | } 273 | 274 | .mapboxgl-ctrl-geocoder { 275 | color: #333333 !important; 276 | } 277 | 278 | /* Fixing the click bug in Swiper on iOS */ 279 | .marker-slider .swiper-slide-active { 280 | transform: translate3d(0, 0, 0) !important; 281 | } 282 | 283 | /* .marker-slider .swiper-slide-prev { 284 | transform: translate3d(0, 0, 0) !important; 285 | } */ 286 | 287 | /* .marker-slider .swiper-slide { 288 | transform: translate3d(0, 0, 0) !important; 289 | } */ 290 | -------------------------------------------------------------------------------- /src/assets/img/404.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxexc/VueMap-Explorer/f8000f68de55158f59205493590d7ac1c20bc603/src/assets/img/404.jpg -------------------------------------------------------------------------------- /src/assets/img/Alexanderplatz.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxexc/VueMap-Explorer/f8000f68de55158f59205493590d7ac1c20bc603/src/assets/img/Alexanderplatz.jpg -------------------------------------------------------------------------------- /src/assets/img/Berlin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxexc/VueMap-Explorer/f8000f68de55158f59205493590d7ac1c20bc603/src/assets/img/Berlin.jpg -------------------------------------------------------------------------------- /src/assets/img/BlankMap-World-min.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxexc/VueMap-Explorer/f8000f68de55158f59205493590d7ac1c20bc603/src/assets/img/BlankMap-World-min.png -------------------------------------------------------------------------------- /src/assets/img/Rome.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxexc/VueMap-Explorer/f8000f68de55158f59205493590d7ac1c20bc603/src/assets/img/Rome.jpg -------------------------------------------------------------------------------- /src/assets/img/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxexc/VueMap-Explorer/f8000f68de55158f59205493590d7ac1c20bc603/src/assets/img/android-chrome-192x192.png -------------------------------------------------------------------------------- /src/assets/img/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxexc/VueMap-Explorer/f8000f68de55158f59205493590d7ac1c20bc603/src/assets/img/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/assets/img/map-pin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/assets/img/marking_point.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxexc/VueMap-Explorer/f8000f68de55158f59205493590d7ac1c20bc603/src/assets/img/marking_point.jpg -------------------------------------------------------------------------------- /src/assets/img/static-map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxexc/VueMap-Explorer/f8000f68de55158f59205493590d7ac1c20bc603/src/assets/img/static-map.png -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/main.css: -------------------------------------------------------------------------------- 1 | @import './base.css'; 2 | 3 | /* remove from display panel "Vue devtools" */ 4 | [id*='__vue-devtools'] { 5 | display: none !important; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/Auth/LoginForm/LoginForm.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 63 | -------------------------------------------------------------------------------- /src/components/Auth/RegistrationForm/RegistrationForm.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 65 | -------------------------------------------------------------------------------- /src/components/ConfirmationModal/ConfirmationModal.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 46 | 47 | 60 | -------------------------------------------------------------------------------- /src/components/CreateNewPlaceModal/CreateNewPlaceModal.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 114 | 115 | 143 | -------------------------------------------------------------------------------- /src/components/EditPlaceModal/EditPlaceModal.vue: -------------------------------------------------------------------------------- 1 | 75 | 76 | 133 | 134 | 167 | -------------------------------------------------------------------------------- /src/components/FavoritePlace/DeleteIcon.vue: -------------------------------------------------------------------------------- 1 | 26 | -------------------------------------------------------------------------------- /src/components/FavoritePlace/EditIcon.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/components/FavoritePlace/FavoritePlace.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 104 | 105 | 124 | -------------------------------------------------------------------------------- /src/components/FavoritePlace/FavoritePlaceIconButton.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /src/components/FavoritePlaces/FavoritePlaces.vue: -------------------------------------------------------------------------------- 1 | 159 | 160 | 274 | 275 | 291 | -------------------------------------------------------------------------------- /src/components/IButton/FullScreenButton.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 67 | -------------------------------------------------------------------------------- /src/components/IButton/IButton.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 58 | 59 | 77 | -------------------------------------------------------------------------------- /src/components/IButton/ResetZoomButton.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 79 | 80 | 94 | -------------------------------------------------------------------------------- /src/components/IButton/RoutesButton.vue: -------------------------------------------------------------------------------- 1 | 98 | -------------------------------------------------------------------------------- /src/components/IButton/Toggle3DButton.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 77 | 78 | 111 | -------------------------------------------------------------------------------- /src/components/IInput/IInput.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 48 | -------------------------------------------------------------------------------- /src/components/IModal/IModal.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 68 | 69 | 104 | -------------------------------------------------------------------------------- /src/components/ISpinner/ISpinner.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | 16 | 61 | -------------------------------------------------------------------------------- /src/components/InputImage/InputImage.vue: -------------------------------------------------------------------------------- 1 | 125 | 126 | 145 | 146 | 159 | -------------------------------------------------------------------------------- /src/components/InputImage/UploadIcon.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /src/components/LogoutButton/LogoutButton.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 29 | -------------------------------------------------------------------------------- /src/components/LogoutButton/LogoutIcon.vue: -------------------------------------------------------------------------------- 1 | 77 | -------------------------------------------------------------------------------- /src/components/RouteStatusBar/RouteStatusBar.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 39 | 40 | 45 | -------------------------------------------------------------------------------- /src/components/SwiperSlider/SwiperSlider.vue: -------------------------------------------------------------------------------- 1 | 134 | 135 | 171 | 172 | 214 | -------------------------------------------------------------------------------- /src/components/SwiperSlider/VerticalSliderCard.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 35 | -------------------------------------------------------------------------------- /src/components/UserInfo/UserIcon.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 36 | -------------------------------------------------------------------------------- /src/components/UserInfo/UserInfo.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 43 | -------------------------------------------------------------------------------- /src/components/__tests__/VueTests.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | 3 | import { mount } from '@vue/test-utils' 4 | import HelloWorld from '../HelloWorld.vue' 5 | 6 | describe('HelloVue', () => { 7 | it('renders properly', () => { 8 | const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } }) 9 | expect(wrapper.text()).toContain('Hello Vitest') 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /src/components/icons/AddPoint.vue: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /src/components/icons/CrossIcon.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 30 | -------------------------------------------------------------------------------- /src/components/icons/EyeIcon.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/components/icons/EyeOffIcon.vue: -------------------------------------------------------------------------------- 1 | 24 | -------------------------------------------------------------------------------- /src/components/icons/MarkerIcon.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 53 | 54 | 90 | -------------------------------------------------------------------------------- /src/composables/useModal.js: -------------------------------------------------------------------------------- 1 | import { ref } from "vue"; 2 | 3 | export const useModal = () => { 4 | const isOpen = ref(false) 5 | 6 | const openModal = () => { 7 | isOpen.value = true 8 | } 9 | 10 | const closeModal = () => { 11 | isOpen.value = false 12 | } 13 | 14 | const toggle = () => { 15 | isOpen.value = !isOpen.value 16 | } 17 | 18 | return { 19 | isOpen, 20 | openModal, 21 | closeModal, 22 | toggle, 23 | } 24 | } -------------------------------------------------------------------------------- /src/composables/useMutation.js: -------------------------------------------------------------------------------- 1 | import { hideSpinner, showSpinner } from "@/utils/spinnerControl" 2 | import { ref } from "vue" 3 | 4 | export const useMutation = ({ mutationFn, onSuccess, onError }) => { 5 | const data = ref() 6 | const isLoading = ref(false) 7 | const error = ref(null) 8 | 9 | const mutation = async (...args) => { 10 | isLoading.value = true; 11 | showSpinner(); 12 | try { 13 | data.value = await mutationFn(...args) 14 | error.value = null 15 | onSuccess?.(data) 16 | } catch (e) { 17 | data.value = null 18 | const serverError = e.response?.data?.error || e.response?.data?.message; 19 | error.value = serverError || "Oops, App took a coffe break!" 20 | console.error("Error:", e) 21 | onError?.(error) 22 | } finally { 23 | isLoading.value = false; 24 | hideSpinner(); 25 | } 26 | } 27 | 28 | return { 29 | data, 30 | isLoading, 31 | error, 32 | mutation 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/layouts/BaseLayout.vue: -------------------------------------------------------------------------------- 1 | 22 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import './assets/main.css'; 2 | import '@mapbox/mapbox-gl-directions/dist/mapbox-gl-directions.css' 3 | 4 | import { createApp } from 'vue'; 5 | import { createPinia } from 'pinia'; 6 | import { router } from './router'; 7 | import App from './App.vue' 8 | import { authService, TOKEN_KEY } from './api/authService'; 9 | 10 | 11 | const token = localStorage.getItem(TOKEN_KEY) 12 | if (token) { 13 | authService.setToken(token); 14 | } 15 | 16 | const app = createApp(App) 17 | const pinia = createPinia() 18 | 19 | app.use(pinia) 20 | app.use(router) 21 | 22 | // --------------- Important!: We can only get a store after! app.use(pinia) --------------- 23 | import { useRouteStore } from '@/stores/routeStore' 24 | import { initRouteService } from './services/routeService'; 25 | const store = useRouteStore() 26 | initRouteService(store) 27 | 28 | // bug-fix iOS overscaling 29 | store.detectPlatform() 30 | const metaViewport = document.querySelector("meta[name='viewport']") 31 | 32 | if (metaViewport) { 33 | if (store.isIOS) { 34 | 35 | // iOS: Turn off zoom 36 | metaViewport.setAttribute( 37 | 'content', 38 | 'width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no, maximum-scale=1.0' 39 | ) 40 | } else { 41 | // not iOS: allow user-scalable 42 | metaViewport.setAttribute( 43 | 'content', 44 | 'width=device-width, initial-scale=1.0, viewport-fit=cover' 45 | ) 46 | // additionally: ,initial-scale=1.0, user-scalable=yes, maximum-scale=5.0 47 | } 48 | } 49 | 50 | // JS for iOS 51 | app.directive('button-animation', { 52 | mounted(el) { 53 | const scaleDown = () => { 54 | el.style.transform = 'scale(0.75)'; 55 | el.style.transition = 'transform 0.1s ease'; 56 | }; 57 | 58 | const scaleUp = () => { 59 | el.style.transform = 'scale(1)'; 60 | el.style.transition = 'transform 0.1s ease'; 61 | }; 62 | 63 | el.addEventListener('mousedown', scaleDown); 64 | el.addEventListener('mouseup', scaleUp); 65 | el.addEventListener('mouseleave', scaleUp); 66 | 67 | el.addEventListener('touchstart', scaleDown, { passive: true }); 68 | el.addEventListener('touchend', scaleUp, { passive: true }); 69 | el.addEventListener('touchcancel', scaleUp, { passive: true }); 70 | }, 71 | }); 72 | 73 | 74 | app.mount('#app') 75 | -------------------------------------------------------------------------------- /src/map/settings.js: -------------------------------------------------------------------------------- 1 | const TOKEN = import.meta.env.VITE_TOKEN_MAPBOX; 2 | 3 | 4 | export const mapSettings = { 5 | style: 'mapbox://styles/mapbox/streets-v12', // 'mapbox://styles/mapbox/streets-v11' v12 v9 6 | apiToken: TOKEN, 7 | projection: 'globe', // projection: 'globe', projection: 'mercator', 8 | pitch: 0, 9 | bearing: 0, 10 | } 11 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router' 2 | import NotFoundView from '@/views/NotFoundView.vue'; 3 | import { authService } from '@/api/authService'; 4 | 5 | 6 | // ** Lazy Loading ** 7 | const GreetingPage = () => import('@/views/GreetingView.vue') 8 | const HomePage = () => import('@/views/HomepageView.vue') 9 | const AuthPage = () => import('@/views/AuthView.vue') 10 | const LoginPage = () => import('@/views/LoginView.vue') 11 | const RegistrationPage = () => import('@/views/Registration.vue') 12 | 13 | // development or production 14 | export const BASE_DEV = process.env.NODE_ENV === 'production' 15 | ? import.meta.env.VITE_BASE_URL 16 | : ''; 17 | 18 | console.log('BASE ROUTER: ', BASE_DEV,); 19 | console.log('process.env.NODE_ENV: ', process.env.NODE_ENV); 20 | 21 | 22 | // Hosting determination 23 | const isGitHub = window.location.hostname === 'maxexc.github.io'; 24 | console.log("Hostname: ", window.location.hostname); 25 | 26 | // Deployment environment 27 | const history = isGitHub ? createWebHashHistory(BASE_DEV) : createWebHistory(BASE_DEV); 28 | 29 | const routes = [ 30 | { path: '/', component: GreetingPage, name: 'Greeting' }, 31 | { path: '/map', component: HomePage, name: 'Map' }, 32 | { 33 | path: '/auth', 34 | component: AuthPage, 35 | redirect: '/auth/login', 36 | children: [ 37 | { path: 'login', component: LoginPage, name: 'Login' }, 38 | { path: 'registration', component: RegistrationPage, name: 'Registration' } 39 | ] 40 | }, 41 | { path: '/:catchAll(.*)', component: NotFoundView, name: 'NotFoundView' } 42 | ] 43 | 44 | export const router = createRouter({ 45 | history: history, 46 | routes, 47 | }) 48 | 49 | router.beforeEach((to, from, next) => { 50 | const authRoutes = ['Login', 'Registration', 'Greeting', 'NotFoundView'] 51 | const { name } = to 52 | 53 | if (authService.isLoggedIn() && authRoutes.includes(name)) { 54 | next({ name: 'Map' }) 55 | } else if (!authRoutes.includes(name) && !authService.isLoggedIn()) { 56 | next({ name: 'Login' }) 57 | } else { 58 | next() 59 | } 60 | }) 61 | -------------------------------------------------------------------------------- /src/services/mapService.js: -------------------------------------------------------------------------------- 1 | let zoomListener = null 2 | 3 | function checkZoomLevel(map) { 4 | const zoomLevel = map.getZoom() 5 | if (zoomLevel >= 15) { 6 | map.setConfigProperty('basemap', 'lightPreset', 'dawn') 7 | } else { 8 | map.setConfigProperty('basemap', 'lightPreset', 'day') 9 | } 10 | } 11 | 12 | export function initializeMap(map) { 13 | map.on('style.load', () => { 14 | map.setFog({ 15 | range: [1, 10], 16 | color: 'white', 17 | 'horizon-blend': 0.1 18 | }) 19 | }) 20 | // map.getCanvas().style.touchAction = 'manipulation' 21 | } 22 | 23 | export function enable3D(map) { 24 | map.setMaxPitch(85) 25 | map.setMinPitch(0) 26 | 27 | map.setFog({ 28 | range: [1, 10], 29 | color: 'white', 30 | 'horizon-blend': 0.03 31 | }) 32 | 33 | checkZoomLevel(map) 34 | 35 | zoomListener = () => checkZoomLevel(map) 36 | map.on('zoom', zoomListener) 37 | } 38 | 39 | export function disable3D(map) { 40 | map.setMaxPitch(60) 41 | map.setMinPitch(0) 42 | 43 | if (zoomListener) { 44 | map.off('zoom', zoomListener) 45 | zoomListener = null 46 | } 47 | } 48 | 49 | export function set3DMode(map, is3DEnabled) { 50 | const newStyle = is3DEnabled 51 | ? 'mapbox://styles/mapbox/standard' 52 | : 'mapbox://styles/mapbox/streets-v11' 53 | 54 | map.setStyle(newStyle) 55 | 56 | map.once('style.load', () => { 57 | if (is3DEnabled) { 58 | enable3D(map) 59 | } else { 60 | disable3D(map) 61 | } 62 | }) 63 | } 64 | 65 | export function flyTo(map, coordinates) { 66 | map.flyTo({ center: coordinates }) 67 | } 68 | 69 | 70 | // ------------------------- *** NEW **** ------------------------------------------------- 71 | 72 | 73 | export function applyDirectionsStyle(map) { 74 | if (!map) return 75 | const layerLine = map.getLayer('directions-route-line') 76 | const layerCasing = map.getLayer('directions-route-line-casing') 77 | const layerDest = map.getLayer('directions-destination-point') 78 | const layerOrig = map.getLayer('directions-origin-point') 79 | 80 | if (layerLine) { 81 | map.setPaintProperty('directions-route-line', 'line-width', 3) 82 | map.setPaintProperty('directions-route-line', 'line-color', '#2bffcd') 83 | } 84 | if (layerCasing) { 85 | map.setPaintProperty('directions-route-line-casing', 'line-width', 9) 86 | } 87 | if (layerDest) { 88 | map.setPaintProperty('directions-destination-point', 'circle-color', '#6A5ACD') 89 | map.setPaintProperty('directions-destination-point', 'circle-radius', 12) 90 | } 91 | if (layerOrig) { 92 | map.setPaintProperty('directions-origin-point', 'circle-radius', 12) 93 | } 94 | } 95 | 96 | export function addMyRouteLayer(map, geojson) { 97 | if (!map) return 98 | if (!map.getSource('my-route')) { 99 | map.addSource('my-route', { 100 | type: 'geojson', 101 | data: geojson 102 | }); 103 | map.addLayer({ 104 | id: 'my-route-line-casing', 105 | type: 'line', 106 | source: 'my-route', 107 | layout: { 108 | 'line-join': 'round', 109 | 'line-cap': 'round' 110 | }, 111 | paint: { 112 | 'line-color': '#007aff', 113 | 'line-width': 7, 114 | 'line-opacity': 1 // 0.8 115 | } 116 | }); 117 | 118 | map.addLayer({ 119 | id: 'my-route-line', 120 | type: 'line', 121 | source: 'my-route', 122 | layout: { 123 | 'line-join': 'round', 124 | 'line-cap': 'round' 125 | }, 126 | paint: { 127 | 'line-color': '#91ffc8', // '#91ffc8' #70ffb8 '#a7fcd1' '#5ed499' '#73ffb9' '#72e3aa' #69ecab 128 | 'line-width': 4, 129 | 'line-opacity': 1 // 0.9 130 | } 131 | }); 132 | } else { 133 | map.getSource('my-route').setData(geojson); 134 | } 135 | } 136 | 137 | export function addPointsLayer(map, pointsGeoJSON) { 138 | if (!map) return 139 | if (!map.getSource('route-points')) { 140 | map.addSource('route-points', { 141 | type: 'geojson', 142 | data: pointsGeoJSON 143 | }); 144 | map.addLayer({ 145 | id: 'route-points-layer', 146 | type: 'circle', 147 | source: 'route-points', 148 | paint: { 149 | 'circle-radius': [ 150 | 'match', 151 | ['slice', ['get', 'name'], 0, 1], 152 | 'A', 11, 153 | 'W', 9.1, 154 | 'B', 11, 155 | 8 156 | ], 157 | 'circle-color': [ 158 | 'match', 159 | ['slice', ['get', 'name'], 0, 1], 160 | 'A', '#1abc9c', // '#20B4B4' #1abc9c 161 | 'W', '#ffca60', // W* #FFC107 #ffa800 #FF9800 #ffef95 #ffc042 #ffca60 162 | 'B', '#9b59b6', // #6A5ACD #9b59b6 #8B7FC9 163 | '#0000ff' 164 | ], 165 | 'circle-stroke-width': 1.5, 166 | 'circle-stroke-color': '#333' 167 | } 168 | }); 169 | map.addLayer({ 170 | id: 'route-points-symbol', 171 | type: 'symbol', 172 | source: 'route-points', 173 | layout: { 174 | 'text-field': ['get', 'name'], 175 | 'text-size': [ 176 | 'match', 177 | ['slice', ['get', 'name'], 0, 1], 178 | 'A', 14, // 10, 12 14 179 | 'W', 8, 180 | 'B', 14, 181 | 8 182 | ], 183 | 'text-font': ['Open Sans Bold'], 184 | 'text-anchor': 'center', 185 | 'text-offset': [0, 0.0] // shifting up 186 | }, 187 | paint: { 188 | 'text-color': [ 189 | 'match', 190 | ['slice', ['get', 'name'], 0, 1], 191 | 'A', '#fff', 192 | 'W', '#1e1e1e', // '#333' '#000' '#1e1e1e' 193 | 'B', '#fff', 194 | '#fff' 195 | ], 196 | 'text-halo-color': [ 197 | 'match', 198 | ['slice', ['get', 'name'], 0, 1], 199 | 'A', '#333', 200 | 'W', '#ffef95', // '#fff' '#ffbf36' #ff3636 201 | 'B', '#333', 202 | '#333' 203 | ], 204 | 'text-halo-width': [ 205 | 'match', 206 | ['slice', ['get', 'name'], 0, 1], 207 | 'A', 0.6, 208 | 'W', 0.7, 209 | 'B', 0.6, 210 | 0.2 211 | ] 212 | } 213 | }); 214 | } else { 215 | map.getSource('route-points').setData(pointsGeoJSON); 216 | } 217 | } 218 | 219 | export function removeTemporaryWaypointsLayer(map) { 220 | if (!map) return 221 | if (map.getSource('directions-waypoint-markers')) { 222 | if (map.getLayer('directions-waypoint-markers-layer')) { 223 | map.removeLayer('directions-waypoint-markers-layer'); 224 | } 225 | map.removeSource('directions-waypoint-markers'); 226 | } 227 | } 228 | 229 | export function showTemporaryWaypointsOnMap(map, directionsInstance) { 230 | if (!map || !directionsInstance) return; 231 | if (!map.getSource('directions-waypoint-markers')) { 232 | map.addSource('directions-waypoint-markers', { 233 | type: 'geojson', 234 | data: { type: 'FeatureCollection', features: [] } 235 | }); 236 | map.addLayer({ 237 | id: 'directions-waypoint-markers-layer', 238 | type: 'circle', 239 | source: 'directions-waypoint-markers', 240 | paint: { 241 | 'circle-radius': 6, 242 | 'circle-color': '#20b4b4', 243 | 'circle-stroke-width': 2, 244 | 'circle-stroke-color': '#333' // #333 #FFC107 #20b4b4 #FF9800 245 | } 246 | }); 247 | } 248 | const waypoints = directionsInstance.getWaypoints().map((wp) => ({ 249 | type: 'Feature', 250 | geometry: { 251 | type: 'Point', 252 | coordinates: [wp.geometry.coordinates[0], wp.geometry.coordinates[1]] 253 | } 254 | })); 255 | map.getSource('directions-waypoint-markers').setData({ 256 | type: 'FeatureCollection', 257 | features: waypoints 258 | }); 259 | // throw new Error('Stop B marker from shifting'); 260 | // undefined[0] // Blocking 'B' from shifting! 261 | } -------------------------------------------------------------------------------- /src/services/routeService.js: -------------------------------------------------------------------------------- 1 | import { ref, shallowRef, watch } from 'vue' 2 | import MapboxDirections from '@mapbox/mapbox-gl-directions/dist/mapbox-gl-directions'; 3 | import { applyDirectionsStyle, removeTemporaryWaypointsLayer } from '@/services/mapService'; 4 | 5 | // ------------------- Main mapbox API layers 6 | export const mapInstance = shallowRef(null) // ref(null) 7 | export const directionsInstance = shallowRef(null) // ref(null) 8 | export const temporaryRoute = shallowRef(null) // ref(null) 9 | export const savedDirectionsConfig = shallowRef(null) // ref(null) 10 | 11 | // ------------------- Pinia Store 12 | let routeStore = null 13 | 14 | export function initRouteService(store) { 15 | routeStore = store 16 | } 17 | 18 | const isRouteBuilding = ref(false) 19 | let checkInterval = null 20 | 21 | 22 | watch(() => isRouteBuilding.value, (newVal, oldVal) => { 23 | if (newVal && !oldVal) { 24 | console.log('Route building started => Start checking for directions panel...') 25 | startDirectionsPanelChecker() 26 | } else if (!newVal && oldVal) { 27 | stopDirectionsPanelChecker() 28 | console.log('Route building ended => Stop checking...') 29 | removeTemporaryWaypointsLayer(mapInstance.value) 30 | } 31 | }) 32 | 33 | export function initDirections(saveRoute) { 34 | if (!mapInstance.value || directionsInstance.value) return 35 | directionsInstance.value = new MapboxDirections({ 36 | accessToken: import.meta.env.VITE_TOKEN_MAPBOX, 37 | unit: 'metric', 38 | profile: 'mapbox/driving', 39 | geometries: 'geojson', 40 | controls: { 41 | instructions: true, 42 | profileSwitcher: true, 43 | inputs: true 44 | } 45 | }) 46 | 47 | directionsInstance.value.on('route', (e) => { 48 | const route = e?.route?.[0] 49 | if (route) { 50 | temporaryRoute.value = { 51 | ...route, 52 | waypoints: directionsInstance.value.getWaypoints() 53 | } 54 | injectRouteButtons(saveRoute) 55 | 56 | setTimeout(() => applyDirectionsStyle(mapInstance.value), 200) 57 | } else { 58 | removeRouteButtons() 59 | } 60 | }) 61 | } 62 | 63 | export function toggleDirections() { 64 | if (!mapInstance.value || !directionsInstance.value) return 65 | 66 | const map = mapInstance.value 67 | const controls = map._controls || [] 68 | const isAdded = controls.includes(directionsInstance.value) 69 | 70 | if (isAdded) { 71 | map.removeControl(directionsInstance.value) 72 | savedDirectionsConfig.value = null 73 | } else { 74 | map.addControl(directionsInstance.value, 'left') 75 | } 76 | } 77 | 78 | export function enableAddPointMode() { 79 | if (!routeStore) return 80 | routeStore.isAddPointMode = true 81 | console.log('Add Point mode is activated (enableAddPointMode)') 82 | } 83 | 84 | export function injectRouteButtons(saveRoute) { 85 | 86 | const summaryEl = document.querySelector('.mapbox-directions-route-summary') 87 | const directionsEl = document.querySelector('.directions-control-directions') 88 | 89 | if (!summaryEl || !directionsEl) return 90 | 91 | // button "Save route" 92 | if (!document.getElementById('my-save-route-btn')) { 93 | const saveBtn = document.createElement('button') 94 | saveBtn.id = 'my-save-route-btn' 95 | saveBtn.innerText = 'Save route' 96 | saveBtn.className = 'my-save-route-btn-class' 97 | saveBtn.onclick = () => { 98 | saveRoute() 99 | } 100 | summaryEl.appendChild(saveBtn) 101 | } 102 | 103 | // button "Add point" 104 | if (!document.getElementById('my-addpoint-btn3')) { 105 | const addPointBtn = document.createElement('button') 106 | addPointBtn.id = 'my-addpoint-btn3' 107 | addPointBtn.className = 'my-addpoint-btn-class' 108 | addPointBtn.innerHTML = ` 109 | 110 | 113 | ` 114 | 115 | addPointBtn.onclick = () => { 116 | enableAddPointMode() 117 | } 118 | 119 | directionsEl.appendChild(addPointBtn) 120 | } 121 | isRouteBuilding.value = true 122 | } 123 | 124 | export function removeRouteButtons() { 125 | const addBtn = document.getElementById('my-addpoint-btn3') 126 | if (addBtn) addBtn.remove() 127 | 128 | const saveBtn = document.getElementById('my-save-route-btn') 129 | if (saveBtn) saveBtn.remove() 130 | } 131 | 132 | export function startDirectionsPanelChecker() { 133 | if (checkInterval) return 134 | checkInterval = setInterval(() => { 135 | const el = document.querySelector('.directions-control-directions') 136 | if (!el) { 137 | console.log('Directions panel disappeared => cleaning up...') 138 | removeTemporaryWaypointsLayer(mapInstance.value) 139 | isRouteBuilding.value = false 140 | } 141 | }, 1000) 142 | } 143 | 144 | export function stopDirectionsPanelChecker() { 145 | if (checkInterval) { 146 | clearInterval(checkInterval) 147 | checkInterval = null 148 | } 149 | } 150 | 151 | export function fitToCurrentRoute() { 152 | if (!routeStore) { 153 | console.warn('routeStore not initialized yet') 154 | return 155 | } 156 | 157 | if (!mapInstance.value || !routeStore.currentRoute) { 158 | console.log('No map or route to fit.') 159 | return 160 | } 161 | 162 | const geometryObj = routeStore.currentRoute.geometry 163 | if (!geometryObj || !geometryObj.geometry || !geometryObj.geometry.coordinates) { 164 | console.log('No geometry.coordinates found in routeStore.currentRoute.geometry') 165 | return 166 | } 167 | 168 | const coords = geometryObj.geometry.coordinates 169 | if (!Array.isArray(coords) || coords.length === 0) { 170 | console.log('Route has no coordinates array.') 171 | return 172 | } 173 | 174 | let minLng = Infinity, 175 | minLat = Infinity, 176 | maxLng = -Infinity, 177 | maxLat = -Infinity 178 | 179 | for (const [lng, lat] of coords) { 180 | if (lng < minLng) minLng = lng 181 | if (lng > maxLng) maxLng = lng 182 | if (lat < minLat) minLat = lat 183 | if (lat > maxLat) maxLat = lat 184 | } 185 | 186 | const bounds = [ 187 | [minLng, minLat], 188 | [maxLng, maxLat] 189 | ] 190 | 191 | mapInstance.value.fitBounds(bounds, { 192 | padding: 60, 193 | duration: 1200 194 | }) 195 | } 196 | 197 | export function removeRoute() { 198 | if (!routeStore) { 199 | console.warn('routeStore not initialized yet') 200 | return 201 | } 202 | 203 | if (!mapInstance.value) return 204 | const map = mapInstance.value 205 | 206 | if (map.getSource('my-route')) { 207 | if (map.getLayer('my-route-line')) { 208 | map.removeLayer('my-route-line') 209 | } 210 | if (map.getLayer('my-route-line-casing')) { 211 | map.removeLayer('my-route-line-casing') 212 | } 213 | map.removeSource('my-route') 214 | } 215 | 216 | if (map.getSource('route-points')) { 217 | if (map.getLayer('route-points-symbol')) { 218 | map.removeLayer('route-points-symbol') 219 | } 220 | if (map.getLayer('route-points-layer')) { 221 | map.removeLayer('route-points-layer') 222 | } 223 | map.removeSource('route-points') 224 | } 225 | 226 | routeStore.clearRoute() 227 | } 228 | -------------------------------------------------------------------------------- /src/services/routeTransformService.js: -------------------------------------------------------------------------------- 1 | import polyline from '@mapbox/polyline' 2 | 3 | export function transformDirectionsRoute(temporaryRoute, userName = 'Guest') { 4 | if (!temporaryRoute || !temporaryRoute.geometry) { 5 | console.warn('[transformDirectionsRoute] no geometry in temporaryRoute?') 6 | return null 7 | } 8 | 9 | const polylineStr = temporaryRoute.geometry 10 | const coords = polyline.decode(polylineStr) 11 | const convertedCoords = coords.map(([lat, lng]) => [lng, lat]) 12 | 13 | const geojson = { 14 | type: 'Feature', 15 | geometry: { 16 | type: 'LineString', 17 | coordinates: convertedCoords 18 | }, 19 | properties: {} 20 | } 21 | 22 | const A = convertedCoords[0] 23 | const B = convertedCoords[convertedCoords.length - 1] 24 | 25 | const wps = temporaryRoute.waypoints || [] 26 | const features = [] 27 | 28 | features.push({ 29 | type: 'Feature', 30 | properties: { 31 | name: 'A', 32 | icon: 'default', 33 | rating: 0, 34 | private: false, 35 | reviews: [] 36 | }, 37 | geometry: { type: 'Point', coordinates: A } 38 | }) 39 | 40 | wps.forEach((wp, i) => { 41 | let name = `W${i + 1}` 42 | features.push({ 43 | type: 'Feature', 44 | properties: { 45 | name, 46 | icon: 'default', 47 | rating: 0, 48 | private: false, 49 | reviews: [] 50 | }, 51 | geometry: { 52 | type: 'Point', 53 | coordinates: [wp.geometry.coordinates[0], wp.geometry.coordinates[1]] 54 | } 55 | }) 56 | }) 57 | 58 | features.push({ 59 | type: 'Feature', 60 | properties: { 61 | name: 'B', 62 | icon: 'default', 63 | rating: 0, 64 | private: false, 65 | reviews: [] 66 | }, 67 | geometry: { type: 'Point', coordinates: B } 68 | }) 69 | 70 | const pointsGeoJSON = { 71 | type: 'FeatureCollection', 72 | features 73 | } 74 | 75 | const routeType = () => { 76 | switch (temporaryRoute.weight_name) { 77 | case 'bicycle': 78 | return 'cycling' 79 | case 'pedestrian': 80 | return 'walking' 81 | case 'auto': 82 | return temporaryRoute.weight_typical ? 'traffic' : 'driving' 83 | default: 84 | return 'default' 85 | } 86 | } 87 | 88 | // TO DO (Generate a name, no modal yet) 89 | const routeName = `${userName}_${Date.now()}` 90 | 91 | // Preparing the object for Pinia/ and Server 92 | return { 93 | name: routeName, 94 | routeType: routeType(), 95 | isShared: false, 96 | rating: 4.5, 97 | geometry: geojson, 98 | points: pointsGeoJSON 99 | // the server will then return a Route with “ownerId” “_id” “createdAt” “updatedAt” 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/stores/routeStore.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { shallowRef } from 'vue' 3 | // *** TO DO *** 4 | // import { createRoute } from '@/api/routesService' // <- New route function from the server 5 | // ... or import { createRoute, getUserRoutes, ... } 6 | 7 | export const useRouteStore = defineStore('route', { 8 | state: () => ({ 9 | currentRoute: shallowRef(null), 10 | isAddPointMode: false, 11 | isMobile: false, 12 | isIOS: false, 13 | 14 | // *** TO DO *** 15 | // userRoutes: [], routes list, shared routes, shared points, etc. 16 | }), 17 | getters: { 18 | hasRoute: (state) => !!state.currentRoute, 19 | routeIcon(state) { 20 | if (!state.currentRoute) return ''; 21 | const type = state.currentRoute.routeType; 22 | const icons = { 23 | driving: '🚗', 24 | walking: '🚶‍♂️', 25 | cycling: '🚴', 26 | traffic: '🚛', 27 | }; 28 | return icons[type] || '📍'; // if undefined, default '🚴‍♀️' '📍' '🏁' 29 | }, 30 | }, 31 | actions: { 32 | setMobile(flag) { 33 | this.isMobile = flag 34 | }, 35 | 36 | setRoute(route) { 37 | this.currentRoute = route 38 | localStorage.setItem('savedRoute', JSON.stringify(route)) 39 | }, 40 | clearRoute() { 41 | this.currentRoute = null 42 | localStorage.removeItem('savedRoute') 43 | }, 44 | loadSavedRoute() { 45 | const saved = localStorage.getItem('savedRoute') 46 | if (saved) { 47 | this.currentRoute = JSON.parse(saved) 48 | } 49 | }, 50 | detectPlatform() { 51 | const iOSRegex = /iPhone|iPad|iPod/i 52 | // Additional iPadOS17 check for iOS to avoid overlapping panels 53 | const isAppleTabletOS17 = 54 | navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1 55 | 56 | this.isIOS = iOSRegex.test(navigator.userAgent) || isAppleTabletOS17 57 | } 58 | 59 | 60 | // *** TO DO *** 61 | 62 | // New logic for saving a route on the server 63 | // async saveRouteToServer(routeObj) { 64 | // try { 65 | // // Send to the server 66 | // const created = await createRoute(routeObj) 67 | // // The server will return _id, etc. Write it to userRoutes 68 | // this.userRoutes.push(created) 69 | // // clear localStorage after a successful send 70 | // // localStorage.removeItem('savedRoute') 71 | // } catch (err) { 72 | // console.error('Error while saving route to server:', err) 73 | // } 74 | // }, 75 | 76 | // // Example: fetch all user routes (called after login) 77 | // async fetchUserRoutes() { 78 | // try { 79 | // // const routes = await getUserRoutes() 80 | // // this.userRoutes = routes 81 | // } catch (err) { 82 | // console.error('Error loading user routes:', err) 83 | // } 84 | // } 85 | } 86 | }) 87 | -------------------------------------------------------------------------------- /src/utils/spinnerControl.js: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | 3 | const visible = ref(false); 4 | 5 | export function showSpinner() { 6 | visible.value = true; 7 | } 8 | 9 | export function hideSpinner() { 10 | visible.value = false; 11 | } 12 | 13 | export { visible }; -------------------------------------------------------------------------------- /src/views/AuthView.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 27 | -------------------------------------------------------------------------------- /src/views/GreetingView.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 45 | 46 | 69 | -------------------------------------------------------------------------------- /src/views/HomepageView.vue: -------------------------------------------------------------------------------- 1 | 423 | 545 | -------------------------------------------------------------------------------- /src/views/LoginView.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 36 | 37 | 66 | -------------------------------------------------------------------------------- /src/views/NotFoundView.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 25 | -------------------------------------------------------------------------------- /src/views/Registration.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 31 | 32 | 61 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{vue,js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: { 9 | colors: { 10 | primary: '#F3743D', 11 | buttonPrimary: '#FF8E5D', 12 | secondary: '#FFA279', 13 | textSecondary: '#333333', 14 | backgroundMain: '#FFE4C4', 15 | grey: '#939393', 16 | border: '#2C2C2C', 17 | borderDivider: 'rgba(44, 44, 44, 0.1)', 18 | borderInput: 'rgba(44, 44, 44, 0.27)', 19 | accent: '#2C3E50', 20 | }, 21 | screens: { 22 | 'sx': '550px', 23 | 'sm': '640px', 24 | 'md': '768px', 25 | 'lg': '1024px', 26 | 'xl': '1280px', 27 | 'h-440': { 'raw': '(min-height: 440px)' }, 28 | } 29 | }, 30 | }, 31 | plugins: [], 32 | } 33 | 34 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url'; 2 | import { defineConfig, loadEnv } from 'vite'; 3 | import vue from '@vitejs/plugin-vue'; 4 | import vueDevTools from 'vite-plugin-vue-devtools'; 5 | import { VitePWA } from 'vite-plugin-pwa'; 6 | 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig(({ mode }) => { 10 | 11 | // environment variable 12 | const isProduction = process.env.NODE_ENV === 'production'; 13 | const env = loadEnv(mode, process.cwd(), ''); 14 | 15 | const BASE_URL = isProduction ? env.VITE_BASE_URL : '/'; 16 | // const BASE_URL = isProduction ? '/VueMap-Explorer/' : '/'; 17 | 18 | 19 | // check mode 20 | console.log('process.env.NODE_ENV: ', process.env.NODE_ENV); 21 | console.log('BASE vite: ', BASE_URL); 22 | 23 | 24 | return { 25 | base: BASE_URL, 26 | plugins: [ 27 | vue(), 28 | isProduction ? null : vueDevTools(), 29 | VitePWA({ 30 | registerType: 'autoUpdate', 31 | includeAssets: ['favicon.svg', 'robots.txt', 'apple-touch-icon.png'], 32 | manifest: { 33 | name: 'VueMap Explorer', 34 | short_name: 'VueMap', 35 | description: 'An interactive map explorer built with Vue.js', 36 | theme_color: '#ffffff', 37 | start_url: BASE_URL, 38 | display: 'standalone', 39 | background_color: '#ffffff', 40 | icons: [ 41 | { 42 | src: 'img/android-chrome-192x192.png', 43 | sizes: '192x192', 44 | type: 'image/png', 45 | }, 46 | { 47 | src: 'img/android-chrome-512x512.png', 48 | sizes: '512x512', 49 | type: 'image/png', 50 | }, 51 | ], 52 | }, 53 | }) 54 | ], 55 | resolve: { 56 | alias: { 57 | '@': fileURLToPath(new URL('./src', import.meta.url)), 58 | src: fileURLToPath(new URL('./src', import.meta.url)), 59 | components: fileURLToPath(new URL('./src/components', import.meta.url)), 60 | views: fileURLToPath(new URL('./src/views', import.meta.url)), 61 | assets: fileURLToPath(new URL('./src/assets', import.meta.url)), 62 | } 63 | }, 64 | build: { 65 | rollupOptions: { 66 | input: { 67 | main: './index.html', 68 | // notFound: './404.html', 69 | } 70 | } 71 | } 72 | } 73 | }); 74 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { mergeConfig, defineConfig, configDefaults } from 'vitest/config' 3 | import viteConfig from './vite.config' 4 | 5 | export default mergeConfig( 6 | viteConfig, 7 | defineConfig({ 8 | test: { 9 | environment: 'jsdom', 10 | exclude: [...configDefaults.exclude, 'e2e/**'], 11 | root: fileURLToPath(new URL('./', import.meta.url)) 12 | } 13 | }) 14 | ) 15 | --------------------------------------------------------------------------------