lang update, screensavers, map update, bug fixes
All checks were successful
release-tag / release-image (push) Successful in 47s

This commit is contained in:
Иван Антонович Козлов 2025-05-29 04:06:50 +03:00
parent 93ef656f7b
commit 43400bb933
4 changed files with 950 additions and 140 deletions

View File

@ -72,6 +72,7 @@ body {
}
.container {
max-height: calc(100vh - 100px);
margin: auto 25px 25px 25px;
/* height: 100%; */
/* background: #806c58; */
@ -114,8 +115,11 @@ 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 {
@ -125,7 +129,7 @@ body {
width: 450px;
margin: 0 25px;
color: #fff;
font-size: 18px;
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
@ -134,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;
@ -168,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 {
@ -521,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;
@ -595,10 +658,6 @@ body {
overflow-y: auto;
}
.carrierinfo .bg.hidden + .carrier-toggle {
left: 10px;
}
.bg.hidden {
transform: translateX(-100%);
transition: transform 0.3s ease;
@ -670,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 {
@ -687,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;
@ -771,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 {

View File

@ -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"
@ -666,7 +663,7 @@
</li>
</ul>
<button class="stop-button white" @click="toggleList">
Остановки
{{ t("stops") }}
</button>
</div>
@ -690,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>
@ -700,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
@ -730,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"
/>
<!-- singlebutton 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>
@ -797,6 +907,22 @@ export default {
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&nbsp;Petersburg",
zh: "圣彼得堡政府支持",
},
hashtag: { ru: "#ВсемПоПути", en: "#OnOurWay", zh: "#同路人" },
},
};
},
computed: {
@ -819,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:",
@ -838,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);
}
}
@ -872,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;
@ -882,12 +1015,16 @@ 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, transfers: {} };
})
@ -927,13 +1064,15 @@ export default {
this.sightTransfers = {};
try {
const stationsRes = await fetch(
`${API_URL}/route/${this.routeId}/station`
this.addLangParam(`${API_URL}/route/${this.routeId}/station`)
);
const stationsData = await stationsRes.json();
for (const station of stationsData) {
try {
const res = await fetch(`${API_URL}/station/${station.id}/sight`);
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) {
@ -983,28 +1122,38 @@ 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 = "";
@ -1075,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() {
@ -1083,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");
@ -1142,4 +1347,34 @@ export default {
white-space: pre;
font-size: 16px;
}
/* slidefade 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>

View File

@ -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 {
@ -41,12 +58,21 @@ export default {
cumulativeDistances: [],
totalLength: 0,
sightPollTimer: null,
sightsData: [],
nearestSightId: null,
lastActivityTime: Date.now(),
videoOverlayVisible: false,
currentVideoSrc: null,
lastVideoPreviewId: null,
inactivityInterval: null,
};
},
async mounted() {
this.initializeMap();
await this.fetchContext(); // obtain current routeId
this.startTracking();
this.setupActivityListeners();
this.startInactivityCheck();
},
methods: {
async fetchSights() {
@ -57,6 +83,7 @@ export default {
console.log("Данные достопримечательностей:", data);
if (Array.isArray(data)) {
this.sightsData = data;
const grouped = {};
data.forEach((sight) => {
@ -131,12 +158,86 @@ export default {
}
},
// userinactivity & 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();
@ -206,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,
@ -225,7 +322,7 @@ export default {
this.map.getCenter()
);
} else {
this.map.fitBounds(polyline.getBounds());
this.map.fitBounds(routeBounds);
}
this.fetchStations();
}
@ -377,6 +474,7 @@ export default {
"top-left": tramTopLeft,
"bottom-left": tramBottomLeft,
};
const svgSrc = svgMap[direction] || tramRight;
const arrowTransform = arrowTransforms[direction] || "";
@ -406,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>
`,
@ -431,6 +529,11 @@ export default {
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);
}
@ -449,6 +552,11 @@ export default {
}
// console.log("Текущие координаты трамвая:", data.currentCoordinates);
const ctxNearest =
data.nearestSightId ||
(data.routeProgress && data.routeProgress.nearestSightId);
if (ctxNearest) this.nearestSightId = ctxNearest;
const { percentageCompleted } = data.routeProgress;
if (this.totalLength === 0) return;
@ -483,23 +591,26 @@ export default {
passedCoords.push(tramLatLng);
const fullCoords = [tramLatLng, ...this.routeLatlngs.slice(segIdx + 1)];
// Удаляем старые линии, если есть
if (this.passedPolyline) this.map.removeLayer(this.passedPolyline);
if (this.fullPolyline) this.map.removeLayer(this.fullPolyline);
// Update / create progress polylines without flicker
if (!this.passedPolyline) {
this.passedPolyline = L.polyline(passedCoords, {
color: "red",
weight: 7,
pane: "routePane",
}).addTo(this.map);
} else {
this.passedPolyline.setLatLngs(passedCoords);
}
// Пройденная часть (красная)
this.passedPolyline = L.polyline(passedCoords, {
color: "red",
weight: 7,
pane: "routePane",
}).addTo(this.map);
// Оставшаяся часть (белая)
this.fullPolyline = L.polyline(fullCoords, {
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);
@ -573,6 +684,10 @@ export default {
clearInterval(this.sightPollTimer);
this.sightPollTimer = null;
}
this.stopInactivityCheck();
["mousemove", "mousedown", "keydown", "touchstart", "scroll"].forEach(
(evt) => window.removeEventListener(evt, this.resetInactivity)
);
},
};
</script>

View File

@ -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"),
@ -205,6 +288,28 @@ export default {
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: {
@ -247,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) {
@ -270,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(
@ -293,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) {
@ -321,11 +486,15 @@ export default {
return;
}
try {
const sightsRes = await axios.get(`${API_URL}/route/${routeId}/sight`);
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(`${API_URL}/sight/${sight.id}`);
const detailRes = await axios.get(
this.addLangParam(`${API_URL}/sight/${sight.id}`)
);
const thumbnailUrl = detailRes.data.thumbnail
? await this.getMediaBlobUrl(detailRes.data.thumbnail)
: "";
@ -341,27 +510,135 @@ export default {
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() {
@ -378,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();
@ -391,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 = "";
@ -412,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",
}
@ -424,12 +705,14 @@ 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`);
const response = await axios.get(
this.addLangParam(`${GEO_URL}/v1/geolocation/context`)
);
this.routeProgress = response.data.routeProgress;
const newRouteId = response.data.routeId;
if (newRouteId && newRouteId !== this.routeId) {
@ -437,7 +720,7 @@ export default {
}
if (this.routeId) {
const stopsResponse = await axios.get(
`${API_URL}/route/${this.routeId}/station`
this.addLangParam(`${API_URL}/route/${this.routeId}/station`)
);
this.stops = stopsResponse.data;
} else {
@ -537,6 +820,7 @@ export default {
window.addEventListener("click", this.handleUserActivity, true);
// this.fetchSightInfo();
// this.fetchArticles();
this.resetLangRevertTimer();
},
unmounted() {
if (this.sightsInactivityTimer) {
@ -557,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>