Compare commits
5 Commits
Author | SHA1 | Date | |
---|---|---|---|
43400bb933 | |||
93ef656f7b | |||
13375abf24 | |||
f9ca0bdbd9 | |||
b9ab746cfd |
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
*.md
|
||||
dist
|
4
.env
4
.env
@ -1,4 +1,6 @@
|
||||
# VUE_APP_API_URL=http://31.129.106.67:8080
|
||||
# VUE_APP_GEO_URL=http://31.129.106.67:6001
|
||||
# VUE_APP_WEATHER_URL=http://31.129.106.67:6002
|
||||
VUE_APP_API_URL=http://127.0.0.1:8080
|
||||
VUE_APP_GEO_URL=http://127.0.0.1:6001
|
||||
VUE_APP_GEO_URL=http://127.0.0.1:6001
|
||||
VUE_APP_WEATHER_URL=http://127.0.0.1:6002
|
51
.gitea/workflows/publish.yaml
Normal file
51
.gitea/workflows/publish.yaml
Normal file
@ -0,0 +1,51 @@
|
||||
name: release-tag
|
||||
|
||||
on:
|
||||
push
|
||||
|
||||
jobs:
|
||||
release-image:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
DOCKER_ORG: krbl
|
||||
DOCKER_LATEST: nightly
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
IMAGE_NAME: white-nights-ts
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
config-inline: |
|
||||
[registry."gitea.unprism.ru"]
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: gitea.unprism.ru
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Get Meta
|
||||
id: meta
|
||||
run: |
|
||||
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
||||
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
gitea.unprism.ru/${{ env.DOCKER_ORG }}/${{ env.IMAGE_NAME }}:${{ env.DOCKER_LATEST }}
|
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@ -0,0 +1,21 @@
|
||||
FROM node:lts-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
34
nginx.conf
Normal file
34
nginx.conf
Normal file
@ -0,0 +1,34 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost; # Замените на ваш домен, если необходимо
|
||||
|
||||
# Корневая директория для статических файлов
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
# Важно для Single Page Applications (SPA) вроде Vue
|
||||
# Пытается отдать файл по $uri, затем директорию $uri/,
|
||||
# иначе отдает /index.html (чтобы Vue Router обработал маршрут)
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Опционально: gzip сжатие для улучшения производительности
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
|
||||
# Опционально: Заголовки кэширования для статических ассетов
|
||||
location ~* \.(?:jpg|jpeg|gif|png|ico|css|js|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# Опционально: Обработка ошибок (рекомендуется)
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
@ -72,6 +72,7 @@ body {
|
||||
}
|
||||
|
||||
.container {
|
||||
max-height: calc(100vh - 100px);
|
||||
margin: auto 25px 25px 25px;
|
||||
/* height: 100%; */
|
||||
/* background: #806c58; */
|
||||
@ -114,17 +115,21 @@ body {
|
||||
.stopdescription {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
padding: 0 15px 15px 15px;
|
||||
padding: 0 15px 0 15px;
|
||||
color: #fff;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 15px;
|
||||
max-height: calc(100% - 430px);
|
||||
}
|
||||
|
||||
.landmarks {
|
||||
background: #806c58;
|
||||
border-radius: 10px 10px 0 0;
|
||||
height: 50px;
|
||||
width: 450px;
|
||||
margin: 0 25px;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -133,17 +138,56 @@ body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.landmarks-arrow {
|
||||
/* .landmarks-arrow {
|
||||
position: absolute;
|
||||
left: 25px;
|
||||
top: 18px;
|
||||
}
|
||||
} */
|
||||
|
||||
.landmarks-container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.language-toggle {
|
||||
background: rgba(0, 0, 0, 0);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
z-index: 1001;
|
||||
transition: left 0.3s ease;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.language-option {
|
||||
background: rgba(0, 0, 0, 0);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
z-index: 1001;
|
||||
transition: left 0.3s ease;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.landmarks-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stoparticles {
|
||||
padding: 0 15px;
|
||||
height: 50px;
|
||||
@ -167,6 +211,12 @@ body {
|
||||
.stoparticle-option {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.description-button {
|
||||
@ -520,23 +570,37 @@ body {
|
||||
.carrier-toggle {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 310px;
|
||||
background: rgba(0, 0, 0, 0);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 5px 10px;
|
||||
padding: 5px 0;
|
||||
border-radius: 5px;
|
||||
z-index: 1001;
|
||||
left: var(--panel-offset, 310px);
|
||||
transition: left 0.3s ease;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.carrierinfo .bg.hidden + .carrier-toggle {
|
||||
left: 10px;
|
||||
.lang-toggle {
|
||||
margin-left: 60px;
|
||||
}
|
||||
|
||||
.lang-option:nth-of-type(1) {
|
||||
margin-left: 60px;
|
||||
}
|
||||
.lang-option:nth-of-type(2) {
|
||||
margin-left: 120px;
|
||||
}
|
||||
.lang-option:nth-of-type(3) {
|
||||
margin-left: 180px;
|
||||
}
|
||||
|
||||
/* .carrierinfo .bg.hidden + .carrier-toggle {
|
||||
left: 10px;
|
||||
} */
|
||||
|
||||
.bg.hidden {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
@ -594,10 +658,6 @@ body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.carrierinfo .bg.hidden + .carrier-toggle {
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.bg.hidden {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
@ -669,7 +729,13 @@ body {
|
||||
);
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.sight-preview-panel.left-panel {
|
||||
right: 490px;
|
||||
left: auto;
|
||||
top: auto;
|
||||
bottom: 200px;
|
||||
}
|
||||
|
||||
.sight-preview-panel img {
|
||||
@ -686,7 +752,7 @@ body {
|
||||
|
||||
.sight-preview-panel p {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
margin: 0 0 20px 0;
|
||||
padding: 10px 10px 0 10px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
@ -770,13 +836,14 @@ li.checked {
|
||||
}
|
||||
|
||||
.sight-card {
|
||||
width: 30%;
|
||||
/* width: 30%; */
|
||||
min-width: 100px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.sight-thumbnail {
|
||||
|
@ -589,10 +589,7 @@
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="gos-name"
|
||||
>При поддержке Правительства <br />
|
||||
Санкт-Петербурга</span
|
||||
>
|
||||
<span class="gos-name" v-html="t('govt_support')"></span>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
@ -602,13 +599,13 @@
|
||||
class="stop-button yellow"
|
||||
@click="toggleGovernorAppeal"
|
||||
>
|
||||
{{ governorAppealTitle || "Обращение" }}
|
||||
{{ governorAppealTitle || t("appeal") }}
|
||||
</button>
|
||||
|
||||
<div ref="dropdownWrapper" class="dropdown-wrapper">
|
||||
<div class="stop-buttons-container">
|
||||
<button class="stop-button white" @click="toggleSights">
|
||||
Достопримечательности
|
||||
{{ t("sights") }}
|
||||
</button>
|
||||
|
||||
<ul
|
||||
@ -635,7 +632,7 @@
|
||||
/>
|
||||
</svg>
|
||||
|
||||
Достопримечательности
|
||||
{{ t("sights") }}
|
||||
</div>
|
||||
<li
|
||||
v-for="sight in sights"
|
||||
@ -644,10 +641,29 @@
|
||||
@click="selectSight(sight.id)"
|
||||
>
|
||||
<div class="sight-name">{{ sight.name }}</div>
|
||||
<div
|
||||
class="sight-transfer-list"
|
||||
v-if="
|
||||
selectedSightId === sight.id && hasTransfers(sight.transfers)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="transfer-row"
|
||||
v-for="item in filteredEntries(sight.transfers)"
|
||||
:key="item.key"
|
||||
>
|
||||
<img
|
||||
:src="transferIcons[item.key]"
|
||||
class="transfer-icon"
|
||||
alt=""
|
||||
/>
|
||||
<span class="transfer-text">{{ item.val }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<button class="stop-button white" @click="toggleList">
|
||||
Остановки
|
||||
{{ t("stops") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -671,7 +687,7 @@
|
||||
style="mix-blend-mode: soft-light"
|
||||
/>
|
||||
</svg>
|
||||
Остановки
|
||||
{{ t("stops") }}
|
||||
</div>
|
||||
<li v-for="station in stations" :key="station.id">
|
||||
<div class="sight-name">{{ station.name }}</div>
|
||||
@ -681,7 +697,7 @@
|
||||
|
||||
<img class="carrier-img" src="../assets/img/get_new.svg" />
|
||||
|
||||
<span class="hashtag">#ВсемПоПути</span>
|
||||
<span class="hashtag">{{ t("hashtag") }}</span>
|
||||
</div>
|
||||
<button class="carrier-toggle" @click="toggleCarrierInfo">
|
||||
<svg
|
||||
@ -711,29 +727,142 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showGovernorAppeal" class="governor-appeal sight-preview-panel">
|
||||
<img :src="governorAppealImage" v-if="governorAppealImage" />
|
||||
<h3>{{ governorAppealTitle }}</h3>
|
||||
<p>{{ governorAppealText }}</p>
|
||||
</div>
|
||||
<div v-if="showSightPreview" class="sight-preview-panel">
|
||||
<div class="sight-preview-wrapper" style="position: relative">
|
||||
<img :src="selectedSightImage" v-if="selectedSightImage" />
|
||||
<img
|
||||
v-if="selectedSightWatermarkLU"
|
||||
:src="selectedSightWatermarkLU"
|
||||
class="watermark watermark-lu"
|
||||
/>
|
||||
<img
|
||||
v-if="selectedSightWatermarkRD"
|
||||
:src="selectedSightWatermarkRD"
|
||||
class="watermark watermark-rd"
|
||||
/>
|
||||
<!-- single‑button globe – shown until the user clicks it -->
|
||||
<button
|
||||
v-if="showLangToggle"
|
||||
class="carrier-toggle lang-toggle"
|
||||
@click="toggleLanguageOptions"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="none"
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<path
|
||||
fill="#fff"
|
||||
fill-rule="evenodd"
|
||||
d="M0 24C0 10.75 10.75 0 24 0s24 10.75 24 24-10.75 24-24 24S0 37.25 0 24Zm36.05-4.59 2.22 6.06 2.2-6.08-1.3.38c-.01-.02-.01-.42-.01-.45-.58-6.91-6.17-12.17-13.22-12.17-.52 0-.95.43-.95.95s.43.95.95.95c5.98 0 10.74 4.42 11.32 10.26l.005.235.005.235-1.22-.37ZM8.903 27.537c2.45-.58 4.73-1.693 6.667-3.253a13.372 13.372 0 0 0 5.911 3.268c.106.013.213.013.32 0a1.483 1.483 0 0 0 1.337-.567c.31-.406.37-.94.16-1.402a1.46 1.46 0 0 0-1.178-.835 10.091 10.091 0 0 1-4.357-2.399 20.41 20.41 0 0 0 5.403-8.413 1.368 1.368 0 0 0-.29-1.22 1.469 1.469 0 0 0-1.163-.56H16.63v-1.754c0-.5-.277-.963-.726-1.214a1.495 1.495 0 0 0-1.452 0c-.449.25-.726.713-.726 1.214v1.78H8.643c-.52 0-.998.268-1.258.701-.26.434-.26.97 0 1.403.26.433.74.7 1.258.7h11.022a19.358 19.358 0 0 1-3.95 5.413 19.152 19.152 0 0 1-2.295-3.24 1.428 1.428 0 0 0-.83-.806 1.498 1.498 0 0 0-1.176.052 1.416 1.416 0 0 0-.75.877c-.11.382-.048.79.171 1.125a22.29 22.29 0 0 0 2.716 3.745 13.713 13.713 0 0 1-5.243 2.58 1.472 1.472 0 0 0-.982.515 1.374 1.374 0 0 0 .216 1.983c.3.236.686.346 1.071.307a.982.982 0 0 0 .29 0ZM34.9 38.88c.351.15.748.16 1.107.027.374-.12.681-.383.851-.727.17-.344.188-.74.05-1.096l-6.565-16.307a1.41 1.41 0 0 0-.532-.65 1.49 1.49 0 0 0-1.635-.007c-.24.157-.426.381-.534.642l-6.506 15.761c-.192.465-.112.995.21 1.389a1.48 1.48 0 0 0 1.35.519c.514-.073.95-.404 1.141-.87l1.568-3.912h6.913l1.816 4.459c.14.344.415.622.766.772Zm-5.995-13.715 2.28 5.68h-4.619l2.339-5.68Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- three language icons – appear after the globe is pressed -->
|
||||
<div v-if="showLanguageOptions" class="language-options">
|
||||
<!-- RU -->
|
||||
<button
|
||||
class="carrier-toggle lang-option"
|
||||
:style="{ opacity: selectedLang === 'ru' ? 1 : 0.8 }"
|
||||
@click="setLanguage('ru')"
|
||||
>
|
||||
<!-- RU SVG -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="none"
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<g clip-path="url(#a)">
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M24 0C10.75 0 0 10.75 0 24s10.75 24 24 24 24-10.75 24-24S37.25 0 24 0Zm.2 33.55h-4.28l-3.63-7.09h-3.18v7.09H9.12V14.18h7.2c2.29 0 4.05.51 5.3 1.53s1.86 2.46 1.86 4.32c0 1.32-.29 2.42-.86 3.31-.57.88-1.44 1.59-2.6 2.11l4.19 7.92v.19l-.01-.01Zm16.1-6.61c0 2.12-.66 3.8-1.99 5.03-1.33 1.23-3.14 1.85-5.44 1.85-2.3 0-4.06-.6-5.39-1.8-1.33-1.2-2.01-2.84-2.04-4.94v-12.9h3.99v12.79c0 1.27.3 2.19.91 2.77.61.58 1.45.87 2.52.87 2.24 0 3.38-1.18 3.42-3.54V14.18h4v12.76h.02Z"
|
||||
/>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M16.309 17.41h-3.21v5.81h3.22c1 0 1.78-.26 2.33-.77s.82-1.21.82-2.11c0-.9-.26-1.63-.78-2.16-.52-.53-1.32-.79-2.39-.79l.01.02Z"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a"><path fill="#fff" d="M0 0h48v48H0z" /></clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- ZH -->
|
||||
<button
|
||||
class="carrier-toggle lang-option"
|
||||
:style="{ opacity: selectedLang === 'zh' ? 1 : 0.8 }"
|
||||
@click="setLanguage('zh')"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="none"
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M10.287 20.382H6.291v3.765h3.996v-3.765Zm3.417 3.765h4.017v-3.765h-4.017v3.765Zm22.421-4.101h-6.267c.803 1.895 1.86 3.598 3.193 5.076 1.26-1.43 2.28-3.11 3.074-5.076Z"
|
||||
/>
|
||||
<path
|
||||
fill="#fff"
|
||||
fill-rule="evenodd"
|
||||
d="M24 48c13.255 0 24-10.745 24-24S37.255 0 24 0 0 10.745 0 24s10.745 24 24 24ZM10.287 13.5h3.417v3.654h7.413v11.292h-3.396v-1.071h-4.017v6.594h-3.417v-6.594H6.291v1.176H3V17.154h7.287V13.5Zm21.063 0h3.354v3.318h8.379v3.228h-3.194c-1.085 2.905-2.514 5.337-4.308 7.36 2.068 1.518 4.584 2.648 7.587 3.323l.679.153-.5.483c-.572.551-1.37 1.712-1.758 2.429l-.14.258-.285-.074c-3.325-.87-6.04-2.226-8.267-4.053-2.267 1.77-4.958 3.108-8.095 4.09l-.313.098-.139-.297c-.278-.596-1.057-1.787-1.545-2.377l-.374-.452.568-.15c2.937-.772 5.404-1.867 7.438-3.343-1.76-2.088-3.118-4.58-4.196-7.448h-3.144v-3.228h8.253V13.5Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- EN -->
|
||||
<button
|
||||
class="carrier-toggle lang-option"
|
||||
:style="{ opacity: selectedLang === 'en' ? 1 : 0.8 }"
|
||||
@click="setLanguage('en')"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="none"
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<g clip-path="url(#b)">
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M24 0C10.75 0 0 10.75 0 24s10.75 24 24 24 24-10.75 24-24S37.25 0 24 0Zm-2.43 33.79H8.41V14.15h13.14v3.28h-9.1v4.68h7.77v3.17h-7.77v5.26h9.12v3.25Zm17.97 0h-4.05l-7.88-12.92v12.92h-4.05V14.15h4.05L35.5 27.1V14.15h4.03v19.64h.01Z"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="b"><path fill="#fff" d="M0 0h48v48H0z" /></clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<h3>{{ selectedSightName }}</h3>
|
||||
<p>{{ selectedSightText }}</p>
|
||||
</div>
|
||||
<transition name="slide-fade">
|
||||
<div v-if="showGovernorAppeal" class="governor-appeal sight-preview-panel">
|
||||
<img :src="governorAppealImage" v-if="governorAppealImage" />
|
||||
<h3>{{ governorAppealTitle }}</h3>
|
||||
<p>{{ governorAppealText }}</p>
|
||||
</div>
|
||||
</transition>
|
||||
<transition name="slide-fade" mode="out-in">
|
||||
<div
|
||||
v-if="showSightPreview"
|
||||
:key="selectedSightId"
|
||||
class="sight-preview-panel"
|
||||
>
|
||||
<div class="sight-preview-wrapper" style="position: relative">
|
||||
<img :src="selectedSightImage" v-if="selectedSightImage" />
|
||||
<img
|
||||
v-if="selectedSightWatermarkLU"
|
||||
:src="selectedSightWatermarkLU"
|
||||
class="watermark watermark-lu"
|
||||
/>
|
||||
<img
|
||||
v-if="selectedSightWatermarkRD"
|
||||
:src="selectedSightWatermarkRD"
|
||||
class="watermark watermark-rd"
|
||||
/>
|
||||
</div>
|
||||
<h3>{{ selectedSightName }}</h3>
|
||||
<p>{{ selectedSightText }}</p>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@ -764,8 +893,47 @@ export default {
|
||||
selectedSightWatermarkLU: "",
|
||||
selectedSightWatermarkRD: "",
|
||||
inactivityTimer: null,
|
||||
stopsInterval: null,
|
||||
sightTransfers: {},
|
||||
showSightTransfers: false,
|
||||
transferIcons: {
|
||||
tram: require("@/icons/tram.svg"),
|
||||
trolleybus: require("@/icons/trolleybus.svg"),
|
||||
bus: require("@/icons/bus.svg"),
|
||||
train: require("@/icons/train.svg"),
|
||||
metro_red: require("@/icons/metro_red.svg"),
|
||||
metro_green: require("@/icons/metro_green.svg"),
|
||||
metro_blue: require("@/icons/metro_blue.svg"),
|
||||
metro_purple: require("@/icons/metro_purple.svg"),
|
||||
metro_orange: require("@/icons/metro_orange.svg"),
|
||||
},
|
||||
selectedLang: localStorage.getItem("selectedLang") || "ru",
|
||||
showLangToggle: true,
|
||||
showLanguageOptions: false,
|
||||
languageOptionsTimer: null,
|
||||
langRevertTimer: null,
|
||||
translations: {
|
||||
sights: { ru: "Достопримечательности", en: "Landmarks", zh: "景点" },
|
||||
stops: { ru: "Остановки", en: "Stops", zh: "站点" },
|
||||
appeal: { ru: "Обращение", en: "Appeal", zh: "致辞" },
|
||||
govt_support: {
|
||||
ru: "При поддержке Правительства <br /> Санкт-Петербурга",
|
||||
en: "Supported by the Government <br /> of St Petersburg",
|
||||
zh: "圣彼得堡政府支持",
|
||||
},
|
||||
hashtag: { ru: "#ВсемПоПути", en: "#OnOurWay", zh: "#同路人" },
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filteredSightTransfers() {
|
||||
return Object.fromEntries(
|
||||
Object.entries(this.sightTransfers || {}).filter(
|
||||
([, v]) => v && v.toString().trim() !== ""
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getCookie(name) {
|
||||
const matches = document.cookie.match(
|
||||
@ -777,16 +945,47 @@ export default {
|
||||
);
|
||||
return matches ? decodeURIComponent(matches[1]) : undefined;
|
||||
},
|
||||
t(key) {
|
||||
const dict = this.translations[key] || {};
|
||||
return dict[this.selectedLang] || dict.ru || key;
|
||||
},
|
||||
async loadGovernorAppeal(appealId) {
|
||||
try {
|
||||
const articleRes = await fetch(
|
||||
this.addLangParam(`${API_URL}/article/${appealId}`)
|
||||
);
|
||||
const articleData = await articleRes.json();
|
||||
this.governorAppealTitle = articleData.heading;
|
||||
this.governorAppealText = articleData.body;
|
||||
|
||||
const mediaRes = await fetch(
|
||||
this.addLangParam(`${API_URL}/article/${appealId}/media`)
|
||||
);
|
||||
const mediaData = await mediaRes.json();
|
||||
this.governorAppealImage = mediaData.length
|
||||
? (
|
||||
await fetch(
|
||||
this.addLangParam(
|
||||
`${API_URL}/media/${mediaData[0].id}/download`
|
||||
)
|
||||
)
|
||||
).url
|
||||
: "";
|
||||
|
||||
this.hasGovernorAppeal = true; // кнопка появится (v-if)
|
||||
} catch (err) {
|
||||
console.error("Ошибка при получении обращения губернатора:", err);
|
||||
}
|
||||
},
|
||||
async fetchStops() {
|
||||
try {
|
||||
const geoResponse = await fetch(`${GEO_URL}/v1/geolocation/context`);
|
||||
const geoData = await geoResponse.json();
|
||||
const routeDetailsRes = await fetch(
|
||||
`${API_URL}/route/${geoData.routeId}`
|
||||
this.addLangParam(`${API_URL}/route/${geoData.routeId}`)
|
||||
);
|
||||
const routeDetails = await routeDetailsRes.json();
|
||||
const appealId = routeDetails.governor_appeal;
|
||||
this.governorAppealId = appealId;
|
||||
|
||||
console.log(
|
||||
"Получено значение governor_appeal:",
|
||||
@ -796,33 +995,9 @@ export default {
|
||||
);
|
||||
|
||||
if (appealId && appealId !== 0) {
|
||||
try {
|
||||
const articleRes = await fetch(`${API_URL}/article/${appealId}`);
|
||||
const articleData = await articleRes.json();
|
||||
this.governorAppealTitle = articleData.heading;
|
||||
this.governorAppealText = articleData.body;
|
||||
|
||||
const mediaRes = await fetch(
|
||||
`${API_URL}/article/${appealId}/media`
|
||||
);
|
||||
const mediaData = await mediaRes.json();
|
||||
if (mediaData.length) {
|
||||
const imageRes = await fetch(
|
||||
`${API_URL}/media/${mediaData[0].id}/download`
|
||||
);
|
||||
this.governorAppealImage = await imageRes.url;
|
||||
} else {
|
||||
this.governorAppealImage = "";
|
||||
}
|
||||
|
||||
console.log("Обращение губернатора загружено:", {
|
||||
title: this.governorAppealTitle,
|
||||
text: this.governorAppealText,
|
||||
image: this.governorAppealImage,
|
||||
});
|
||||
this.hasGovernorAppeal = true;
|
||||
} catch (err) {
|
||||
console.error("Ошибка при получении обращения губернатора:", err);
|
||||
if (this.governorAppealId !== appealId) {
|
||||
this.governorAppealId = appealId;
|
||||
await this.loadGovernorAppeal(appealId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -830,7 +1005,7 @@ export default {
|
||||
if (!this.routeId) throw new Error("Route number not found");
|
||||
|
||||
const stopsResponse = await fetch(
|
||||
`${API_URL}/route/${this.routeId}/station`
|
||||
this.addLangParam(`${API_URL}/route/${this.routeId}/station`)
|
||||
);
|
||||
const stopsData = await stopsResponse.json();
|
||||
this.stations = stopsData;
|
||||
@ -840,20 +1015,94 @@ export default {
|
||||
},
|
||||
async fetchSights() {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/route/${this.routeId}/sight`);
|
||||
const response = await fetch(
|
||||
this.addLangParam(`${API_URL}/route/${this.routeId}/sight`)
|
||||
);
|
||||
const rawSights = await response.json();
|
||||
|
||||
const detailedSights = await Promise.all(
|
||||
rawSights.map(async (sight) => {
|
||||
const detailRes = await fetch(`${API_URL}/sight/${sight.id}`);
|
||||
const detailRes = await fetch(
|
||||
this.addLangParam(`${API_URL}/sight/${sight.id}`)
|
||||
);
|
||||
const detail = await detailRes.json();
|
||||
return { id: sight.id, name: detail.name };
|
||||
return { id: sight.id, name: detail.name, transfers: {} };
|
||||
})
|
||||
);
|
||||
|
||||
this.sights = detailedSights;
|
||||
} catch (error) {
|
||||
console.error("Ошибка при получении достопримечательностей:", error);
|
||||
}
|
||||
},
|
||||
hasTransfers(transfers) {
|
||||
if (!transfers) return false;
|
||||
return Object.values(transfers).some(
|
||||
(v) => v && v.toString().trim() !== ""
|
||||
);
|
||||
},
|
||||
filteredEntries(transfers) {
|
||||
if (!transfers) return [];
|
||||
return Object.entries(transfers)
|
||||
.filter(([, v]) => v && v.toString().trim() !== "")
|
||||
.map(([key, val]) => ({ key, val }));
|
||||
},
|
||||
startStopsPolling() {
|
||||
if (this.stopsInterval) return;
|
||||
this.stopsInterval = setInterval(async () => {
|
||||
if (!this.stations.length) {
|
||||
await this.fetchStops();
|
||||
} else {
|
||||
clearInterval(this.stopsInterval);
|
||||
this.stopsInterval = null;
|
||||
}
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
async findTransfersForSight(sightId) {
|
||||
this.showSightTransfers = false;
|
||||
this.sightTransfers = {};
|
||||
try {
|
||||
const stationsRes = await fetch(
|
||||
this.addLangParam(`${API_URL}/route/${this.routeId}/station`)
|
||||
);
|
||||
const stationsData = await stationsRes.json();
|
||||
|
||||
for (const station of stationsData) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
this.addLangParam(`${API_URL}/station/${station.id}/sight`)
|
||||
);
|
||||
const stationSights = await res.json();
|
||||
const hasSight = stationSights.some((s) => s.id === sightId);
|
||||
if (hasSight) {
|
||||
this.sightTransfers = station.transfers || {};
|
||||
// update transfers on the selected sight object
|
||||
const idx = this.sights.findIndex((s) => s.id === sightId);
|
||||
if (idx !== -1) {
|
||||
this.sights[idx].transfers = this.sightTransfers;
|
||||
}
|
||||
this.showSightTransfers = true;
|
||||
console.log(
|
||||
`Пересадки на станции ${station.name}:`,
|
||||
station.transfers
|
||||
);
|
||||
break;
|
||||
}
|
||||
} catch (innerErr) {
|
||||
console.error(
|
||||
`Ошибка при получении достопримечательностей станции ${station.id}:`,
|
||||
innerErr
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"Ошибка при поиске пересадок для достопримечательности:",
|
||||
err
|
||||
);
|
||||
}
|
||||
},
|
||||
async toggleSights() {
|
||||
this.showGovernorAppeal = false;
|
||||
this.$emit("president-appeal-toggle", this.showGovernorAppeal);
|
||||
@ -865,6 +1114,7 @@ export default {
|
||||
this.showSightsList = false;
|
||||
this.showSightPreview = false;
|
||||
this.selectedSightId = null;
|
||||
this.showSightTransfers = false;
|
||||
} else {
|
||||
this.showSightsList = true;
|
||||
}
|
||||
@ -872,34 +1122,45 @@ export default {
|
||||
async selectSight(sightId) {
|
||||
this.selectedSightId = sightId;
|
||||
try {
|
||||
const sightRes = await fetch(`${API_URL}/sight/${sightId}`);
|
||||
const sightRes = await fetch(
|
||||
this.addLangParam(`${API_URL}/sight/${sightId}`)
|
||||
);
|
||||
const sightData = await sightRes.json();
|
||||
this.selectedSightName = sightData.name;
|
||||
|
||||
const articleId = sightData.left_article;
|
||||
|
||||
const articleRes = await fetch(`${API_URL}/article/${articleId}`);
|
||||
const articleRes = await fetch(
|
||||
this.addLangParam(`${API_URL}/article/${articleId}`)
|
||||
);
|
||||
const articleData = await articleRes.json();
|
||||
this.selectedSightText = articleData.body;
|
||||
|
||||
const mediaRes = await fetch(`${API_URL}/article/${articleId}/media`);
|
||||
const mediaRes = await fetch(
|
||||
this.addLangParam(`${API_URL}/article/${articleId}/media`)
|
||||
);
|
||||
const mediaData = await mediaRes.json();
|
||||
if (mediaData.length) {
|
||||
const imageRes = await fetch(
|
||||
`${API_URL}/media/${mediaData[0].id}/download`
|
||||
this.addLangParam(`${API_URL}/media/${mediaData[0].id}/download`)
|
||||
);
|
||||
this.selectedSightImage = await imageRes.url;
|
||||
this.selectedSightWatermarkLU = sightData.watermark_lu
|
||||
? `${API_URL}/media/${sightData.watermark_lu}/download`
|
||||
? this.addLangParam(
|
||||
`${API_URL}/media/${sightData.watermark_lu}/download`
|
||||
)
|
||||
: "";
|
||||
this.selectedSightWatermarkRD = sightData.watermark_rd
|
||||
? `${API_URL}/media/${sightData.watermark_rd}/download`
|
||||
? this.addLangParam(
|
||||
`${API_URL}/media/${sightData.watermark_rd}/download`
|
||||
)
|
||||
: "";
|
||||
} else {
|
||||
this.selectedSightImage = "";
|
||||
}
|
||||
|
||||
this.showSightPreview = true;
|
||||
await this.findTransfersForSight(sightId);
|
||||
} catch (err) {
|
||||
console.error("Ошибка при выборе достопримечательности:", err);
|
||||
}
|
||||
@ -919,6 +1180,7 @@ export default {
|
||||
this.showSightsList = false;
|
||||
this.showSightPreview = false;
|
||||
this.showGovernorAppeal = false;
|
||||
this.showSightTransfers = false;
|
||||
this.$emit("president-appeal-toggle", this.showGovernorAppeal);
|
||||
|
||||
const root = document.documentElement;
|
||||
@ -962,6 +1224,61 @@ export default {
|
||||
},
|
||||
handleUserActivity() {
|
||||
this.resetInactivityTimer();
|
||||
this.resetLangRevertTimer();
|
||||
},
|
||||
toggleLanguageOptions() {
|
||||
this.showLangToggle = false;
|
||||
this.showLanguageOptions = true;
|
||||
this.startLanguageOptionsTimer();
|
||||
},
|
||||
startLanguageOptionsTimer() {
|
||||
clearTimeout(this.languageOptionsTimer);
|
||||
this.languageOptionsTimer = setTimeout(this.hideLanguageOptions, 10000);
|
||||
},
|
||||
hideLanguageOptions() {
|
||||
this.showLanguageOptions = false;
|
||||
this.showLangToggle = true;
|
||||
},
|
||||
setLanguage(lang) {
|
||||
if (this.selectedLang === lang) {
|
||||
this.hideLanguageOptions();
|
||||
this.resetLangRevertTimer();
|
||||
return;
|
||||
}
|
||||
this.selectedLang = lang;
|
||||
localStorage.setItem("selectedLang", lang);
|
||||
|
||||
this.hideLanguageOptions();
|
||||
this.resetLangRevertTimer();
|
||||
|
||||
// заново тянем данные в нужном языке
|
||||
this.fetchStops(); // остановки + обращение губернатора
|
||||
this.fetchSights(); // список достопримечательностей
|
||||
if (this.selectedSightId) {
|
||||
// если была открыта карточка – обновляем и её
|
||||
this.selectSight(this.selectedSightId);
|
||||
}
|
||||
// подтягиваем заголовок обращения губернатора в новом языке
|
||||
if (this.governorAppealId) {
|
||||
this.loadGovernorAppeal(this.governorAppealId);
|
||||
}
|
||||
},
|
||||
addLangParam(url) {
|
||||
// add ?lang=XX to every API_URL request when language ≠ ru
|
||||
if (this.selectedLang !== "ru" && url.startsWith(API_URL)) {
|
||||
return (
|
||||
url + (url.includes("?") ? "&" : "?") + "lang=" + this.selectedLang
|
||||
);
|
||||
}
|
||||
return url;
|
||||
},
|
||||
resetLangRevertTimer() {
|
||||
clearTimeout(this.langRevertTimer);
|
||||
this.langRevertTimer = setTimeout(() => {
|
||||
if (this.selectedLang !== "ru") {
|
||||
this.setLanguage("ru");
|
||||
}
|
||||
}, 300000);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
@ -970,6 +1287,7 @@ export default {
|
||||
document.addEventListener(evt, this.handleUserActivity)
|
||||
);
|
||||
this.resetInactivityTimer();
|
||||
this.resetLangRevertTimer();
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty("--panel-offset", "20px");
|
||||
const routeInfo = document.querySelector(".routeinfo");
|
||||
@ -978,6 +1296,7 @@ export default {
|
||||
weatherInfo?.classList.add("shifted-left");
|
||||
this.fetchStops();
|
||||
this.fetchSights();
|
||||
this.startStopsPolling();
|
||||
console.log("hasGovernorAppeal в mounted:", this.hasGovernorAppeal);
|
||||
},
|
||||
beforeUnmount() {
|
||||
@ -986,6 +1305,76 @@ export default {
|
||||
);
|
||||
clearTimeout(this.inactivityTimer);
|
||||
document.removeEventListener("click", this.handleClickOutside);
|
||||
if (this.stopsInterval) {
|
||||
clearInterval(this.stopsInterval);
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sight-transfers {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.transfer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.transfer-key {
|
||||
font-weight: 600;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.transfer-value {
|
||||
white-space: pre;
|
||||
}
|
||||
.sight-transfer-list {
|
||||
margin-top: 4px;
|
||||
margin: 10px 20px 0 20px;
|
||||
padding-bottom: 10px;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.transfer-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.transfer-text {
|
||||
white-space: pre;
|
||||
font-size: 16px;
|
||||
}
|
||||
/* slide‑fade transition */
|
||||
.slide-fade-enter-active,
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
/* simple fade */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.language-options {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 1001;
|
||||
}
|
||||
.lang-option svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,5 +1,22 @@
|
||||
<template>
|
||||
<!-- map container -->
|
||||
<div id="map" style="height: 100vh; background-color: transparent"></div>
|
||||
|
||||
<!-- fullscreen video shown after 10 s of inactivity -->
|
||||
<div
|
||||
id="video-overlay"
|
||||
v-show="videoOverlayVisible"
|
||||
style="
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 10000;
|
||||
background: #000;
|
||||
pointer-events: none;
|
||||
"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@ -18,10 +35,10 @@ import { API_URL, GEO_URL } from "../config";
|
||||
const arrowTransforms = {
|
||||
right: "",
|
||||
left: "",
|
||||
"top-right": "",
|
||||
"bottom-right": "",
|
||||
"top-left": "",
|
||||
"bottom-left": "",
|
||||
"top-right": "translate(-25%, -50%)",
|
||||
"bottom-right": "translate(-25%, 50%)",
|
||||
"top-left": "translate(-100%, -50%)",
|
||||
"bottom-left": "translate(-100%, 50%)",
|
||||
};
|
||||
|
||||
export default {
|
||||
@ -36,21 +53,37 @@ export default {
|
||||
fullPolyline: null,
|
||||
passedPolyline: null,
|
||||
tramDirection: "right",
|
||||
routeId: null,
|
||||
segmentLengths: [],
|
||||
cumulativeDistances: [],
|
||||
totalLength: 0,
|
||||
sightPollTimer: null,
|
||||
sightsData: [],
|
||||
nearestSightId: null,
|
||||
lastActivityTime: Date.now(),
|
||||
videoOverlayVisible: false,
|
||||
currentVideoSrc: null,
|
||||
lastVideoPreviewId: null,
|
||||
inactivityInterval: null,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
async mounted() {
|
||||
this.initializeMap();
|
||||
this.fetchRoute();
|
||||
this.fetchSights();
|
||||
await this.fetchContext(); // obtain current routeId
|
||||
this.startTracking();
|
||||
this.setupActivityListeners();
|
||||
this.startInactivityCheck();
|
||||
},
|
||||
methods: {
|
||||
async fetchSights() {
|
||||
if (!this.routeId) return;
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/route/1/sight`);
|
||||
const response = await fetch(`${API_URL}/route/${this.routeId}/sight`);
|
||||
const data = await response.json();
|
||||
console.log("Данные достопримечательностей:", data);
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
this.sightsData = data;
|
||||
const grouped = {};
|
||||
|
||||
data.forEach((sight) => {
|
||||
@ -99,14 +132,112 @@ export default {
|
||||
} catch (error) {
|
||||
console.error("Ошибка при получении достопримечательностей:", error);
|
||||
}
|
||||
// Stop polling once at least one sight marker exists
|
||||
if (this.sightMarkers.length > 0 && this.sightPollTimer) {
|
||||
clearInterval(this.sightPollTimer);
|
||||
this.sightPollTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
// ── Poll fetchSights every 3 s until at least one sight is on the map ──
|
||||
startSightPolling() {
|
||||
if (this.sightPollTimer) return; // already polling
|
||||
this.sightPollTimer = setInterval(() => {
|
||||
this.fetchSights();
|
||||
if (this.sightMarkers.length > 0) {
|
||||
clearInterval(this.sightPollTimer);
|
||||
this.sightPollTimer = null;
|
||||
}
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
stopSightPolling() {
|
||||
if (this.sightPollTimer) {
|
||||
clearInterval(this.sightPollTimer);
|
||||
this.sightPollTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
// ───────────────── user‑inactivity & video overlay helpers ──────────────────
|
||||
setupActivityListeners() {
|
||||
["mousemove", "mousedown", "keydown", "touchstart", "scroll"].forEach(
|
||||
(evt) => window.addEventListener(evt, this.resetInactivity)
|
||||
);
|
||||
},
|
||||
resetInactivity() {
|
||||
this.lastActivityTime = Date.now();
|
||||
if (this.videoOverlayVisible) this.hideVideoOverlay();
|
||||
},
|
||||
startInactivityCheck() {
|
||||
if (this.inactivityInterval) return;
|
||||
this.inactivityInterval = setInterval(() => {
|
||||
if (Date.now() - this.lastActivityTime >= 300_000) {
|
||||
this.showNearestSightVideo();
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
stopInactivityCheck() {
|
||||
if (this.inactivityInterval) {
|
||||
clearInterval(this.inactivityInterval);
|
||||
this.inactivityInterval = null;
|
||||
}
|
||||
},
|
||||
async showNearestSightVideo() {
|
||||
if (!this.sightsData.length) return;
|
||||
|
||||
// choose closest sight that has a preview
|
||||
let sight = this.sightsData.find((s) => s.id === this.nearestSightId);
|
||||
if (!sight || !sight.video_preview) {
|
||||
sight = this.sightsData.find((s) => s.video_preview);
|
||||
}
|
||||
if (!sight || !sight.video_preview) return;
|
||||
|
||||
if (this.lastVideoPreviewId === sight.video_preview) {
|
||||
this.videoOverlayVisible = true;
|
||||
return; // already showing it
|
||||
}
|
||||
|
||||
const src = `${API_URL}/media/${sight.video_preview}/download`;
|
||||
this.replaceVideo(src, sight.video_preview);
|
||||
},
|
||||
replaceVideo(src, previewId) {
|
||||
const overlay = document.getElementById("video-overlay");
|
||||
if (!overlay) return;
|
||||
|
||||
const vid = document.createElement("video");
|
||||
vid.src = src;
|
||||
vid.autoplay = true;
|
||||
vid.muted = true;
|
||||
vid.loop = true;
|
||||
vid.playsInline = true;
|
||||
vid.style.cssText = "width:100%;height:100%;object-fit:cover;";
|
||||
|
||||
vid.addEventListener("canplay", () => {
|
||||
[...overlay.children].forEach((child) => {
|
||||
if (child !== vid) overlay.removeChild(child);
|
||||
});
|
||||
this.videoOverlayVisible = true;
|
||||
this.lastVideoPreviewId = previewId;
|
||||
});
|
||||
|
||||
overlay.appendChild(vid);
|
||||
},
|
||||
hideVideoOverlay() {
|
||||
this.videoOverlayVisible = false;
|
||||
const overlay = document.getElementById("video-overlay");
|
||||
if (overlay) overlay.innerHTML = "";
|
||||
this.lastVideoPreviewId = null;
|
||||
},
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
initializeMap() {
|
||||
this.map = L.map("map", {
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
minZoom: 12,
|
||||
maxZoom: 14,
|
||||
maxZoom: 14, // default max zoom
|
||||
minZoom: 12, // default min zoom
|
||||
zoomSnap: 0, // allow fractional zoom levels
|
||||
zoomDelta: 0.5, // smoother wheel steps
|
||||
});
|
||||
this.map.whenReady(() => {
|
||||
const mapPane = this.map.getPane("mapPane") || this.map.getContainer();
|
||||
@ -120,11 +251,27 @@ export default {
|
||||
},
|
||||
|
||||
async fetchRoute() {
|
||||
fetch(`${API_URL}/route/1`)
|
||||
if (!this.routeId) return;
|
||||
fetch(`${API_URL}/route/${this.routeId}`)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.path && Array.isArray(data.path)) {
|
||||
this.routeLatlngs = data.path.map((coord) => [coord[0], coord[1]]);
|
||||
// ── Pre-compute cumulative distances for distance-based progress ──
|
||||
this.segmentLengths = [];
|
||||
this.cumulativeDistances = [0];
|
||||
for (let i = 1; i < this.routeLatlngs.length; i++) {
|
||||
const prev = L.latLng(this.routeLatlngs[i - 1]);
|
||||
const curr = L.latLng(this.routeLatlngs[i]);
|
||||
const segLen = prev.distanceTo(curr); // metres
|
||||
this.segmentLengths.push(segLen);
|
||||
this.cumulativeDistances.push(
|
||||
this.cumulativeDistances[i - 1] + segLen
|
||||
);
|
||||
}
|
||||
this.totalLength =
|
||||
this.cumulativeDistances[this.cumulativeDistances.length - 1] ||
|
||||
0;
|
||||
if (
|
||||
typeof data.scale_min === "number" &&
|
||||
typeof data.scale_max === "number"
|
||||
@ -160,11 +307,7 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
const polyline = L.polyline(this.routeLatlngs, {
|
||||
color: "red",
|
||||
weight: 7,
|
||||
pane: "routePane",
|
||||
}).addTo(this.map);
|
||||
const routeBounds = L.latLngBounds(this.routeLatlngs);
|
||||
if (data.center_latitude && data.center_longitude) {
|
||||
console.log("Установка центра карты:", {
|
||||
latitude: data.center_latitude,
|
||||
@ -179,7 +322,7 @@ export default {
|
||||
this.map.getCenter()
|
||||
);
|
||||
} else {
|
||||
this.map.fitBounds(polyline.getBounds());
|
||||
this.map.fitBounds(routeBounds);
|
||||
}
|
||||
this.fetchStations();
|
||||
}
|
||||
@ -190,6 +333,7 @@ export default {
|
||||
},
|
||||
|
||||
async fetchStations() {
|
||||
if (!this.routeId) return;
|
||||
this.stationMarkers.forEach(({ marker }) => {
|
||||
if (this.map.hasLayer(marker)) {
|
||||
this.map.removeLayer(marker);
|
||||
@ -199,8 +343,12 @@ export default {
|
||||
|
||||
try {
|
||||
const [ruStations, enStations] = await Promise.all([
|
||||
fetch(`${API_URL}/route/1/station`).then((r) => r.json()),
|
||||
fetch(`${API_URL}/route/1/station?lang=en`).then((r) => r.json()),
|
||||
fetch(`${API_URL}/route/${this.routeId}/station`).then((r) =>
|
||||
r.json()
|
||||
),
|
||||
fetch(`${API_URL}/route/${this.routeId}/station?lang=en`).then((r) =>
|
||||
r.json()
|
||||
),
|
||||
]);
|
||||
|
||||
const enById = {};
|
||||
@ -326,6 +474,7 @@ export default {
|
||||
"top-left": tramTopLeft,
|
||||
"bottom-left": tramBottomLeft,
|
||||
};
|
||||
|
||||
const svgSrc = svgMap[direction] || tramRight;
|
||||
|
||||
const arrowTransform = arrowTransforms[direction] || "";
|
||||
@ -355,7 +504,7 @@ export default {
|
||||
background: yellow;
|
||||
border: 5px solid black;
|
||||
border-radius: 50%;
|
||||
transform: translateY(-50%);
|
||||
transform: translate(5px, -50%);
|
||||
z-index: 2;"></div>
|
||||
</div>
|
||||
`,
|
||||
@ -368,43 +517,100 @@ export default {
|
||||
setInterval(this.updateTramPosition, 500);
|
||||
},
|
||||
|
||||
async fetchContext() {
|
||||
try {
|
||||
const response = await fetch(`${GEO_URL}/v1/geolocation/context`);
|
||||
const data = await response.json();
|
||||
const ctxRouteId =
|
||||
(data && data.routeId) ||
|
||||
(data.routeProgress && data.routeProgress.routeId) ||
|
||||
null;
|
||||
|
||||
if (ctxRouteId && ctxRouteId !== this.routeId) {
|
||||
this.routeId = ctxRouteId;
|
||||
}
|
||||
|
||||
const ctxNearest =
|
||||
data.nearestSightId ||
|
||||
(data.routeProgress && data.routeProgress.nearestSightId);
|
||||
if (ctxNearest) this.nearestSightId = ctxNearest;
|
||||
} catch (error) {
|
||||
console.error("Ошибка при получении контекста геолокации:", error);
|
||||
}
|
||||
},
|
||||
|
||||
async updateTramPosition() {
|
||||
try {
|
||||
const response = await fetch(`${GEO_URL}/v1/geolocation/context`);
|
||||
const data = await response.json();
|
||||
if (
|
||||
data.routeId &&
|
||||
data.routeId !== 0 &&
|
||||
data.routeId !== this.routeId
|
||||
) {
|
||||
this.routeId = data.routeId;
|
||||
}
|
||||
// console.log("Текущие координаты трамвая:", data.currentCoordinates);
|
||||
|
||||
const ctxNearest =
|
||||
data.nearestSightId ||
|
||||
(data.routeProgress && data.routeProgress.nearestSightId);
|
||||
if (ctxNearest) this.nearestSightId = ctxNearest;
|
||||
|
||||
const { percentageCompleted } = data.routeProgress;
|
||||
|
||||
if (this.routeLatlngs.length === 0) return;
|
||||
if (this.totalLength === 0) return;
|
||||
|
||||
const progressIndex = Math.min(
|
||||
Math.floor(percentageCompleted * this.routeLatlngs.length),
|
||||
this.routeLatlngs.length - 1
|
||||
);
|
||||
// Дистанция, пройденная трамваем (м)
|
||||
const targetDistance = percentageCompleted * this.totalLength;
|
||||
|
||||
const tramLatLng = this.routeLatlngs[progressIndex];
|
||||
// Определяем сегмент маршрута, в котором находится трамвай
|
||||
let segIdx = 0;
|
||||
while (
|
||||
segIdx < this.cumulativeDistances.length - 1 &&
|
||||
this.cumulativeDistances[segIdx + 1] < targetDistance
|
||||
) {
|
||||
segIdx++;
|
||||
}
|
||||
|
||||
// Удаляем старые линии, если есть
|
||||
if (this.passedPolyline) this.map.removeLayer(this.passedPolyline);
|
||||
if (this.fullPolyline) this.map.removeLayer(this.fullPolyline);
|
||||
const segStart = this.routeLatlngs[segIdx];
|
||||
const segEnd =
|
||||
this.routeLatlngs[Math.min(segIdx + 1, this.routeLatlngs.length - 1)];
|
||||
const segStartDist = this.cumulativeDistances[segIdx];
|
||||
const segLen = this.cumulativeDistances[segIdx + 1] - segStartDist || 1;
|
||||
|
||||
// Красная линия — уже проехали
|
||||
this.passedPolyline = L.polyline(
|
||||
this.routeLatlngs.slice(0, progressIndex + 1),
|
||||
{
|
||||
// Интерполируем точку на сегменте
|
||||
const t = segLen === 0 ? 0 : (targetDistance - segStartDist) / segLen;
|
||||
const tramLatLng = [
|
||||
segStart[0] + (segEnd[0] - segStart[0]) * t,
|
||||
segStart[1] + (segEnd[1] - segStart[1]) * t,
|
||||
];
|
||||
|
||||
// Формируем координаты пройденной и оставшейся части
|
||||
const passedCoords = this.routeLatlngs.slice(0, segIdx + 1);
|
||||
passedCoords.push(tramLatLng);
|
||||
const fullCoords = [tramLatLng, ...this.routeLatlngs.slice(segIdx + 1)];
|
||||
|
||||
// ── Update / create progress polylines without flicker ──
|
||||
if (!this.passedPolyline) {
|
||||
this.passedPolyline = L.polyline(passedCoords, {
|
||||
color: "red",
|
||||
weight: 7,
|
||||
pane: "routePane",
|
||||
}
|
||||
).addTo(this.map);
|
||||
}).addTo(this.map);
|
||||
} else {
|
||||
this.passedPolyline.setLatLngs(passedCoords);
|
||||
}
|
||||
|
||||
// Белая линия — ещё впереди
|
||||
this.fullPolyline = L.polyline(this.routeLatlngs.slice(progressIndex), {
|
||||
color: "white",
|
||||
weight: 7,
|
||||
pane: "routePane",
|
||||
}).addTo(this.map);
|
||||
if (!this.fullPolyline) {
|
||||
this.fullPolyline = L.polyline(fullCoords, {
|
||||
color: "white",
|
||||
weight: 7,
|
||||
pane: "routePane",
|
||||
}).addTo(this.map);
|
||||
} else {
|
||||
this.fullPolyline.setLatLngs(fullCoords);
|
||||
}
|
||||
|
||||
if (this.tramMarker && !this.map.hasLayer(this.tramMarker)) {
|
||||
this.tramMarker.addTo(this.map);
|
||||
@ -425,8 +631,9 @@ export default {
|
||||
0
|
||||
);
|
||||
|
||||
const isPassed = stationIndex < progressIndex;
|
||||
const color = isPassed ? "red" : "white";
|
||||
const stationPassed =
|
||||
this.cumulativeDistances[stationIndex] <= targetDistance;
|
||||
const color = stationPassed ? "red" : "white";
|
||||
|
||||
marker.setStyle({
|
||||
color: "black",
|
||||
@ -461,5 +668,26 @@ export default {
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
// When routeId becomes available or changes, (re)load dependent data
|
||||
routeId(newVal, oldVal) {
|
||||
if (newVal && newVal !== oldVal) {
|
||||
this.fetchRoute();
|
||||
this.fetchSights();
|
||||
this.fetchStations();
|
||||
this.startSightPolling();
|
||||
}
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.sightPollTimer) {
|
||||
clearInterval(this.sightPollTimer);
|
||||
this.sightPollTimer = null;
|
||||
}
|
||||
this.stopInactivityCheck();
|
||||
["mousemove", "mousedown", "keydown", "touchstart", "scroll"].forEach(
|
||||
(evt) => window.removeEventListener(evt, this.resetInactivity)
|
||||
);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -85,6 +85,8 @@ export default {
|
||||
isStartScrolling: false,
|
||||
isEndScrolling: false,
|
||||
isEnScrolling: false,
|
||||
pollId: null,
|
||||
hasLoadedStops: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
@ -101,41 +103,69 @@ export default {
|
||||
this.$nextTick(this.checkScroll);
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
const contextRes = await fetch(`${GEO_URL}/v1/geolocation/context`);
|
||||
const data = await contextRes.json();
|
||||
this.routeNumber = data.routeNumber;
|
||||
|
||||
const startStopId = data.startStopId;
|
||||
const endStopId = data.endStopId;
|
||||
|
||||
const [startStopRes, endStopRes] = await Promise.all([
|
||||
fetch(`${API_URL}/station/${startStopId}`),
|
||||
fetch(`${API_URL}/station/${endStopId}`),
|
||||
]);
|
||||
|
||||
const startStopData = await startStopRes.json();
|
||||
const endStopData = await endStopRes.json();
|
||||
|
||||
this.startStopName = startStopData.name;
|
||||
this.endStopName = endStopData.name;
|
||||
|
||||
const [startStopEnRes, endStopEnRes] = await Promise.all([
|
||||
fetch(`${API_URL}/station/${startStopId}?lang=en`),
|
||||
fetch(`${API_URL}/station/${endStopId}?lang=en`),
|
||||
]);
|
||||
|
||||
const startStopEnData = await startStopEnRes.json();
|
||||
const endStopEnData = await endStopEnRes.json();
|
||||
|
||||
this.startStopNameEn = startStopEnData.name;
|
||||
this.endStopNameEn = endStopEnData.name;
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.checkScroll();
|
||||
});
|
||||
mounted() {
|
||||
this.startPollingContext();
|
||||
},
|
||||
methods: {
|
||||
startPollingContext() {
|
||||
// first request immediately, then every 3 s
|
||||
this.fetchContext();
|
||||
this.pollId = setInterval(this.fetchContext, 3000);
|
||||
},
|
||||
|
||||
async fetchContext() {
|
||||
try {
|
||||
const res = await fetch(`${GEO_URL}/v1/geolocation/context`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.routeNumber) {
|
||||
this.routeNumber = data.routeNumber;
|
||||
}
|
||||
|
||||
const { startStopId, endStopId } = data;
|
||||
|
||||
// when both IDs are available for the first time, load names and stop polling
|
||||
if (startStopId && endStopId && !this.hasLoadedStops) {
|
||||
await this.loadStopNames(startStopId, endStopId);
|
||||
this.hasLoadedStops = true;
|
||||
if (this.pollId) {
|
||||
clearInterval(this.pollId);
|
||||
this.pollId = null;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch geolocation context", err);
|
||||
}
|
||||
},
|
||||
|
||||
async loadStopNames(startStopId, endStopId) {
|
||||
try {
|
||||
const [startRuRes, endRuRes] = await Promise.all([
|
||||
fetch(`${API_URL}/station/${startStopId}`),
|
||||
fetch(`${API_URL}/station/${endStopId}`),
|
||||
]);
|
||||
const startRu = await startRuRes.json();
|
||||
const endRu = await endRuRes.json();
|
||||
|
||||
this.startStopName = startRu.name;
|
||||
this.endStopName = endRu.name;
|
||||
|
||||
const [startEnRes, endEnRes] = await Promise.all([
|
||||
fetch(`${API_URL}/station/${startStopId}?lang=en`),
|
||||
fetch(`${API_URL}/station/${endStopId}?lang=en`),
|
||||
]);
|
||||
const startEn = await startEnRes.json();
|
||||
const endEn = await endEnRes.json();
|
||||
|
||||
this.startStopNameEn = startEn.name;
|
||||
this.endStopNameEn = endEn.name;
|
||||
|
||||
this.$nextTick(this.checkScroll);
|
||||
} catch (err) {
|
||||
console.error("Failed to load station names", err);
|
||||
}
|
||||
},
|
||||
|
||||
checkScroll() {
|
||||
const threshold = 280;
|
||||
if (this.$refs.startStopRuText) {
|
||||
|
@ -1,6 +1,30 @@
|
||||
<template>
|
||||
<div class="stopinfo">
|
||||
<div class="bg">
|
||||
<transition name="slide-fade">
|
||||
<div
|
||||
v-if="cardDetail"
|
||||
class="sight-preview-panel left-panel"
|
||||
@click.stop
|
||||
>
|
||||
<div class="sight-preview-wrapper">
|
||||
<img v-if="cardDetail.imageUrl" :src="cardDetail.imageUrl" />
|
||||
</div>
|
||||
<h3>{{ cardDetail.name }}</h3>
|
||||
<p>{{ selectedSightArticleBody }}</p>
|
||||
<div class="stoparticles">
|
||||
<span
|
||||
v-for="article in cardDetail.articles"
|
||||
:key="article.id"
|
||||
:class="{ selected: article.id === selectedSightArticleId }"
|
||||
@click.stop="selectSightArticle(article.id)"
|
||||
class="stoparticle-option"
|
||||
>
|
||||
{{ article.heading }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<div class="container">
|
||||
<div class="image-wrapper">
|
||||
<!-- @click="openModal" -->
|
||||
@ -116,7 +140,58 @@
|
||||
d="M.382 11.643c-.24.227-.382.553-.382.92 0 .737.552 1.29 1.288 1.29.368 0 .708-.128.92-.368L13.052 2.408h-1.515l10.844 11.077c.227.24.566.368.92.368a1.26 1.26 0 0 0 1.289-1.29 1.25 1.25 0 0 0-.383-.92L13.25.425A1.288 1.288 0 0 0 12.302 0a1.32 1.32 0 0 0-.963.425L.382 11.643Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="header-name">Достопримечательности</span>
|
||||
<span class="header-name">{{ t("sights") }}</span>
|
||||
|
||||
<button
|
||||
v-if="showLangToggle"
|
||||
class="language-toggle"
|
||||
@click="toggleLanguageOptions"
|
||||
@click.stop
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="28"
|
||||
height="28"
|
||||
fill="none"
|
||||
viewBox="0 0 28 28"
|
||||
>
|
||||
<path
|
||||
fill="#fff"
|
||||
fill-rule="evenodd"
|
||||
d="M0 14C0 6.27 6.27 0 14 0s14 6.27 14 14-6.27 14-14 14S0 21.73 0 14Zm21.03-2.678 1.294 3.536 1.284-3.547-.759.222c-.006-.012-.006-.245-.006-.263-.338-4.03-3.599-7.1-7.711-7.1a.558.558 0 0 0-.554.555c0 .303.25.554.554.554 3.488 0 6.265 2.579 6.603 5.985l.003.137.003.137-.712-.216ZM5.193 16.063a9.797 9.797 0 0 0 3.888-1.897 7.8 7.8 0 0 0 3.449 1.906.784.784 0 0 0 .186 0c.303.032.6-.094.78-.33a.798.798 0 0 0 .094-.819.851.851 0 0 0-.688-.487 5.887 5.887 0 0 1-2.541-1.399 11.906 11.906 0 0 0 3.151-4.907.797.797 0 0 0-.17-.712.857.857 0 0 0-.677-.328H9.701V6.068a.814.814 0 0 0-.424-.708.872.872 0 0 0-.847 0 .813.813 0 0 0-.423.708v1.038H5.042a.854.854 0 0 0-.734.41.793.793 0 0 0 0 .817.854.854 0 0 0 .734.41h6.43A11.292 11.292 0 0 1 9.166 11.9a11.17 11.17 0 0 1-1.338-1.89.833.833 0 0 0-.484-.47.874.874 0 0 0-.687.03.826.826 0 0 0-.437.511.793.793 0 0 0 .1.657 13 13 0 0 0 1.584 2.184 8 8 0 0 1-3.058 1.505.86.86 0 0 0-.574.3.801.801 0 0 0 .126 1.157c.176.138.4.202.625.18a.573.573 0 0 0 .17 0Zm15.164 6.617a.874.874 0 0 0 .646.016.838.838 0 0 0 .497-.424c.099-.2.11-.432.028-.64L17.7 12.12a.824.824 0 0 0-.31-.38.869.869 0 0 0-.954-.004.826.826 0 0 0-.312.375l-3.795 9.194a.796.796 0 0 0 .123.81.864.864 0 0 0 1.453-.205l.915-2.282h4.032l1.06 2.602a.832.832 0 0 0 .446.45Zm-3.497-8 1.33 3.313h-2.694l1.364-3.313Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- тройка языковых иконок -->
|
||||
<div
|
||||
v-if="showLanguageOptions"
|
||||
class="language-options"
|
||||
@click.stop
|
||||
>
|
||||
<!-- RU -->
|
||||
<button
|
||||
class="language-option"
|
||||
:style="{ opacity: selectedLang === 'ru' ? 1 : 0.8 }"
|
||||
@click="setLanguage('ru')"
|
||||
v-html="icons.ru"
|
||||
/>
|
||||
<!-- EN -->
|
||||
<button
|
||||
class="language-option"
|
||||
:style="{ opacity: selectedLang === 'en' ? 1 : 0.8 }"
|
||||
@click="setLanguage('en')"
|
||||
v-html="icons.en"
|
||||
/>
|
||||
<!-- ZH -->
|
||||
<button
|
||||
class="language-option"
|
||||
:style="{ opacity: selectedLang === 'zh' ? 1 : 0.8 }"
|
||||
@click="setLanguage('zh')"
|
||||
v-html="icons.zh"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="slide-fade">
|
||||
@ -148,6 +223,10 @@
|
||||
v-for="sight in group"
|
||||
:key="sight.id"
|
||||
class="sight-card"
|
||||
:class="{
|
||||
'selected-card': sight.id === selectedSightCardId,
|
||||
}"
|
||||
@click.stop="openSightCardDetails(sight.id)"
|
||||
>
|
||||
<img class="sight-thumbnail" :src="sight.thumbnailUrl" />
|
||||
<div class="sight-title">{{ sight.name }}</div>
|
||||
@ -186,9 +265,13 @@ export default {
|
||||
articles: [],
|
||||
selectedArticleId: null,
|
||||
selectedArticleBody: "",
|
||||
selectedSightArticleId: null,
|
||||
selectedSightArticleBody: "",
|
||||
sights: [],
|
||||
showSightsList: false,
|
||||
selectedLetter: null,
|
||||
selectedSightCardId: null,
|
||||
cardDetail: null,
|
||||
showTransfers: false,
|
||||
transferIcons: {
|
||||
tram: require("@/icons/tram.svg"),
|
||||
@ -204,6 +287,29 @@ export default {
|
||||
nextStopTransfers: null,
|
||||
stops: [],
|
||||
routeProgress: null,
|
||||
routeId: null,
|
||||
selectedLang: localStorage.getItem("selectedLangRight") || "ru",
|
||||
showLangToggle: true, // видна «глобус»-кнопка
|
||||
showLanguageOptions: false, // виден блок из трёх иконок
|
||||
languageOptionsTimer: null, // таймер на 10 с
|
||||
langRevertTimer: null, // таймер на 30 с
|
||||
icons: {
|
||||
ru: `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="none" viewBox="0 0 28 28">
|
||||
<path fill="#fff" d="M14 0C6.27 0 0 6.27 0 14s6.27 14 14 14 14-6.27 14-14S21.73 0 14 0Zm.117 19.57H11.62l-2.117-4.135H7.646v4.136H5.32V8.27h4.2c1.336 0 2.363.298 3.092.893.729.595 1.085 1.435 1.085 2.52 0 .77-.17 1.412-.502 1.931-.332.513-.84.928-1.517 1.23l2.444 4.62v.112l-.005-.006Zm9.391-3.855c0 1.237-.385 2.217-1.16 2.934-.776.718-1.832 1.08-3.174 1.08-1.341 0-2.368-.35-3.144-1.05-.776-.7-1.173-1.657-1.19-2.882V8.272h2.328v7.46c0 .741.175 1.278.53 1.616.356.339.846.508 1.47.508 1.307 0 1.972-.689 1.995-2.065V8.27h2.334v7.444h.011Z"/>
|
||||
<path fill="#fff" d="M9.513 10.156H7.641v3.389h1.878c.583 0 1.038-.152 1.36-.45.32-.297.478-.705.478-1.23s-.152-.95-.455-1.26c-.304-.31-.77-.46-1.394-.46l.005.01Z"/>
|
||||
</svg>`,
|
||||
en: `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="none" viewBox="0 0 28 28">
|
||||
<path fill="#fff" d="M14 0C6.27 0 0 6.27 0 14s6.27 14 14 14 14-6.27 14-14S21.73 0 14 0Zm-1.418 19.71H4.906V8.255h7.665v1.914H7.263v2.73h4.532v1.849H7.263v3.068h5.32v1.896Zm10.483 0h-2.363l-4.596-7.536v7.537h-2.363V8.254h2.363l4.602 7.554V8.254h2.351v11.457h.006Z"/>
|
||||
</svg>`,
|
||||
zh: `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="none" viewBox="0 0 28 28">
|
||||
<path fill="#fff" d="M6 11.89H3.67v2.196H6v-2.197Zm1.994 2.196h2.343v-2.197H7.994v2.197Zm13.079-2.392h-3.656a10.503 10.503 0 0 0 1.863 2.96c.735-.833 1.33-1.813 1.793-2.96Z"/>
|
||||
<path fill="#fff" fill-rule="evenodd" d="M14 28c7.732 0 14-6.268 14-14S21.732 0 14 0 0 6.268 0 14s6.268 14 14 14ZM6 7.875h1.994v2.132h4.324v6.586h-1.98v-.624H7.993v3.846H6.001V15.97H3.67v.686H1.75v-6.649H6V7.876Zm12.288 0h1.956v1.936h4.888v1.883h-1.864c-.632 1.694-1.466 3.113-2.512 4.293 1.206.885 2.674 1.545 4.425 1.938l.396.09-.292.281c-.333.322-.798.999-1.025 1.417l-.082.15-.166-.043c-1.94-.507-3.523-1.298-4.822-2.364-1.323 1.033-2.892 1.814-4.722 2.386l-.183.057-.08-.173c-.163-.348-.617-1.042-.902-1.387l-.218-.263.33-.088c1.714-.45 3.153-1.089 4.34-1.95-1.026-1.218-1.819-2.671-2.448-4.345h-1.834V9.812h4.815V7.875Z" clip-rule="evenodd"/>
|
||||
</svg>`,
|
||||
},
|
||||
translations: {
|
||||
sights: { ru: "Достопримечательности", en: "Landmarks", zh: "景点" },
|
||||
// при необходимости — другие подписи
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@ -246,6 +352,64 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
t(key) {
|
||||
const dict = this.translations[key] || {};
|
||||
return dict[this.selectedLang] || dict.ru || key;
|
||||
},
|
||||
// показать 3 иконки
|
||||
toggleLanguageOptions() {
|
||||
this.showLangToggle = false;
|
||||
this.showLanguageOptions = true;
|
||||
this.startLanguageOptionsTimer();
|
||||
},
|
||||
|
||||
// таймер 10 с
|
||||
startLanguageOptionsTimer() {
|
||||
clearTimeout(this.languageOptionsTimer);
|
||||
this.languageOptionsTimer = setTimeout(this.hideLanguageOptions, 10_000);
|
||||
},
|
||||
|
||||
// скрыть 3 иконки, вернуть глобус
|
||||
hideLanguageOptions() {
|
||||
this.showLanguageOptions = false;
|
||||
this.showLangToggle = true;
|
||||
},
|
||||
|
||||
// выбор языка
|
||||
setLanguage(lang) {
|
||||
if (this.selectedLang === lang) {
|
||||
this.hideLanguageOptions();
|
||||
this.resetLangRevertTimer();
|
||||
return;
|
||||
}
|
||||
this.selectedLang = lang;
|
||||
localStorage.setItem("selectedLangRight", lang);
|
||||
this.hideLanguageOptions();
|
||||
this.resetLangRevertTimer();
|
||||
|
||||
// перезагрузить данные на новом языке
|
||||
this.fetchSightInfo();
|
||||
this.fetchArticles();
|
||||
this.fetchSights();
|
||||
},
|
||||
|
||||
// добавить ?lang=… к URL-у, если язык не ru
|
||||
addLangParam(url) {
|
||||
if (this.selectedLang !== "ru") {
|
||||
return (
|
||||
url + (url.includes("?") ? "&" : "?") + "lang=" + this.selectedLang
|
||||
);
|
||||
}
|
||||
return url;
|
||||
},
|
||||
|
||||
// таймер 5 минут с возврата на ru
|
||||
resetLangRevertTimer() {
|
||||
clearTimeout(this.langRevertTimer);
|
||||
this.langRevertTimer = setTimeout(() => {
|
||||
if (this.selectedLang !== "ru") this.setLanguage("ru");
|
||||
}, 300_000);
|
||||
},
|
||||
openModal() {
|
||||
const imageDiv = this.$el.querySelector(".img");
|
||||
if (imageDiv) {
|
||||
@ -269,7 +433,9 @@ export default {
|
||||
console.log("No sightId provided for fetchSightInfo");
|
||||
return;
|
||||
}
|
||||
const response = await axios.get(`${API_URL}/sight/${this.sightId}`);
|
||||
const response = await axios.get(
|
||||
this.addLangParam(`${API_URL}/sight/${this.sightId}`)
|
||||
);
|
||||
this.stopName = response.data.name;
|
||||
if (response.data.watermark_lu) {
|
||||
this.watermarkLU = await this.getMediaBlobUrl(
|
||||
@ -292,7 +458,7 @@ export default {
|
||||
return;
|
||||
}
|
||||
const response = await axios.get(
|
||||
`${API_URL}/sight/${this.sightId}/article`
|
||||
this.addLangParam(`${API_URL}/sight/${this.sightId}/article`)
|
||||
);
|
||||
this.articles = response.data;
|
||||
if (this.articles.length > 0) {
|
||||
@ -301,50 +467,178 @@ export default {
|
||||
}
|
||||
},
|
||||
async fetchSights() {
|
||||
const geoRes = await axios.get(`${GEO_URL}/v1/geolocation/context`);
|
||||
const routeId = geoRes.data.routeId;
|
||||
// Prefer cached routeId, otherwise fetch context once
|
||||
let routeId = this.routeId;
|
||||
if (!routeId) {
|
||||
console.warn("Missing routeId in geo context:", geoRes.data);
|
||||
try {
|
||||
const geoRes = await axios.get(`${GEO_URL}/v1/geolocation/context`);
|
||||
routeId = geoRes.data.routeId;
|
||||
if (routeId && routeId !== this.routeId) {
|
||||
this.routeId = routeId;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get routeId from context:", error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!routeId) {
|
||||
console.warn("Missing routeId — skipping fetchSights");
|
||||
return;
|
||||
}
|
||||
const sightsRes = await axios.get(`${API_URL}/route/${routeId}/sight`);
|
||||
const rawSights = sightsRes.data;
|
||||
const detailedSights = await Promise.all(
|
||||
rawSights.map(async (sight) => {
|
||||
const detailRes = await axios.get(`${API_URL}/sight/${sight.id}`);
|
||||
const thumbnailUrl = detailRes.data.thumbnail
|
||||
? await this.getMediaBlobUrl(detailRes.data.thumbnail)
|
||||
: "";
|
||||
return {
|
||||
id: sight.id,
|
||||
name: detailRes.data.name,
|
||||
thumbnailUrl,
|
||||
};
|
||||
})
|
||||
);
|
||||
this.sights = detailedSights;
|
||||
try {
|
||||
const sightsRes = await axios.get(
|
||||
this.addLangParam(`${API_URL}/route/${routeId}/sight`)
|
||||
);
|
||||
const rawSights = sightsRes.data;
|
||||
const detailedSights = await Promise.all(
|
||||
rawSights.map(async (sight) => {
|
||||
const detailRes = await axios.get(
|
||||
this.addLangParam(`${API_URL}/sight/${sight.id}`)
|
||||
);
|
||||
const thumbnailUrl = detailRes.data.thumbnail
|
||||
? await this.getMediaBlobUrl(detailRes.data.thumbnail)
|
||||
: "";
|
||||
return {
|
||||
id: sight.id,
|
||||
name: detailRes.data.name,
|
||||
thumbnailUrl,
|
||||
};
|
||||
})
|
||||
);
|
||||
this.sights = detailedSights;
|
||||
} catch (error) {
|
||||
console.error("Error fetching sights:", error);
|
||||
}
|
||||
},
|
||||
async openSightCardDetails(id) {
|
||||
// закрыть, если нажали повторно
|
||||
if (this.selectedSightCardId === id) {
|
||||
this.selectedSightCardId = null;
|
||||
this.cardDetail = null;
|
||||
return;
|
||||
}
|
||||
this.selectedSightCardId = id;
|
||||
this.cardDetail = null;
|
||||
this.resetSightsInactivityTimer();
|
||||
|
||||
try {
|
||||
const [detailRes, articlesRes] = await Promise.all([
|
||||
axios.get(this.addLangParam(`${API_URL}/sight/${id}`)),
|
||||
axios.get(this.addLangParam(`${API_URL}/sight/${id}/article`)),
|
||||
]);
|
||||
|
||||
// Fetch media for every article
|
||||
const articlesWithMedia = await Promise.all(
|
||||
articlesRes.data.map(async (article) => {
|
||||
try {
|
||||
const mediaRes = await axios.get(
|
||||
this.addLangParam(`${API_URL}/article/${article.id}/media`)
|
||||
);
|
||||
return { ...article, media: mediaRes.data };
|
||||
} catch (mediaErr) {
|
||||
console.error(
|
||||
`Failed to fetch media for article ${article.id}:`,
|
||||
mediaErr
|
||||
);
|
||||
return { ...article, media: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Console output: sight name, articles, and their media
|
||||
console.log("Sight clicked:", {
|
||||
name: detailRes.data.name,
|
||||
articles: articlesWithMedia,
|
||||
});
|
||||
|
||||
// Choose preview image: prefer the first article's first media, otherwise fall back to sight thumbnail
|
||||
let imageUrl = "";
|
||||
if (
|
||||
articlesWithMedia.length > 0 &&
|
||||
articlesWithMedia[0].media &&
|
||||
articlesWithMedia[0].media.length > 0
|
||||
) {
|
||||
const firstMediaId = articlesWithMedia[0].media[0].id;
|
||||
try {
|
||||
imageUrl = await this.getMediaBlobUrl(firstMediaId);
|
||||
} catch {
|
||||
imageUrl = this.addLangParam(
|
||||
`${API_URL}/media/${firstMediaId}/download`
|
||||
);
|
||||
}
|
||||
} else if (detailRes.data.thumbnail) {
|
||||
try {
|
||||
imageUrl = await this.getMediaBlobUrl(detailRes.data.thumbnail);
|
||||
} catch {
|
||||
imageUrl = this.addLangParam(
|
||||
`${API_URL}/media/${detailRes.data.thumbnail}/download`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.cardDetail = {
|
||||
name: detailRes.data.name,
|
||||
imageUrl,
|
||||
articles: articlesWithMedia,
|
||||
};
|
||||
if (articlesWithMedia.length > 0) {
|
||||
this.selectedSightArticleId = articlesWithMedia[0].id;
|
||||
this.selectedSightArticleBody = articlesWithMedia[0].body;
|
||||
} else {
|
||||
this.selectedSightArticleId = null;
|
||||
this.selectedSightArticleBody = "";
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load sight card details:", err);
|
||||
}
|
||||
},
|
||||
async selectSightArticle(id) {
|
||||
this.selectedSightArticleId = id;
|
||||
const selected = this.cardDetail?.articles.find((a) => a.id === id);
|
||||
this.selectedSightArticleBody = selected ? selected.body : "";
|
||||
if (selected && selected.media && selected.media.length > 0) {
|
||||
try {
|
||||
this.cardDetail.imageUrl = await this.getMediaBlobUrl(
|
||||
selected.media[0].id
|
||||
);
|
||||
} catch {
|
||||
this.cardDetail.imageUrl = this.addLangParam(
|
||||
`${API_URL}/media/${selected.media[0].id}/download`
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
hideSightUI() {
|
||||
this.showSightsList = false;
|
||||
this.selectedSightCardId = null;
|
||||
this.cardDetail = null;
|
||||
},
|
||||
toggleSightsList() {
|
||||
// переключаем видимость
|
||||
this.showSightsList = !this.showSightsList;
|
||||
if (this.showSightsList) {
|
||||
|
||||
// если список теперь скрыт — закрываем и карточку, сбрасываем таймер
|
||||
if (!this.showSightsList) {
|
||||
this.selectedSightCardId = null;
|
||||
this.cardDetail = null;
|
||||
if (this.sightsInactivityTimer) {
|
||||
clearTimeout(this.sightsInactivityTimer);
|
||||
this.sightsInactivityTimer = null;
|
||||
}
|
||||
} else {
|
||||
// список открыт — запускаем таймер неактивности
|
||||
this.resetSightsInactivityTimer();
|
||||
} else if (this.sightsInactivityTimer) {
|
||||
clearTimeout(this.sightsInactivityTimer);
|
||||
this.sightsInactivityTimer = null;
|
||||
}
|
||||
},
|
||||
resetSightsInactivityTimer() {
|
||||
if (this.articleInactivityTimer) {
|
||||
clearTimeout(this.articleInactivityTimer);
|
||||
}
|
||||
if (this.sightsInactivityTimer) {
|
||||
clearTimeout(this.sightsInactivityTimer);
|
||||
}
|
||||
if (this.showSightsList) {
|
||||
if (this.sightsInactivityTimer) clearTimeout(this.sightsInactivityTimer);
|
||||
|
||||
// запускаем новый, если открыт список или карточка
|
||||
if (this.showSightsList || this.cardDetail) {
|
||||
this.sightsInactivityTimer = setTimeout(() => {
|
||||
this.showSightsList = false;
|
||||
this.hideSightUI(); // скрываем всё
|
||||
this.sightsInactivityTimer = null;
|
||||
}, 300000); // 5 m
|
||||
}, 300_000); // 5 m
|
||||
}
|
||||
},
|
||||
resetArticleInactivityTimer() {
|
||||
@ -361,8 +655,10 @@ export default {
|
||||
}, 300_000); // 5 m
|
||||
},
|
||||
handleUserActivity() {
|
||||
if (this.showSightsList) this.resetSightsInactivityTimer();
|
||||
if (this.showSightsList || this.cardDetail)
|
||||
this.resetSightsInactivityTimer();
|
||||
this.resetArticleInactivityTimer();
|
||||
this.resetLangRevertTimer();
|
||||
},
|
||||
selectArticle(id) {
|
||||
this.resetArticleInactivityTimer();
|
||||
@ -374,14 +670,16 @@ export default {
|
||||
async fetchArticleMedia(articleId) {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${API_URL}/article/${articleId}/media`
|
||||
this.addLangParam(`${API_URL}/article/${articleId}/media`)
|
||||
);
|
||||
if (response.data && response.data.length > 0) {
|
||||
const mediaId = response.data[0].id;
|
||||
try {
|
||||
this.imageUrl = await this.getMediaBlobUrl(mediaId);
|
||||
} catch {
|
||||
this.imageUrl = `${API_URL}/media/${mediaId}/download`;
|
||||
this.imageUrl = this.addLangParam(
|
||||
`${API_URL}/media/${mediaId}/download`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.imageUrl = "";
|
||||
@ -395,7 +693,7 @@ export default {
|
||||
async getMediaBlobUrl(mediaId) {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${API_URL}/media/${mediaId}/download`,
|
||||
this.addLangParam(`${API_URL}/media/${mediaId}/download`),
|
||||
{
|
||||
responseType: "blob",
|
||||
}
|
||||
@ -407,17 +705,27 @@ export default {
|
||||
error?.response?.status,
|
||||
error?.response?.data || error.message
|
||||
);
|
||||
return `${API_URL}/media/${mediaId}/download`;
|
||||
return this.addLangParam(`${API_URL}/media/${mediaId}/download`);
|
||||
}
|
||||
},
|
||||
async fetchGeolocationContext() {
|
||||
try {
|
||||
const response = await axios.get(`${GEO_URL}/v1/geolocation/context`);
|
||||
this.routeProgress = response.data.routeProgress;
|
||||
const stopsResponse = await axios.get(
|
||||
`${API_URL}/route/${response.data.routeId}/station`
|
||||
const response = await axios.get(
|
||||
this.addLangParam(`${GEO_URL}/v1/geolocation/context`)
|
||||
);
|
||||
this.stops = stopsResponse.data;
|
||||
this.routeProgress = response.data.routeProgress;
|
||||
const newRouteId = response.data.routeId;
|
||||
if (newRouteId && newRouteId !== this.routeId) {
|
||||
this.routeId = newRouteId;
|
||||
}
|
||||
if (this.routeId) {
|
||||
const stopsResponse = await axios.get(
|
||||
this.addLangParam(`${API_URL}/route/${this.routeId}/station`)
|
||||
);
|
||||
this.stops = stopsResponse.data;
|
||||
} else {
|
||||
this.stops = [];
|
||||
}
|
||||
let newSightId = response.data.nearestSightId;
|
||||
|
||||
if (!newSightId) {
|
||||
@ -490,6 +798,14 @@ export default {
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
routeId(newVal, oldVal) {
|
||||
if (newVal && newVal !== oldVal) {
|
||||
// Refresh sights because routeId changed
|
||||
this.fetchSights();
|
||||
}
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchSights();
|
||||
await this.fetchGeolocationContext();
|
||||
@ -504,6 +820,7 @@ export default {
|
||||
window.addEventListener("click", this.handleUserActivity, true);
|
||||
// this.fetchSightInfo();
|
||||
// this.fetchArticles();
|
||||
this.resetLangRevertTimer();
|
||||
},
|
||||
unmounted() {
|
||||
if (this.sightsInactivityTimer) {
|
||||
@ -524,3 +841,113 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sight-card {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sight-card-detail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 105%;
|
||||
width: 260px;
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: #fff;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.sight-card-detail .detail-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sight-card-detail .detail-name {
|
||||
margin: 0 0 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-articles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-article {
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.sight-side-panel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 33%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.panel-image-wrapper {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.panel-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.panel-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.panel-description {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.panel-articles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.panel-article-option {
|
||||
padding: 4px 8px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.panel-article-option.selected {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
.language-options {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.lang-option svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
</style>
|
||||
|
@ -72,7 +72,7 @@
|
||||
|
||||
<script>
|
||||
import "../assets/style/main.css";
|
||||
import { API_URL } from "../config";
|
||||
import { API_URL, WEATHER_URL } from "../config";
|
||||
|
||||
import axios from "axios";
|
||||
import clearIcon from "@/icons/clear-day.svg";
|
||||
@ -96,7 +96,7 @@ export default {
|
||||
isModalOpen: false,
|
||||
autoCloseTimer: null,
|
||||
imageUrl: "",
|
||||
sightId: 14,
|
||||
sightId: null,
|
||||
stopName: "",
|
||||
articles: [],
|
||||
selectedArticleId: null,
|
||||
@ -120,10 +120,16 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async fetchSightInfo() {
|
||||
// Do nothing if sightId is null, zero, or empty
|
||||
if (!this.sightId) return;
|
||||
|
||||
const response = await axios.get(`${API_URL}/sight/${this.sightId}`);
|
||||
this.stopName = response.data.name;
|
||||
},
|
||||
async fetchArticles() {
|
||||
// Do nothing if sightId is null, zero, or empty
|
||||
if (!this.sightId) return;
|
||||
|
||||
const response = await axios.get(
|
||||
`${API_URL}/sight/${this.sightId}/article`
|
||||
);
|
||||
@ -183,12 +189,9 @@ export default {
|
||||
async fetchWeatherData() {
|
||||
console.log("Fetching weather data...");
|
||||
const now = new Date();
|
||||
const response = await axios.post(
|
||||
"https://weather.wn.krbl.ru/v1/weather",
|
||||
{
|
||||
coordinates: { latitude: 59.938784, longitude: 30.314997 },
|
||||
}
|
||||
);
|
||||
const response = await axios.post(`${WEATHER_URL}/v1/weather`, {
|
||||
coordinates: { latitude: 59.938784, longitude: 30.314997 },
|
||||
});
|
||||
this.currentWeather = {
|
||||
...response.data.currentWeather,
|
||||
temperatureCelsius: Math.round(
|
||||
|
@ -1,2 +1,3 @@
|
||||
export const API_URL = process.env.VUE_APP_API_URL;
|
||||
export const GEO_URL = process.env.VUE_APP_GEO_URL;
|
||||
export const WEATHER_URL = process.env.VUE_APP_WEATHER_URL;
|
||||
|
Loading…
Reference in New Issue
Block a user