diff --git a/src/assets/style/main.css b/src/assets/style/main.css
index 5c04c1b..4f0fa0d 100644
--- a/src/assets/style/main.css
+++ b/src/assets/style/main.css
@@ -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 {
diff --git a/src/components/carrierinfo.vue b/src/components/carrierinfo.vue
index 74ad041..c337402 100644
--- a/src/components/carrierinfo.vue
+++ b/src/components/carrierinfo.vue
@@ -589,10 +589,7 @@
clip-rule="evenodd"
/>
- При поддержке Правительства
- Санкт-Петербурга
+
@@ -602,13 +599,13 @@
class="stop-button yellow"
@click="toggleGovernorAppeal"
>
- {{ governorAppealTitle || "Обращение" }}
+ {{ governorAppealTitle || t("appeal") }}
@@ -690,7 +687,7 @@
style="mix-blend-mode: soft-light"
/>
- Остановки
+ {{ t("stops") }}
{{ station.name }}
@@ -700,7 +697,7 @@
- #ВсемПоПути
+ {{ t("hashtag") }}
-
-
-
![]()
-
{{ governorAppealTitle }}
-
{{ governorAppealText }}
-
-
-
-
![]()
-
![]()
-
![]()
+
+
+
+
+
+
+
+
+
+
+
+
+
-
{{ selectedSightName }}
-
{{ selectedSightText }}
+
+
+
![]()
+
{{ governorAppealTitle }}
+
{{ governorAppealText }}
+
+
+
+
+
+
{{ selectedSightName }}
+
{{ selectedSightText }}
+
+
diff --git a/src/components/stopinfo.vue b/src/components/stopinfo.vue
index 9d34320..a67847c 100644
--- a/src/components/stopinfo.vue
+++ b/src/components/stopinfo.vue
@@ -1,6 +1,30 @@
+
+
+
+
![]()
+
+
{{ cardDetail.name }}
+
{{ selectedSightArticleBody }}
+
+
+ {{ article.heading }}
+
+
+
+
@@ -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"
/>
-
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -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)"
>
{{ sight.name }}
@@ -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: ``,
+ en: ``,
+ zh: ``,
+ },
+ translations: {
+ sights: { ru: "Достопримечательности", en: "Landmarks", zh: "景点" },
+ // при необходимости — другие подписи
+ },
};
},
computed: {
@@ -247,6 +352,64 @@ export default {
},
},
methods: {
+ t(key) {
+ const dict = this.translations[key] || {};
+ return dict[this.selectedLang] || dict.ru || key;
+ },
+ // показать 3 иконки
+ toggleLanguageOptions() {
+ this.showLangToggle = false;
+ this.showLanguageOptions = true;
+ this.startLanguageOptionsTimer();
+ },
+
+ // таймер 10 с
+ startLanguageOptionsTimer() {
+ clearTimeout(this.languageOptionsTimer);
+ this.languageOptionsTimer = setTimeout(this.hideLanguageOptions, 10_000);
+ },
+
+ // скрыть 3 иконки, вернуть глобус
+ hideLanguageOptions() {
+ this.showLanguageOptions = false;
+ this.showLangToggle = true;
+ },
+
+ // выбор языка
+ setLanguage(lang) {
+ if (this.selectedLang === lang) {
+ this.hideLanguageOptions();
+ this.resetLangRevertTimer();
+ return;
+ }
+ this.selectedLang = lang;
+ localStorage.setItem("selectedLangRight", lang);
+ this.hideLanguageOptions();
+ this.resetLangRevertTimer();
+
+ // перезагрузить данные на новом языке
+ this.fetchSightInfo();
+ this.fetchArticles();
+ this.fetchSights();
+ },
+
+ // добавить ?lang=… к URL-у, если язык не ru
+ addLangParam(url) {
+ if (this.selectedLang !== "ru") {
+ return (
+ url + (url.includes("?") ? "&" : "?") + "lang=" + this.selectedLang
+ );
+ }
+ return url;
+ },
+
+ // таймер 5 минут с возврата на ru
+ resetLangRevertTimer() {
+ clearTimeout(this.langRevertTimer);
+ this.langRevertTimer = setTimeout(() => {
+ if (this.selectedLang !== "ru") this.setLanguage("ru");
+ }, 300_000);
+ },
openModal() {
const imageDiv = this.$el.querySelector(".img");
if (imageDiv) {
@@ -270,7 +433,9 @@ export default {
console.log("No sightId provided for fetchSightInfo");
return;
}
- const response = await axios.get(`${API_URL}/sight/${this.sightId}`);
+ const response = await axios.get(
+ this.addLangParam(`${API_URL}/sight/${this.sightId}`)
+ );
this.stopName = response.data.name;
if (response.data.watermark_lu) {
this.watermarkLU = await this.getMediaBlobUrl(
@@ -293,7 +458,7 @@ export default {
return;
}
const response = await axios.get(
- `${API_URL}/sight/${this.sightId}/article`
+ this.addLangParam(`${API_URL}/sight/${this.sightId}/article`)
);
this.articles = response.data;
if (this.articles.length > 0) {
@@ -321,11 +486,15 @@ export default {
return;
}
try {
- const sightsRes = await axios.get(`${API_URL}/route/${routeId}/sight`);
+ const sightsRes = await axios.get(
+ this.addLangParam(`${API_URL}/route/${routeId}/sight`)
+ );
const rawSights = sightsRes.data;
const detailedSights = await Promise.all(
rawSights.map(async (sight) => {
- const detailRes = await axios.get(`${API_URL}/sight/${sight.id}`);
+ const detailRes = await axios.get(
+ this.addLangParam(`${API_URL}/sight/${sight.id}`)
+ );
const thumbnailUrl = detailRes.data.thumbnail
? await this.getMediaBlobUrl(detailRes.data.thumbnail)
: "";
@@ -341,27 +510,135 @@ export default {
console.error("Error fetching sights:", error);
}
},
+ async openSightCardDetails(id) {
+ // закрыть, если нажали повторно
+ if (this.selectedSightCardId === id) {
+ this.selectedSightCardId = null;
+ this.cardDetail = null;
+ return;
+ }
+ this.selectedSightCardId = id;
+ this.cardDetail = null;
+ this.resetSightsInactivityTimer();
+
+ try {
+ const [detailRes, articlesRes] = await Promise.all([
+ axios.get(this.addLangParam(`${API_URL}/sight/${id}`)),
+ axios.get(this.addLangParam(`${API_URL}/sight/${id}/article`)),
+ ]);
+
+ // Fetch media for every article
+ const articlesWithMedia = await Promise.all(
+ articlesRes.data.map(async (article) => {
+ try {
+ const mediaRes = await axios.get(
+ this.addLangParam(`${API_URL}/article/${article.id}/media`)
+ );
+ return { ...article, media: mediaRes.data };
+ } catch (mediaErr) {
+ console.error(
+ `Failed to fetch media for article ${article.id}:`,
+ mediaErr
+ );
+ return { ...article, media: [] };
+ }
+ })
+ );
+
+ // Console output: sight name, articles, and their media
+ console.log("Sight clicked:", {
+ name: detailRes.data.name,
+ articles: articlesWithMedia,
+ });
+
+ // Choose preview image: prefer the first article's first media, otherwise fall back to sight thumbnail
+ let imageUrl = "";
+ if (
+ articlesWithMedia.length > 0 &&
+ articlesWithMedia[0].media &&
+ articlesWithMedia[0].media.length > 0
+ ) {
+ const firstMediaId = articlesWithMedia[0].media[0].id;
+ try {
+ imageUrl = await this.getMediaBlobUrl(firstMediaId);
+ } catch {
+ imageUrl = this.addLangParam(
+ `${API_URL}/media/${firstMediaId}/download`
+ );
+ }
+ } else if (detailRes.data.thumbnail) {
+ try {
+ imageUrl = await this.getMediaBlobUrl(detailRes.data.thumbnail);
+ } catch {
+ imageUrl = this.addLangParam(
+ `${API_URL}/media/${detailRes.data.thumbnail}/download`
+ );
+ }
+ }
+
+ this.cardDetail = {
+ name: detailRes.data.name,
+ imageUrl,
+ articles: articlesWithMedia,
+ };
+ if (articlesWithMedia.length > 0) {
+ this.selectedSightArticleId = articlesWithMedia[0].id;
+ this.selectedSightArticleBody = articlesWithMedia[0].body;
+ } else {
+ this.selectedSightArticleId = null;
+ this.selectedSightArticleBody = "";
+ }
+ } catch (err) {
+ console.error("Failed to load sight card details:", err);
+ }
+ },
+ async selectSightArticle(id) {
+ this.selectedSightArticleId = id;
+ const selected = this.cardDetail?.articles.find((a) => a.id === id);
+ this.selectedSightArticleBody = selected ? selected.body : "";
+ if (selected && selected.media && selected.media.length > 0) {
+ try {
+ this.cardDetail.imageUrl = await this.getMediaBlobUrl(
+ selected.media[0].id
+ );
+ } catch {
+ this.cardDetail.imageUrl = this.addLangParam(
+ `${API_URL}/media/${selected.media[0].id}/download`
+ );
+ }
+ }
+ },
+ hideSightUI() {
+ this.showSightsList = false;
+ this.selectedSightCardId = null;
+ this.cardDetail = null;
+ },
toggleSightsList() {
+ // переключаем видимость
this.showSightsList = !this.showSightsList;
- if (this.showSightsList) {
+
+ // если список теперь скрыт — закрываем и карточку, сбрасываем таймер
+ if (!this.showSightsList) {
+ this.selectedSightCardId = null;
+ this.cardDetail = null;
+ if (this.sightsInactivityTimer) {
+ clearTimeout(this.sightsInactivityTimer);
+ this.sightsInactivityTimer = null;
+ }
+ } else {
+ // список открыт — запускаем таймер неактивности
this.resetSightsInactivityTimer();
- } else if (this.sightsInactivityTimer) {
- clearTimeout(this.sightsInactivityTimer);
- this.sightsInactivityTimer = null;
}
},
resetSightsInactivityTimer() {
- if (this.articleInactivityTimer) {
- clearTimeout(this.articleInactivityTimer);
- }
- if (this.sightsInactivityTimer) {
- clearTimeout(this.sightsInactivityTimer);
- }
- if (this.showSightsList) {
+ if (this.sightsInactivityTimer) clearTimeout(this.sightsInactivityTimer);
+
+ // запускаем новый, если открыт список или карточка
+ if (this.showSightsList || this.cardDetail) {
this.sightsInactivityTimer = setTimeout(() => {
- this.showSightsList = false;
+ this.hideSightUI(); // скрываем всё
this.sightsInactivityTimer = null;
- }, 300000); // 5 m
+ }, 300_000); // 5 m
}
},
resetArticleInactivityTimer() {
@@ -378,8 +655,10 @@ export default {
}, 300_000); // 5 m
},
handleUserActivity() {
- if (this.showSightsList) this.resetSightsInactivityTimer();
+ if (this.showSightsList || this.cardDetail)
+ this.resetSightsInactivityTimer();
this.resetArticleInactivityTimer();
+ this.resetLangRevertTimer();
},
selectArticle(id) {
this.resetArticleInactivityTimer();
@@ -391,14 +670,16 @@ export default {
async fetchArticleMedia(articleId) {
try {
const response = await axios.get(
- `${API_URL}/article/${articleId}/media`
+ this.addLangParam(`${API_URL}/article/${articleId}/media`)
);
if (response.data && response.data.length > 0) {
const mediaId = response.data[0].id;
try {
this.imageUrl = await this.getMediaBlobUrl(mediaId);
} catch {
- this.imageUrl = `${API_URL}/media/${mediaId}/download`;
+ this.imageUrl = this.addLangParam(
+ `${API_URL}/media/${mediaId}/download`
+ );
}
} else {
this.imageUrl = "";
@@ -412,7 +693,7 @@ export default {
async getMediaBlobUrl(mediaId) {
try {
const response = await axios.get(
- `${API_URL}/media/${mediaId}/download`,
+ this.addLangParam(`${API_URL}/media/${mediaId}/download`),
{
responseType: "blob",
}
@@ -424,12 +705,14 @@ export default {
error?.response?.status,
error?.response?.data || error.message
);
- return `${API_URL}/media/${mediaId}/download`;
+ return this.addLangParam(`${API_URL}/media/${mediaId}/download`);
}
},
async fetchGeolocationContext() {
try {
- const response = await axios.get(`${GEO_URL}/v1/geolocation/context`);
+ const response = await axios.get(
+ this.addLangParam(`${GEO_URL}/v1/geolocation/context`)
+ );
this.routeProgress = response.data.routeProgress;
const newRouteId = response.data.routeId;
if (newRouteId && newRouteId !== this.routeId) {
@@ -437,7 +720,7 @@ export default {
}
if (this.routeId) {
const stopsResponse = await axios.get(
- `${API_URL}/route/${this.routeId}/station`
+ this.addLangParam(`${API_URL}/route/${this.routeId}/station`)
);
this.stops = stopsResponse.data;
} else {
@@ -537,6 +820,7 @@ export default {
window.addEventListener("click", this.handleUserActivity, true);
// this.fetchSightInfo();
// this.fetchArticles();
+ this.resetLangRevertTimer();
},
unmounted() {
if (this.sightsInactivityTimer) {
@@ -557,3 +841,113 @@ export default {
},
};
+
+