From 8a58ce51c06ac7740cb51d173501cf6a8bd434ae Mon Sep 17 00:00:00 2001 From: jubnl Date: Fri, 17 Apr 2026 19:28:40 +0200 Subject: [PATCH] feat(maps): add kill switches for Google Places autocomplete and details Add admin toggles for places_autocomplete_enabled and places_details_enabled alongside the existing places_photos_enabled, all default ON. - adminService: getPlacesAutocomplete/updatePlacesAutocomplete, getPlacesDetails/updatePlacesDetails - admin routes: GET/PUT /admin/places-autocomplete, /admin/places-details - maps routes: autocomplete returns { suggestions: [], source: 'disabled' } when off; details returns { place: null, disabled: true } when off - authService: both flags included in getAppConfig() response - authStore: placesAutocompleteEnabled + placesDetailsEnabled state and setters - App.tsx: wire both flags from app-config on load - AdminPage: two new toggle rows using var(--text-primary)/var(--border-primary) consistent with rest of UI - i18n: all 15 locales (en, de, ar, br, cs, es, fr, hu, id, it, nl, pl, ru, zh, zhTw) --- client/src/App.tsx | 6 ++- client/src/api/client.ts | 4 ++ client/src/i18n/translations/ar.ts | 6 +++ client/src/i18n/translations/br.ts | 6 +++ client/src/i18n/translations/cs.ts | 6 +++ client/src/i18n/translations/de.ts | 4 ++ client/src/i18n/translations/en.ts | 4 ++ client/src/i18n/translations/es.ts | 6 +++ client/src/i18n/translations/fr.ts | 6 +++ client/src/i18n/translations/hu.ts | 6 +++ client/src/i18n/translations/id.ts | 6 +++ client/src/i18n/translations/it.ts | 6 +++ client/src/i18n/translations/nl.ts | 6 +++ client/src/i18n/translations/pl.ts | 6 +++ client/src/i18n/translations/ru.ts | 6 +++ client/src/i18n/translations/zh.ts | 6 +++ client/src/i18n/translations/zhTw.ts | 6 +++ client/src/pages/AdminPage.tsx | 55 ++++++++++++++++++++++++++-- client/src/store/authStore.ts | 8 ++++ server/src/routes/admin.ts | 38 +++++++++++++++++++ server/src/routes/maps.ts | 6 +++ server/src/services/adminService.ts | 24 ++++++++++++ server/src/services/authService.ts | 6 +++ 23 files changed, 228 insertions(+), 5 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 9d00169d..941492c2 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -100,7 +100,7 @@ function RootRedirect() { } export default function App() { - const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled } = useAuthStore() + const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore() const { loadSettings } = useSettingsStore() const { loadAddons } = useAddonStore() @@ -116,7 +116,7 @@ export default function App() { loadUser() } } - authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; permissions?: Record }) => { + authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record }) => { if (config?.demo_mode) setDemoMode(true) if (config?.dev_mode) setDevMode(true) if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease) @@ -126,6 +126,8 @@ export default function App() { if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa) if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled) if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled) + if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled) + if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled) if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions) if (config?.version) { diff --git a/client/src/api/client.ts b/client/src/api/client.ts index b4461730..179e02ff 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -274,6 +274,10 @@ export const adminApi = { updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data), getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data), updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data), + getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data), + updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data), + getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data), + updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data), getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data), updateCollabFeatures: (features: Record) => apiClient.put('/admin/collab-features', features).then(r => r.data), packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data), diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index e4a7ef49..218536cc 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -588,6 +588,12 @@ const ar: Record = { 'admin.fileTypesSaved': 'تم حفظ إعدادات أنواع الملفات', // Packing Templates & Bag Tracking + 'admin.placesPhotos.title': 'صور الأماكن', + 'admin.placesPhotos.subtitle': 'جلب الصور من Google Places API. عطّلها للحفاظ على حصة API. صور Wikimedia غير متأثرة.', + 'admin.placesAutocomplete.title': 'الإكمال التلقائي للأماكن', + 'admin.placesAutocomplete.subtitle': 'استخدام Google Places API لاقتراحات البحث. عطّلها للحفاظ على حصة API.', + 'admin.placesDetails.title': 'تفاصيل الأماكن', + 'admin.placesDetails.subtitle': 'جلب معلومات تفصيلية عن الأماكن (الساعات، التقييم، الموقع) من Google Places API. عطّلها للحفاظ على حصة API.', 'admin.bagTracking.title': 'تتبع الأمتعة', 'admin.bagTracking.subtitle': 'تفعيل الوزن وتعيين الأمتعة للعناصر', 'admin.collab.chat.title': 'الدردشة', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 8c4d0c4f..9264943f 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -546,6 +546,12 @@ const br: Record = { 'admin.fileTypesSaved': 'Configurações de tipos de arquivo salvas', // Packing Templates & Bag Tracking + 'admin.placesPhotos.title': 'Fotos de Locais', + 'admin.placesPhotos.subtitle': 'Busca fotos da Google Places API. Desative para economizar cota da API. Fotos do Wikimedia não são afetadas.', + 'admin.placesAutocomplete.title': 'Autocompletar de Locais', + 'admin.placesAutocomplete.subtitle': 'Usa a Google Places API para sugestões de pesquisa. Desative para economizar cota da API.', + 'admin.placesDetails.title': 'Detalhes do Local', + 'admin.placesDetails.subtitle': 'Busca informações detalhadas do local (horários, avaliação, site) da Google Places API. Desative para economizar cota da API.', 'admin.bagTracking.title': 'Rastreamento de malas', 'admin.bagTracking.subtitle': 'Ativar peso e atribuição de mala para itens da lista', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index a7185359..7aca6d21 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -546,6 +546,12 @@ const cs: Record = { 'admin.fileTypesSaved': 'Nastavení souborů uloženo', // Šablony balení (Packing Templates) + 'admin.placesPhotos.title': 'Fotografie míst', + 'admin.placesPhotos.subtitle': 'Načítání fotografií z Google Places API. Zakázáním ušetříte kvótu API. Fotografie z Wikimedia nejsou ovlivněny.', + 'admin.placesAutocomplete.title': 'Automatické doplňování míst', + 'admin.placesAutocomplete.subtitle': 'Použití Google Places API pro návrhy vyhledávání. Zakázáním ušetříte kvótu API.', + 'admin.placesDetails.title': 'Podrobnosti o místě', + 'admin.placesDetails.subtitle': 'Načítání podrobných informací o místě (hodiny, hodnocení, web) z Google Places API. Zakázáním ušetříte kvótu API.', 'admin.bagTracking.title': 'Sledování zavazadel', 'admin.bagTracking.subtitle': 'Povolit váhu a přiřazení k zavazadlům u položek balení', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 7adecba1..c0e65fde 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -551,6 +551,10 @@ const de: Record = { 'admin.placesPhotos.title': 'Ortsfotos', 'admin.placesPhotos.subtitle': 'Fotos von der Google Places API laden. Deaktivieren, um API-Kontingent zu sparen. Wikimedia-Fotos sind davon nicht betroffen.', + 'admin.placesAutocomplete.title': 'Orts-Autovervollständigung', + 'admin.placesAutocomplete.subtitle': 'Google Places API für Suchvorschläge nutzen. Deaktivieren, um API-Kontingent zu sparen.', + 'admin.placesDetails.title': 'Ortsdetails', + 'admin.placesDetails.subtitle': 'Detaillierte Ortsinformationen (Öffnungszeiten, Bewertung, Website) von der Google Places API laden. Deaktivieren, um API-Kontingent zu sparen.', // Packing Templates & Bag Tracking 'admin.bagTracking.title': 'Gepäck-Tracking', 'admin.bagTracking.subtitle': 'Gewicht und Gepäckstück-Zuordnung für Packlisteneinträge aktivieren', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 7cdfccda..fcf3eedf 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -611,6 +611,10 @@ const en: Record = { 'admin.placesPhotos.title': 'Place Photos', 'admin.placesPhotos.subtitle': 'Fetch photos from the Google Places API. Disable to save API quota. Wikimedia photos are unaffected.', + 'admin.placesAutocomplete.title': 'Place Autocomplete', + 'admin.placesAutocomplete.subtitle': 'Use the Google Places API for search suggestions. Disable to save API quota.', + 'admin.placesDetails.title': 'Place Details', + 'admin.placesDetails.subtitle': 'Fetch detailed place information (hours, rating, website) from the Google Places API. Disable to save API quota.', // Packing Templates & Bag Tracking 'admin.bagTracking.title': 'Bag Tracking', 'admin.bagTracking.subtitle': 'Enable weight and bag assignment for packing items', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 13b5854f..84dd3c9d 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -541,6 +541,12 @@ const es: Record = { 'admin.fileTypesFormat': 'Extensiones separadas por comas (p. ej. jpg,png,pdf,doc). Usa * para permitir todos los tipos.', 'admin.fileTypesSaved': 'Ajustes de tipos de archivo guardados', + 'admin.placesPhotos.title': 'Fotos de Lugares', + 'admin.placesPhotos.subtitle': 'Obtiene fotos de la Google Places API. Desactiva para ahorrar cuota de API. Las fotos de Wikimedia no se ven afectadas.', + 'admin.placesAutocomplete.title': 'Autocompletado de Lugares', + 'admin.placesAutocomplete.subtitle': 'Usa la Google Places API para sugerencias de búsqueda. Desactiva para ahorrar cuota de API.', + 'admin.placesDetails.title': 'Detalles del Lugar', + 'admin.placesDetails.subtitle': 'Obtiene información detallada del lugar (horarios, valoración, web) de la Google Places API. Desactiva para ahorrar cuota de API.', 'admin.bagTracking.title': 'Seguimiento de equipaje', 'admin.bagTracking.subtitle': 'Activar peso y asignación de equipaje para artículos de la lista', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 2a1d6fcc..436bd107 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -545,6 +545,12 @@ const fr: Record = { 'admin.fileTypesFormat': 'Extensions séparées par des virgules (ex. jpg,png,pdf,doc). Utilisez * pour autoriser tous les types.', 'admin.fileTypesSaved': 'Paramètres des types de fichiers enregistrés', + 'admin.placesPhotos.title': 'Photos de lieux', + 'admin.placesPhotos.subtitle': "Récupère les photos depuis l'API Google Places. Désactivez pour économiser le quota API. Les photos Wikimedia ne sont pas affectées.", + 'admin.placesAutocomplete.title': 'Autocomplétion des lieux', + 'admin.placesAutocomplete.subtitle': "Utilise l'API Google Places pour les suggestions de recherche. Désactivez pour économiser le quota API.", + 'admin.placesDetails.title': 'Détails du lieu', + 'admin.placesDetails.subtitle': "Récupère les informations détaillées du lieu (horaires, note, site web) depuis l'API Google Places. Désactivez pour économiser le quota API.", 'admin.bagTracking.title': 'Suivi des bagages', 'admin.bagTracking.subtitle': 'Activer le poids et l\'attribution de bagages pour les articles', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 462186f0..e3b19545 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -546,6 +546,12 @@ const hu: Record = { 'admin.fileTypesSaved': 'Fájltípus-beállítások mentve', // Csomagolási sablonok és poggyászkövetés + 'admin.placesPhotos.title': 'Helyfotók', + 'admin.placesPhotos.subtitle': 'Fotók lekérése a Google Places API-ból. Tiltsa le az API-kvóta megtakarításához. A Wikimedia-fotók nem érintettek.', + 'admin.placesAutocomplete.title': 'Hely automatikus kiegészítése', + 'admin.placesAutocomplete.subtitle': 'A Google Places API használata keresési javaslatokhoz. Tiltsa le az API-kvóta megtakarításához.', + 'admin.placesDetails.title': 'Hely részletei', + 'admin.placesDetails.subtitle': 'Részletes helyinformációk lekérése (nyitvatartás, értékelés, weboldal) a Google Places API-ból. Tiltsa le az API-kvóta megtakarításához.', 'admin.bagTracking.title': 'Poggyászkövetés', 'admin.bagTracking.subtitle': 'Súly- és táskahozzárendelés engedélyezése csomagolási tételeknél', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index 5e7a0f1e..bdba0cf8 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -610,6 +610,12 @@ const id: Record = { 'admin.fileTypesSaved': 'Pengaturan jenis file disimpan', // Packing Templates & Bag Tracking + 'admin.placesPhotos.title': 'Foto Tempat', + 'admin.placesPhotos.subtitle': 'Mengambil foto dari Google Places API. Nonaktifkan untuk menghemat kuota API. Foto Wikimedia tidak terpengaruh.', + 'admin.placesAutocomplete.title': 'Pelengkapan Otomatis Tempat', + 'admin.placesAutocomplete.subtitle': 'Menggunakan Google Places API untuk saran pencarian. Nonaktifkan untuk menghemat kuota API.', + 'admin.placesDetails.title': 'Detail Tempat', + 'admin.placesDetails.subtitle': 'Mengambil informasi detail tempat (jam, penilaian, situs web) dari Google Places API. Nonaktifkan untuk menghemat kuota API.', 'admin.bagTracking.title': 'Pelacak Tas', 'admin.bagTracking.subtitle': 'Aktifkan berat dan penugasan tas untuk item packing', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 58ecfea6..178305e5 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -545,6 +545,12 @@ const it: Record = { 'admin.fileTypesFormat': 'Estensioni separate da virgola (es. jpg,png,pdf,doc). Usa * per consentire tutti i tipi.', 'admin.fileTypesSaved': 'Impostazioni dei tipi di file salvate', // Packing Templates & Bag Tracking + 'admin.placesPhotos.title': 'Foto dei luoghi', + 'admin.placesPhotos.subtitle': "Recupera le foto dall'API Google Places. Disabilita per risparmiare la quota API. Le foto di Wikimedia non sono interessate.", + 'admin.placesAutocomplete.title': 'Completamento automatico dei luoghi', + 'admin.placesAutocomplete.subtitle': "Utilizza l'API Google Places per i suggerimenti di ricerca. Disabilita per risparmiare la quota API.", + 'admin.placesDetails.title': 'Dettagli del luogo', + 'admin.placesDetails.subtitle': "Recupera informazioni dettagliate sul luogo (orari, valutazione, sito web) dall'API Google Places. Disabilita per risparmiare la quota API.", 'admin.bagTracking.title': 'Tracciamento valigia', 'admin.bagTracking.subtitle': 'Abilita il peso e l\'assegnazione della valigia per gli elementi della lista valigia', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index cc9d97cd..17a7165c 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -546,6 +546,12 @@ const nl: Record = { 'admin.fileTypesFormat': 'Kommagescheiden extensies (bijv. jpg,png,pdf,doc). Gebruik * om alle typen toe te staan.', 'admin.fileTypesSaved': 'Bestandstype-instellingen opgeslagen', + 'admin.placesPhotos.title': "Plaatsfoto's", + 'admin.placesPhotos.subtitle': "Haalt foto's op via de Google Places API. Schakel uit om API-quota te besparen. Wikimedia-foto's worden niet beïnvloed.", + 'admin.placesAutocomplete.title': 'Plaatsautocomplete', + 'admin.placesAutocomplete.subtitle': 'Gebruikt de Google Places API voor zoeksuggesties. Schakel uit om API-quota te besparen.', + 'admin.placesDetails.title': 'Plaatsdetails', + 'admin.placesDetails.subtitle': 'Haalt gedetailleerde plaatsinformatie (openingstijden, beoordeling, website) op via de Google Places API. Schakel uit om API-quota te besparen.', 'admin.bagTracking.title': 'Bagagetracking', 'admin.bagTracking.subtitle': 'Gewicht en bagagetoewijzing inschakelen voor paklijstitems', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 427a2134..080d5cb3 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -518,6 +518,12 @@ const pl: Record = { 'admin.fileTypesSaved': 'Ustawienia typów plików zostały zapisane', // Packing Templates & Bag Tracking + 'admin.placesPhotos.title': 'Zdjęcia miejsc', + 'admin.placesPhotos.subtitle': 'Pobiera zdjęcia z Google Places API. Wyłącz, aby zaoszczędzić limit API. Zdjęcia z Wikimedia nie są objęte.', + 'admin.placesAutocomplete.title': 'Autouzupełnianie miejsc', + 'admin.placesAutocomplete.subtitle': 'Używa Google Places API do sugestii wyszukiwania. Wyłącz, aby zaoszczędzić limit API.', + 'admin.placesDetails.title': 'Szczegóły miejsca', + 'admin.placesDetails.subtitle': 'Pobiera szczegółowe informacje o miejscu (godziny, ocena, strona) z Google Places API. Wyłącz, aby zaoszczędzić limit API.', 'admin.bagTracking.title': 'Kontrola bagażu', 'admin.bagTracking.subtitle': 'Włącz wagę i przypisywanie do toreb dla przedmiotów do pakowania', 'admin.collab.chat.title': 'Czat', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 0bf70d93..9961de3d 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -546,6 +546,12 @@ const ru: Record = { 'admin.fileTypesFormat': 'Расширения через запятую (напр. jpg,png,pdf,doc). Используйте * для разрешения всех типов.', 'admin.fileTypesSaved': 'Настройки типов файлов сохранены', + 'admin.placesPhotos.title': 'Фотографии мест', + 'admin.placesPhotos.subtitle': 'Загрузка фотографий из Google Places API. Отключите для экономии квоты API. Фотографии Wikimedia не затронуты.', + 'admin.placesAutocomplete.title': 'Автодополнение мест', + 'admin.placesAutocomplete.subtitle': 'Использование Google Places API для поисковых подсказок. Отключите для экономии квоты API.', + 'admin.placesDetails.title': 'Сведения о месте', + 'admin.placesDetails.subtitle': 'Загрузка подробной информации о месте (часы работы, рейтинг, веб-сайт) из Google Places API. Отключите для экономии квоты API.', 'admin.bagTracking.title': 'Отслеживание багажа', 'admin.bagTracking.subtitle': 'Включить вес и привязку к багажу для вещей', 'admin.collab.chat.title': 'Чат', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 60aacb85..98d5b4db 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -546,6 +546,12 @@ const zh: Record = { 'admin.fileTypesFormat': '以逗号分隔的扩展名(如 jpg,png,pdf,doc)。使用 * 允许所有类型。', 'admin.fileTypesSaved': '文件类型设置已保存', + 'admin.placesPhotos.title': '地点照片', + 'admin.placesPhotos.subtitle': '从 Google Places API 获取照片。禁用可节省 API 配额。Wikimedia 照片不受影响。', + 'admin.placesAutocomplete.title': '地点自动补全', + 'admin.placesAutocomplete.subtitle': '使用 Google Places API 提供搜索建议。禁用可节省 API 配额。', + 'admin.placesDetails.title': '地点详情', + 'admin.placesDetails.subtitle': '从 Google Places API 获取地点详细信息(营业时间、评分、网站)。禁用可节省 API 配额。', 'admin.bagTracking.title': '行李追踪', 'admin.bagTracking.subtitle': '为打包物品启用重量和行李分配', 'admin.collab.chat.title': '聊天', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 50a0f6b1..9d83ae40 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -606,6 +606,12 @@ const zhTw: Record = { 'admin.fileTypesFormat': '以逗號分隔的副檔名(如 jpg,png,pdf,doc)。使用 * 允許所有型別。', 'admin.fileTypesSaved': '檔案型別設定已儲存', + 'admin.placesPhotos.title': '地點照片', + 'admin.placesPhotos.subtitle': '從 Google Places API 獲取照片。停用可節省 API 配額。Wikimedia 照片不受影響。', + 'admin.placesAutocomplete.title': '地點自動補全', + 'admin.placesAutocomplete.subtitle': '使用 Google Places API 提供搜尋建議。停用可節省 API 配額。', + 'admin.placesDetails.title': '地點詳情', + 'admin.placesDetails.subtitle': '從 Google Places API 獲取地點詳細資訊(營業時間、評分、網站)。停用可節省 API 配額。', 'admin.bagTracking.title': '行李追蹤', 'admin.bagTracking.subtitle': '為打包物品啟用重量和行李分配', 'admin.collab.chat.title': '聊天', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 0a4d876c..9b976734 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -198,6 +198,14 @@ export default function AdminPage(): React.ReactElement { const [placesPhotosEnabled, setPlacesPhotosEnabledState] = useState(true) useEffect(() => { adminApi.getPlacesPhotos().then(d => setPlacesPhotosEnabledState(d.enabled)).catch(() => {}) }, []) + // Places autocomplete + const [placesAutocompleteEnabled, setPlacesAutocompleteEnabledState] = useState(true) + useEffect(() => { adminApi.getPlacesAutocomplete().then(d => setPlacesAutocompleteEnabledState(d.enabled)).catch(() => {}) }, []) + + // Places details + const [placesDetailsEnabled, setPlacesDetailsEnabledState] = useState(true) + useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(d.enabled)).catch(() => {}) }, []) + // Collab features const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true }) useEffect(() => { adminApi.getCollabFeatures().then(d => setCollabFeatures(d)).catch(() => {}) }, []) @@ -246,7 +254,7 @@ export default function AdminPage(): React.ReactElement { const [updateInfo, setUpdateInfo] = useState(null) const [showUpdateModal, setShowUpdateModal] = useState(false) - const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, logout } = useAuthStore() + const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, logout } = useAuthStore() const navigate = useNavigate() const toast = useToast() @@ -1040,9 +1048,50 @@ export default function AdminPage(): React.ReactElement { setPlacesPhotosEnabled(next) try { await adminApi.updatePlacesPhotos(next) } catch { setPlacesPhotosEnabledState(!next); setPlacesPhotosEnabled(!next) } }} - className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${placesPhotosEnabled ? 'bg-indigo-600' : 'bg-slate-200'}`} + className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors" + style={{ background: placesPhotosEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }} > - + + + + + {/* Place Autocomplete Toggle */} +
+
+

{t('admin.placesAutocomplete.title')}

+

{t('admin.placesAutocomplete.subtitle')}

+
+ +
+ + {/* Place Details Toggle */} +
+
+

{t('admin.placesDetails.title')}

+

{t('admin.placesDetails.subtitle')}

+
+
diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 6c6efbf9..8d8c342d 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -34,6 +34,8 @@ interface AuthState { appRequireMfa: boolean tripRemindersEnabled: boolean placesPhotosEnabled: boolean + placesAutocompleteEnabled: boolean + placesDetailsEnabled: boolean login: (email: string, password: string) => Promise completeMfaLogin: (mfaToken: string, code: string) => Promise @@ -55,6 +57,8 @@ interface AuthState { setAppRequireMfa: (val: boolean) => void setTripRemindersEnabled: (val: boolean) => void setPlacesPhotosEnabled: (val: boolean) => void + setPlacesAutocompleteEnabled: (val: boolean) => void + setPlacesDetailsEnabled: (val: boolean) => void demoLogin: () => Promise } @@ -77,6 +81,8 @@ export const useAuthStore = create()( appRequireMfa: false, tripRemindersEnabled: false, placesPhotosEnabled: true, + placesAutocompleteEnabled: true, + placesDetailsEnabled: true, login: async (email: string, password: string) => { authSequence++ @@ -261,6 +267,8 @@ export const useAuthStore = create()( setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }), setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }), setPlacesPhotosEnabled: (val: boolean) => set({ placesPhotosEnabled: val }), + setPlacesAutocompleteEnabled: (val: boolean) => set({ placesAutocompleteEnabled: val }), + setPlacesDetailsEnabled: (val: boolean) => set({ placesDetailsEnabled: val }), demoLogin: async () => { authSequence++ diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 58cf4749..1c9c2aca 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -220,6 +220,44 @@ router.put('/places-photos', (req: Request, res: Response) => { res.json(result); }); +// ── Places Autocomplete ────────────────────────────────────────────────── + +router.get('/places-autocomplete', (_req: Request, res: Response) => { + res.json(svc.getPlacesAutocomplete()); +}); + +router.put('/places-autocomplete', (req: Request, res: Response) => { + if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' }); + const result = svc.updatePlacesAutocomplete(req.body.enabled); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.places_autocomplete', + ip: getClientIp(req), + details: { enabled: result.enabled }, + }); + res.json(result); +}); + +// ── Places Details ─────────────────────────────────────────────────────── + +router.get('/places-details', (_req: Request, res: Response) => { + res.json(svc.getPlacesDetails()); +}); + +router.put('/places-details', (req: Request, res: Response) => { + if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' }); + const result = svc.updatePlacesDetails(req.body.enabled); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.places_details', + ip: getClientIp(req), + details: { enabled: result.enabled }, + }); + res.json(result); +}); + // ── Collab Features ─────────────────────────────────────────────────────── router.get('/collab-features', (_req: Request, res: Response) => { diff --git a/server/src/routes/maps.ts b/server/src/routes/maps.ts index 9692747f..427d4500 100644 --- a/server/src/routes/maps.ts +++ b/server/src/routes/maps.ts @@ -35,6 +35,9 @@ router.post('/search', authenticate, async (req: Request, res: Response) => { // POST /autocomplete router.post('/autocomplete', authenticate, async (req: Request, res: Response) => { + const autocompleteEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined; + if (autocompleteEnabledRow?.value === 'false') return res.status(200).json({ suggestions: [], source: 'disabled' }); + const authReq = req as AuthRequest; const { input, lang, locationBias } = req.body; @@ -73,6 +76,9 @@ router.post('/autocomplete', authenticate, async (req: Request, res: Response) = // GET /details/:placeId router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => { + const detailsEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined; + if (detailsEnabledRow?.value === 'false') return res.status(200).json({ place: null, disabled: true }); + const authReq = req as AuthRequest; const { placeId } = req.params; const expand = req.query.expand as string | undefined; diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index 8f1a9344..66a2d349 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -471,6 +471,30 @@ export function updatePlacesPhotos(enabled: boolean) { return { enabled: !!enabled }; } +// ── Places Autocomplete ──────────────────────────────────────────────────── + +export function getPlacesAutocomplete() { + const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined; + return { enabled: row?.value !== 'false' }; +} + +export function updatePlacesAutocomplete(enabled: boolean) { + db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_autocomplete_enabled', ?)").run(enabled ? 'true' : 'false'); + return { enabled: !!enabled }; +} + +// ── Places Details ───────────────────────────────────────────────────────── + +export function getPlacesDetails() { + const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined; + return { enabled: row?.value !== 'false' }; +} + +export function updatePlacesDetails(enabled: boolean) { + db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_details_enabled', ?)").run(enabled ? 'true' : 'false'); + return { enabled: !!enabled }; +} + // ── Collab Features ─────────────────────────────────────────────────────── const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const; diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index 31c71326..b091bf58 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -231,6 +231,10 @@ export function getAppConfig(authenticatedUser: { id: number } | null) { const tripRemindersEnabled = tripReminderSetting !== 'false'; const placesPhotosSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined)?.value; const placesPhotosEnabled = placesPhotosSetting !== 'false'; + const placesAutocompleteSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined)?.value; + const placesAutocompleteEnabled = placesAutocompleteSetting !== 'false'; + const placesDetailsSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined)?.value; + const placesDetailsEnabled = placesDetailsSetting !== 'false'; const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get()); return { @@ -261,6 +265,8 @@ export function getAppConfig(authenticatedUser: { id: number } | null) { available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true }, trip_reminders_enabled: tripRemindersEnabled, places_photos_enabled: placesPhotosEnabled, + places_autocomplete_enabled: placesAutocompleteEnabled, + places_details_enabled: placesDetailsEnabled, permissions: authenticatedUser ? getAllPermissions() : undefined, dev_mode: process.env.NODE_ENV === 'development', };