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:
		| @@ -72,6 +72,7 @@ body { | |||||||
| } | } | ||||||
|  |  | ||||||
| .container { | .container { | ||||||
|  |   max-height: calc(100vh - 100px); | ||||||
|   margin: auto 25px 25px 25px; |   margin: auto 25px 25px 25px; | ||||||
|   /* height: 100%; */ |   /* height: 100%; */ | ||||||
|   /* background: #806c58; */ |   /* background: #806c58; */ | ||||||
| @@ -114,8 +115,11 @@ body { | |||||||
| .stopdescription { | .stopdescription { | ||||||
|   font-size: 16px; |   font-size: 16px; | ||||||
|   font-weight: 400; |   font-weight: 400; | ||||||
|   padding: 0 15px 15px 15px; |   padding: 0 15px 0 15px; | ||||||
|   color: #fff; |   color: #fff; | ||||||
|  |   overflow-y: auto; | ||||||
|  |   margin-bottom: 15px; | ||||||
|  |   max-height: calc(100% - 430px); | ||||||
| } | } | ||||||
|  |  | ||||||
| .landmarks { | .landmarks { | ||||||
| @@ -125,7 +129,7 @@ body { | |||||||
|   width: 450px; |   width: 450px; | ||||||
|   margin: 0 25px; |   margin: 0 25px; | ||||||
|   color: #fff; |   color: #fff; | ||||||
|   font-size: 18px; |   font-size: 16px; | ||||||
|   font-weight: 600; |   font-weight: 600; | ||||||
|   display: flex; |   display: flex; | ||||||
|   align-items: center; |   align-items: center; | ||||||
| @@ -134,17 +138,56 @@ body { | |||||||
|   position: relative; |   position: relative; | ||||||
| } | } | ||||||
|  |  | ||||||
| .landmarks-arrow { | /* .landmarks-arrow { | ||||||
|   position: absolute; |   position: absolute; | ||||||
|   left: 25px; |   left: 25px; | ||||||
|   top: 18px; |   top: 18px; | ||||||
| } | } */ | ||||||
|  |  | ||||||
| .landmarks-container { | .landmarks-container { | ||||||
|   position: absolute; |   position: absolute; | ||||||
|   bottom: 0; |   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 { | .stoparticles { | ||||||
|   padding: 0 15px; |   padding: 0 15px; | ||||||
|   height: 50px; |   height: 50px; | ||||||
| @@ -168,6 +211,12 @@ body { | |||||||
| .stoparticle-option { | .stoparticle-option { | ||||||
|   color: #fff; |   color: #fff; | ||||||
|   font-size: 18px; |   font-size: 18px; | ||||||
|  |   width: 100%; | ||||||
|  |   height: 100%; | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: center; | ||||||
|  |   cursor: pointer; | ||||||
| } | } | ||||||
|  |  | ||||||
| .description-button { | .description-button { | ||||||
| @@ -521,23 +570,37 @@ body { | |||||||
| .carrier-toggle { | .carrier-toggle { | ||||||
|   position: absolute; |   position: absolute; | ||||||
|   bottom: 10px; |   bottom: 10px; | ||||||
|   left: 310px; |  | ||||||
|   background: rgba(0, 0, 0, 0); |   background: rgba(0, 0, 0, 0); | ||||||
|   border: none; |   border: none; | ||||||
|   color: #fff; |   color: #fff; | ||||||
|   font-size: 24px; |   font-size: 24px; | ||||||
|   cursor: pointer; |   cursor: pointer; | ||||||
|   padding: 5px 10px; |   padding: 5px 0; | ||||||
|   border-radius: 5px; |   border-radius: 5px; | ||||||
|   z-index: 1001; |   z-index: 1001; | ||||||
|  |   left: var(--panel-offset, 310px); | ||||||
|   transition: left 0.3s ease; |   transition: left 0.3s ease; | ||||||
|   pointer-events: auto; |   pointer-events: auto; | ||||||
| } | } | ||||||
|  |  | ||||||
| .carrierinfo .bg.hidden + .carrier-toggle { | .lang-toggle { | ||||||
|   left: 10px; |   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 { | .bg.hidden { | ||||||
|   transform: translateX(-100%); |   transform: translateX(-100%); | ||||||
|   transition: transform 0.3s ease; |   transition: transform 0.3s ease; | ||||||
| @@ -595,10 +658,6 @@ body { | |||||||
|   overflow-y: auto; |   overflow-y: auto; | ||||||
| } | } | ||||||
|  |  | ||||||
| .carrierinfo .bg.hidden + .carrier-toggle { |  | ||||||
|   left: 10px; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .bg.hidden { | .bg.hidden { | ||||||
|   transform: translateX(-100%); |   transform: translateX(-100%); | ||||||
|   transition: transform 0.3s ease; |   transition: transform 0.3s ease; | ||||||
| @@ -670,7 +729,13 @@ body { | |||||||
|   ); |   ); | ||||||
|   border-radius: 10px; |   border-radius: 10px; | ||||||
|   color: white; |   color: white; | ||||||
|   padding-bottom: 20px; | } | ||||||
|  |  | ||||||
|  | .sight-preview-panel.left-panel { | ||||||
|  |   right: 490px; | ||||||
|  |   left: auto; | ||||||
|  |   top: auto; | ||||||
|  |   bottom: 200px; | ||||||
| } | } | ||||||
|  |  | ||||||
| .sight-preview-panel img { | .sight-preview-panel img { | ||||||
| @@ -687,7 +752,7 @@ body { | |||||||
|  |  | ||||||
| .sight-preview-panel p { | .sight-preview-panel p { | ||||||
|   font-size: 16px; |   font-size: 16px; | ||||||
|   margin: 0; |   margin: 0 0 20px 0; | ||||||
|   padding: 10px 10px 0 10px; |   padding: 10px 10px 0 10px; | ||||||
|   max-height: 150px; |   max-height: 150px; | ||||||
|   overflow-y: auto; |   overflow-y: auto; | ||||||
| @@ -771,13 +836,14 @@ li.checked { | |||||||
| } | } | ||||||
|  |  | ||||||
| .sight-card { | .sight-card { | ||||||
|   width: 30%; |   /* width: 30%; */ | ||||||
|   min-width: 100px; |   min-width: 100px; | ||||||
|   text-align: center; |   text-align: center; | ||||||
|   color: white; |   color: white; | ||||||
|   display: flex; |   display: flex; | ||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
|   align-items: center; |   align-items: center; | ||||||
|  |   justify-content: end; | ||||||
| } | } | ||||||
|  |  | ||||||
| .sight-thumbnail { | .sight-thumbnail { | ||||||
|   | |||||||
| @@ -589,10 +589,7 @@ | |||||||
|             clip-rule="evenodd" |             clip-rule="evenodd" | ||||||
|           /> |           /> | ||||||
|         </svg> |         </svg> | ||||||
|         <span class="gos-name" |         <span class="gos-name" v-html="t('govt_support')"></span> | ||||||
|           >При поддержке Правительства <br /> |  | ||||||
|           Санкт-Петербурга</span |  | ||||||
|         > |  | ||||||
|       </div> |       </div> | ||||||
|  |  | ||||||
|       <br /> |       <br /> | ||||||
| @@ -602,13 +599,13 @@ | |||||||
|         class="stop-button yellow" |         class="stop-button yellow" | ||||||
|         @click="toggleGovernorAppeal" |         @click="toggleGovernorAppeal" | ||||||
|       > |       > | ||||||
|         {{ governorAppealTitle || "Обращение" }} |         {{ governorAppealTitle || t("appeal") }} | ||||||
|       </button> |       </button> | ||||||
|  |  | ||||||
|       <div ref="dropdownWrapper" class="dropdown-wrapper"> |       <div ref="dropdownWrapper" class="dropdown-wrapper"> | ||||||
|         <div class="stop-buttons-container"> |         <div class="stop-buttons-container"> | ||||||
|           <button class="stop-button white" @click="toggleSights"> |           <button class="stop-button white" @click="toggleSights"> | ||||||
|             Достопримечательности |             {{ t("sights") }} | ||||||
|           </button> |           </button> | ||||||
|  |  | ||||||
|           <ul |           <ul | ||||||
| @@ -635,7 +632,7 @@ | |||||||
|                 /> |                 /> | ||||||
|               </svg> |               </svg> | ||||||
|  |  | ||||||
|               Достопримечательности |               {{ t("sights") }} | ||||||
|             </div> |             </div> | ||||||
|             <li |             <li | ||||||
|               v-for="sight in sights" |               v-for="sight in sights" | ||||||
| @@ -666,7 +663,7 @@ | |||||||
|             </li> |             </li> | ||||||
|           </ul> |           </ul> | ||||||
|           <button class="stop-button white" @click="toggleList"> |           <button class="stop-button white" @click="toggleList"> | ||||||
|             Остановки |             {{ t("stops") }} | ||||||
|           </button> |           </button> | ||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
| @@ -690,7 +687,7 @@ | |||||||
|                 style="mix-blend-mode: soft-light" |                 style="mix-blend-mode: soft-light" | ||||||
|               /> |               /> | ||||||
|             </svg> |             </svg> | ||||||
|             Остановки |             {{ t("stops") }} | ||||||
|           </div> |           </div> | ||||||
|           <li v-for="station in stations" :key="station.id"> |           <li v-for="station in stations" :key="station.id"> | ||||||
|             <div class="sight-name">{{ station.name }}</div> |             <div class="sight-name">{{ station.name }}</div> | ||||||
| @@ -700,7 +697,7 @@ | |||||||
|  |  | ||||||
|       <img class="carrier-img" src="../assets/img/get_new.svg" /> |       <img class="carrier-img" src="../assets/img/get_new.svg" /> | ||||||
|  |  | ||||||
|       <span class="hashtag">#ВсемПоПути</span> |       <span class="hashtag">{{ t("hashtag") }}</span> | ||||||
|     </div> |     </div> | ||||||
|     <button class="carrier-toggle" @click="toggleCarrierInfo"> |     <button class="carrier-toggle" @click="toggleCarrierInfo"> | ||||||
|       <svg |       <svg | ||||||
| @@ -730,13 +727,125 @@ | |||||||
|         /> |         /> | ||||||
|       </svg> |       </svg> | ||||||
|     </button> |     </button> | ||||||
|  |     <!-- 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> |     </div> | ||||||
|  |   </div> | ||||||
|  |   <transition name="slide-fade"> | ||||||
|     <div v-if="showGovernorAppeal" class="governor-appeal sight-preview-panel"> |     <div v-if="showGovernorAppeal" class="governor-appeal sight-preview-panel"> | ||||||
|       <img :src="governorAppealImage" v-if="governorAppealImage" /> |       <img :src="governorAppealImage" v-if="governorAppealImage" /> | ||||||
|       <h3>{{ governorAppealTitle }}</h3> |       <h3>{{ governorAppealTitle }}</h3> | ||||||
|       <p>{{ governorAppealText }}</p> |       <p>{{ governorAppealText }}</p> | ||||||
|     </div> |     </div> | ||||||
|   <div v-if="showSightPreview" class="sight-preview-panel"> |   </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"> |       <div class="sight-preview-wrapper" style="position: relative"> | ||||||
|         <img :src="selectedSightImage" v-if="selectedSightImage" /> |         <img :src="selectedSightImage" v-if="selectedSightImage" /> | ||||||
|         <img |         <img | ||||||
| @@ -753,6 +862,7 @@ | |||||||
|       <h3>{{ selectedSightName }}</h3> |       <h3>{{ selectedSightName }}</h3> | ||||||
|       <p>{{ selectedSightText }}</p> |       <p>{{ selectedSightText }}</p> | ||||||
|     </div> |     </div> | ||||||
|  |   </transition> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script> | <script> | ||||||
| @@ -797,6 +907,22 @@ export default { | |||||||
|         metro_purple: require("@/icons/metro_purple.svg"), |         metro_purple: require("@/icons/metro_purple.svg"), | ||||||
|         metro_orange: require("@/icons/metro_orange.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: { |   computed: { | ||||||
| @@ -819,16 +945,47 @@ export default { | |||||||
|       ); |       ); | ||||||
|       return matches ? decodeURIComponent(matches[1]) : undefined; |       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() { |     async fetchStops() { | ||||||
|       try { |       try { | ||||||
|         const geoResponse = await fetch(`${GEO_URL}/v1/geolocation/context`); |         const geoResponse = await fetch(`${GEO_URL}/v1/geolocation/context`); | ||||||
|         const geoData = await geoResponse.json(); |         const geoData = await geoResponse.json(); | ||||||
|         const routeDetailsRes = await fetch( |         const routeDetailsRes = await fetch( | ||||||
|           `${API_URL}/route/${geoData.routeId}` |           this.addLangParam(`${API_URL}/route/${geoData.routeId}`) | ||||||
|         ); |         ); | ||||||
|         const routeDetails = await routeDetailsRes.json(); |         const routeDetails = await routeDetailsRes.json(); | ||||||
|         const appealId = routeDetails.governor_appeal; |         const appealId = routeDetails.governor_appeal; | ||||||
|         this.governorAppealId = appealId; |  | ||||||
|  |  | ||||||
|         console.log( |         console.log( | ||||||
|           "Получено значение governor_appeal:", |           "Получено значение governor_appeal:", | ||||||
| @@ -838,33 +995,9 @@ export default { | |||||||
|         ); |         ); | ||||||
|  |  | ||||||
|         if (appealId && appealId !== 0) { |         if (appealId && appealId !== 0) { | ||||||
|           try { |           if (this.governorAppealId !== appealId) { | ||||||
|             const articleRes = await fetch(`${API_URL}/article/${appealId}`); |             this.governorAppealId = appealId; | ||||||
|             const articleData = await articleRes.json(); |             await this.loadGovernorAppeal(appealId); | ||||||
|             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); |  | ||||||
|           } |           } | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -872,7 +1005,7 @@ export default { | |||||||
|         if (!this.routeId) throw new Error("Route number not found"); |         if (!this.routeId) throw new Error("Route number not found"); | ||||||
|  |  | ||||||
|         const stopsResponse = await fetch( |         const stopsResponse = await fetch( | ||||||
|           `${API_URL}/route/${this.routeId}/station` |           this.addLangParam(`${API_URL}/route/${this.routeId}/station`) | ||||||
|         ); |         ); | ||||||
|         const stopsData = await stopsResponse.json(); |         const stopsData = await stopsResponse.json(); | ||||||
|         this.stations = stopsData; |         this.stations = stopsData; | ||||||
| @@ -882,12 +1015,16 @@ export default { | |||||||
|     }, |     }, | ||||||
|     async fetchSights() { |     async fetchSights() { | ||||||
|       try { |       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 rawSights = await response.json(); | ||||||
|  |  | ||||||
|         const detailedSights = await Promise.all( |         const detailedSights = await Promise.all( | ||||||
|           rawSights.map(async (sight) => { |           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(); |             const detail = await detailRes.json(); | ||||||
|             return { id: sight.id, name: detail.name, transfers: {} }; |             return { id: sight.id, name: detail.name, transfers: {} }; | ||||||
|           }) |           }) | ||||||
| @@ -927,13 +1064,15 @@ export default { | |||||||
|       this.sightTransfers = {}; |       this.sightTransfers = {}; | ||||||
|       try { |       try { | ||||||
|         const stationsRes = await fetch( |         const stationsRes = await fetch( | ||||||
|           `${API_URL}/route/${this.routeId}/station` |           this.addLangParam(`${API_URL}/route/${this.routeId}/station`) | ||||||
|         ); |         ); | ||||||
|         const stationsData = await stationsRes.json(); |         const stationsData = await stationsRes.json(); | ||||||
|  |  | ||||||
|         for (const station of stationsData) { |         for (const station of stationsData) { | ||||||
|           try { |           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 stationSights = await res.json(); | ||||||
|             const hasSight = stationSights.some((s) => s.id === sightId); |             const hasSight = stationSights.some((s) => s.id === sightId); | ||||||
|             if (hasSight) { |             if (hasSight) { | ||||||
| @@ -983,28 +1122,38 @@ export default { | |||||||
|     async selectSight(sightId) { |     async selectSight(sightId) { | ||||||
|       this.selectedSightId = sightId; |       this.selectedSightId = sightId; | ||||||
|       try { |       try { | ||||||
|         const sightRes = await fetch(`${API_URL}/sight/${sightId}`); |         const sightRes = await fetch( | ||||||
|  |           this.addLangParam(`${API_URL}/sight/${sightId}`) | ||||||
|  |         ); | ||||||
|         const sightData = await sightRes.json(); |         const sightData = await sightRes.json(); | ||||||
|         this.selectedSightName = sightData.name; |         this.selectedSightName = sightData.name; | ||||||
|  |  | ||||||
|         const articleId = sightData.left_article; |         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(); |         const articleData = await articleRes.json(); | ||||||
|         this.selectedSightText = articleData.body; |         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(); |         const mediaData = await mediaRes.json(); | ||||||
|         if (mediaData.length) { |         if (mediaData.length) { | ||||||
|           const imageRes = await fetch( |           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.selectedSightImage = await imageRes.url; | ||||||
|           this.selectedSightWatermarkLU = sightData.watermark_lu |           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 |           this.selectedSightWatermarkRD = sightData.watermark_rd | ||||||
|             ? `${API_URL}/media/${sightData.watermark_rd}/download` |             ? this.addLangParam( | ||||||
|  |                 `${API_URL}/media/${sightData.watermark_rd}/download` | ||||||
|  |               ) | ||||||
|             : ""; |             : ""; | ||||||
|         } else { |         } else { | ||||||
|           this.selectedSightImage = ""; |           this.selectedSightImage = ""; | ||||||
| @@ -1075,6 +1224,61 @@ export default { | |||||||
|     }, |     }, | ||||||
|     handleUserActivity() { |     handleUserActivity() { | ||||||
|       this.resetInactivityTimer(); |       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() { |   mounted() { | ||||||
| @@ -1083,6 +1287,7 @@ export default { | |||||||
|       document.addEventListener(evt, this.handleUserActivity) |       document.addEventListener(evt, this.handleUserActivity) | ||||||
|     ); |     ); | ||||||
|     this.resetInactivityTimer(); |     this.resetInactivityTimer(); | ||||||
|  |     this.resetLangRevertTimer(); | ||||||
|     const root = document.documentElement; |     const root = document.documentElement; | ||||||
|     root.style.setProperty("--panel-offset", "20px"); |     root.style.setProperty("--panel-offset", "20px"); | ||||||
|     const routeInfo = document.querySelector(".routeinfo"); |     const routeInfo = document.querySelector(".routeinfo"); | ||||||
| @@ -1142,4 +1347,34 @@ export default { | |||||||
|   white-space: pre; |   white-space: pre; | ||||||
|   font-size: 16px; |   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> | </style> | ||||||
|   | |||||||
| @@ -1,5 +1,22 @@ | |||||||
| <template> | <template> | ||||||
|  |   <!-- map container --> | ||||||
|   <div id="map" style="height: 100vh; background-color: transparent"></div> |   <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> | </template> | ||||||
|  |  | ||||||
| <script> | <script> | ||||||
| @@ -18,10 +35,10 @@ import { API_URL, GEO_URL } from "../config"; | |||||||
| const arrowTransforms = { | const arrowTransforms = { | ||||||
|   right: "", |   right: "", | ||||||
|   left: "", |   left: "", | ||||||
|   "top-right": "", |   "top-right": "translate(-25%, -50%)", | ||||||
|   "bottom-right": "", |   "bottom-right": "translate(-25%, 50%)", | ||||||
|   "top-left": "", |   "top-left": "translate(-100%, -50%)", | ||||||
|   "bottom-left": "", |   "bottom-left": "translate(-100%, 50%)", | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export default { | export default { | ||||||
| @@ -41,12 +58,21 @@ export default { | |||||||
|       cumulativeDistances: [], |       cumulativeDistances: [], | ||||||
|       totalLength: 0, |       totalLength: 0, | ||||||
|       sightPollTimer: null, |       sightPollTimer: null, | ||||||
|  |       sightsData: [], | ||||||
|  |       nearestSightId: null, | ||||||
|  |       lastActivityTime: Date.now(), | ||||||
|  |       videoOverlayVisible: false, | ||||||
|  |       currentVideoSrc: null, | ||||||
|  |       lastVideoPreviewId: null, | ||||||
|  |       inactivityInterval: null, | ||||||
|     }; |     }; | ||||||
|   }, |   }, | ||||||
|   async mounted() { |   async mounted() { | ||||||
|     this.initializeMap(); |     this.initializeMap(); | ||||||
|     await this.fetchContext(); // obtain current routeId |     await this.fetchContext(); // obtain current routeId | ||||||
|     this.startTracking(); |     this.startTracking(); | ||||||
|  |     this.setupActivityListeners(); | ||||||
|  |     this.startInactivityCheck(); | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|     async fetchSights() { |     async fetchSights() { | ||||||
| @@ -57,6 +83,7 @@ export default { | |||||||
|         console.log("Данные достопримечательностей:", data); |         console.log("Данные достопримечательностей:", data); | ||||||
|  |  | ||||||
|         if (Array.isArray(data)) { |         if (Array.isArray(data)) { | ||||||
|  |           this.sightsData = data; | ||||||
|           const grouped = {}; |           const grouped = {}; | ||||||
|  |  | ||||||
|           data.forEach((sight) => { |           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() { |     initializeMap() { | ||||||
|       this.map = L.map("map", { |       this.map = L.map("map", { | ||||||
|         zoomControl: false, |         zoomControl: false, | ||||||
|         attributionControl: false, |         attributionControl: false, | ||||||
|         minZoom: 12, |         maxZoom: 14, // default max zoom | ||||||
|         maxZoom: 14, |         minZoom: 12, // default min zoom | ||||||
|  |         zoomSnap: 0, // allow fractional zoom levels | ||||||
|  |         zoomDelta: 0.5, // smoother wheel steps | ||||||
|       }); |       }); | ||||||
|       this.map.whenReady(() => { |       this.map.whenReady(() => { | ||||||
|         const mapPane = this.map.getPane("mapPane") || this.map.getContainer(); |         const mapPane = this.map.getPane("mapPane") || this.map.getContainer(); | ||||||
| @@ -206,11 +307,7 @@ export default { | |||||||
|               } |               } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             const polyline = L.polyline(this.routeLatlngs, { |             const routeBounds = L.latLngBounds(this.routeLatlngs); | ||||||
|               color: "red", |  | ||||||
|               weight: 7, |  | ||||||
|               pane: "routePane", |  | ||||||
|             }).addTo(this.map); |  | ||||||
|             if (data.center_latitude && data.center_longitude) { |             if (data.center_latitude && data.center_longitude) { | ||||||
|               console.log("Установка центра карты:", { |               console.log("Установка центра карты:", { | ||||||
|                 latitude: data.center_latitude, |                 latitude: data.center_latitude, | ||||||
| @@ -225,7 +322,7 @@ export default { | |||||||
|                 this.map.getCenter() |                 this.map.getCenter() | ||||||
|               ); |               ); | ||||||
|             } else { |             } else { | ||||||
|               this.map.fitBounds(polyline.getBounds()); |               this.map.fitBounds(routeBounds); | ||||||
|             } |             } | ||||||
|             this.fetchStations(); |             this.fetchStations(); | ||||||
|           } |           } | ||||||
| @@ -377,6 +474,7 @@ export default { | |||||||
|         "top-left": tramTopLeft, |         "top-left": tramTopLeft, | ||||||
|         "bottom-left": tramBottomLeft, |         "bottom-left": tramBottomLeft, | ||||||
|       }; |       }; | ||||||
|  |  | ||||||
|       const svgSrc = svgMap[direction] || tramRight; |       const svgSrc = svgMap[direction] || tramRight; | ||||||
|  |  | ||||||
|       const arrowTransform = arrowTransforms[direction] || ""; |       const arrowTransform = arrowTransforms[direction] || ""; | ||||||
| @@ -406,7 +504,7 @@ export default { | |||||||
|               background: yellow; |               background: yellow; | ||||||
|               border: 5px solid black; |               border: 5px solid black; | ||||||
|               border-radius: 50%; |               border-radius: 50%; | ||||||
|               transform: translateY(-50%); |               transform: translate(5px, -50%); | ||||||
|               z-index: 2;"></div> |               z-index: 2;"></div> | ||||||
|           </div> |           </div> | ||||||
|         `, |         `, | ||||||
| @@ -431,6 +529,11 @@ export default { | |||||||
|         if (ctxRouteId && ctxRouteId !== this.routeId) { |         if (ctxRouteId && ctxRouteId !== this.routeId) { | ||||||
|           this.routeId = ctxRouteId; |           this.routeId = ctxRouteId; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         const ctxNearest = | ||||||
|  |           data.nearestSightId || | ||||||
|  |           (data.routeProgress && data.routeProgress.nearestSightId); | ||||||
|  |         if (ctxNearest) this.nearestSightId = ctxNearest; | ||||||
|       } catch (error) { |       } catch (error) { | ||||||
|         console.error("Ошибка при получении контекста геолокации:", error); |         console.error("Ошибка при получении контекста геолокации:", error); | ||||||
|       } |       } | ||||||
| @@ -449,6 +552,11 @@ export default { | |||||||
|         } |         } | ||||||
|         // console.log("Текущие координаты трамвая:", data.currentCoordinates); |         // console.log("Текущие координаты трамвая:", data.currentCoordinates); | ||||||
|  |  | ||||||
|  |         const ctxNearest = | ||||||
|  |           data.nearestSightId || | ||||||
|  |           (data.routeProgress && data.routeProgress.nearestSightId); | ||||||
|  |         if (ctxNearest) this.nearestSightId = ctxNearest; | ||||||
|  |  | ||||||
|         const { percentageCompleted } = data.routeProgress; |         const { percentageCompleted } = data.routeProgress; | ||||||
|  |  | ||||||
|         if (this.totalLength === 0) return; |         if (this.totalLength === 0) return; | ||||||
| @@ -483,23 +591,26 @@ export default { | |||||||
|         passedCoords.push(tramLatLng); |         passedCoords.push(tramLatLng); | ||||||
|         const fullCoords = [tramLatLng, ...this.routeLatlngs.slice(segIdx + 1)]; |         const fullCoords = [tramLatLng, ...this.routeLatlngs.slice(segIdx + 1)]; | ||||||
|  |  | ||||||
|         // Удаляем старые линии, если есть |         // ── Update / create progress polylines without flicker ── | ||||||
|         if (this.passedPolyline) this.map.removeLayer(this.passedPolyline); |         if (!this.passedPolyline) { | ||||||
|         if (this.fullPolyline) this.map.removeLayer(this.fullPolyline); |  | ||||||
|  |  | ||||||
|         // Пройденная часть (красная) |  | ||||||
|           this.passedPolyline = L.polyline(passedCoords, { |           this.passedPolyline = L.polyline(passedCoords, { | ||||||
|             color: "red", |             color: "red", | ||||||
|             weight: 7, |             weight: 7, | ||||||
|             pane: "routePane", |             pane: "routePane", | ||||||
|           }).addTo(this.map); |           }).addTo(this.map); | ||||||
|  |         } else { | ||||||
|  |           this.passedPolyline.setLatLngs(passedCoords); | ||||||
|  |         } | ||||||
|  |  | ||||||
|         // Оставшаяся часть (белая) |         if (!this.fullPolyline) { | ||||||
|           this.fullPolyline = L.polyline(fullCoords, { |           this.fullPolyline = L.polyline(fullCoords, { | ||||||
|             color: "white", |             color: "white", | ||||||
|             weight: 7, |             weight: 7, | ||||||
|             pane: "routePane", |             pane: "routePane", | ||||||
|           }).addTo(this.map); |           }).addTo(this.map); | ||||||
|  |         } else { | ||||||
|  |           this.fullPolyline.setLatLngs(fullCoords); | ||||||
|  |         } | ||||||
|  |  | ||||||
|         if (this.tramMarker && !this.map.hasLayer(this.tramMarker)) { |         if (this.tramMarker && !this.map.hasLayer(this.tramMarker)) { | ||||||
|           this.tramMarker.addTo(this.map); |           this.tramMarker.addTo(this.map); | ||||||
| @@ -573,6 +684,10 @@ export default { | |||||||
|       clearInterval(this.sightPollTimer); |       clearInterval(this.sightPollTimer); | ||||||
|       this.sightPollTimer = null; |       this.sightPollTimer = null; | ||||||
|     } |     } | ||||||
|  |     this.stopInactivityCheck(); | ||||||
|  |     ["mousemove", "mousedown", "keydown", "touchstart", "scroll"].forEach( | ||||||
|  |       (evt) => window.removeEventListener(evt, this.resetInactivity) | ||||||
|  |     ); | ||||||
|   }, |   }, | ||||||
| }; | }; | ||||||
| </script> | </script> | ||||||
|   | |||||||
| @@ -1,6 +1,30 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="stopinfo"> |   <div class="stopinfo"> | ||||||
|     <div class="bg"> |     <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="container"> | ||||||
|         <div class="image-wrapper"> |         <div class="image-wrapper"> | ||||||
|           <!-- @click="openModal" --> |           <!-- @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" |                   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> |               </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> | ||||||
|           </div> |           </div> | ||||||
|           <transition name="slide-fade"> |           <transition name="slide-fade"> | ||||||
| @@ -148,6 +223,10 @@ | |||||||
|                     v-for="sight in group" |                     v-for="sight in group" | ||||||
|                     :key="sight.id" |                     :key="sight.id" | ||||||
|                     class="sight-card" |                     class="sight-card" | ||||||
|  |                     :class="{ | ||||||
|  |                       'selected-card': sight.id === selectedSightCardId, | ||||||
|  |                     }" | ||||||
|  |                     @click.stop="openSightCardDetails(sight.id)" | ||||||
|                   > |                   > | ||||||
|                     <img class="sight-thumbnail" :src="sight.thumbnailUrl" /> |                     <img class="sight-thumbnail" :src="sight.thumbnailUrl" /> | ||||||
|                     <div class="sight-title">{{ sight.name }}</div> |                     <div class="sight-title">{{ sight.name }}</div> | ||||||
| @@ -186,9 +265,13 @@ export default { | |||||||
|       articles: [], |       articles: [], | ||||||
|       selectedArticleId: null, |       selectedArticleId: null, | ||||||
|       selectedArticleBody: "", |       selectedArticleBody: "", | ||||||
|  |       selectedSightArticleId: null, | ||||||
|  |       selectedSightArticleBody: "", | ||||||
|       sights: [], |       sights: [], | ||||||
|       showSightsList: false, |       showSightsList: false, | ||||||
|       selectedLetter: null, |       selectedLetter: null, | ||||||
|  |       selectedSightCardId: null, | ||||||
|  |       cardDetail: null, | ||||||
|       showTransfers: false, |       showTransfers: false, | ||||||
|       transferIcons: { |       transferIcons: { | ||||||
|         tram: require("@/icons/tram.svg"), |         tram: require("@/icons/tram.svg"), | ||||||
| @@ -205,6 +288,28 @@ export default { | |||||||
|       stops: [], |       stops: [], | ||||||
|       routeProgress: null, |       routeProgress: null, | ||||||
|       routeId: 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: { |   computed: { | ||||||
| @@ -247,6 +352,64 @@ export default { | |||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
|   methods: { |   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() { |     openModal() { | ||||||
|       const imageDiv = this.$el.querySelector(".img"); |       const imageDiv = this.$el.querySelector(".img"); | ||||||
|       if (imageDiv) { |       if (imageDiv) { | ||||||
| @@ -270,7 +433,9 @@ export default { | |||||||
|         console.log("No sightId provided for fetchSightInfo"); |         console.log("No sightId provided for fetchSightInfo"); | ||||||
|         return; |         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; |       this.stopName = response.data.name; | ||||||
|       if (response.data.watermark_lu) { |       if (response.data.watermark_lu) { | ||||||
|         this.watermarkLU = await this.getMediaBlobUrl( |         this.watermarkLU = await this.getMediaBlobUrl( | ||||||
| @@ -293,7 +458,7 @@ export default { | |||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|       const response = await axios.get( |       const response = await axios.get( | ||||||
|         `${API_URL}/sight/${this.sightId}/article` |         this.addLangParam(`${API_URL}/sight/${this.sightId}/article`) | ||||||
|       ); |       ); | ||||||
|       this.articles = response.data; |       this.articles = response.data; | ||||||
|       if (this.articles.length > 0) { |       if (this.articles.length > 0) { | ||||||
| @@ -321,11 +486,15 @@ export default { | |||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|       try { |       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 rawSights = sightsRes.data; | ||||||
|         const detailedSights = await Promise.all( |         const detailedSights = await Promise.all( | ||||||
|           rawSights.map(async (sight) => { |           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 |             const thumbnailUrl = detailRes.data.thumbnail | ||||||
|               ? await this.getMediaBlobUrl(detailRes.data.thumbnail) |               ? await this.getMediaBlobUrl(detailRes.data.thumbnail) | ||||||
|               : ""; |               : ""; | ||||||
| @@ -341,27 +510,135 @@ export default { | |||||||
|         console.error("Error fetching sights:", error); |         console.error("Error fetching sights:", error); | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     toggleSightsList() { |     async openSightCardDetails(id) { | ||||||
|       this.showSightsList = !this.showSightsList; |       // закрыть, если нажали повторно | ||||||
|       if (this.showSightsList) { |       if (this.selectedSightCardId === id) { | ||||||
|  |         this.selectedSightCardId = null; | ||||||
|  |         this.cardDetail = null; | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |       this.selectedSightCardId = id; | ||||||
|  |       this.cardDetail = null; | ||||||
|       this.resetSightsInactivityTimer(); |       this.resetSightsInactivityTimer(); | ||||||
|       } else if (this.sightsInactivityTimer) { |  | ||||||
|  |       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) { | ||||||
|  |         this.selectedSightCardId = null; | ||||||
|  |         this.cardDetail = null; | ||||||
|  |         if (this.sightsInactivityTimer) { | ||||||
|           clearTimeout(this.sightsInactivityTimer); |           clearTimeout(this.sightsInactivityTimer); | ||||||
|           this.sightsInactivityTimer = null; |           this.sightsInactivityTimer = null; | ||||||
|         } |         } | ||||||
|  |       } else { | ||||||
|  |         // список открыт — запускаем таймер неактивности | ||||||
|  |         this.resetSightsInactivityTimer(); | ||||||
|  |       } | ||||||
|     }, |     }, | ||||||
|     resetSightsInactivityTimer() { |     resetSightsInactivityTimer() { | ||||||
|       if (this.articleInactivityTimer) { |       if (this.sightsInactivityTimer) clearTimeout(this.sightsInactivityTimer); | ||||||
|         clearTimeout(this.articleInactivityTimer); |  | ||||||
|       } |       // запускаем новый, если открыт список или карточка | ||||||
|       if (this.sightsInactivityTimer) { |       if (this.showSightsList || this.cardDetail) { | ||||||
|         clearTimeout(this.sightsInactivityTimer); |  | ||||||
|       } |  | ||||||
|       if (this.showSightsList) { |  | ||||||
|         this.sightsInactivityTimer = setTimeout(() => { |         this.sightsInactivityTimer = setTimeout(() => { | ||||||
|           this.showSightsList = false; |           this.hideSightUI(); // скрываем всё | ||||||
|           this.sightsInactivityTimer = null; |           this.sightsInactivityTimer = null; | ||||||
|         }, 300000); // 5 m |         }, 300_000); // 5 m | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     resetArticleInactivityTimer() { |     resetArticleInactivityTimer() { | ||||||
| @@ -378,8 +655,10 @@ export default { | |||||||
|       }, 300_000); // 5 m |       }, 300_000); // 5 m | ||||||
|     }, |     }, | ||||||
|     handleUserActivity() { |     handleUserActivity() { | ||||||
|       if (this.showSightsList) this.resetSightsInactivityTimer(); |       if (this.showSightsList || this.cardDetail) | ||||||
|  |         this.resetSightsInactivityTimer(); | ||||||
|       this.resetArticleInactivityTimer(); |       this.resetArticleInactivityTimer(); | ||||||
|  |       this.resetLangRevertTimer(); | ||||||
|     }, |     }, | ||||||
|     selectArticle(id) { |     selectArticle(id) { | ||||||
|       this.resetArticleInactivityTimer(); |       this.resetArticleInactivityTimer(); | ||||||
| @@ -391,14 +670,16 @@ export default { | |||||||
|     async fetchArticleMedia(articleId) { |     async fetchArticleMedia(articleId) { | ||||||
|       try { |       try { | ||||||
|         const response = await axios.get( |         const response = await axios.get( | ||||||
|           `${API_URL}/article/${articleId}/media` |           this.addLangParam(`${API_URL}/article/${articleId}/media`) | ||||||
|         ); |         ); | ||||||
|         if (response.data && response.data.length > 0) { |         if (response.data && response.data.length > 0) { | ||||||
|           const mediaId = response.data[0].id; |           const mediaId = response.data[0].id; | ||||||
|           try { |           try { | ||||||
|             this.imageUrl = await this.getMediaBlobUrl(mediaId); |             this.imageUrl = await this.getMediaBlobUrl(mediaId); | ||||||
|           } catch { |           } catch { | ||||||
|             this.imageUrl = `${API_URL}/media/${mediaId}/download`; |             this.imageUrl = this.addLangParam( | ||||||
|  |               `${API_URL}/media/${mediaId}/download` | ||||||
|  |             ); | ||||||
|           } |           } | ||||||
|         } else { |         } else { | ||||||
|           this.imageUrl = ""; |           this.imageUrl = ""; | ||||||
| @@ -412,7 +693,7 @@ export default { | |||||||
|     async getMediaBlobUrl(mediaId) { |     async getMediaBlobUrl(mediaId) { | ||||||
|       try { |       try { | ||||||
|         const response = await axios.get( |         const response = await axios.get( | ||||||
|           `${API_URL}/media/${mediaId}/download`, |           this.addLangParam(`${API_URL}/media/${mediaId}/download`), | ||||||
|           { |           { | ||||||
|             responseType: "blob", |             responseType: "blob", | ||||||
|           } |           } | ||||||
| @@ -424,12 +705,14 @@ export default { | |||||||
|           error?.response?.status, |           error?.response?.status, | ||||||
|           error?.response?.data || error.message |           error?.response?.data || error.message | ||||||
|         ); |         ); | ||||||
|         return `${API_URL}/media/${mediaId}/download`; |         return this.addLangParam(`${API_URL}/media/${mediaId}/download`); | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     async fetchGeolocationContext() { |     async fetchGeolocationContext() { | ||||||
|       try { |       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; |         this.routeProgress = response.data.routeProgress; | ||||||
|         const newRouteId = response.data.routeId; |         const newRouteId = response.data.routeId; | ||||||
|         if (newRouteId && newRouteId !== this.routeId) { |         if (newRouteId && newRouteId !== this.routeId) { | ||||||
| @@ -437,7 +720,7 @@ export default { | |||||||
|         } |         } | ||||||
|         if (this.routeId) { |         if (this.routeId) { | ||||||
|           const stopsResponse = await axios.get( |           const stopsResponse = await axios.get( | ||||||
|             `${API_URL}/route/${this.routeId}/station` |             this.addLangParam(`${API_URL}/route/${this.routeId}/station`) | ||||||
|           ); |           ); | ||||||
|           this.stops = stopsResponse.data; |           this.stops = stopsResponse.data; | ||||||
|         } else { |         } else { | ||||||
| @@ -537,6 +820,7 @@ export default { | |||||||
|     window.addEventListener("click", this.handleUserActivity, true); |     window.addEventListener("click", this.handleUserActivity, true); | ||||||
|     // this.fetchSightInfo(); |     // this.fetchSightInfo(); | ||||||
|     // this.fetchArticles(); |     // this.fetchArticles(); | ||||||
|  |     this.resetLangRevertTimer(); | ||||||
|   }, |   }, | ||||||
|   unmounted() { |   unmounted() { | ||||||
|     if (this.sightsInactivityTimer) { |     if (this.sightsInactivityTimer) { | ||||||
| @@ -557,3 +841,113 @@ export default { | |||||||
|   }, |   }, | ||||||
| }; | }; | ||||||
| </script> | </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> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user