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 { | ||||
|   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,13 +727,125 @@ | ||||
|         /> | ||||
|       </svg> | ||||
|     </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> | ||||
|   <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> | ||||
|   <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"> | ||||
|         <img :src="selectedSightImage" v-if="selectedSightImage" /> | ||||
|         <img | ||||
| @@ -753,6 +862,7 @@ | ||||
|       <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); | ||||
|         } | ||||
|  | ||||
|         // Оставшаяся часть (белая) | ||||
|         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); | ||||
|       } | ||||
|     }, | ||||
|     toggleSightsList() { | ||||
|       this.showSightsList = !this.showSightsList; | ||||
|       if (this.showSightsList) { | ||||
|     async openSightCardDetails(id) { | ||||
|       // закрыть, если нажали повторно | ||||
|       if (this.selectedSightCardId === id) { | ||||
|         this.selectedSightCardId = null; | ||||
|         this.cardDetail = null; | ||||
|         return; | ||||
|       } | ||||
|       this.selectedSightCardId = id; | ||||
|       this.cardDetail = null; | ||||
|       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); | ||||
|           this.sightsInactivityTimer = null; | ||||
|         } | ||||
|       } else { | ||||
|         // список открыт — запускаем таймер неактивности | ||||
|         this.resetSightsInactivityTimer(); | ||||
|       } | ||||
|     }, | ||||
|     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> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user