lang update, screensavers, map update, bug fixes
All checks were successful
release-tag / release-image (push) Successful in 47s
All checks were successful
release-tag / release-image (push) Successful in 47s
This commit is contained in:
parent
93ef656f7b
commit
43400bb933
@ -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 {
|
||||
|
@ -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"
|
||||
/>
|
||||
<!-- 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>
|
||||
@ -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 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;
|
||||
}
|
||||
/* 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 {
|
||||
@ -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 {
|
||||
}
|
||||
},
|
||||
|
||||
// ───────────────── 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();
|
||||
@ -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>
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user