├── .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 | [](https://vuejs.org/)
10 | [](https://vitejs.dev/)
11 | [](https://www.mapbox.com/)
12 | [](#pwa-installation-instructions)
13 | [](#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 |
32 |
33 |
34 |
35 |
36 |
37 |
43 | © Created by maxexc
44 |
45 |
53 |
54 |
55 |
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 |
26 |
62 |
63 |
--------------------------------------------------------------------------------
/src/components/Auth/RegistrationForm/RegistrationForm.vue:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
64 |
65 |
--------------------------------------------------------------------------------
/src/components/ConfirmationModal/ConfirmationModal.vue:
--------------------------------------------------------------------------------
1 |
26 |
27 |
28 |
29 |
30 | {{ title }}
31 |
32 |
33 | Cancel
34 | Delete
35 |
36 |
37 |
41 | {{ errorMessage }}
42 |
43 |
44 |
45 |
46 |
47 |
60 |
--------------------------------------------------------------------------------
/src/components/CreateNewPlaceModal/CreateNewPlaceModal.vue:
--------------------------------------------------------------------------------
1 |
58 |
59 |
60 |
61 |
112 |
113 |
114 |
115 |
143 |
--------------------------------------------------------------------------------
/src/components/EditPlaceModal/EditPlaceModal.vue:
--------------------------------------------------------------------------------
1 |
75 |
76 |
77 |
78 |
120 |
121 | Click here to change photo
122 |
123 |
124 |
128 | {{ props.errorMessage }}
129 |
130 |
131 |
132 |
133 |
134 |
167 |
--------------------------------------------------------------------------------
/src/components/FavoritePlace/DeleteIcon.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
17 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/src/components/FavoritePlace/EditIcon.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/components/FavoritePlace/FavoritePlace.vue:
--------------------------------------------------------------------------------
1 |
47 |
48 |
49 |
56 |
62 |
67 |
68 |
69 |
70 |
73 | {{ title }}
74 |
75 |
76 |
79 |
80 | i
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
{{ description }}
91 |
92 |
93 |
94 |
99 | lat: {{ coordinates[1].toFixed(6) }}, lng: {{ coordinates[0].toFixed(6) }}
100 |
101 |
102 |
103 |
104 |
105 |
124 |
--------------------------------------------------------------------------------
/src/components/FavoritePlace/FavoritePlaceIconButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/components/FavoritePlaces/FavoritePlaces.vue:
--------------------------------------------------------------------------------
1 |
159 |
160 |
161 |
164 |
165 |
173 |
186 |
191 | Add marker
192 |
193 |
194 |
195 |
196 |
197 | {{ isMobile ? ' Selected marker:' : ' Added markers:' }}
198 |
199 |
204 | {{ logoutMessage }}
205 |
206 |
207 | {{ userError }}
208 |
209 |
210 |
211 |
214 |
218 | Loading...
219 |
220 |
221 | List of markers is empty.
222 |
223 |
224 |
233 |
234 |
235 |
236 |
250 | No marker selected.
251 |
252 |
253 |
254 |
255 |
263 |
264 |
272 |
273 |
274 |
275 |
291 |
--------------------------------------------------------------------------------
/src/components/IButton/FullScreenButton.vue:
--------------------------------------------------------------------------------
1 |
31 |
32 |
33 |
39 |
48 |
51 |
52 |
61 |
64 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/src/components/IButton/IButton.vue:
--------------------------------------------------------------------------------
1 |
43 |
44 |
45 |
46 |
51 | Loading...
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
77 |
--------------------------------------------------------------------------------
/src/components/IButton/ResetZoomButton.vue:
--------------------------------------------------------------------------------
1 |
28 |
29 |
30 |
35 |
45 |
52 |
53 |
56 |
59 |
62 |
65 |
68 |
71 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
94 |
--------------------------------------------------------------------------------
/src/components/IButton/RoutesButton.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
8 |
11 |
14 |
17 |
20 |
21 |
22 |
25 |
28 |
31 |
34 |
37 |
40 |
43 |
46 |
49 |
52 |
55 |
58 |
59 |
62 |
65 |
68 |
71 |
74 |
75 |
76 |
79 |
82 |
83 |
84 |
87 |
90 |
93 |
94 |
95 |
96 |
97 |
98 |
--------------------------------------------------------------------------------
/src/components/IButton/Toggle3DButton.vue:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
24 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
51 |
59 |
67 |
68 |
69 |
{{ is3DEnabled ? '3D On' : '3D Off' }}
74 |
75 |
76 |
77 |
78 |
111 |
--------------------------------------------------------------------------------
/src/components/IInput/IInput.vue:
--------------------------------------------------------------------------------
1 |
32 |
33 |
34 |
35 |
36 | {{ props.label }}
37 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/src/components/IModal/IModal.vue:
--------------------------------------------------------------------------------
1 |
43 |
44 |
45 |
46 |
47 |
53 |
54 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
104 |
--------------------------------------------------------------------------------
/src/components/ISpinner/ISpinner.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
13 |
14 |
15 |
16 |
61 |
--------------------------------------------------------------------------------
/src/components/InputImage/InputImage.vue:
--------------------------------------------------------------------------------
1 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
140 | {{ errorMessage }}
141 |
142 |
143 |
144 |
145 |
146 |
159 |
--------------------------------------------------------------------------------
/src/components/InputImage/UploadIcon.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/components/LogoutButton/LogoutButton.vue:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
22 | Loading...
23 | Log out
24 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/src/components/LogoutButton/LogoutIcon.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
26 |
42 |
58 |
74 |
75 |
76 |
77 |
--------------------------------------------------------------------------------
/src/components/RouteStatusBar/RouteStatusBar.vue:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
17 |
18 |
24 |
27 | Route
28 | {{ routeIcon }}
29 | is loaded
30 |
31 |
32 |
37 |
38 |
39 |
40 |
45 |
--------------------------------------------------------------------------------
/src/components/SwiperSlider/SwiperSlider.vue:
--------------------------------------------------------------------------------
1 |
134 |
135 |
136 | {
147 | swiperInstance = swiper
148 | }
149 | "
150 | >
151 |
157 |
168 |
169 |
170 |
171 |
172 |
214 |
--------------------------------------------------------------------------------
/src/components/SwiperSlider/VerticalSliderCard.vue:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 |
22 |
27 |
31 |
{{ props.title }}
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/components/UserInfo/UserIcon.vue:
--------------------------------------------------------------------------------
1 |
2 |
22 |
23 |
24 |
36 |
--------------------------------------------------------------------------------
/src/components/UserInfo/UserInfo.vue:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 |
29 |
32 |
33 |
34 |
35 | Loading...
36 |
37 | {{ userInfo?.data?.name || 'User' }}
38 |
39 |
40 |
41 |
42 |
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 |
2 |
10 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/components/icons/CrossIcon.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/components/icons/EyeIcon.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/components/icons/EyeOffIcon.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/components/icons/MarkerIcon.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
20 |
32 |
37 |
38 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
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 |
2 |
3 |
6 |
7 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
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 |
7 |
8 |
11 |
12 |
17 | Create an account
18 |
19 |
20 | Sign In
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/views/GreetingView.vue:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
17 |
18 |
22 |
23 |
24 |
25 |
26 |
27 | VueMap platform
28 |
29 |
30 |
31 |
32 | An advanced yet intuitive 3D mapping platform designed for everything from small
33 | personal trips to large-scale route management.
34 |
35 | Easily mark, share, and manage your routes and points with a user-friendly interface.
36 |
37 |
38 |
39 | Get Started
40 |
41 |
42 |
43 |
44 |
45 |
46 |
69 |
--------------------------------------------------------------------------------
/src/views/HomepageView.vue:
--------------------------------------------------------------------------------
1 |
423 |
424 |
427 |
435 |
440 |
444 |
(activeId = val)"
454 | @route-request="onToggleDirections"
455 | :is-route="directionsActive"
456 | :user-info="userInfoData"
457 | :is-user-loading="isUserLoading"
458 | :user-error="userError"
459 | :on-logout="logOut"
460 | :logout-data="logOutData"
461 | :is-log-out-loading="isLogOutLoading"
462 | :logout-error="logOutError"
463 | />
464 |
472 |
476 | {{ refreshError }}
477 |
478 |
486 |
487 |
488 |
491 |
502 |
508 |
509 |
510 |
511 |
512 |
518 |
523 |
524 |
525 |
526 |
527 |
528 |
529 |
530 |
540 |
541 |
542 |
543 |
544 |
545 |
--------------------------------------------------------------------------------
/src/views/LoginView.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 |
22 |
28 |
29 |
30 | {{ error }}
31 |
32 |
33 |
34 |
35 |
36 |
37 |
66 |
--------------------------------------------------------------------------------
/src/views/NotFoundView.vue:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
10 |
15 |
16 |
17 |
404
18 |
Page Not Found
19 |
20 | Back to Home Page
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/src/views/Registration.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | {{ error }}
26 |
27 |
28 |
29 |
30 |
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 |
--------------------------------------------------------------------------------