App.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. <!--
  2. - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
  3. - SPDX-License-Identifier: AGPL-3.0-or-later
  4. -->
  5. <template>
  6. <div id="weather-status-menu-item">
  7. <NcActions class="weather-status-menu-item__subheader"
  8. :aria-hidden="true"
  9. :aria-label="currentWeatherMessage"
  10. :menu-name="currentWeatherMessage">
  11. <template #icon>
  12. <NcLoadingIcon v-if="loading" />
  13. <img v-else
  14. :src="weatherIconUrl"
  15. alt=""
  16. class="weather-image">
  17. </template>
  18. <NcActionText v-if="gotWeather"
  19. :aria-hidden="true">
  20. <template #icon>
  21. <NcLoadingIcon v-if="loading" />
  22. <div v-else class="weather-action-image-container">
  23. <img :src="futureWeatherIconUrl"
  24. alt=""
  25. class="weather-image">
  26. </div>
  27. </template>
  28. {{ forecastMessage }}
  29. </NcActionText>
  30. <NcActionLink v-if="gotWeather"
  31. target="_blank"
  32. :aria-hidden="true"
  33. :href="weatherLinkTarget"
  34. :close-after-click="true">
  35. <template #icon>
  36. <NcIconSvgWrapper name="MapMarker"
  37. :svg="mapMarkerSvg"
  38. :size="20" />
  39. </template>
  40. {{ locationText }}
  41. </NcActionLink>
  42. <NcActionButton v-if="gotWeather"
  43. :aria-hidden="true"
  44. @click="onAddRemoveFavoriteClick">
  45. <template #icon>
  46. <NcIconSvgWrapper name="Star"
  47. :svg="addRemoveFavoriteSvg"
  48. :size="20"
  49. class="favorite-color" />
  50. </template>
  51. {{ addRemoveFavoriteText }}
  52. </NcActionButton>
  53. <NcActionSeparator v-if="address && !errorMessage" />
  54. <NcActionButton :close-after-click="true"
  55. :aria-hidden="true"
  56. @click="onBrowserLocationClick">
  57. <template #icon>
  58. <NcIconSvgWrapper name="Crosshairs"
  59. :svg="crosshairsSvg"
  60. :size="20" />
  61. </template>
  62. {{ t('weather_status', 'Detect location') }}
  63. </NcActionButton>
  64. <NcActionInput ref="addressInput"
  65. :label="t('weather_status', 'Set custom address')"
  66. :disabled="false"
  67. icon="icon-rename"
  68. :aria-hidden="true"
  69. type="text"
  70. value=""
  71. @submit="onAddressSubmit" />
  72. <template v-if="favorites.length > 0">
  73. <NcActionCaption :name="t('weather_status', 'Favorites')" />
  74. <NcActionButton v-for="favorite in favorites"
  75. :key="favorite"
  76. :aria-hidden="true"
  77. @click="onFavoriteClick($event, favorite)">
  78. <template #icon>
  79. <NcIconSvgWrapper name="Star"
  80. :svg="starSvg"
  81. :size="20"
  82. :class="{'favorite-color': address === favorite}" />
  83. </template>
  84. {{ favorite }}
  85. </NcActionButton>
  86. </template>
  87. </NcActions>
  88. </div>
  89. </template>
  90. <script>
  91. import { showError } from '@nextcloud/dialogs'
  92. import moment from '@nextcloud/moment'
  93. import { getLocale } from '@nextcloud/l10n'
  94. import { imagePath } from '@nextcloud/router'
  95. import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
  96. import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
  97. import NcActionCaption from '@nextcloud/vue/dist/Components/NcActionCaption.js'
  98. import NcActionInput from '@nextcloud/vue/dist/Components/NcActionInput.js'
  99. import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js'
  100. import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator.js'
  101. import NcActionText from '@nextcloud/vue/dist/Components/NcActionText.js'
  102. import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
  103. import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
  104. import * as network from './services/weatherStatusService.js'
  105. import crosshairsSvg from '@mdi/svg/svg/crosshairs.svg?raw'
  106. import mapMarkerSvg from '@mdi/svg/svg/map-marker.svg?raw'
  107. import starSvg from '@mdi/svg/svg/star.svg?raw'
  108. import starOutlineSvg from '@mdi/svg/svg/star-outline.svg?raw'
  109. const MODE_BROWSER_LOCATION = 1
  110. const MODE_MANUAL_LOCATION = 2
  111. const weatherOptions = {
  112. clearsky_day: {
  113. text: (temperature, unit, later = false) => later
  114. ? t('weather_status', '{temperature} {unit} clear sky later today', { temperature, unit })
  115. : t('weather_status', '{temperature} {unit} clear sky', { temperature, unit }),
  116. },
  117. clearsky_night: {
  118. text: (temperature, unit, later = false) => later
  119. ? t('weather_status', '{temperature} {unit} clear sky later today', { temperature, unit })
  120. : t('weather_status', '{temperature} {unit} clear sky', { temperature, unit }),
  121. },
  122. cloudy: {
  123. text: (temperature, unit, later = false) => later
  124. ? t('weather_status', '{temperature} {unit} cloudy later today', { temperature, unit })
  125. : t('weather_status', '{temperature} {unit} cloudy', { temperature, unit }),
  126. },
  127. snowandthunder: {
  128. text: (temperature, unit, later = false) => later
  129. ? t('weather_status', '{temperature} {unit} snow and thunder later today', { temperature, unit })
  130. : t('weather_status', '{temperature} {unit} snow and thunder', { temperature, unit }),
  131. },
  132. snowshowersandthunder_day: {
  133. text: (temperature, unit, later = false) => later
  134. ? t('weather_status', '{temperature} {unit} snow showers and thunder later today', { temperature, unit })
  135. : t('weather_status', '{temperature} {unit} snow showers and thunder', { temperature, unit }),
  136. },
  137. snowshowersandthunder_night: {
  138. text: (temperature, unit, later = false) => later
  139. ? t('weather_status', '{temperature} {unit} snow showers and thunder later today', { temperature, unit })
  140. : t('weather_status', '{temperature} {unit} snow showers and thunder', { temperature, unit }),
  141. },
  142. snowshowersandthunder_polartwilight: {
  143. text: (temperature, unit, later = false) => later
  144. ? t('weather_status', '{temperature} {unit} snow showers, thunder and polar twilight later today', { temperature, unit })
  145. : t('weather_status', '{temperature} {unit} snow showers, thunder and polar twilight', { temperature, unit }),
  146. },
  147. snowshowers_day: {
  148. text: (temperature, unit, later = false) => later
  149. ? t('weather_status', '{temperature} {unit} snow showers later today', { temperature, unit })
  150. : t('weather_status', '{temperature} {unit} snow showers', { temperature, unit }),
  151. },
  152. snowshowers_night: {
  153. text: (temperature, unit, later = false) => later
  154. ? t('weather_status', '{temperature} {unit} snow showers later today', { temperature, unit })
  155. : t('weather_status', '{temperature} {unit} snow showers', { temperature, unit }),
  156. },
  157. snowshowers_polartwilight: {
  158. text: (temperature, unit, later = false) => later
  159. ? t('weather_status', '{temperature} {unit} snow showers and polar twilight later today', { temperature, unit })
  160. : t('weather_status', '{temperature} {unit} snow showers and polar twilight', { temperature, unit }),
  161. },
  162. snow: {
  163. text: (temperature, unit, later = false) => later
  164. ? t('weather_status', '{temperature} {unit} snow later today', { temperature, unit })
  165. : t('weather_status', '{temperature} {unit} snow', { temperature, unit }),
  166. },
  167. fair_day: {
  168. text: (temperature, unit, later = false) => later
  169. ? t('weather_status', '{temperature} {unit} fair weather later today', { temperature, unit })
  170. : t('weather_status', '{temperature} {unit} fair weather', { temperature, unit }),
  171. },
  172. fair_night: {
  173. text: (temperature, unit, later = false) => later
  174. ? t('weather_status', '{temperature} {unit} fair weather later today', { temperature, unit })
  175. : t('weather_status', '{temperature} {unit} fair weather', { temperature, unit }),
  176. },
  177. partlycloudy_day: {
  178. text: (temperature, unit, later = false) => later
  179. ? t('weather_status', '{temperature} {unit} partly cloudy later today', { temperature, unit })
  180. : t('weather_status', '{temperature} {unit} partly cloudy', { temperature, unit }),
  181. },
  182. partlycloudy_night: {
  183. text: (temperature, unit, later = false) => later
  184. ? t('weather_status', '{temperature} {unit} partly cloudy later today', { temperature, unit })
  185. : t('weather_status', '{temperature} {unit} partly cloudy', { temperature, unit }),
  186. },
  187. fog: {
  188. text: (temperature, unit, later = false) => later
  189. ? t('weather_status', '{temperature} {unit} foggy later today', { temperature, unit })
  190. : t('weather_status', '{temperature} {unit} foggy', { temperature, unit }),
  191. },
  192. lightrain: {
  193. text: (temperature, unit, later = false) => later
  194. ? t('weather_status', '{temperature} {unit} light rainfall later today', { temperature, unit })
  195. : t('weather_status', '{temperature} {unit} light rainfall', { temperature, unit }),
  196. },
  197. rain: {
  198. text: (temperature, unit, later = false) => later
  199. ? t('weather_status', '{temperature} {unit} rainfall later today', { temperature, unit })
  200. : t('weather_status', '{temperature} {unit} rainfall', { temperature, unit }),
  201. },
  202. heavyrain: {
  203. text: (temperature, unit, later = false) => later
  204. ? t('weather_status', '{temperature} {unit} heavy rainfall later today', { temperature, unit })
  205. : t('weather_status', '{temperature} {unit} heavy rainfall', { temperature, unit }),
  206. },
  207. rainshowers_day: {
  208. text: (temperature, unit, later = false) => later
  209. ? t('weather_status', '{temperature} {unit} rainfall showers later today', { temperature, unit })
  210. : t('weather_status', '{temperature} {unit} rainfall showers', { temperature, unit }),
  211. },
  212. rainshowers_night: {
  213. text: (temperature, unit, later = false) => later
  214. ? t('weather_status', '{temperature} {unit} rainfall showers later today', { temperature, unit })
  215. : t('weather_status', '{temperature} {unit} rainfall showers', { temperature, unit }),
  216. },
  217. lightrainshowers_day: {
  218. text: (temperature, unit, later = false) => later
  219. ? t('weather_status', '{temperature} {unit} light rainfall showers later today', { temperature, unit })
  220. : t('weather_status', '{temperature} {unit} light rainfall showers', { temperature, unit }),
  221. },
  222. lightrainshowers_night: {
  223. text: (temperature, unit, later = false) => later
  224. ? t('weather_status', '{temperature} {unit} light rainfall showers later today', { temperature, unit })
  225. : t('weather_status', '{temperature} {unit} light rainfall showers', { temperature, unit }),
  226. },
  227. heavyrainshowers_day: {
  228. text: (temperature, unit, later = false) => later
  229. ? t('weather_status', '{temperature} {unit} heavy rainfall showers later today', { temperature, unit })
  230. : t('weather_status', '{temperature} {unit} heavy rainfall showers', { temperature, unit }),
  231. },
  232. heavyrainshowers_night: {
  233. text: (temperature, unit, later = false) => later
  234. ? t('weather_status', '{temperature} {unit} heavy rainfall showers later today', { temperature, unit })
  235. : t('weather_status', '{temperature} {unit} heavy rainfall showers', { temperature, unit }),
  236. },
  237. }
  238. export default {
  239. name: 'App',
  240. components: {
  241. NcActions,
  242. NcActionButton,
  243. NcActionCaption,
  244. NcActionInput,
  245. NcActionLink,
  246. NcActionSeparator,
  247. NcActionText,
  248. NcLoadingIcon,
  249. NcIconSvgWrapper,
  250. },
  251. data() {
  252. return {
  253. crosshairsSvg,
  254. mapMarkerSvg,
  255. starSvg,
  256. starOutlineSvg,
  257. locale: getLocale(),
  258. loading: true,
  259. errorMessage: '',
  260. mode: MODE_BROWSER_LOCATION,
  261. address: null,
  262. lat: null,
  263. lon: null,
  264. // how many hours ahead do we want to see the forecast?
  265. offset: 5,
  266. forecasts: [],
  267. loop: null,
  268. favorites: [],
  269. }
  270. },
  271. computed: {
  272. useFahrenheitLocale() {
  273. return ['en_US', 'en_MH', 'en_FM', 'en_PW', 'en_KY', 'en_LR'].includes(this.locale)
  274. },
  275. temperatureUnit() {
  276. return this.useFahrenheitLocale ? '°F' : '°C'
  277. },
  278. locationText() {
  279. return t('weather_status', 'More weather for {adr}', { adr: this.address })
  280. },
  281. temperature() {
  282. return this.getTemperature(this.forecasts, 0)
  283. },
  284. futureTemperature() {
  285. return this.getTemperature(this.forecasts, this.offset)
  286. },
  287. weatherCode() {
  288. return this.getWeatherCode(this.forecasts, 0)
  289. },
  290. futureWeatherCode() {
  291. return this.getWeatherCode(this.forecasts, this.offset)
  292. },
  293. weatherIconUrl() {
  294. return this.getWeatherIconUrl(this.weatherCode)
  295. },
  296. futureWeatherIconUrl() {
  297. return this.getWeatherIconUrl(this.futureWeatherCode)
  298. },
  299. /**
  300. * The message displayed in the top right corner
  301. *
  302. * @return {string}
  303. */
  304. currentWeatherMessage() {
  305. if (this.loading) {
  306. return t('weather_status', 'Loading weather')
  307. } else if (this.errorMessage) {
  308. return this.errorMessage
  309. } else if (this.gotWeather) {
  310. return this.getWeatherMessage(this.weatherCode, this.temperature)
  311. } else {
  312. return t('weather_status', 'Set location for weather')
  313. }
  314. },
  315. forecastMessage() {
  316. if (this.loading) {
  317. return t('weather_status', 'Loading weather')
  318. } else if (this.gotWeather) {
  319. return this.getWeatherMessage(this.futureWeatherCode, this.futureTemperature, true)
  320. } else {
  321. return t('weather_status', 'Set location for weather')
  322. }
  323. },
  324. weatherLinkTarget() {
  325. return 'https://www.windy.com/-Rain-thunder-rain?rain,' + this.lat + ',' + this.lon + ',11'
  326. },
  327. gotWeather() {
  328. return this.address && !this.errorMessage
  329. },
  330. addRemoveFavoriteSvg() {
  331. return this.currentAddressIsFavorite
  332. ? starSvg
  333. : starOutlineSvg
  334. },
  335. addRemoveFavoriteText() {
  336. return this.currentAddressIsFavorite
  337. ? t('weather_status', 'Remove from favorites')
  338. : t('weather_status', 'Add as favorite')
  339. },
  340. currentAddressIsFavorite() {
  341. return this.favorites.find((f) => {
  342. return f === this.address
  343. })
  344. },
  345. },
  346. mounted() {
  347. this.initWeatherStatus()
  348. },
  349. methods: {
  350. async initWeatherStatus() {
  351. try {
  352. const loc = await network.getLocation()
  353. this.lat = loc.lat
  354. this.lon = loc.lon
  355. this.address = loc.address
  356. this.mode = loc.mode
  357. if (this.mode === MODE_BROWSER_LOCATION) {
  358. this.askBrowserLocation()
  359. } else if (this.mode === MODE_MANUAL_LOCATION) {
  360. this.startLoop()
  361. }
  362. const favs = await network.getFavorites()
  363. this.favorites = favs
  364. } catch (err) {
  365. if (err?.code === 'ECONNABORTED') {
  366. console.info('The weather status request was cancelled because the user navigates.')
  367. return
  368. }
  369. if (err.response && err.response.status === 401) {
  370. showError(t('weather_status', 'You are not logged in.'))
  371. } else {
  372. showError(t('weather_status', 'There was an error getting the weather status information.'))
  373. }
  374. console.error(err)
  375. }
  376. },
  377. startLoop() {
  378. clearInterval(this.loop)
  379. if (this.lat && this.lon) {
  380. this.loop = setInterval(() => this.getForecast(), 60 * 1000 * 60)
  381. this.getForecast()
  382. } else {
  383. this.loading = false
  384. }
  385. },
  386. askBrowserLocation() {
  387. this.loading = true
  388. this.errorMessage = ''
  389. if (navigator.geolocation && window.isSecureContext) {
  390. navigator.geolocation.getCurrentPosition((position) => {
  391. console.debug('browser location success')
  392. this.lat = position.coords.latitude
  393. this.lon = position.coords.longitude
  394. this.saveMode(MODE_BROWSER_LOCATION)
  395. this.mode = MODE_BROWSER_LOCATION
  396. this.saveLocation(this.lat, this.lon)
  397. },
  398. (error) => {
  399. console.debug('location permission refused')
  400. console.debug(error)
  401. this.saveMode(MODE_MANUAL_LOCATION)
  402. this.mode = MODE_MANUAL_LOCATION
  403. // fallback on what we have if possible
  404. if (this.lat && this.lon) {
  405. this.startLoop()
  406. } else {
  407. this.usePersonalAddress()
  408. }
  409. })
  410. } else {
  411. console.debug('no secure context!')
  412. this.saveMode(MODE_MANUAL_LOCATION)
  413. this.mode = MODE_MANUAL_LOCATION
  414. this.startLoop()
  415. }
  416. },
  417. async getForecast() {
  418. try {
  419. this.forecasts = await network.fetchForecast()
  420. } catch (err) {
  421. this.errorMessage = t('weather_status', 'No weather information found')
  422. console.debug(err)
  423. }
  424. this.loading = false
  425. },
  426. async setAddress(address) {
  427. this.loading = true
  428. this.errorMessage = ''
  429. try {
  430. const loc = await network.setAddress(address)
  431. if (loc.success) {
  432. this.lat = loc.lat
  433. this.lon = loc.lon
  434. this.address = loc.address
  435. this.mode = MODE_MANUAL_LOCATION
  436. this.startLoop()
  437. } else {
  438. this.errorMessage = t('weather_status', 'Location not found')
  439. this.loading = false
  440. }
  441. } catch (err) {
  442. if (err.response && err.response.status === 401) {
  443. showError(t('weather_status', 'You are not logged in.'))
  444. } else {
  445. showError(t('weather_status', 'There was an error setting the location address.'))
  446. }
  447. this.loading = false
  448. }
  449. },
  450. async saveLocation(lat, lon) {
  451. try {
  452. const loc = await network.setLocation(lat, lon)
  453. this.address = loc.address
  454. this.startLoop()
  455. } catch (err) {
  456. if (err.response && err.response.status === 401) {
  457. showError(t('weather_status', 'You are not logged in.'))
  458. } else {
  459. showError(t('weather_status', 'There was an error setting the location.'))
  460. }
  461. console.debug(err)
  462. }
  463. },
  464. async saveMode(mode) {
  465. try {
  466. await network.setMode(mode)
  467. } catch (err) {
  468. if (err.response && err.response.status === 401) {
  469. showError(t('weather_status', 'You are not logged in.'))
  470. } else {
  471. showError(t('weather_status', 'There was an error saving the mode.'))
  472. }
  473. console.debug(err)
  474. }
  475. },
  476. onBrowserLocationClick() {
  477. this.askBrowserLocation()
  478. },
  479. async usePersonalAddress() {
  480. this.loading = true
  481. try {
  482. const loc = await network.usePersonalAddress()
  483. this.lat = loc.lat
  484. this.lon = loc.lon
  485. this.address = loc.address
  486. this.mode = MODE_MANUAL_LOCATION
  487. this.startLoop()
  488. } catch (err) {
  489. if (err.response && err.response.status === 401) {
  490. showError(t('weather_status', 'You are not logged in.'))
  491. } else {
  492. showError(t('weather_status', 'There was an error using personal address.'))
  493. }
  494. console.debug(err)
  495. this.loading = false
  496. }
  497. },
  498. onAddressSubmit() {
  499. const newAddress = this.$refs.addressInput.$el.querySelector('input[type="text"]').value
  500. this.setAddress(newAddress)
  501. },
  502. getLocalizedTemperature(celcius) {
  503. return this.useFahrenheitLocale
  504. ? (celcius * (9 / 5)) + 32
  505. : celcius
  506. },
  507. onAddRemoveFavoriteClick() {
  508. const currentIsFavorite = this.currentAddressIsFavorite
  509. if (currentIsFavorite) {
  510. const i = this.favorites.indexOf(currentIsFavorite)
  511. if (i !== -1) {
  512. this.favorites.splice(i, 1)
  513. }
  514. } else {
  515. this.favorites.push(this.address)
  516. }
  517. network.saveFavorites(this.favorites)
  518. },
  519. onFavoriteClick(e, favAddress) {
  520. // clicked on the icon
  521. if (e.target.classList.contains('action-button__icon')) {
  522. const i = this.favorites.indexOf(favAddress)
  523. if (i !== -1) {
  524. this.favorites.splice(i, 1)
  525. }
  526. network.saveFavorites(this.favorites)
  527. } else if (favAddress !== this.address) {
  528. // clicked on the text
  529. this.setAddress(favAddress)
  530. }
  531. },
  532. formatTime(time) {
  533. return moment(time).format('LT')
  534. },
  535. getTemperature(forecasts, offset = 0) {
  536. return forecasts.length > offset ? forecasts[offset].data.instant.details.air_temperature : ''
  537. },
  538. getWeatherCode(forecasts, offset = 0) {
  539. return forecasts.length > offset ? forecasts[offset].data.next_1_hours.summary.symbol_code : ''
  540. },
  541. getWeatherIconUrl(weatherCode) {
  542. // those icons were obtained there: https://github.com/metno/weathericons/tree/main/weather/svg
  543. return (weatherCode && weatherCode in weatherOptions)
  544. ? imagePath('weather_status', 'met.no.icons/' + weatherCode + '.svg')
  545. : imagePath('weather_status', 'met.no.icons/fair_day.svg')
  546. },
  547. getWeatherMessage(weatherCode, temperature, later = false) {
  548. return weatherCode && weatherCode in weatherOptions
  549. ? weatherOptions[weatherCode].text(
  550. Math.round(this.getLocalizedTemperature(temperature)),
  551. this.temperatureUnit,
  552. later,
  553. )
  554. : t('weather_status', 'Unknown weather code')
  555. },
  556. },
  557. }
  558. </script>
  559. <style lang="scss">
  560. .weather-action-image-container {
  561. width: var(--default-clickable-area);
  562. height: var(--default-clickable-area);
  563. display: flex;
  564. align-items: center;
  565. justify-content: center;
  566. }
  567. .weather-image {
  568. width: calc(var(--default-clickable-area) - 2 * var(--default-grid-baseline));
  569. }
  570. // Set color to primary element for current / active favorite address
  571. .favorite-color {
  572. color: var(--color-favorite);
  573. }
  574. </style>