diff --git a/package-lock.json b/package-lock.json index 865366b..6c070f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,11 +12,13 @@ "@raruto/leaflet-elevation": "^1.9.0", "@stencil/core": "^3.4.1", "choices.js": "^10.2.0", + "idb": "^8.0.0", "leaflet": "^1.9.4", "leaflet-i18n": "^0.3.3", "leaflet-rotate": "^0.2.8", "leaflet.locatecontrol": "^0.79.0", "leaflet.markercluster": "^1.5.3", + "leaflet.offline": "^3.0.1", "swiper": "^9.4.1" }, "devDependencies": { @@ -16906,6 +16908,11 @@ "url": "https://opencollective.com/postcss/" } }, + "node_modules/idb": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.0.tgz", + "integrity": "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -18028,6 +18035,23 @@ "leaflet": "^1.3.1" } }, + "node_modules/leaflet.offline": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/leaflet.offline/-/leaflet.offline-3.0.1.tgz", + "integrity": "sha512-sxGKa0j44bcdytU23bnPMkAPzfCo4mxA7oQeprLbIvkI1TsHO02askIRHPzWupeHA5IK0uUbyf52Ppi7KaRW1w==", + "dependencies": { + "idb": "^7.1.1", + "leaflet": "^1.6.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/leaflet.offline/node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", diff --git a/package.json b/package.json index 02cf23c..2bd864a 100644 --- a/package.json +++ b/package.json @@ -34,11 +34,13 @@ "@raruto/leaflet-elevation": "^1.9.0", "@stencil/core": "^3.4.1", "choices.js": "^10.2.0", + "idb": "^8.0.0", "leaflet": "^1.9.4", "leaflet-i18n": "^0.3.3", "leaflet-rotate": "^0.2.8", "leaflet.locatecontrol": "^0.79.0", "leaflet.markercluster": "^1.5.3", + "leaflet.offline": "^3.0.1", "swiper": "^9.4.1" }, "devDependencies": { diff --git a/src/components.d.ts b/src/components.d.ts index 89acfb1..ec9433b 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -33,9 +33,12 @@ export namespace Components { "colorTrekLine": string; "districts": string; "emergencyNumber": number; + "enableOffline": boolean; "fabBackgroundColor": string; "fabColor": string; "fontFamily": string; + "globalTilesMaxZoomOffline": number; + "globalTilesMinZoomOffline": number; "inBbox": string; "languages": string; "nameLayer": string; @@ -47,6 +50,8 @@ export namespace Components { "themes": string; "touristicContents": boolean; "touristicEvents": boolean; + "trekTilesMaxZoomOffline": number; + "trekTilesMinZoomOffline": number; "treks": boolean; "urlLayer": string; "useGradient": boolean; @@ -99,6 +104,7 @@ export namespace Components { "fontFamily": string; "isLargeView": boolean; "nameLayer": string; + "trekTilesMaxZoomOffline": number; "urlLayer": string; "useGradient": boolean; "zoom": number; @@ -120,6 +126,10 @@ export namespace Components { interface GrwSensitiveAreaDetail { "sensitiveArea": SensitiveArea; } + interface GrwSwitch { + "action": Function; + "fontFamily": string; + } interface GrwTouristicContentCard { "fontFamily": string; "isInsideHorizontalList": boolean; @@ -142,7 +152,7 @@ export namespace Components { "api": string; "languages": string; "portals": string; - "touristicContentId": string; + "touristicContentId": number; } interface GrwTouristicContentsList { "colorOnSecondaryContainer": string; @@ -226,17 +236,31 @@ export namespace Components { "colorPrimaryContainer": string; "colorSecondaryContainer": string; "colorSurfaceContainerLow": string; + "defaultBackgroundLayerAttribution": any; + "defaultBackgroundLayerUrl": any; "emergencyNumber": number; + "enableOffline": boolean; "fontFamily": string; + "globalTilesMaxZoomOffline": number; + "globalTilesMinZoomOffline": number; "isLargeView": boolean; "trek": Trek; + "trekTilesMaxZoomOffline": number; + "trekTilesMinZoomOffline": number; "weather": boolean; } interface GrwTrekProvider { "api": string; + "cities": string; + "districts": string; + "inBbox": string; "languages": string; "portals": string; - "trekId": string; + "practices": string; + "routes": string; + "structures": string; + "themes": string; + "trekId": number; } interface GrwTreksList { "colorOnSecondaryContainer": string; @@ -244,6 +268,7 @@ export namespace Components { "colorPrimaryApp": string; "colorSecondaryContainer": string; "colorSurfaceContainerLow": string; + "displayOnlyOfflineTreks": boolean; "fontFamily": string; "isLargeView": boolean; } @@ -260,6 +285,10 @@ export namespace Components { "themes": string; } } +export interface GrwAppCustomEvent extends CustomEvent { + detail: T; + target: HTMLGrwAppElement; +} export interface GrwFiltersCustomEvent extends CustomEvent { detail: T; target: HTMLGrwFiltersElement; @@ -367,6 +396,12 @@ declare global { prototype: HTMLGrwSensitiveAreaDetailElement; new (): HTMLGrwSensitiveAreaDetailElement; }; + interface HTMLGrwSwitchElement extends Components.GrwSwitch, HTMLStencilElement { + } + var HTMLGrwSwitchElement: { + prototype: HTMLGrwSwitchElement; + new (): HTMLGrwSwitchElement; + }; interface HTMLGrwTouristicContentCardElement extends Components.GrwTouristicContentCard, HTMLStencilElement { } var HTMLGrwTouristicContentCardElement: { @@ -471,6 +506,7 @@ declare global { "grw-segmented-segment": HTMLGrwSegmentedSegmentElement; "grw-select-language": HTMLGrwSelectLanguageElement; "grw-sensitive-area-detail": HTMLGrwSensitiveAreaDetailElement; + "grw-switch": HTMLGrwSwitchElement; "grw-touristic-content-card": HTMLGrwTouristicContentCardElement; "grw-touristic-content-detail": HTMLGrwTouristicContentDetailElement; "grw-touristic-content-provider": HTMLGrwTouristicContentProviderElement; @@ -514,12 +550,17 @@ declare namespace LocalJSX { "colorTrekLine"?: string; "districts"?: string; "emergencyNumber"?: number; + "enableOffline"?: boolean; "fabBackgroundColor"?: string; "fabColor"?: string; "fontFamily"?: string; + "globalTilesMaxZoomOffline"?: number; + "globalTilesMinZoomOffline"?: number; "inBbox"?: string; "languages"?: string; "nameLayer"?: string; + "onTrekDeletePress"?: (event: GrwAppCustomEvent) => void; + "onTrekDownloadPress"?: (event: GrwAppCustomEvent) => void; "portals"?: string; "practices"?: string; "rounded"?: boolean; @@ -528,6 +569,8 @@ declare namespace LocalJSX { "themes"?: string; "touristicContents"?: boolean; "touristicEvents"?: boolean; + "trekTilesMaxZoomOffline"?: number; + "trekTilesMinZoomOffline"?: number; "treks"?: boolean; "urlLayer"?: string; "useGradient"?: boolean; @@ -585,6 +628,7 @@ declare namespace LocalJSX { "onTouristicContentCardPress"?: (event: GrwMapCustomEvent) => void; "onTouristicEventCardPress"?: (event: GrwMapCustomEvent) => void; "onTrekCardPress"?: (event: GrwMapCustomEvent) => void; + "trekTilesMaxZoomOffline"?: number; "urlLayer"?: string; "useGradient"?: boolean; "zoom"?: number; @@ -606,6 +650,10 @@ declare namespace LocalJSX { interface GrwSensitiveAreaDetail { "sensitiveArea"?: SensitiveArea; } + interface GrwSwitch { + "action"?: Function; + "fontFamily"?: string; + } interface GrwTouristicContentCard { "fontFamily"?: string; "isInsideHorizontalList"?: boolean; @@ -631,7 +679,7 @@ declare namespace LocalJSX { "api"?: string; "languages"?: string; "portals"?: string; - "touristicContentId"?: string; + "touristicContentId"?: number; } interface GrwTouristicContentsList { "colorOnSecondaryContainer"?: string; @@ -721,8 +769,13 @@ declare namespace LocalJSX { "colorPrimaryContainer"?: string; "colorSecondaryContainer"?: string; "colorSurfaceContainerLow"?: string; + "defaultBackgroundLayerAttribution"?: any; + "defaultBackgroundLayerUrl"?: any; "emergencyNumber"?: number; + "enableOffline"?: boolean; "fontFamily"?: string; + "globalTilesMaxZoomOffline"?: number; + "globalTilesMinZoomOffline"?: number; "isLargeView"?: boolean; "onDescriptionIsInViewport"?: (event: GrwTrekDetailCustomEvent) => void; "onInformationPlacesIsInViewport"?: (event: GrwTrekDetailCustomEvent) => void; @@ -733,14 +786,27 @@ declare namespace LocalJSX { "onStepsIsInViewport"?: (event: GrwTrekDetailCustomEvent) => void; "onTouristicContentsIsInViewport"?: (event: GrwTrekDetailCustomEvent) => void; "onTouristicEventsIsInViewport"?: (event: GrwTrekDetailCustomEvent) => void; + "onTrekDeleteConfirm"?: (event: GrwTrekDetailCustomEvent) => void; + "onTrekDeleteSuccessConfirm"?: (event: GrwTrekDetailCustomEvent) => void; + "onTrekDownloadConfirm"?: (event: GrwTrekDetailCustomEvent) => void; + "onTrekDownloadedSuccessConfirm"?: (event: GrwTrekDetailCustomEvent) => void; "trek"?: Trek; + "trekTilesMaxZoomOffline"?: number; + "trekTilesMinZoomOffline"?: number; "weather"?: boolean; } interface GrwTrekProvider { "api"?: string; + "cities"?: string; + "districts"?: string; + "inBbox"?: string; "languages"?: string; "portals"?: string; - "trekId"?: string; + "practices"?: string; + "routes"?: string; + "structures"?: string; + "themes"?: string; + "trekId"?: number; } interface GrwTreksList { "colorOnSecondaryContainer"?: string; @@ -748,6 +814,7 @@ declare namespace LocalJSX { "colorPrimaryApp"?: string; "colorSecondaryContainer"?: string; "colorSurfaceContainerLow"?: string; + "displayOnlyOfflineTreks"?: boolean; "fontFamily"?: string; "isLargeView"?: boolean; } @@ -777,6 +844,7 @@ declare namespace LocalJSX { "grw-segmented-segment": GrwSegmentedSegment; "grw-select-language": GrwSelectLanguage; "grw-sensitive-area-detail": GrwSensitiveAreaDetail; + "grw-switch": GrwSwitch; "grw-touristic-content-card": GrwTouristicContentCard; "grw-touristic-content-detail": GrwTouristicContentDetail; "grw-touristic-content-provider": GrwTouristicContentProvider; @@ -811,6 +879,7 @@ declare module "@stencil/core" { "grw-segmented-segment": LocalJSX.GrwSegmentedSegment & JSXBase.HTMLAttributes; "grw-select-language": LocalJSX.GrwSelectLanguage & JSXBase.HTMLAttributes; "grw-sensitive-area-detail": LocalJSX.GrwSensitiveAreaDetail & JSXBase.HTMLAttributes; + "grw-switch": LocalJSX.GrwSwitch & JSXBase.HTMLAttributes; "grw-touristic-content-card": LocalJSX.GrwTouristicContentCard & JSXBase.HTMLAttributes; "grw-touristic-content-detail": LocalJSX.GrwTouristicContentDetail & JSXBase.HTMLAttributes; "grw-touristic-content-provider": LocalJSX.GrwTouristicContentProvider & JSXBase.HTMLAttributes; diff --git a/src/components/grw-app/grw-app.scss b/src/components/grw-app/grw-app.scss index 77326bb..388ffc7 100644 --- a/src/components/grw-app/grw-app.scss +++ b/src/components/grw-app/grw-app.scss @@ -185,3 +185,94 @@ grw-touristic-event-detail { margin-top: 16px; } } + +.modal-container { + z-index: 40000; + position: absolute; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-family); + top: -64px; + left: 0px; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.6); + + .modal-content-container { + display: flex; + justify-content: center; + flex-direction: column; + width: 380px; + height: 120px; + padding: 16px; + margin: 0px 16px; + background-color: var(--color-background); + border-radius: var(--border-radius, 8px); + } + + .modal-message-container { + display: flex; + align-items: center; + justify-content: center; + + .modal-loader { + width: 16px; + height: 16px; + border: 3px solid var(--color-primary-container); + border-bottom-color: var(--color-on-primary-container); + border-radius: 50%; + display: inline-block; + box-sizing: border-box; + animation: rotation 1s linear infinite; + margin-right: 16px; + } + + @keyframes rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + } + + .modal-buttons-container, + .modal-button-container { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 16px; + .modal-button { + cursor: pointer; + user-select: none; + font-size: 14px; + font-weight: 500; + height: 32px; + box-shadow: var(--elevation-0); + border: none; + -webkit-tap-highlight-color: transparent; + line-height: 20px; + color: var(--color-on-primary-container); + background-color: transparent; + font-family: var(--font-family); + } + } + .modal-button-container { + justify-content: flex-end; + } +} + +.grw-offline-container { + display: flex; + align-items: center; + margin: 0px 0px 0px 16px; + height: 48px; + .grw-offline-label { + display: flex; + } + grw-switch { + margin-top: -10px; + } +} diff --git a/src/components/grw-app/grw-app.tsx b/src/components/grw-app/grw-app.tsx index 7a910b0..417f1c4 100644 --- a/src/components/grw-app/grw-app.tsx +++ b/src/components/grw-app/grw-app.tsx @@ -1,6 +1,7 @@ -import { Component, Host, h, Listen, State, Prop, Element, Watch } from '@stencil/core'; +import { Component, Host, h, Listen, State, Prop, Element, Watch, Event, EventEmitter, Fragment } from '@stencil/core'; import { translate } from 'i18n/i18n'; import state, { reset } from 'store/store'; +import { handleTreksFiltersAndSearch } from 'utils/utils'; @Component({ tag: 'grw-app', @@ -68,6 +69,23 @@ export class GrwApp { @Prop() touristicContents = false; @Prop() touristicEvents = false; + @Prop() enableOffline = false; + @Prop() globalTilesMinZoomOffline = 0; + @Prop() globalTilesMaxZoomOffline = 11; + @Prop() trekTilesMinZoomOffline = 12; + @Prop() trekTilesMaxZoomOffline = 16; + + @State() showOfflineModal = false; + @State() showConfirmModal = false; + @State() showLoaderModal = false; + @State() showSuccessModal = false; + @State() showConfirmDeleteModal = false; + @State() showDeletingMessage = false; + @State() showDeleteSuccessMessage = false; + + @Event() trekDownloadPress: EventEmitter; + @Event() trekDeletePress: EventEmitter; + largeViewSize = 1024; handlePopStateBind: (event: any) => void = this.handlePopState.bind(this); @@ -149,6 +167,40 @@ export class GrwApp { } } + @Listen('trekDownloadConfirm', { target: 'window' }) + onTrekDownloadConfirm() { + this.showLoaderModal = false; + this.showSuccessModal = false; + this.showDeleteSuccessMessage = false; + this.showDeletingMessage = false; + this.showConfirmDeleteModal = false; + this.showOfflineModal = true; + this.showConfirmModal = true; + } + + @Listen('trekDownloadedSuccessConfirm', { target: 'window' }) + onTrekDownloadedSuccessConfirm() { + this.showLoaderModal = false; + this.showSuccessModal = true; + } + + @Listen('trekDeleteConfirm', { target: 'window' }) + onTrekDeleteConfirm() { + this.showLoaderModal = false; + this.showSuccessModal = false; + this.showDeleteSuccessMessage = false; + this.showOfflineModal = true; + this.showConfirmModal = true; + this.showConfirmDeleteModal = true; + } + + @Listen('trekDeleteSuccessConfirm', { target: 'window' }) + onTrekDeleteSuccessConfirm() { + this.showLoaderModal = false; + this.showSuccessModal = true; + this.showDeleteSuccessMessage = true; + } + @Watch('isLargeView') watchPropHandler() { window.dispatchEvent(new window.Event('resize')); @@ -311,6 +363,29 @@ export class GrwApp { window.removeEventListener('popstate', this.handlePopStateBind); } + handleOkDeleteModal() { + this.trekDeletePress.emit(); + this.showDeletingMessage = true; + this.showConfirmDeleteModal = false; + this.showConfirmModal = false; + this.showLoaderModal = true; + } + + handleOkDownloadModal() { + this.trekDownloadPress.emit(); + this.showConfirmModal = false; + this.showLoaderModal = true; + } + + handleCancelModal() { + this.showOfflineModal = false; + } + + handleOffline() { + state.offlineTreks = !state.offlineTreks; + state.currentTreks = handleTreksFiltersAndSearch(); + } + render() { return ( 1 ? '136px' : '64px', + '--header-height': + Number(this.treks) + Number(this.touristicContents) + Number(this.touristicEvents) > 1 + ? this.enableOffline + ? '136px' + : '188px' + : this.enableOffline + ? '116px' + : '64px', '--header-with-segment': Number(this.treks) + Number(this.touristicContents) + Number(this.touristicEvents) > 1 ? '16px' : '0px', - '--header-with-languages': this.languages.length > 1 ? '38px' : '0px', + '--header-with-languages': this.languages.split(',').length > 1 ? '38px' : '0px', '--border-radius': this.rounded ? '' : '0px', }} > @@ -354,18 +436,30 @@ export class GrwApp { )} {this.showTrek && this.currentTrekId && !state.currentTrek && ( - + )} {this.showTouristicContent && this.currentTouristicContentId && ( )} {!this.showTrek && !this.showTouristicContent && !this.showTouristicEvent ? ( -
-
- + +
+
+ +
+
+ this.handleFilters()} + icon={'filter_list'} + name={translate[state.language].filter} + > +
-
- this.handleFilters()} - icon={'filter_list'} - name={translate[state.language].filter} - > -
-
+ {this.enableOffline && ( +
+
Afficher uniquement les itinéraires hors ligne
+ this.handleOffline()}> +
+ )} + ) : (
@@ -542,7 +644,66 @@ export class GrwApp { weather={this.weather} is-large-view={this.isLargeView} emergency-number={this.emergencyNumber} + defaultBackgroundLayerUrl={this.urlLayer.split(',http').map((url, index) => (index === 0 ? url : 'http' + url))[0]} + defaultBackgroundLayerAttribution={this.attributionLayer ? this.attributionLayer.split(',')[0] : []} + enable-offline={this.enableOffline} + global-tiles-min-zoom-offline={this.globalTilesMinZoomOffline} + global-tiles-max-zoom-offline={this.globalTilesMaxZoomOffline} + trek-tiles-min-zoom-offline={this.trekTilesMinZoomOffline} + trek-tiles-max-zoom-offline={this.trekTilesMaxZoomOffline} > + {this.showOfflineModal && ( + + )}
)} {this.showTouristicContent && ( @@ -616,6 +777,7 @@ export class GrwApp { color-poi-icon={this.colorPoiIcon} is-large-view={this.isLargeView} use-gradient={this.useGradient} + trek-tiles-max-zoom-offline={this.trekTilesMaxZoomOffline} > )}
diff --git a/src/components/grw-app/readme.md b/src/components/grw-app/readme.md index 8988af3..905ad98 100644 --- a/src/components/grw-app/readme.md +++ b/src/components/grw-app/readme.md @@ -5,51 +5,73 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| --------------------------- | ------------------------------ | ----------- | --------- | ----------- | -| `api` | `api` | | `string` | `undefined` | -| `appHeight` | `app-height` | | `string` | `'100vh'` | -| `appWidth` | `app-width` | | `string` | `'100%'` | -| `attributionLayer` | `attribution-layer` | | `string` | `undefined` | -| `center` | `center` | | `string` | `undefined` | -| `cities` | `cities` | | `string` | `undefined` | -| `colorBackground` | `color-background` | | `string` | `'#fef7ff'` | -| `colorOnPrimary` | `color-on-primary` | | `string` | `'#ffffff'` | -| `colorOnPrimaryContainer` | `color-on-primary-container` | | `string` | `'#21005e'` | -| `colorOnSecondaryContainer` | `color-on-secondary-container` | | `string` | `'#1d192b'` | -| `colorOnSurface` | `color-on-surface` | | `string` | `'#49454e'` | -| `colorOnSurfaceVariant` | `color-on-surface-variant` | | `string` | `'#1c1b1f'` | -| `colorPoiIcon` | `color-poi-icon` | | `string` | `'#974c6e'` | -| `colorPrimaryApp` | `color-primary-app` | | `string` | `'#6750a4'` | -| `colorPrimaryContainer` | `color-primary-container` | | `string` | `'#eaddff'` | -| `colorSecondaryContainer` | `color-secondary-container` | | `string` | `'#e8def8'` | -| `colorSensitiveArea` | `color-sensitive-area` | | `string` | `'#4974a5'` | -| `colorSurface` | `color-surface` | | `string` | `'#1c1b1f'` | -| `colorSurfaceContainerHigh` | `color-surface-container-high` | | `string` | `'#ece6f0'` | -| `colorSurfaceContainerLow` | `color-surface-container-low` | | `string` | `'#f7f2fa'` | -| `colorSurfaceVariant` | `color-surface-variant` | | `string` | `'#fef7ff'` | -| `colorTrekLine` | `color-trek-line` | | `string` | `'#6b0030'` | -| `districts` | `districts` | | `string` | `undefined` | -| `emergencyNumber` | `emergency-number` | | `number` | `undefined` | -| `fabBackgroundColor` | `fab-background-color` | | `string` | `'#eaddff'` | -| `fabColor` | `fab-color` | | `string` | `'#21005d'` | -| `fontFamily` | `font-family` | | `string` | `'Roboto'` | -| `inBbox` | `in-bbox` | | `string` | `undefined` | -| `languages` | `languages` | | `string` | `'fr'` | -| `nameLayer` | `name-layer` | | `string` | `undefined` | -| `portals` | `portals` | | `string` | `undefined` | -| `practices` | `practices` | | `string` | `undefined` | -| `rounded` | `rounded` | | `boolean` | `true` | -| `routes` | `routes` | | `string` | `undefined` | -| `structures` | `structures` | | `string` | `undefined` | -| `themes` | `themes` | | `string` | `undefined` | -| `touristicContents` | `touristic-contents` | | `boolean` | `false` | -| `touristicEvents` | `touristic-events` | | `boolean` | `false` | -| `treks` | `treks` | | `boolean` | `true` | -| `urlLayer` | `url-layer` | | `string` | `undefined` | -| `useGradient` | `use-gradient` | | `boolean` | `false` | -| `weather` | `weather` | | `boolean` | `false` | -| `zoom` | `zoom` | | `number` | `undefined` | +| Property | Attribute | Description | Type | Default | +| --------------------------- | ------------------------------- | ----------- | --------- | ----------- | +| `api` | `api` | | `string` | `undefined` | +| `appHeight` | `app-height` | | `string` | `'100vh'` | +| `appWidth` | `app-width` | | `string` | `'100%'` | +| `attributionLayer` | `attribution-layer` | | `string` | `undefined` | +| `center` | `center` | | `string` | `undefined` | +| `cities` | `cities` | | `string` | `undefined` | +| `colorBackground` | `color-background` | | `string` | `'#fef7ff'` | +| `colorOnPrimary` | `color-on-primary` | | `string` | `'#ffffff'` | +| `colorOnPrimaryContainer` | `color-on-primary-container` | | `string` | `'#21005e'` | +| `colorOnSecondaryContainer` | `color-on-secondary-container` | | `string` | `'#1d192b'` | +| `colorOnSurface` | `color-on-surface` | | `string` | `'#49454e'` | +| `colorOnSurfaceVariant` | `color-on-surface-variant` | | `string` | `'#1c1b1f'` | +| `colorPoiIcon` | `color-poi-icon` | | `string` | `'#974c6e'` | +| `colorPrimaryApp` | `color-primary-app` | | `string` | `'#6750a4'` | +| `colorPrimaryContainer` | `color-primary-container` | | `string` | `'#eaddff'` | +| `colorSecondaryContainer` | `color-secondary-container` | | `string` | `'#e8def8'` | +| `colorSensitiveArea` | `color-sensitive-area` | | `string` | `'#4974a5'` | +| `colorSurface` | `color-surface` | | `string` | `'#1c1b1f'` | +| `colorSurfaceContainerHigh` | `color-surface-container-high` | | `string` | `'#ece6f0'` | +| `colorSurfaceContainerLow` | `color-surface-container-low` | | `string` | `'#f7f2fa'` | +| `colorSurfaceVariant` | `color-surface-variant` | | `string` | `'#fef7ff'` | +| `colorTrekLine` | `color-trek-line` | | `string` | `'#6b0030'` | +| `districts` | `districts` | | `string` | `undefined` | +| `emergencyNumber` | `emergency-number` | | `number` | `undefined` | +| `enableOffline` | `enable-offline` | | `boolean` | `false` | +| `fabBackgroundColor` | `fab-background-color` | | `string` | `'#eaddff'` | +| `fabColor` | `fab-color` | | `string` | `'#21005d'` | +| `fontFamily` | `font-family` | | `string` | `'Roboto'` | +| `globalTilesMaxZoomOffline` | `global-tiles-max-zoom-offline` | | `number` | `11` | +| `globalTilesMinZoomOffline` | `global-tiles-min-zoom-offline` | | `number` | `0` | +| `inBbox` | `in-bbox` | | `string` | `undefined` | +| `languages` | `languages` | | `string` | `'fr'` | +| `nameLayer` | `name-layer` | | `string` | `undefined` | +| `portals` | `portals` | | `string` | `undefined` | +| `practices` | `practices` | | `string` | `undefined` | +| `rounded` | `rounded` | | `boolean` | `true` | +| `routes` | `routes` | | `string` | `undefined` | +| `structures` | `structures` | | `string` | `undefined` | +| `themes` | `themes` | | `string` | `undefined` | +| `touristicContents` | `touristic-contents` | | `boolean` | `false` | +| `touristicEvents` | `touristic-events` | | `boolean` | `false` | +| `trekTilesMaxZoomOffline` | `trek-tiles-max-zoom-offline` | | `number` | `16` | +| `trekTilesMinZoomOffline` | `trek-tiles-min-zoom-offline` | | `number` | `12` | +| `treks` | `treks` | | `boolean` | `true` | +| `urlLayer` | `url-layer` | | `string` | `undefined` | +| `useGradient` | `use-gradient` | | `boolean` | `false` | +| `weather` | `weather` | | `boolean` | `false` | +| `zoom` | `zoom` | | `number` | `undefined` | + + +## Events + +| Event | Description | Type | +| ------------------- | ----------- | --------------------- | +| `trekDeletePress` | | `CustomEvent` | +| `trekDownloadPress` | | `CustomEvent` | + + +## Shadow Parts + +| Part | Description | +| ------------------------- | ----------- | +| `"grw-offline-container"` | | +| `"icon"` | | +| `"modal-button"` | | ## Dependencies @@ -67,6 +89,7 @@ - [grw-segmented-segment](../grw-segmented-segment) - [grw-search](../grw-search) - [grw-common-button](../grw-common-button) +- [grw-switch](../grw-switch) - [grw-treks-list](../grw-treks-list) - [grw-touristic-contents-list](../grw-touristic-contents-list) - [grw-touristic-events-list](../grw-touristic-events-list) @@ -91,6 +114,7 @@ graph TD; grw-app --> grw-segmented-segment grw-app --> grw-search grw-app --> grw-common-button + grw-app --> grw-switch grw-app --> grw-treks-list grw-app --> grw-touristic-contents-list grw-app --> grw-touristic-events-list diff --git a/src/components/grw-filters/grw-filters.scss b/src/components/grw-filters/grw-filters.scss index 19aede9..505454f 100644 --- a/src/components/grw-filters/grw-filters.scss +++ b/src/components/grw-filters/grw-filters.scss @@ -103,7 +103,7 @@ border: none; -webkit-tap-highlight-color: transparent; line-height: 20px; - color: var(--color-primary-container); + color: var(--color-on-primary-container); background-color: transparent; font-family: var(--font-family); } diff --git a/src/components/grw-information-desk/grw-information-desk.scss b/src/components/grw-information-desk/grw-information-desk.scss index e6fc2c7..6556fd4 100644 --- a/src/components/grw-information-desk/grw-information-desk.scss +++ b/src/components/grw-information-desk/grw-information-desk.scss @@ -78,7 +78,7 @@ border-radius: var(--border-radius, 8px); padding: 4px 8px; box-shadow: var(--elevation-1); - height: 32px; + min-height: 32px; gap: 8px; box-sizing: border-box; text-align: start; diff --git a/src/components/grw-information-desk/grw-information-desk.tsx b/src/components/grw-information-desk/grw-information-desk.tsx index 944e9f3..d8189be 100644 --- a/src/components/grw-information-desk/grw-information-desk.tsx +++ b/src/components/grw-information-desk/grw-information-desk.tsx @@ -1,7 +1,8 @@ -import { Component, Host, h, Prop, State, Event, EventEmitter } from '@stencil/core'; +import { Component, Host, h, Prop, State, Event, EventEmitter, getAssetPath, Build } from '@stencil/core'; import state from 'store/store'; import { translate } from 'i18n/i18n'; -import { InformationDesk } from 'types/types'; +import { InformationDesk, Trek } from 'types/types'; +import { getDataInStore } from 'services/grw-db.service'; @Component({ tag: 'grw-information-desk', @@ -15,7 +16,11 @@ export class GrwInformationDeskDetail { @State() showInformationDeskDescriptionButton = false; @Prop() informationDesk: InformationDesk; + @State() offline = false; + @State() showDefaultImage = false; + componentDidLoad() { + this.handleOffline(); if (this.descriptionRef) { this.showInformationDeskDescriptionButton = this.descriptionRef.clientHeight < this.descriptionRef.scrollHeight; } @@ -29,7 +34,16 @@ export class GrwInformationDeskDetail { this.centerOnMap.emit({ latitude: this.informationDesk.latitude, longitude: this.informationDesk.longitude }); } + async handleOffline() { + if (this.informationDesk) { + const trekInStore: Trek = await getDataInStore('treks', state.currentTrek.id); + const informationDeskInStore: InformationDesk = await getDataInStore('informationDesks', this.informationDesk.id); + this.offline = trekInStore && trekInStore.offline && Boolean(informationDeskInStore); + } + } + render() { + const defaultImageSrc = getAssetPath(`${Build.isDev ? '/' : ''}assets/default-image.svg`); return ( {this.informationDesk.photo_url && ( @@ -41,6 +55,11 @@ export class GrwInformationDeskDetail { crossorigin="anonymous" src={this.informationDesk.photo_url} loading="lazy" + /* @ts-ignore */ + onerror={event => { + event.target.onerror = null; + event.target.src = defaultImageSrc; + }} /> )} diff --git a/src/components/grw-map/grw-map.tsx b/src/components/grw-map/grw-map.tsx index 4161622..b37452e 100644 --- a/src/components/grw-map/grw-map.tsx +++ b/src/components/grw-map/grw-map.tsx @@ -9,6 +9,10 @@ import 'leaflet.markercluster/dist/leaflet.markercluster.js'; import state, { onChange } from 'store/store'; import { translate } from 'i18n/i18n'; import { getTrekGeometry } from 'services/treks.service'; +import { tileLayerOffline } from 'leaflet.offline'; +import { Trek } from 'components'; +import { getDataInStore } from 'services/grw-db.service'; +import { arrayBufferToBlob } from 'utils/utils'; @Component({ tag: 'grw-map', @@ -42,6 +46,7 @@ export class GrwMap { @Prop() colorSensitiveArea = '#4974a5'; @Prop() colorPoiIcon = '#974c6e'; @Prop() useGradient = false; + @Prop() trekTilesMaxZoomOffline = 16; @Prop() isLargeView = false; map: L.Map; @@ -49,7 +54,7 @@ export class GrwMap { bounds; treksLayer: L.GeoJSON; toutisticContentsLayer: L.GeoJSON; - toutisticEventsLayer: L.GeoJSON; + touristicEventsLayer: L.GeoJSON; treksMarkerClusterGroup: MarkerClusterGroup; touristicContentsMarkerClusterGroup: MarkerClusterGroup; @@ -66,7 +71,7 @@ export class GrwMap { currentPoisLayer: L.GeoJSON; currentInformationDesksLayer: L.GeoJSON; currentToutisticContentsLayer: L.GeoJSON; - currentToutisticEventsLayer: L.GeoJSON; + currenttouristicEventsLayer: L.GeoJSON; currentToutisticContentLayer: L.GeoJSON; currentToutisticEventLayer: L.GeoJSON; elevationControl: L.Control.Layers; @@ -141,8 +146,8 @@ export class GrwMap { @Listen('touristicEventsIsInViewport', { target: 'window' }) touristicEventsIsInViewport(event: CustomEvent) { - if (this.currentToutisticEventsLayer) { - this.handleLayerVisibility(event.detail, this.currentToutisticEventsLayer); + if (this.currenttouristicEventsLayer) { + this.handleLayerVisibility(event.detail, this.currenttouristicEventsLayer); } } @@ -198,6 +203,16 @@ export class GrwMap { this.removeSelectedTouristicEvent(); } + @Listen('trekDownloadedSuccessConfirm', { target: 'window' }) + onTrekDownloadedSuccessConfirm() { + this.map.setMaxZoom(this.trekTilesMaxZoomOffline); + } + + @Listen('trekDeleteSuccessConfirm', { target: 'window' }) + onTrekDeleteSuccessConfirm() { + this.map.setMaxZoom(this.maxZoom); + } + componentDidLoad() { this.map = L.map(this.mapRef, { center: this.center.split(',').map(Number) as L.LatLngExpression, @@ -215,7 +230,7 @@ export class GrwMap { urlLayers.forEach((urlLayer, index) => { this.tileLayer.push({ key: `${nameLayers[index]}`, - value: L.tileLayer(urlLayer, { + value: tileLayerOffline(urlLayer, { maxZoom: this.maxZoom, attribution: `${ attributionLayers[index] && attributionLayers[index] !== '' ? attributionLayers[index].concat(' | ') : '' @@ -224,6 +239,7 @@ export class GrwMap { }), }); }); + this.tileLayer[0].value.addTo(this.map); if (urlLayers.length > 1) { @@ -234,28 +250,28 @@ export class GrwMap { { collapsed: true }, ) .addTo(this.map); + } - (L.Control as any).Contract = L.Control.extend({ - onAdd: () => { - const contractContainer = L.DomUtil.create('div'); - contractContainer.className = 'leaflet-bar leaflet-control'; - const contract = contractContainer.appendChild(L.DomUtil.create('a')); - contract.href = '#'; - contract.style.backgroundImage = `var(--contract-image-src)`; - contractContainer.onclick = e => { - this.bounds && this.map.fitBounds(this.bounds); - e.preventDefault(); - }; - return contractContainer; - }, - }); + (L.Control as any).Contract = L.Control.extend({ + onAdd: () => { + const contractContainer = L.DomUtil.create('div'); + contractContainer.className = 'leaflet-bar leaflet-control'; + const contract = contractContainer.appendChild(L.DomUtil.create('a')); + contract.href = '#'; + contract.style.backgroundImage = `var(--contract-image-src)`; + contractContainer.onclick = e => { + this.bounds && this.map.fitBounds(this.bounds); + e.preventDefault(); + }; + return contractContainer; + }, + }); - (L.control as any).contract = opts => { - return new (L.Control as any).Contract(opts); - }; + (L.control as any).contract = opts => { + return new (L.Control as any).Contract(opts); + }; - (L.control as any).contract({ position: 'topleft' }).addTo(this.map); - } + (L.control as any).contract({ position: 'topleft' }).addTo(this.map); if (state.currentTrek) { this.addTrek(); @@ -401,7 +417,7 @@ export class GrwMap { this.handleLayersControlEvent(); } - addTreks(resetBounds = false) { + async addTreks(resetBounds = false) { state.treksWithinBounds = state.currentTreks; const treksCurrentDepartureCoordinates = []; @@ -434,12 +450,15 @@ export class GrwMap { this.bounds = state.currentMapBounds; } } + + const treksIcons = await this.getIcons(treksFeatureCollection, 'practice'); + this.treksLayer = L.geoJSON(treksFeatureCollection, { pointToLayer: (geoJsonPoint, latlng) => L.marker(latlng, { icon: L.divIcon({ - html: geoJsonPoint.properties.practice - ? `
` + html: treksIcons[geoJsonPoint.properties.practice] + ? `
` : `
`, className: 'trek-marker', iconSize: 32, @@ -448,12 +467,18 @@ export class GrwMap { autoPanOnFocus: false, } as any), onEachFeature: (geoJsonPoint, layer) => { - layer.once('click', () => { + layer.once('click', async () => { + const dataInStore = await getDataInStore('images', geoJsonPoint.properties.imgSrc); + const imgSrc = dataInStore + ? window.URL.createObjectURL(arrayBufferToBlob(dataInStore.data, dataInStore.type)) + : geoJsonPoint.properties.imgSrc + ? geoJsonPoint.properties.imgSrc + : null; const trekDeparturePopup = L.DomUtil.create('div'); trekDeparturePopup.className = 'trek-departure-popup'; - if (geoJsonPoint.properties.imgSrc) { + if (imgSrc) { const trekImg = L.DomUtil.create('img'); - trekImg.src = geoJsonPoint.properties.imgSrc; + trekImg.src = imgSrc; trekImg.crossOrigin = 'anonymous'; trekDeparturePopup.appendChild(trekImg); } @@ -499,8 +524,7 @@ export class GrwMap { this.treksMarkerClusterGroup.clearLayers(); this.treksMarkerClusterGroup.addLayer(this.treksLayer); } - - this.bounds && this.map.fitBounds(this.bounds); + this.bounds && this.bounds._northEast && this.bounds._southWest && this.map.fitBounds(this.bounds); !this.mapIsReady && (this.mapIsReady = !this.mapIsReady); this.map.on('moveend', this.handleTreksWithinBoundsBind); @@ -547,7 +571,13 @@ export class GrwMap { } } - addTrek() { + async addTrek() { + const trekInStore: Trek = await getDataInStore('treks', state.currentTrek.id); + if (trekInStore) { + this.map.setMaxZoom(this.trekTilesMaxZoomOffline); + } else { + this.map.setMaxZoom(this.maxZoom); + } this.trekPopupIsOpen = false; state.selectedTrekId = null; this.hideTrekLine(); @@ -642,11 +672,13 @@ export class GrwMap { }); } + const poiIcons = await this.getIcons(currentPoisFeatureCollection, 'type_pictogram'); + this.currentPoisLayer = L.geoJSON(currentPoisFeatureCollection, { pointToLayer: (geoJsonPoint, latlng) => L.marker(latlng, { icon: L.divIcon({ - html: geoJsonPoint.properties.type_pictogram ? `` : ``, + html: poiIcons[geoJsonPoint.properties.type_pictogram] ? `` : ``, className: 'poi-icon', iconSize: 48, } as any), @@ -683,16 +715,21 @@ export class GrwMap { }); } + const toutisticContentsIcons = await this.getIcons(currentTouristicContentsFeatureCollection, 'category_pictogram'); + this.currentToutisticContentsLayer = L.geoJSON(currentTouristicContentsFeatureCollection, { - pointToLayer: (geoJsonPoint, latlng) => - L.marker(latlng, { + pointToLayer: (geoJsonPoint, latlng) => { + return L.marker(latlng, { icon: L.divIcon({ - html: geoJsonPoint.properties.category_pictogram ? `` : ``, + html: toutisticContentsIcons[geoJsonPoint.properties.category_pictogram] + ? `` + : ``, className: 'touristic-content-icon', iconSize: 48, } as any), autoPanOnFocus: false, - } as any), + } as any); + }, onEachFeature: (geoJsonPoint, layer) => { layer.once('mouseover', () => { const touristicContentTooltip = L.DomUtil.create('div'); @@ -724,13 +761,14 @@ export class GrwMap { }); } - this.currentToutisticEventsLayer = L.geoJSON(currentTouristicEventsFeatureCollection, { + const toutisticEventsIcons = await this.getIcons(currentTouristicEventsFeatureCollection, 'type_pictogram'); + this.currenttouristicEventsLayer = L.geoJSON(currentTouristicEventsFeatureCollection, { pointToLayer: (geoJsonPoint, latlng) => L.marker(latlng, { icon: L.divIcon({ - html: geoJsonPoint.properties.type_pictogram + html: toutisticEventsIcons[geoJsonPoint.properties.type_pictogram] ? ` - ` + ` : ``, className: 'touristic-event-icon', iconSize: 48, @@ -770,11 +808,14 @@ export class GrwMap { } if (currentInformationDesksFeatureCollection.features.length > 0) { + const informationDesksIcons = await this.getIcons(currentInformationDesksFeatureCollection, 'type_pictogram'); this.currentInformationDesksLayer = L.geoJSON(currentInformationDesksFeatureCollection, { pointToLayer: (geoJsonPoint, latlng) => L.marker(latlng, { icon: L.divIcon({ - html: geoJsonPoint.properties.type_pictogram ? `` : ``, + html: informationDesksIcons[geoJsonPoint.properties.type_pictogram] + ? `` + : ``, className: 'information-desks-icon', iconSize: 48, } as any), @@ -861,12 +902,18 @@ export class GrwMap { } as any); }, onEachFeature: (geoJsonPoint, layer) => { - layer.once('click', () => { + layer.once('click', async () => { + const dataInStore = await getDataInStore('images', geoJsonPoint.properties.imgSrc); + const imgSrc = dataInStore + ? window.URL.createObjectURL(arrayBufferToBlob(dataInStore.data, dataInStore.type)) + : geoJsonPoint.properties.imgSrc + ? geoJsonPoint.properties.imgSrc + : null; const trekDeparturePopup = L.DomUtil.create('div'); trekDeparturePopup.className = 'trek-departure-popup'; - if (geoJsonPoint.properties.imgSrc) { + if (imgSrc) { const trekImg = L.DomUtil.create('img'); - trekImg.src = geoJsonPoint.properties.imgSrc; + trekImg.src = imgSrc; trekImg.crossOrigin = 'anonymous'; trekDeparturePopup.appendChild(trekImg); } @@ -1005,8 +1052,8 @@ export class GrwMap { overlays[translate[state.language].layers.touristicContents] = this.currentToutisticContentsLayer; } - if (this.currentToutisticEventsLayer) { - overlays[translate[state.language].layers.touristicEvents] = this.currentToutisticEventsLayer; + if (this.currenttouristicEventsLayer) { + overlays[translate[state.language].layers.touristicEvents] = this.currenttouristicEventsLayer; } if (this.layersControl) { @@ -1025,7 +1072,22 @@ export class GrwMap { !this.mapIsReady && (this.mapIsReady = !this.mapIsReady); } - addSelectedCurrentTrek(id, customCoordinates?) { + async getIcons(featuresCollection, property) { + const icons = {}; + for (let index = 0; index < featuresCollection.features.length; index++) { + if (featuresCollection.features[index].properties[property]) { + icons[featuresCollection.features[index].properties[property]] = await this.getIcon(featuresCollection.features[index].properties[property]); + } + } + return icons; + } + async getIcon(pictogram) { + const dataInStore = await getDataInStore('images', pictogram); + const icon = dataInStore ? window.URL.createObjectURL(arrayBufferToBlob(dataInStore.data, dataInStore.type)) : pictogram ? pictogram : null; + return icon; + } + + async addSelectedCurrentTrek(id, customCoordinates?) { const treksFeatureCollection: FeatureCollection = { type: 'FeatureCollection', features: [], @@ -1048,13 +1110,14 @@ export class GrwMap { this.removeSelectedCurrentTrek(); state.selectedTrekId = id; + const treksIcons = await this.getIcons(treksFeatureCollection, 'practice'); this.selectedCurrentTrekLayer = L.geoJSON(treksFeatureCollection, { pointToLayer: (geoJsonPoint, latlng) => L.marker(latlng, { zIndexOffset: 4000000, icon: L.divIcon({ - html: geoJsonPoint.properties.practice - ? `
` + html: treksIcons[geoJsonPoint.properties.practice] + ? `
` : `
`, className: 'selected-trek-marker', iconSize: 48, @@ -1063,12 +1126,18 @@ export class GrwMap { autoPanOnFocus: false, } as any), onEachFeature: (geoJsonPoint, layer) => { - layer.once('click', () => { + layer.once('click', async () => { + const dataInStore = await getDataInStore('images', geoJsonPoint.properties.imgSrc); + const imgSrc = dataInStore + ? window.URL.createObjectURL(arrayBufferToBlob(dataInStore.data, dataInStore.type)) + : geoJsonPoint.properties.imgSrc + ? geoJsonPoint.properties.imgSrc + : null; const trekDeparturePopup = L.DomUtil.create('div'); trekDeparturePopup.className = 'trek-departure-popup'; - if (geoJsonPoint.properties.imgSrc) { + if (imgSrc) { const trekImg = L.DomUtil.create('img'); - trekImg.src = geoJsonPoint.properties.imgSrc; + trekImg.src = imgSrc; trekImg.crossOrigin = 'anonymous'; trekDeparturePopup.appendChild(trekImg); } @@ -1151,12 +1220,18 @@ export class GrwMap { autoPanOnFocus: false, } as any), onEachFeature: (geoJsonPoint, layer) => { - layer.once('click', () => { + layer.once('click', async () => { + const dataInStore = await getDataInStore('images', geoJsonPoint.properties.imgSrc); + const imgSrc = dataInStore + ? window.URL.createObjectURL(arrayBufferToBlob(dataInStore.data, dataInStore.type)) + : geoJsonPoint.properties.imgSrc + ? geoJsonPoint.properties.imgSrc + : null; const trekDeparturePopup = L.DomUtil.create('div'); trekDeparturePopup.className = 'trek-departure-popup'; - if (geoJsonPoint.properties.imgSrc) { + if (imgSrc) { const trekImg = L.DomUtil.create('img'); - trekImg.src = geoJsonPoint.properties.imgSrc; + trekImg.src = imgSrc; trekImg.crossOrigin = 'anonymous'; trekDeparturePopup.appendChild(trekImg); } @@ -1276,9 +1351,9 @@ export class GrwMap { this.map.removeLayer(this.currentToutisticContentsLayer); this.currentToutisticContentsLayer = null; } - if (this.currentToutisticEventsLayer) { - this.map.removeLayer(this.currentToutisticEventsLayer); - this.currentToutisticEventsLayer = null; + if (this.currenttouristicEventsLayer) { + this.map.removeLayer(this.currenttouristicEventsLayer); + this.currenttouristicEventsLayer = null; } } @@ -1306,7 +1381,7 @@ export class GrwMap { } } - addTouristicContent() { + async addTouristicContent() { this.touristicContentPopupIsOpen = false; state.selectedTouristicContentId = null; this.removeSelectedTouristicContent(); @@ -1329,12 +1404,13 @@ export class GrwMap { } if (!this.currentToutisticContentLayer) { + const toutisticContentIcons = await this.getIcons(touristicContentsFeatureCollection, 'practice'); this.currentToutisticContentLayer = L.geoJSON(touristicContentsFeatureCollection, { pointToLayer: (geoJsonPoint, latlng) => L.marker(latlng, { icon: L.divIcon({ - html: geoJsonPoint.properties.practice - ? `
` + html: toutisticContentIcons[geoJsonPoint.properties.practice] + ? `
` : `
`, className: 'touristic-content-marker', iconSize: 48, @@ -1356,7 +1432,7 @@ export class GrwMap { } } - addTouristicContents(resetBounds = false) { + async addTouristicContents(resetBounds = false) { state.touristicContentsWithinBounds = state.currentTouristicContents; const touristicContentsCurrentCoordinates = []; @@ -1391,12 +1467,13 @@ export class GrwMap { this.bounds = state.currentMapBounds; } } + const toutisticContentIcons = await this.getIcons(touristicContentsFeatureCollection, 'practice'); this.toutisticContentsLayer = L.geoJSON(touristicContentsFeatureCollection, { pointToLayer: (geoJsonPoint, latlng) => L.marker(latlng, { icon: L.divIcon({ - html: geoJsonPoint.properties.practice - ? `
` + html: toutisticContentIcons[geoJsonPoint.properties.practice] + ? `
` : `
`, className: 'touristic-content-marker', iconSize: 32, @@ -1476,7 +1553,7 @@ export class GrwMap { } } - addTouristicEvent() { + async addTouristicEvent() { const touristicEventsFeatureCollection: FeatureCollection = { type: 'FeatureCollection', features: [], @@ -1496,12 +1573,13 @@ export class GrwMap { } if (!this.currentToutisticEventLayer) { + const toutisticEventsIcons = await this.getIcons(touristicEventsFeatureCollection, 'type'); this.currentToutisticEventLayer = L.geoJSON(touristicEventsFeatureCollection, { pointToLayer: (geoJsonPoint, latlng) => L.marker(latlng, { icon: L.divIcon({ - html: geoJsonPoint.properties.type - ? `
` + html: toutisticEventsIcons[geoJsonPoint.properties.type] + ? `
` : `
`, className: 'touristic-event-marker', iconSize: 48, @@ -1523,7 +1601,7 @@ export class GrwMap { } } - addSelectedTouristicContent(id, customCoordinates?) { + async addSelectedTouristicContent(id, customCoordinates?) { const touristicContentFeatureCollection: FeatureCollection = { type: 'FeatureCollection', features: [], @@ -1546,13 +1624,14 @@ export class GrwMap { this.removeSelectedTouristicContent(); state.selectedTouristicContentId = id; + const toutisticContentIcons = await this.getIcons(touristicContentFeatureCollection, 'category'); this.selectedTouristicContentLayer = L.geoJSON(touristicContentFeatureCollection, { pointToLayer: (geoJsonPoint, latlng) => L.marker(latlng, { zIndexOffset: 4000000, icon: L.divIcon({ - html: geoJsonPoint.properties.category - ? `
` + html: toutisticContentIcons[geoJsonPoint.properties.category] + ? `
` : `
`, className: 'selected-touristic-content-marker', iconSize: 48, @@ -1610,7 +1689,7 @@ export class GrwMap { this.selectedTouristicContentLayer = null; } - addTouristicEvents(resetBounds = false) { + async addTouristicEvents(resetBounds = false) { state.touristicEventsWithinBounds = state.currentTouristicEvents; const touristicEventsCurrentCoordinates = []; @@ -1637,7 +1716,7 @@ export class GrwMap { } } - if (!this.toutisticEventsLayer) { + if (!this.touristicEventsLayer) { if ((touristicEventsCurrentCoordinates.length > 0 && !state.currentMapBounds) || resetBounds) { this.bounds = L.latLngBounds(touristicEventsCurrentCoordinates.map(coordinate => [coordinate[1], coordinate[0]])); } else { @@ -1645,12 +1724,13 @@ export class GrwMap { this.bounds = state.currentMapBounds; } } - this.toutisticEventsLayer = L.geoJSON(touristicEventsFeatureCollection, { + const touristicEventsIcons = await this.getIcons(touristicEventsFeatureCollection, 'type'); + this.touristicEventsLayer = L.geoJSON(touristicEventsFeatureCollection, { pointToLayer: (geoJsonPoint, latlng) => L.marker(latlng, { icon: L.divIcon({ - html: geoJsonPoint.properties.type - ? `
` + html: touristicEventsIcons[geoJsonPoint.properties.type] + ? `
` : `
`, className: 'touristic-event-marker', iconSize: 32, @@ -1699,7 +1779,7 @@ export class GrwMap { }, }); - this.touristicEventsMarkerClusterGroup.addLayer(this.toutisticEventsLayer); + this.touristicEventsMarkerClusterGroup.addLayer(this.touristicEventsLayer); this.map.addLayer(this.touristicEventsMarkerClusterGroup); } else { if (touristicEventsCurrentCoordinates.length > 0) { @@ -1707,10 +1787,10 @@ export class GrwMap { } else { this.map.fire('moveend'); } - this.toutisticEventsLayer.clearLayers(); - this.toutisticEventsLayer.addData(touristicEventsFeatureCollection); + this.touristicEventsLayer.clearLayers(); + this.touristicEventsLayer.addData(touristicEventsFeatureCollection); this.touristicEventsMarkerClusterGroup.clearLayers(); - this.touristicEventsMarkerClusterGroup.addLayer(this.toutisticEventsLayer); + this.touristicEventsMarkerClusterGroup.addLayer(this.touristicEventsLayer); } this.bounds && this.map.fitBounds(this.bounds); @@ -1721,16 +1801,16 @@ export class GrwMap { } removeTouristicEvents() { - if (this.toutisticEventsLayer) { + if (this.touristicEventsLayer) { state.currentMapBounds = this.map.getBounds(); this.map.removeLayer(this.touristicEventsMarkerClusterGroup); - this.toutisticEventsLayer = null; + this.touristicEventsLayer = null; this.touristicEventsMarkerClusterGroup = null; this.map.off('moveend', this.handleTouristicEventsWithinBoundsBind); } } - addSelectedTouristicEvent(id, customCoordinates?) { + async addSelectedTouristicEvent(id, customCoordinates?) { const touristicEventFeatureCollection: FeatureCollection = { type: 'FeatureCollection', features: [], @@ -1753,13 +1833,14 @@ export class GrwMap { this.removeSelectedTouristicEvent(); state.selectedTouristicEventId = id; + const touristicEventsIcons = await this.getIcons(touristicEventFeatureCollection, 'category'); this.selectedTouristicEventLayer = L.geoJSON(touristicEventFeatureCollection, { pointToLayer: (geoJsonPoint, latlng) => L.marker(latlng, { zIndexOffset: 4000000, icon: L.divIcon({ - html: geoJsonPoint.properties.category - ? `
` + html: touristicEventsIcons[geoJsonPoint.properties.category] + ? `
` : `
`, className: 'selected-touristic-event-marker', iconSize: 48, diff --git a/src/components/grw-map/readme.md b/src/components/grw-map/readme.md index 10e66e1..2dc7308 100644 --- a/src/components/grw-map/readme.md +++ b/src/components/grw-map/readme.md @@ -5,24 +5,25 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| ------------------------- | ---------------------------- | ----------- | --------- | ----------- | -| `attributionLayer` | `attribution-layer` | | `string` | `undefined` | -| `center` | `center` | | `string` | `'1, 1'` | -| `colorBackground` | `color-background` | | `string` | `'#fef7ff'` | -| `colorOnPrimaryContainer` | `color-on-primary-container` | | `string` | `'#21005e'` | -| `colorOnSurface` | `color-on-surface` | | `string` | `'#49454e'` | -| `colorPoiIcon` | `color-poi-icon` | | `string` | `'#974c6e'` | -| `colorPrimaryApp` | `color-primary-app` | | `string` | `'#6b0030'` | -| `colorPrimaryContainer` | `color-primary-container` | | `string` | `'#eaddff'` | -| `colorSensitiveArea` | `color-sensitive-area` | | `string` | `'#4974a5'` | -| `colorTrekLine` | `color-trek-line` | | `string` | `'#6b0030'` | -| `fontFamily` | `font-family` | | `string` | `'Roboto'` | -| `isLargeView` | `is-large-view` | | `boolean` | `false` | -| `nameLayer` | `name-layer` | | `string` | `undefined` | -| `urlLayer` | `url-layer` | | `string` | `undefined` | -| `useGradient` | `use-gradient` | | `boolean` | `false` | -| `zoom` | `zoom` | | `number` | `10` | +| Property | Attribute | Description | Type | Default | +| ------------------------- | ----------------------------- | ----------- | --------- | ----------- | +| `attributionLayer` | `attribution-layer` | | `string` | `undefined` | +| `center` | `center` | | `string` | `'1, 1'` | +| `colorBackground` | `color-background` | | `string` | `'#fef7ff'` | +| `colorOnPrimaryContainer` | `color-on-primary-container` | | `string` | `'#21005e'` | +| `colorOnSurface` | `color-on-surface` | | `string` | `'#49454e'` | +| `colorPoiIcon` | `color-poi-icon` | | `string` | `'#974c6e'` | +| `colorPrimaryApp` | `color-primary-app` | | `string` | `'#6b0030'` | +| `colorPrimaryContainer` | `color-primary-container` | | `string` | `'#eaddff'` | +| `colorSensitiveArea` | `color-sensitive-area` | | `string` | `'#4974a5'` | +| `colorTrekLine` | `color-trek-line` | | `string` | `'#6b0030'` | +| `fontFamily` | `font-family` | | `string` | `'Roboto'` | +| `isLargeView` | `is-large-view` | | `boolean` | `false` | +| `nameLayer` | `name-layer` | | `string` | `undefined` | +| `trekTilesMaxZoomOffline` | `trek-tiles-max-zoom-offline` | | `number` | `16` | +| `urlLayer` | `url-layer` | | `string` | `undefined` | +| `useGradient` | `use-gradient` | | `boolean` | `false` | +| `zoom` | `zoom` | | `number` | `10` | ## Events diff --git a/src/components/grw-poi-detail/grw-poi.tsx b/src/components/grw-poi-detail/grw-poi.tsx index c12137b..aa5340e 100644 --- a/src/components/grw-poi-detail/grw-poi.tsx +++ b/src/components/grw-poi-detail/grw-poi.tsx @@ -1,8 +1,9 @@ -import { Component, Host, h, Prop, State, Build, getAssetPath } from '@stencil/core'; +import { Component, Host, h, Prop, State, Build, getAssetPath, Listen } from '@stencil/core'; import Swiper, { Navigation, Pagination, Keyboard } from 'swiper'; import state from 'store/store'; -import { Poi } from 'types/types'; +import { Poi, Trek } from 'types/types'; import { translate } from 'i18n/i18n'; +import { getDataInStore } from 'services/grw-db.service'; @Component({ tag: 'grw-poi', @@ -22,7 +23,21 @@ export class GrwPoiDetail { @State() showPoiDescriptionButton = false; @State() displayFullscreen = false; + @State() offline = false; + + @Listen('trekDownloadedSuccessConfirm', { target: 'window' }) + onTrekDownloadedSuccessConfirm() { + this.swiperPoi.slideTo(0); + this.offline = true; + } + + @Listen('trekDeleteSuccessConfirm', { target: 'window' }) + onTrekDeleteSuccessConfirm() { + this.offline = false; + } + componentDidLoad() { + this.handleOffline(); this.swiperPoi = new Swiper(this.swiperPoiRef, { modules: [Navigation, Pagination, Keyboard], navigation: { @@ -35,7 +50,7 @@ export class GrwPoiDetail { loop: true, }); this.swiperPoiRef.onfullscreenchange = () => { - this.displayFullscreen = !this.displayFullscreen; + this.displayFullscreen = !this.displayFullscreen && !this.offline; if (this.displayFullscreen) { this.swiperPoi.keyboard.enable(); } else { @@ -55,6 +70,14 @@ export class GrwPoiDetail { } } + async handleOffline() { + if (this.poi) { + const trekInStore: Trek = await getDataInStore('treks', state.currentTrek.id); + const poiInStore: Poi = await getDataInStore('pois', this.poi.id); + this.offline = trekInStore && trekInStore.offline && Boolean(poiInStore); + } + } + render() { const defaultImageSrc = getAssetPath(`${Build.isDev ? '/' : ''}assets/default-image.svg`); return ( @@ -79,6 +102,12 @@ export class GrwPoiDetail { class={`poi-img${this.displayFullscreen ? ' img-fullscreen' : ''}`} src={this.displayFullscreen ? attachment.url : attachment.thumbnail} loading="lazy" + /* @ts-ignore */ + onerror={event => { + event.target.onerror = null; + event.target.className = 'default-poi-img'; + event.target.src = defaultImageSrc; + }} onClick={() => this.handleFullscreen()} /> @@ -96,9 +125,9 @@ export class GrwPoiDetail { )} -
(this.paginationElPoiRef = el)}>
-
(this.prevElPoiRef = el)}>
-
(this.nextElPoiRef = el)}>
+
(this.paginationElPoiRef = el)}>
+
(this.prevElPoiRef = el)}>
+
(this.nextElPoiRef = el)}>
diff --git a/src/components/grw-switch/grw-switch.scss b/src/components/grw-switch/grw-switch.scss new file mode 100644 index 0000000..ca919e4 --- /dev/null +++ b/src/components/grw-switch/grw-switch.scss @@ -0,0 +1,104 @@ +.material-symbols { + font-family: 'Material Symbols Outlined'; +} + +.material-symbols-outlined { + font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48; +} + +.md3.switch { + user-select: none; + position: relative; + -webkit-tap-highlight-color: transparent; + margin: 10px; + font-family: var(--font-family); +} + +.md3.switch input { + opacity: 0; + width: 0; + height: 0; + margin: 0; + padding: 0; +} + +.md3.switch span.slider { + position: absolute; + cursor: pointer; + background-color: var(--color-primary-container); + width: 48px; + height: 28px; + border-radius: 28px; + border: 2px solid var(--color-outline); + transition: background-color 0.1s ease-in-out, border-color 0.1s ease-in-out; +} + +/* thumb */ +.md3.switch span.slider::before { + position: absolute; + content: ''; + height: 16px; + width: 16px; + left: 0px; + margin: 6px; + background-color: var(--color-outline); + border-radius: 28px; + transition: left 175ms cubic-bezier(0, 0.5, 0.5, 1.5), background-color 0.1s ease-in-out, height 50ms ease-in-out, width 50ms ease-in-out, margin 50ms ease-in-out; +} + +.md3.switch span.slider span.icon { + display: flex; + align-items: center; + justify-content: center; + position: relative; + left: 0px; + margin: 6px 0px; + height: 16px; + width: 16px; + font-size: 16px; + text-align: center; + opacity: 0; + transition: left 175ms cubic-bezier(0, 0.5, 0.5, 1.5); + color: var(--color-surface-variant); +} + +.md3.switch input:checked + span.slider { + background-color: var(--color-primary); + border-color: transparent; +} + +.md3.switch input:checked + span.slider::before { + background-color: var(--color-on-primary); + height: 24px; + width: 24px; + left: 20px; + margin: 2px; +} + +.md3.switch input:not(:disabled):hover + span.slider::before { + background-color: var(--color-on-surface-variant); +} + +.md3.switch input:not(:disabled):checked:hover + span.slider::before { + background-color: var(--color-primary-container); +} + +.md3.switch input:not(:disabled):active + span.slider::before { + height: 28px; + width: 28px; + margin: 0px; +} + +.md3.switch input:not(:disabled):checked:active + span.slider::before { + background-color: var(--color-primary-container); +} + +.md3.switch input:checked + span.slider span.icon { + left: 26px; + opacity: 1; + color: var(--color-on-primary-container); +} + +.md3.switch input:focus-visible + span.slider { + outline: 2px solid var(--color-primary); +} diff --git a/src/components/grw-switch/grw-switch.tsx b/src/components/grw-switch/grw-switch.tsx new file mode 100644 index 0000000..ca3e6f5 --- /dev/null +++ b/src/components/grw-switch/grw-switch.tsx @@ -0,0 +1,32 @@ +import { Component, Host, Prop, h } from '@stencil/core'; + +@Component({ + tag: 'grw-switch', + styleUrl: 'grw-switch.scss', + shadow: true, +}) +export class GrwSwitch { + @Prop() action: Function; + + @Prop() fontFamily = 'Roboto'; + + render() { + return ( + + + + ); + } +} diff --git a/src/components/grw-switch/readme.md b/src/components/grw-switch/readme.md new file mode 100644 index 0000000..572d838 --- /dev/null +++ b/src/components/grw-switch/readme.md @@ -0,0 +1,38 @@ +# grw-switch + + + + + + +## Properties + +| Property | Attribute | Description | Type | Default | +| ------------ | ------------- | ----------- | ---------- | ----------- | +| `action` | -- | | `Function` | `undefined` | +| `fontFamily` | `font-family` | | `string` | `'Roboto'` | + + +## Shadow Parts + +| Part | Description | +| -------- | ----------- | +| `"icon"` | | + + +## Dependencies + +### Used by + + - [grw-app](../grw-app) + +### Graph +```mermaid +graph TD; + grw-app --> grw-switch + style grw-switch fill:#f9f,stroke:#333,stroke-width:4px +``` + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/src/components/grw-touristic-content-card/grw-touristic-content-card.tsx b/src/components/grw-touristic-content-card/grw-touristic-content-card.tsx index c2c1760..8b5674b 100644 --- a/src/components/grw-touristic-content-card/grw-touristic-content-card.tsx +++ b/src/components/grw-touristic-content-card/grw-touristic-content-card.tsx @@ -1,7 +1,8 @@ import { Build, Component, Host, getAssetPath, h, State, Prop, Event, EventEmitter, Listen } from '@stencil/core'; +import { getDataInStore } from 'services/grw-db.service'; import state from 'store/store'; import Swiper, { Keyboard, Navigation, Pagination } from 'swiper'; -import { TouristicContent } from 'types/types'; +import { TouristicContent, Trek } from 'types/types'; @Component({ tag: 'grw-touristic-content-card', @@ -26,7 +27,21 @@ export class GrwTouristicContentCard { @Event() cardTouristicContentMouseOver: EventEmitter; @Event() cardTouristicContentMouseLeave: EventEmitter; + @State() offline = false; + + @Listen('trekDownloadedSuccessConfirm', { target: 'window' }) + onTrekDownloadedSuccessConfirm() { + this.swiperTouristicContent.slideTo(0); + this.offline = true; + } + + @Listen('trekDeleteSuccessConfirm', { target: 'window' }) + onTrekDeleteSuccessConfirm() { + this.offline = false; + } + componentDidLoad() { + this.handleOffline(); if (this.swiperTouristicContentRef) { this.swiperTouristicContent = new Swiper(this.swiperTouristicContentRef, { modules: [Navigation, Pagination, Keyboard], @@ -40,7 +55,7 @@ export class GrwTouristicContentCard { loop: true, }); this.swiperTouristicContentRef.onfullscreenchange = () => { - this.displayFullscreen = !this.displayFullscreen; + this.displayFullscreen = !this.displayFullscreen && !this.offline; if (this.displayFullscreen) { this.swiperTouristicContent.keyboard.enable(); } else { @@ -50,6 +65,14 @@ export class GrwTouristicContentCard { } } + async handleOffline() { + if (state.currentTrek) { + const trekInStore: Trek = await getDataInStore('treks', state.currentTrek.id); + const touristicContentInStore: TouristicContent = await getDataInStore('touristicContents', this.touristicContent.id); + this.offline = trekInStore && trekInStore.offline && touristicContentInStore && touristicContentInStore.offline; + } + } + handleFullscreen() { if (this.touristicContent.attachments && this.touristicContent.attachments[0] && this.touristicContent.attachments[0].url) { this.swiperTouristicContentRef.requestFullscreen(); @@ -107,6 +130,12 @@ export class GrwTouristicContentCard { class={`touristic-content-img${this.displayFullscreen ? ' img-fullscreen' : ''}`} src={this.displayFullscreen ? attachment.url : attachment.thumbnail} loading="lazy" + /* @ts-ignore */ + onerror={event => { + event.target.onerror = null; + event.target.className = 'default-touristic-event-img'; + event.target.src = defaultImageSrc; + }} onClick={() => this.handleFullscreen()} />
@@ -114,7 +143,7 @@ export class GrwTouristicContentCard { ) : (
)}
-
(this.paginationElTouristicContentRef = el)}>
-
(this.prevElTouristicContentRef = el)}>
-
(this.nextElTouristicContentRef = el)}>
+
(this.paginationElTouristicContentRef = el)} + >
+
(this.prevElTouristicContentRef = el)} + >
+
(this.nextElTouristicContentRef = el)} + >
) : this.touristicContent.attachments.filter(attachment => attachment.type === 'image').length > 0 ? ( { this.displayFullscreen = !this.displayFullscreen; - this.displayFullscreen ? this.swiperImages.keyboard.enable() : this.swiperImages.keyboard.disable(); + this.displayFullscreen && !this.offline ? this.swiperImages.keyboard.enable() : this.swiperImages.keyboard.disable(); }; + this.offline = state.currentTouristicContent.offline; } handleFullscreen(close: boolean = false) { @@ -92,7 +94,7 @@ export class GrwTouristicContentDetail {
(this.presentationRef = el)}>
(this.swiperImagesRef = el)}>
- {state.currentTouristicContent.attachments.length > 0 ? ( + {state.currentTouristicContent.attachments.filter(attachment => attachment.type === 'image').length > 0 ? ( state.currentTouristicContent.attachments .filter(attachment => attachment.type === 'image') .map(attachment => { @@ -135,9 +137,9 @@ export class GrwTouristicContentDetail {
)}
-
(this.paginationElImagesRef = el)}>
-
(this.prevElImagesRef = el)}>
-
(this.nextElImagesRef = el)}>
+
(this.paginationElImagesRef = el)}>
+
(this.prevElImagesRef = el)}>
+
(this.nextElImagesRef = el)}>
@@ -125,9 +154,14 @@ export class GrwTouristicEvent { )} -
(this.paginationElTouristicEventRef = el)}>
-
(this.prevElTouristicEventRef = el)}>
-
(this.nextElTouristicEventRef = el)}>
+
(this.paginationElTouristicEventRef = el)} + >
+
(this.prevElTouristicEventRef = el)}>
+
(this.nextElTouristicEventRef = el)}>
) : this.touristicEvent.attachments.filter(attachment => attachment.type === 'image').length > 0 ? ( { this.displayFullscreen = !this.displayFullscreen; - this.displayFullscreen ? this.swiperImages.keyboard.enable() : this.swiperImages.keyboard.disable(); + this.displayFullscreen && !this.offline ? this.swiperImages.keyboard.enable() : this.swiperImages.keyboard.disable(); }; + this.offline = state.currentTouristicEvent.offline; } handleFullscreen(close: boolean = false) { @@ -92,7 +94,7 @@ export class GrwTouristicEventDetail {
(this.presentationRef = el)}>
(this.swiperImagesRef = el)}>
- {state.currentTouristicEvent.attachments.length > 0 ? ( + {state.currentTouristicEvent.attachments.filter(attachment => attachment.type === 'image').length > 0 ? ( state.currentTouristicEvent.attachments .filter(attachment => attachment.type === 'image') .map(attachment => { @@ -135,9 +137,9 @@ export class GrwTouristicEventDetail {
)}
-
(this.paginationElImagesRef = el)}>
-
(this.prevElImagesRef = el)}>
-
(this.nextElImagesRef = el)}>
+
(this.paginationElImagesRef = el)}>
+
(this.prevElImagesRef = el)}>
+
(this.nextElImagesRef = el)}>
diff --git a/src/components/grw-trek-card/grw-trek-card.scss b/src/components/grw-trek-card/grw-trek-card.scss index c1ccc09..ae3310b 100644 --- a/src/components/grw-trek-card/grw-trek-card.scss +++ b/src/components/grw-trek-card/grw-trek-card.scss @@ -169,3 +169,21 @@ max-width: 20px; } } + +.trek-img-container { + position: relative; + .offline-indicator { + display: flex; + align-items: center; + justify-content: center; + background: #ffffff; + border-radius: 24px; + height: 24px; + width: 24px; + top: 16px; + right: 16px; + position: absolute; + font-size: 24px; + color: var(--color-on-surface); + } +} diff --git a/src/components/grw-trek-card/grw-trek-card.tsx b/src/components/grw-trek-card/grw-trek-card.tsx index fdd6a5c..3121117 100644 --- a/src/components/grw-trek-card/grw-trek-card.tsx +++ b/src/components/grw-trek-card/grw-trek-card.tsx @@ -1,4 +1,5 @@ import { Component, Host, h, Prop, Event, EventEmitter, State, Listen, Fragment, getAssetPath, Build } from '@stencil/core'; +import { trekIsAvailableOffline } from 'services/treks.service'; import state, { onChange } from 'store/store'; import { City, Difficulty, Practice, Route, Themes, Trek } from 'types/types'; import { formatDuration, formatLength, formatAscent } from 'utils/utils'; @@ -30,6 +31,19 @@ export class GrwTrekCard { @Event() cardTrekMouseOver: EventEmitter; @Event() cardTrekMouseLeave: EventEmitter; + @State() isAvailableOffline = false; + @State() showDefaultImage = false; + + @Listen('trekDownloadedSuccessConfirm', { target: 'window' }) + onTrekDownloadedSuccessConfirm() { + this.isAvailableOffline = true; + } + + @Listen('trekDeleteSuccessConfirm', { target: 'window' }) + onTrekDeleteSuccessConfirm() { + this.isAvailableOffline = false; + } + connectedCallback() { this.currentTrek = this.trek ? this.trek : state.currentTrek; if (this.currentTrek) { @@ -38,6 +52,7 @@ export class GrwTrekCard { this.practice = state.practices.find(practice => practice.id === this.currentTrek.practice); this.themes = state.themes.filter(theme => this.currentTrek.themes.includes(theme.id)); this.departureCity = state.cities.find(city => city.id === this.currentTrek.departure_city); + this.handleOffline(this.currentTrek.id); } onChange('currentTrek', () => { this.currentTrek = this.trek ? this.trek : state.currentTrek; @@ -47,10 +62,15 @@ export class GrwTrekCard { this.practice = this.currentTrek.practice && state.practices ? state.practices.find(practice => practice.id === this.currentTrek.practice) : null; this.themes = this.currentTrek.themes && state.themes ? state.themes.filter(theme => this.currentTrek.themes.includes(theme.id)) : null; this.departureCity = this.currentTrek.departure_city && state.cities ? state.cities.find(city => city.id === this.currentTrek.departure_city) : null; + this.handleOffline(this.currentTrek.id); } }); } + async handleOffline(trekId) { + this.isAvailableOffline = await trekIsAvailableOffline(trekId); + } + @Listen('mouseover') handleMouseOver() { this.cardTrekMouseOver.emit(this.currentTrek.id); @@ -90,6 +110,14 @@ export class GrwTrekCard { onClick={() => this.trekCardPress.emit(this.currentTrek.id)} >
+ {this.isAvailableOffline && ( +
+ {/* @ts-ignore */} + + offline_pin + +
+ )} {this.currentTrek.attachments.filter(attachment => attachment.type === 'image').length > 0 ? ( attachment.type === 'image')[0].thumbnail}`} loading="lazy" + /* @ts-ignore */ + onerror={event => { + event.target.onerror = null; + event.target.className = 'trek-img default-trek-img'; + event.target.src = defaultImageSrc; + }} /> ) : ( ; + @Event() trekDownloadedSuccessConfirm: EventEmitter; + + @Event() trekDeleteConfirm: EventEmitter; + @Event() trekDeleteSuccessConfirm: EventEmitter; + indicatorSelectedTrekOption = { translateX: null, width: null, backgroundSize: null, ref: null }; componentDidLoad() { @@ -187,7 +211,7 @@ export class GrwTrekDetail { }); this.swiperImagesRef.onfullscreenchange = () => { this.displayFullscreen = !this.displayFullscreen; - this.displayFullscreen ? this.swiperImages.keyboard.enable() : this.swiperImages.keyboard.disable(); + this.displayFullscreen && !this.offline ? this.swiperImages.keyboard.enable() : this.swiperImages.keyboard.disable(); }; this.swiperStep = new Swiper(this.swiperStepRef, { modules: [FreeMode, Mousewheel, Scrollbar], @@ -208,7 +232,7 @@ export class GrwTrekDetail { slidesPerView: 2.5, }, }, - loop: true, + loop: false, }); this.swiperPois = new Swiper(this.swiperPoisRef, { modules: [FreeMode, Mousewheel, Scrollbar], @@ -228,7 +252,7 @@ export class GrwTrekDetail { slidesPerView: 2.5, }, }, - loop: true, + loop: false, }); this.swiperInformationDesks = new Swiper(this.swiperInformationDesksRef, { modules: [FreeMode, Mousewheel, Scrollbar], @@ -248,7 +272,7 @@ export class GrwTrekDetail { slidesPerView: 2.5, }, }, - loop: true, + loop: false, }); this.swiperTouristicContents = new Swiper(this.swiperTouristicContentsRef, { modules: [FreeMode, Mousewheel, Scrollbar], @@ -268,7 +292,7 @@ export class GrwTrekDetail { slidesPerView: 2.5, }, }, - loop: true, + loop: false, }); this.swiperTouristicEvents = new Swiper(this.swiperTouristicEventsRef, { modules: [FreeMode, Mousewheel, Scrollbar], @@ -288,7 +312,7 @@ export class GrwTrekDetail { slidesPerView: 2.5, }, }, - loop: true, + loop: false, }); if (this.presentationRef) { this.presentationObserver = new IntersectionObserver( @@ -424,9 +448,10 @@ export class GrwTrekDetail { } } - connectedCallback() { + async connectedCallback() { this.currentTrek = this.trek ? this.trek : state.currentTrek; if (this.currentTrek) { + this.offline = this.currentTrek.offline; this.defaultOptions = this.handleOptions(); this.options = { ...this.defaultOptions, presentation: { ...this.defaultOptions.presentation, indicator: true } }; this.difficulty = state.difficulties.find(difficulty => difficulty.id === this.currentTrek.difficulty); @@ -442,9 +467,10 @@ export class GrwTrekDetail { this.cities = this.currentTrek.cities.map(currentCity => state.cities.find(city => city.id === currentCity)?.name); this.hasStep = this.currentTrek.children.length > 0; } - onChange('currentTrek', () => { + onChange('currentTrek', async () => { this.currentTrek = this.trek ? this.trek : state.currentTrek; if (this.currentTrek) { + this.offline = this.currentTrek.offline; this.defaultOptions = this.handleOptions(); this.options = { ...this.defaultOptions, presentation: { ...this.defaultOptions.presentation, indicator: true } }; this.difficulty = state.difficulties.find(difficulty => difficulty.id === this.currentTrek.difficulty); @@ -513,6 +539,7 @@ export class GrwTrekDetail { touristicEvents: { ...touristicEvents, visible: Boolean(state.trekTouristicEvents && state.trekTouristicEvents.length > 0) }, }; } + handleFullscreen(close: boolean = false) { if (!close) { if (this.currentTrek.attachments && this.currentTrek.attachments[0] && this.currentTrek.attachments[0].url) { @@ -561,7 +588,276 @@ export class GrwTrekDetail { return state.currentTrekSteps ? state.currentTrekSteps.findIndex(step => step.id === this.currentTrek.id) : 0; } + async downloadGlobalTiles(url, attribution) { + const offlineLayer = tileLayerOffline(url, { attribution }); + + const treksDepartureCoordinates = []; + + if (state.treks) { + for (const trek of state.treks) { + treksDepartureCoordinates.push(trek.departure_geom); + } + } + + const bounds = L.latLngBounds(treksDepartureCoordinates.map(coordinate => [coordinate[1], coordinate[0]])); + + await writeOrUpdateTilesInStore(offlineLayer, bounds, this.globalTilesMinZoomOffline, this.globalTilesMaxZoomOffline); + } + + async downloadTrekTiles(url, attribution) { + const offlineLayer = tileLayerOffline(url, { attribution }); + + const bounds = L.latLngBounds(this.currentTrek.geometry.coordinates.map(coordinate => [coordinate[1], coordinate[0]])); + + await writeOrUpdateTilesInStore(offlineLayer, bounds, this.trekTilesMinZoomOffline, this.trekTilesMaxZoomOffline); + } + + async downloadTrek() { + // download treks list data + const controller = new AbortController(); + const signal = controller.signal; + const init: RequestInit = { cache: Build.isDev ? 'force-cache' : 'default', signal: signal }; + const offlineTreks = (await getAllDataInStore('treks')).filter(trek => trek.offline === true); + let treks; + if (!state.treks) { + const treksList = await getTreksList( + state.api, + state.language, + state.inBboxFromProviders, + state.citiesFromProviders, + state.districts, + state.structuresFromProviders, + state.themesFromProviders, + state.portalsFromProviders, + state.routesFromProviders, + state.practicesFromProviders, + init, + ); + state.treks = (await treksList.json()).results; + } + treks = state.treks; + treks = treks.filter(trek => offlineTreks.findIndex(offlineTrek => offlineTrek.id === trek.id) === -1); + + if (!state.districts) { + const districtsList = await getDistricts(state.api, state.language, init); + state.districts = (await districtsList.json()).results; + } + + await writeOrUpdateDataInStore('treks', treks); + await writeOrUpdateDataInStore('districts', state.districts); + + // download global tiles + // await this.downloadGlobalTiles(this.defaultBackgroundLayerUrl, this.defaultBackgroundLayerAttribution); + + // download trek tiles + // await this.downloadTrekTiles(this.defaultBackgroundLayerUrl, this.defaultBackgroundLayerAttribution); + + // download global medias + await writeOrUpdateFilesInStore(state.difficulties, imagesRegExp); + await writeOrUpdateFilesInStore(state.routes, imagesRegExp); + await writeOrUpdateFilesInStore(state.practices, imagesRegExp); + await writeOrUpdateFilesInStore(state.themes, imagesRegExp); + await writeOrUpdateFilesInStore(state.labels, imagesRegExp); + await writeOrUpdateFilesInStore(state.districts, imagesRegExp); + await writeOrUpdateFilesInStore(state.sources, imagesRegExp); + await writeOrUpdateFilesInStore(state.accessibilities, imagesRegExp); + await writeOrUpdateFilesInStore(state.poiTypes, imagesRegExp); + await writeOrUpdateFilesInStore(state.currentInformationDesks, imagesRegExp); + await writeOrUpdateFilesInStore(state.touristicContentCategories, imagesRegExp); + await writeOrUpdateFilesInStore(state.touristicEventTypes, imagesRegExp); + await writeOrUpdateFilesInStore(state.networks, imagesRegExp); + + // // download trek images + await writeOrUpdateFilesInStore(this.currentTrek, imagesRegExp, true); + + // // download trek data + await writeOrUpdateDataInStore('difficulties', state.difficulties); + await writeOrUpdateDataInStore('routes', state.routes); + await writeOrUpdateDataInStore( + 'practices', + state.practices.map(practice => ({ ...practice, selected: false })), + ); + await writeOrUpdateDataInStore('themes', state.themes); + await writeOrUpdateDataInStore('cities', state.cities); + await writeOrUpdateDataInStore('accessibilities', state.accessibilities); + await writeOrUpdateDataInStore('ratings', state.ratings); + await writeOrUpdateDataInStore('ratingsScale', state.ratingsScale); + await writeOrUpdateDataInStore('sensitiveAreas', state.currentSensitiveAreas); + await writeOrUpdateDataInStore('labels', state.labels); + await writeOrUpdateDataInStore('sources', state.sources); + await writeOrUpdateDataInStore('accessibilitiesLevel', state.accessibilitiesLevel); + await writeOrUpdateDataInStore('touristicContentCategories', state.touristicContentCategories); + await writeOrUpdateDataInStore('touristicEvents', state.trekTouristicEvents); + await writeOrUpdateDataInStore('touristicEventTypes', state.touristicEventTypes); + await writeOrUpdateDataInStore('networks', state.networks); + await writeOrUpdateDataInStore('pois', state.currentPois); + await writeOrUpdateDataInStore('poiTypes', state.poiTypes); + await writeOrUpdateDataInStore('informationDesks', state.currentInformationDesks); + + await writeOrUpdateDataInStore('treks', [{ ...this.currentTrek, offline: true }]); + + await writeOrUpdateFilesInStore(state.currentPois, imagesRegExp, true); + await writeOrUpdateFilesInStore(state.currentInformationDesks, imagesRegExp, true); + + // download items data + await writeOrUpdateFilesInStore(state.trekTouristicContents, imagesRegExp, true); + state.trekTouristicContents.forEach(trekTouristicContent => { + trekTouristicContent.offline = true; + }); + await writeOrUpdateDataInStore('touristicContents', state.trekTouristicContents); + + await writeOrUpdateFilesInStore(state.trekTouristicEvents, imagesRegExp, true); + state.trekTouristicEvents.forEach(trekTouristicEvent => { + trekTouristicEvent.offline = true; + }); + await writeOrUpdateDataInStore('touristicEvents', state.trekTouristicEvents); + + // download itinerancy + if (this.currentTrek.children && this.currentTrek.children.length > 0) { + // fetch and store children treks + const steps: number[] = []; + steps.push(...this.currentTrek.children); + const stepRequests = []; + const stepsTouristicContentsRequests = []; + const stepsTouristicEventsRequests = []; + + steps.forEach(stepId => { + stepRequests.push(getTrek(state.api, state.language, stepId, init)); + stepsTouristicContentsRequests.push(getTouristicContentsNearTrek(state.api, state.language, stepId, init)); + stepsTouristicEventsRequests.push(getTouristicEventsNearTrek(state.api, state.language, stepId, init)); + }); + + const trekSteps: Treks = await Promise.all([...stepRequests]).then(responses => Promise.all(responses.map(response => response.json()))); + + const stepsTouristicContentsResponses = await Promise.all([...stepsTouristicContentsRequests]).then(responses => Promise.all(responses.map(response => response.json()))); + + const stepsTouristicEventsResponses = await Promise.all([...stepsTouristicEventsRequests]).then(responses => Promise.all(responses.map(response => response.json()))); + + for (let index = 0; index < trekSteps.length; index++) { + trekSteps[index].offline = true; + await writeOrUpdateDataInStore('treks', [trekSteps[index]]); + await writeOrUpdateFilesInStore(trekSteps[index], imagesRegExp, true); + } + + for (let index = 0; index < stepsTouristicContentsResponses.length; index++) { + const stepsTouristicContentsResponse = stepsTouristicContentsResponses[index]; + for (let index = 0; index < stepsTouristicContentsResponse.results.length; index++) { + stepsTouristicContentsResponse.results[index].offline = true; + await writeOrUpdateDataInStore('touristicContents', [stepsTouristicContentsResponse.results[index]]); + await writeOrUpdateFilesInStore(stepsTouristicContentsResponse.results[index], imagesRegExp, true); + } + } + + for (let index = 0; index < stepsTouristicEventsResponses.length; index++) { + const stepsTouristicEventsResponse = stepsTouristicEventsResponses[index]; + for (let index = 0; index < stepsTouristicEventsResponse.results.length; index++) { + stepsTouristicEventsResponse[index].offline = true; + await writeOrUpdateDataInStore('touristicEvents', [stepsTouristicEventsResponse[index]]); + await writeOrUpdateFilesInStore(stepsTouristicEventsResponse[index], imagesRegExp, true); + } + } + } + + state.treks.find(trek => trek.id === this.currentTrek.id).offline = true; + if (!state.currentTreks) { + state.currentTreks = state.treks; + } + state.currentTreks.find(trek => trek.id === this.currentTrek.id).offline = true; + this.swiperImages.slideTo(0); + this.offline = true; + this.trekDownloadedSuccessConfirm.emit(); + } + + async deleteTrek() { + const treksInStore: Treks = []; + const trekInStore = await getDataInStore('treks', this.currentTrek.id); + this.deleteOfflineTrekProperties(trekInStore); + treksInStore.push(trekInStore); + if (this.currentTrek.children.length > 0) { + for (let index = 0; index < this.currentTrek.children.length; index++) { + const trekInStore = await getDataInStore('treks', this.currentTrek.children[index]); + this.deleteOfflineTrekProperties(trekInStore); + treksInStore.push(trekInStore); + } + } + await writeOrUpdateDataInStore('treks', treksInStore); + + if (state.treks) { + delete state.treks.find(trek => trek.id === this.currentTrek.id).offline; + + if (!state.currentTreks) { + state.currentTreks = state.treks; + } + delete state.currentTreks.find(trek => trek.id === this.currentTrek.id).offline; + } + + if (state.parentTrek) { + delete state.parentTrek.offline; + } + + if (state.currentTrekSteps) { + state.currentTrekSteps.forEach(currentTrekStep => { + delete currentTrekStep.offline; + }); + } + + this.offline = false; + this.trekDeleteSuccessConfirm.emit(); + } + + displayTrekDownloadModal() { + this.trekDownloadConfirm.emit(); + } + + displayTrekDeleteModal() { + this.trekDeleteConfirm.emit(); + } + + @Listen('trekDownloadPress', { target: 'window' }) + onTrekDownloadPress() { + this.downloadTrek(); + } + + @Listen('trekDeletePress', { target: 'window' }) + onTrekDeletePress() { + this.deleteTrek(); + } + + deleteOfflineTrekProperties(trekInStore) { + delete trekInStore.descent; + delete trekInStore.geometry; + delete trekInStore.gpx; + delete trekInStore.kml; + delete trekInStore.pdf; + delete trekInStore.parking_location; + delete trekInStore.arrival; + delete trekInStore.ambiance; + delete trekInStore.access; + delete trekInStore.public_transport; + delete trekInStore.advice; + delete trekInStore.advised_parking; + delete trekInStore.gear; + delete trekInStore.source; + delete trekInStore.points_reference; + delete trekInStore.disabled_infrastructure; + delete trekInStore.accessibility_level; + delete trekInStore.accessibility_slope; + delete trekInStore.accessibility_width; + delete trekInStore.accessibility_signage; + delete trekInStore.accessibility_covering; + delete trekInStore.accessibility_exposure; + delete trekInStore.accessibility_advice; + delete trekInStore.ratings; + delete trekInStore.ratings_description; + delete trekInStore.children; + delete trekInStore.networks; + delete trekInStore.web_links; + delete trekInStore.update_datetime; + delete trekInStore.offline; + } + render() { + const defaultImageSrc = getAssetPath(`${Build.isDev ? '/' : ''}assets/default-image.svg`); return ( (this.presentationRef = el)}>
(this.swiperImagesRef = el)}>
- {this.currentTrek.attachments - .filter(attachment => attachment.type === 'image') - .map(attachment => { - const legend = [attachment.legend, attachment.author].filter(Boolean).join(' - '); - return ( -
- {this.displayFullscreen && ( -
this.handleFullscreen(true)}> - {/* @ts-ignore */} - - close - + {this.currentTrek.attachments.filter(attachment => attachment.type === 'image').length > 0 ? ( + this.currentTrek.attachments + .filter(attachment => attachment.type === 'image') + .map(attachment => { + const legend = [attachment.legend, attachment.author].filter(Boolean).join(' - '); + return ( +
+ {this.displayFullscreen && ( +
this.handleFullscreen(true)}> + {/* @ts-ignore */} + + close + +
+ )} +
+ {legend}
- )} -
- {legend} + this.handleFullscreen()} + /* @ts-ignore */ + onerror={event => { + event.target.onerror = null; + event.target.className = 'trek-img default-trek-img'; + event.target.src = defaultImageSrc; + }} + />
- this.handleFullscreen()} - /> -
- ); - })} + ); + }) + ) : ( + + )} +
+
+
(this.paginationElImagesRef = el)}>
+
(this.prevElImagesRef = el)}>
+
(this.nextElImagesRef = el)}>
-
(this.paginationElImagesRef = el)}>
-
(this.prevElImagesRef = el)}>
-
(this.nextElImagesRef = el)}>
@@ -869,27 +1184,55 @@ export class GrwTrekDetail {
{translate[state.language].downloads}
+ {this.enableOffline && !this.offline && ( + + )} + {this.enableOffline && this.offline && ( + + )}
@@ -1041,7 +1384,7 @@ export class GrwTrekDetail { {this.currentTrek.advice && (
{/* @ts-ignore */} - + warning
@@ -1050,7 +1393,7 @@ export class GrwTrekDetail { {this.currentTrek.gear && (
{/* @ts-ignore */} - + backpack
@@ -1159,13 +1502,16 @@ export class GrwTrekDetail { { call - {this.emergencyNumber.toString()} + + {this.emergencyNumber.toString()} + }
diff --git a/src/components/grw-trek-detail/readme.md b/src/components/grw-trek-detail/readme.md index 9d077ef..adb760f 100644 --- a/src/components/grw-trek-detail/readme.md +++ b/src/components/grw-trek-detail/readme.md @@ -5,21 +5,28 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| --------------------------- | ------------------------------ | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| `colorBackground` | `color-background` | | `string` | `'#fef7ff'` | -| `colorOnPrimaryContainer` | `color-on-primary-container` | | `string` | `'#21005e'` | -| `colorOnSecondaryContainer` | `color-on-secondary-container` | | `string` | `'#1d192b'` | -| `colorOnSurface` | `color-on-surface` | | `string` | `'#49454e'` | -| `colorPrimaryApp` | `color-primary-app` | | `string` | `'#6b0030'` | -| `colorPrimaryContainer` | `color-primary-container` | | `string` | `'#eaddff'` | -| `colorSecondaryContainer` | `color-secondary-container` | | `string` | `'#e8def8'` | -| `colorSurfaceContainerLow` | `color-surface-container-low` | | `string` | `'#f7f2fa'` | -| `emergencyNumber` | `emergency-number` | | `number` | `undefined` | -| `fontFamily` | `font-family` | | `string` | `'Roboto'` | -| `isLargeView` | `is-large-view` | | `boolean` | `false` | -| `trek` | -- | | `{ id: number; name: string; attachments: Attachments; description?: string; description_teaser: string; difficulty: number; route: number; practice: number; themes: number[]; duration: number; length_2d: number; ascent: number; descent: number; departure: string; departure_city: string; arrival?: string; geometry?: LineString; departure_geom?: Position; gpx?: string; kml?: string; pdf?: string; parking_location?: Position; ambiance?: string; access?: string; public_transport?: string; advice?: string; advised_parking?: string; gear?: string; labels?: number[]; points_reference?: MultiPoint; source?: number[]; structure?: number; disabled_infrastructure?: string; accessibilities?: number[]; accessibility_level?: number; accessibility_slope?: string; accessibility_width?: string; accessibility_signage?: string; accessibility_covering?: string; accessibility_exposure?: string; accessibility_advice?: string; cities?: string[]; information_desks?: number[]; children?: number[]; ratings?: number[]; ratings_description?: string; networks: number[]; web_links: Weblink[]; }` | `undefined` | -| `weather` | `weather` | | `boolean` | `false` | +| Property | Attribute | Description | Type | Default | +| ----------------------------------- | -------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `colorBackground` | `color-background` | | `string` | `'#fef7ff'` | +| `colorOnPrimaryContainer` | `color-on-primary-container` | | `string` | `'#21005e'` | +| `colorOnSecondaryContainer` | `color-on-secondary-container` | | `string` | `'#1d192b'` | +| `colorOnSurface` | `color-on-surface` | | `string` | `'#49454e'` | +| `colorPrimaryApp` | `color-primary-app` | | `string` | `'#6b0030'` | +| `colorPrimaryContainer` | `color-primary-container` | | `string` | `'#eaddff'` | +| `colorSecondaryContainer` | `color-secondary-container` | | `string` | `'#e8def8'` | +| `colorSurfaceContainerLow` | `color-surface-container-low` | | `string` | `'#f7f2fa'` | +| `defaultBackgroundLayerAttribution` | `default-background-layer-attribution` | | `any` | `undefined` | +| `defaultBackgroundLayerUrl` | `default-background-layer-url` | | `any` | `undefined` | +| `emergencyNumber` | `emergency-number` | | `number` | `undefined` | +| `enableOffline` | `enable-offline` | | `boolean` | `false` | +| `fontFamily` | `font-family` | | `string` | `'Roboto'` | +| `globalTilesMaxZoomOffline` | `global-tiles-max-zoom-offline` | | `number` | `11` | +| `globalTilesMinZoomOffline` | `global-tiles-min-zoom-offline` | | `number` | `0` | +| `isLargeView` | `is-large-view` | | `boolean` | `false` | +| `trek` | -- | | `{ id: number; name: string; attachments: Attachments; description?: string; description_teaser: string; difficulty: number; route: number; practice: number; themes: number[]; duration: number; length_2d: number; ascent: number; descent: number; departure: string; departure_city: string; arrival?: string; geometry?: LineString; departure_geom?: Position; gpx?: string; kml?: string; pdf?: string; parking_location?: Position; ambiance?: string; access?: string; public_transport?: string; advice?: string; advised_parking?: string; gear?: string; labels?: number[]; points_reference?: MultiPoint; source?: number[]; structure?: number; disabled_infrastructure?: string; accessibilities?: number[]; accessibility_level?: number; accessibility_slope?: string; accessibility_width?: string; accessibility_signage?: string; accessibility_covering?: string; accessibility_exposure?: string; accessibility_advice?: string; cities?: string[]; information_desks?: number[]; children?: number[]; ratings?: number[]; ratings_description?: string; networks: number[]; web_links: Weblink[]; update_datetime: string; offline?: boolean; }` | `undefined` | +| `trekTilesMaxZoomOffline` | `trek-tiles-max-zoom-offline` | | `number` | `16` | +| `trekTilesMinZoomOffline` | `trek-tiles-min-zoom-offline` | | `number` | `12` | +| `weather` | `weather` | | `boolean` | `false` | ## Events @@ -35,6 +42,10 @@ | `stepsIsInViewport` | | `CustomEvent` | | `touristicContentsIsInViewport` | | `CustomEvent` | | `touristicEventsIsInViewport` | | `CustomEvent` | +| `trekDeleteConfirm` | | `CustomEvent` | +| `trekDeleteSuccessConfirm` | | `CustomEvent` | +| `trekDownloadConfirm` | | `CustomEvent` | +| `trekDownloadedSuccessConfirm` | | `CustomEvent` | ## Shadow Parts @@ -98,6 +109,7 @@ | `"divider"` | | | `"download-title"` | | | `"downloads-container"` | | +| `"emergency-number"` | | | `"ensitive-areas-title"` | | | `"gear"` | | | `"gear-container"` | | @@ -113,6 +125,7 @@ | `"label-sub-container"` | | | `"links-container"` | | | `"network"` | | +| `"offline-button"` | | | `"parent-trek-container"` | | | `"parent-trek-title"` | | | `"pois-container"` | | diff --git a/src/components/grw-treks-list/grw-treks-list.tsx b/src/components/grw-treks-list/grw-treks-list.tsx index 3836e8a..c3777cc 100644 --- a/src/components/grw-treks-list/grw-treks-list.tsx +++ b/src/components/grw-treks-list/grw-treks-list.tsx @@ -18,8 +18,9 @@ export class GrwTreksList { @Prop() colorSecondaryContainer = '#e8def8'; @Prop() colorOnSecondaryContainer = '#1d192b'; @Prop() colorSurfaceContainerLow = '#f7f2fa'; - + @Prop() displayOnlyOfflineTreks = false; @Prop() isLargeView = false; + step = 10; shouldAddInfiniteScrollEvent = true; diff --git a/src/components/grw-treks-list/readme.md b/src/components/grw-treks-list/readme.md index 1bc22a5..73164ab 100644 --- a/src/components/grw-treks-list/readme.md +++ b/src/components/grw-treks-list/readme.md @@ -12,6 +12,7 @@ | `colorPrimaryApp` | `color-primary-app` | | `string` | `'#6b0030'` | | `colorSecondaryContainer` | `color-secondary-container` | | `string` | `'#e8def8'` | | `colorSurfaceContainerLow` | `color-surface-container-low` | | `string` | `'#f7f2fa'` | +| `displayOnlyOfflineTreks` | `display-only-offline-treks` | | `boolean` | `false` | | `fontFamily` | `font-family` | | `string` | `'Roboto'` | | `isLargeView` | `is-large-view` | | `boolean` | `false` | diff --git a/src/services/grw-db.service.ts b/src/services/grw-db.service.ts new file mode 100644 index 0000000..2c89d49 --- /dev/null +++ b/src/services/grw-db.service.ts @@ -0,0 +1,366 @@ +import { DBSchema, openDB } from 'idb'; +import L from 'leaflet'; +import { TileInfo, TileLayerOffline, downloadTile, getBlobByKey, saveTile } from 'leaflet.offline'; +import { + Difficulty, + Route, + Practice, + Trek, + Theme, + City, + Accessibility, + Rating, + RatingScale, + SensitiveArea, + Label, + Source, + AccessibilityLevel, + TouristicContent, + TouristicContentCategory, + TouristicEvent, + TouristicEventType, + Network, + Poi, + PoiType, + InformationDesk, + Treks, + Difficulties, + Routes, + Practices, + Themes, + Cities, + Accessibilities, + Ratings, + RatingsScale, + SensitiveAreas, + Labels, + Sources, + AccessibilitiesLevel, + TouristicContents, + TouristicContentCategories, + TouristicEvents, + TouristicEventTypes, + Networks, + Pois, + PoiTypes, + InformationDesks, + District, + Districts, + ImageInStore, +} from 'types/types'; +import { blobToArrayBuffer, getFilesToStore } from 'utils/utils'; + +type ObjectStores = ObjectStore[]; + +type ObjectStore = { name: ObjectStoresName; keyPath: KeyPath }; + +type ObjectStoresName = + | 'districts' + | 'treks' + | 'difficulties' + | 'routes' + | 'practices' + | 'themes' + | 'cities' + | 'accessibilities' + | 'ratings' + | 'ratingsScale' + | 'sensitiveAreas' + | 'labels' + | 'sources' + | 'accessibilitiesLevel' + | 'touristicContents' + | 'touristicContentCategories' + | 'touristicEvents' + | 'touristicEventTypes' + | 'networks' + | 'pois' + | 'poiTypes' + | 'informationDesks' + | 'images'; + +type KeyPath = 'id' | 'url'; + +type ObjectStoresType = T extends 'districts' + ? District + : T extends 'treks' + ? Trek + : T extends 'difficulties' + ? Difficulty + : T extends 'routes' + ? Route + : T extends 'practices' + ? Practice + : T extends 'themes' + ? Theme + : T extends 'cities' + ? City + : T extends 'accessibilities' + ? Accessibility + : T extends 'ratings' + ? Rating + : T extends 'ratingsScale' + ? RatingScale + : T extends 'sensitiveAreas' + ? SensitiveArea + : T extends 'labels' + ? Label + : T extends 'sources' + ? Source + : T extends 'accessibilitiesLevel' + ? AccessibilityLevel + : T extends 'touristicContents' + ? TouristicContent + : T extends 'touristicContentCategories' + ? TouristicContentCategory + : T extends 'touristicEvents' + ? TouristicEvent + : T extends 'touristicEventTypes' + ? TouristicEventType + : T extends 'networks' + ? Network + : T extends 'pois' + ? Poi + : T extends 'poiTypes' + ? PoiType + : T extends 'informationDesks' + ? InformationDesk + : T extends 'images' + ? ImageInStore + : never; + +type ObjectStoresData = + | Treks + | Districts + | Difficulties + | Routes + | Practices + | Themes + | Cities + | Accessibilities + | Ratings + | RatingsScale + | SensitiveAreas + | Labels + | Sources + | AccessibilitiesLevel + | TouristicContents + | TouristicContentCategories + | TouristicEvents + | TouristicEventTypes + | Networks + | Pois + | PoiTypes + | InformationDesks + | ImageInStore[]; + +interface GrwDB extends DBSchema { + districts: { + value: Trek; + key: number; + }; + treks: { + value: Trek; + key: number; + }; + difficulties: { + value: Difficulty; + key: number; + }; + routes: { + value: Route; + key: number; + }; + practices: { + value: Practice; + key: number; + }; + themes: { + value: Theme; + key: number; + }; + cities: { + value: City; + key: number; + }; + accessibilities: { + value: Accessibility; + key: number; + }; + ratings: { + value: Rating; + key: number; + }; + ratingsScale: { + value: RatingScale; + key: number; + }; + sensitiveAreas: { + value: SensitiveArea; + key: number; + }; + labels: { + value: Label; + key: number; + }; + sources: { + value: Source; + key: number; + }; + accessibilitiesLevel: { + value: AccessibilityLevel; + key: number; + }; + touristicContents: { + value: TouristicContent; + key: number; + }; + touristicContentCategories: { + value: TouristicContentCategory; + key: number; + }; + touristicEvents: { + value: TouristicEvent; + key: number; + }; + touristicEventTypes: { + value: TouristicEventType; + key: number; + }; + networks: { + value: Network; + key: number; + }; + pois: { + value: Poi; + key: number; + }; + poiTypes: { + value: PoiType; + key: number; + }; + informationDesks: { + value: InformationDesk; + key: number; + }; + images: { + value: ImageInStore; + key: string; + }; +} + +const grwDbVersion = 1; + +export async function getGrwDB() { + const grwDb = await openDB('grw', grwDbVersion, { + upgrade(db) { + const objectStoresNames: ObjectStores = [ + { name: 'districts', keyPath: 'id' }, + { name: 'treks', keyPath: 'id' }, + { name: 'difficulties', keyPath: 'id' }, + { name: 'routes', keyPath: 'id' }, + { name: 'practices', keyPath: 'id' }, + { name: 'themes', keyPath: 'id' }, + { name: 'cities', keyPath: 'id' }, + { name: 'accessibilities', keyPath: 'id' }, + { name: 'ratings', keyPath: 'id' }, + { name: 'ratingsScale', keyPath: 'id' }, + { name: 'sensitiveAreas', keyPath: 'id' }, + { name: 'labels', keyPath: 'id' }, + { name: 'sources', keyPath: 'id' }, + { name: 'accessibilitiesLevel', keyPath: 'id' }, + { name: 'touristicContents', keyPath: 'id' }, + { name: 'touristicContentCategories', keyPath: 'id' }, + { name: 'touristicEvents', keyPath: 'id' }, + { name: 'touristicEventTypes', keyPath: 'id' }, + { name: 'networks', keyPath: 'id' }, + { name: 'pois', keyPath: 'id' }, + { name: 'poiTypes', keyPath: 'id' }, + { name: 'informationDesks', keyPath: 'id' }, + { name: 'images', keyPath: 'url' }, + ]; + + objectStoresNames.forEach(objectStoresName => { + if (!db.objectStoreNames.contains(objectStoresName.name)) { + db.createObjectStore(objectStoresName.name, { keyPath: objectStoresName.keyPath }); + } + }); + }, + }); + return grwDb; +} + +export async function getDataInStore(name: T, dataId: number | string): Promise> { + const grwDb = await getGrwDB(); + const data = await grwDb.get(name, dataId); + grwDb.close(); + return data as ObjectStoresType; +} + +export async function getAllDataInStore(name: T): Promise[]> { + const grwDb = await getGrwDB(); + const data = await grwDb.getAll(name); + grwDb.close(); + return data as ObjectStoresType[]; +} + +export async function writeOrUpdateDataInStore(name: ObjectStoresName, data: ObjectStoresData) { + if (data) { + const grwDb = await getGrwDB(); + const tx = grwDb.transaction(name, 'readwrite'); + await Promise.all([...data.map(d => tx.store.put(d)), tx.done]); + grwDb.close(); + } +} + +export async function deleteDataInStore(name: ObjectStoresName, dataId: number[]) { + const grwDb = await getGrwDB(); + const tx = grwDb.transaction(name, 'readwrite'); + await Promise.all([...dataId.map(d => tx.store.delete(d)), tx.done]); + grwDb.close(); +} + +export async function writeOrUpdateFilesInStore(value, imagesRegExp, onlyFirstArrayFile = false) { + const filesToStore = await getFilesToStore(value, imagesRegExp, onlyFirstArrayFile); + + const filesToStoreNotInStore = []; + for (let i = 0; i < filesToStore.length; i++) { + if (!(await getDataInStore('images', filesToStore[i]))) filesToStoreNotInStore.push(filesToStore[i]); + } + + const filesToStorePromises = filesToStoreNotInStore.map(fileToStore => fetch(fileToStore)); + const filesToStoreToBlobPromises = await Promise.all(filesToStorePromises).catch(() => null); + if (filesToStoreToBlobPromises) { + const filesToStore = await Promise.all( + filesToStoreToBlobPromises.map(response => { + return response.blob(); + }), + ); + + for (let index = 0; index < filesToStore.length; index++) { + const data = await blobToArrayBuffer(filesToStore[index]); + await writeOrUpdateDataInStore('images', [{ url: filesToStoreToBlobPromises[index].url, data, type: filesToStore[index].type }]); + } + } +} + +export async function writeOrUpdateTilesInStore(offlineLayer: TileLayerOffline, bounds, minZoom, MaxZoom) { + const tilesToStore: TileInfo[] = []; + for (let index = minZoom; index <= MaxZoom; index++) { + tilesToStore.push( + ...offlineLayer.getTileUrls(L.bounds(L.CRS.EPSG3857.latLngToPoint(bounds.getNorthWest(), index), L.CRS.EPSG3857.latLngToPoint(bounds.getSouthEast(), index)), index), + ); + } + + const tilesToDownload = []; + for (let index = 0; index < tilesToStore.length; index++) { + if (!(await getBlobByKey(tilesToStore[index].key))) { + tilesToDownload.push(downloadTile(tilesToStore[index].url).catch(() => null)); + } + } + + const tilesBlob = await Promise.all(tilesToDownload.filter(tileToDownload => tileToDownload)).then(blob => blob); + for (let index = 0; index < tilesBlob.length; index++) { + await saveTile(tilesToStore[index], tilesBlob[index]); + } +} diff --git a/src/services/touristic-contents.service.ts b/src/services/touristic-contents.service.ts new file mode 100644 index 0000000..30129f3 --- /dev/null +++ b/src/services/touristic-contents.service.ts @@ -0,0 +1,13 @@ +export function getTouristicContent(api, language, touristicContentId, init) { + return fetch( + `${api}touristiccontent/${touristicContentId}/?language=${language}&published=true&fields=id,name,attachments,description,description_teaser,category,geometry,cities,pdf,practical_info,contact,email,website,districts`, + init, + ); +} + +export function getTouristicContentsNearTrek(api, language, trekId, init) { + return fetch( + `${api}touristiccontent/?language=${language}&near_trek=${trekId}&published=true&fields=id,name,attachments,description,description_teaser,category,geometry,cities,pdf,practical_info,contact,email,website,districts&page_size=999`, + init, + ); +} diff --git a/src/services/touristic-events.service.ts b/src/services/touristic-events.service.ts new file mode 100644 index 0000000..8dc848e --- /dev/null +++ b/src/services/touristic-events.service.ts @@ -0,0 +1,10 @@ +export function getTouristicEvent(api, language, touristicEventId, init) { + fetch( + `${api}touristicevent/${touristicEventId}/?language=${language}&fields=id,name,attachments,description,description_teaser,type,geometry,cities,pdf,practical_info,contact,email,website,begin_date,end_date&published=true`, + init, + ); +} + +export function getTouristicEventsNearTrek(api, language, trekId, init) { + return fetch(`${api}touristicevent/?language=${language}&near_trek=${trekId}&published=true&fields=id,name,attachments,type,geometry&page_size=999`, init); +} diff --git a/src/services/treks.service.ts b/src/services/treks.service.ts index 6b3cdb5..d4d9655 100644 --- a/src/services/treks.service.ts +++ b/src/services/treks.service.ts @@ -1,8 +1,42 @@ import { Build } from '@stencil/core'; import state from 'store/store'; +import { getDataInStore } from './grw-db.service'; export function getTrekGeometry(id: number) { return fetch(`${state.api}trek/${id}/?language=${state.language}&published=true&fields=geometry`, { cache: Build.isDev ? 'force-cache' : 'default' }).then(response => response.json(), ); } + +export function getTreksList(api, language, inBbox, cities, districts, structures, themes, portals, routes, practices, init) { + let treksRequest = `${api}trek/?language=${language}&published=true`; + + inBbox && (treksRequest += `&in_bbox=${inBbox}`); + cities && (treksRequest += `&cities=${cities}`); + districts && (treksRequest += `&districts=${districts}`); + structures && (treksRequest += `&structures=${structures}`); + themes && (treksRequest += `&themes=${themes}`); + portals && (treksRequest += `&portals=${portals}`); + routes && (treksRequest += `&routes=${routes}`); + practices && (treksRequest += `&practices=${practices}`); + + treksRequest += `&fields=id,name,attachments,description_teaser,difficulty,duration,ascent,length_2d,practice,themes,route,departure,departure_city,departure_geom,cities,accessibilities,labels,districts&page_size=999`; + + return fetch(treksRequest, init); +} + +export function getTrek(api, language, trekId, init) { + return fetch( + `${api}trek/${trekId}/?language=${language}&published=true&fields=id,name,attachments,description,description_teaser,difficulty,duration,ascent,descent,length_2d,practice,themes,route,geometry,gpx,kml,pdf,parking_location,departure,departure_city,arrival,cities,ambiance,access,public_transport,advice,advised_parking,gear,labels,source,points_reference,disabled_infrastructure,accessibility_level,accessibility_slope,accessibility_width,accessibility_signage,accessibility_covering,accessibility_exposure,accessibility_advice,accessibilities,ratings,ratings_description,information_desks,children,networks,web_links,update_datetime,departure_geom,districts`, + init, + ); +} + +export function getDistricts(api, language, init) { + return fetch(`${api}district/?language=${language}&fields=id,name&published=true&page_size=999`, init); +} + +export async function trekIsAvailableOffline(trekId) { + const trek = await getDataInStore('treks', trekId); + return trek && trek.offline; +} diff --git a/src/store/grw-touristic-content-provider.tsx b/src/store/grw-touristic-content-provider.tsx index d213da5..a7c860c 100644 --- a/src/store/grw-touristic-content-provider.tsx +++ b/src/store/grw-touristic-content-provider.tsx @@ -1,5 +1,9 @@ import { Build, Component, h, Host, Prop } from '@stencil/core'; +import { getAllDataInStore, getDataInStore } from 'services/grw-db.service'; +import { getTouristicContent } from 'services/touristic-contents.service'; import state from 'store/store'; +import { TouristicContent } from 'types/types'; +import { imagesRegExp, setFilesFromStore } from 'utils/utils'; @Component({ tag: 'grw-touristic-content-provider', @@ -8,7 +12,7 @@ import state from 'store/store'; export class GrwTouristicContentProvider { @Prop() languages = 'fr'; @Prop() api: string; - @Prop() touristicContentId: string; + @Prop() touristicContentId: number; @Prop() portals: string; controller = new AbortController(); @@ -24,7 +28,27 @@ export class GrwTouristicContentProvider { this.handleTouristicContent(); } - handleTouristicContent() { + async handleTouristicContent() { + const touristicContentInStore: TouristicContent = await getDataInStore('touristicContents', this.touristicContentId); + if (touristicContentInStore && touristicContentInStore.offline) { + this.handleOfflineTouristicContent(touristicContentInStore); + } else { + this.handleOnlineTouristicContent(); + } + } + + async handleOfflineTouristicContent(touristicContent: TouristicContent) { + if (!state.cities) { + state.cities = await getAllDataInStore('cities'); + } + if (!state.touristicContentCategories) { + state.touristicContentCategories = await getAllDataInStore('touristicContentCategories'); + } + setFilesFromStore(touristicContent, imagesRegExp); + state.currentTouristicContent = touristicContent; + } + + handleOnlineTouristicContent() { const requests = []; requests.push(!state.cities ? fetch(`${state.api}city/?language=${state.language}&fields=id,name&published=true&page_size=999`, this.init) : new Response('null')); requests.push( @@ -38,13 +62,7 @@ export class GrwTouristicContentProvider { : new Response('null'), ); try { - Promise.all([ - ...requests, - fetch( - `${state.api}touristiccontent/${this.touristicContentId}/?language=${state.language}&published=true&fields=id,name,attachments,description,description_teaser,category,geometry,cities,pdf,practical_info,contact,email,website`, - this.init, - ), - ]) + Promise.all([...requests, getTouristicContent(state.api, state.language, this.touristicContentId, this.init)]) .then(responses => Promise.all(responses.map(response => response.json()))) .then(([cities, touristicContentCategory, touristicContent]) => { state.networkError = false; diff --git a/src/store/grw-touristic-event-provider.tsx b/src/store/grw-touristic-event-provider.tsx index d8c83d5..285a26f 100644 --- a/src/store/grw-touristic-event-provider.tsx +++ b/src/store/grw-touristic-event-provider.tsx @@ -1,5 +1,9 @@ import { Build, Component, h, Host, Prop } from '@stencil/core'; +import { TouristicEvent } from 'components'; +import { getAllDataInStore, getDataInStore } from 'services/grw-db.service'; +import { getTouristicEvent } from 'services/touristic-events.service'; import state from 'store/store'; +import { imagesRegExp, setFilesFromStore } from 'utils/utils'; @Component({ tag: 'grw-touristic-event-provider', @@ -24,7 +28,27 @@ export class GrwTouristicEventProvider { this.handleTouristicEvent(); } - handleTouristicEvent() { + async handleTouristicEvent() { + const touristicEventInStore: TouristicEvent = await getDataInStore('touristicEvents', this.touristicEventId); + if (touristicEventInStore && touristicEventInStore.offline) { + this.handleOfflineTouristicEvent(touristicEventInStore); + } else { + this.handleOnlineTouristicEvent(); + } + } + + async handleOfflineTouristicEvent(touristicEvent: TouristicEvent) { + if (!state.cities) { + state.cities = await getAllDataInStore('cities'); + } + if (!state.touristicEventTypes) { + state.touristicEventTypes = await getAllDataInStore('touristicEventTypes'); + } + setFilesFromStore(touristicEvent, imagesRegExp); + state.currentTouristicEvent = touristicEvent; + } + + handleOnlineTouristicEvent() { const requests = []; requests.push(!state.cities ? fetch(`${state.api}city/?language=${state.language}&fields=id,name&published=true&page_size=999`, this.init) : new Response('null')); requests.push( @@ -39,17 +63,10 @@ export class GrwTouristicEventProvider { ); try { - Promise.all([ - ...requests, - fetch( - `${state.api}touristicevent/${this.touristicEventId}/?language=${state.language}&fields=id,name,attachments,description,description_teaser,type,geometry,cities,pdf,practical_info,contact,email,website,begin_date,end_date&published=true`, - this.init, - ), - ]) + Promise.all([...requests, getTouristicEvent(state.api, state.language, this.touristicEventId, this.init)]) .then(responses => Promise.all(responses.map(response => response.json()))) .then(([cities, touristicEventTypes, touristicEvent]) => { state.networkError = false; - if (cities) { state.cities = cities.results; } diff --git a/src/store/grw-trek-provider.tsx b/src/store/grw-trek-provider.tsx index dbaff77..4be15bf 100644 --- a/src/store/grw-trek-provider.tsx +++ b/src/store/grw-trek-provider.tsx @@ -1,5 +1,11 @@ import { Build, Component, h, Host, Prop } from '@stencil/core'; +import { getAllDataInStore, getDataInStore } from 'services/grw-db.service'; +import { getTouristicContentsNearTrek } from 'services/touristic-contents.service'; +import { getTouristicEventsNearTrek } from 'services/touristic-events.service'; +import { getTrek } from 'services/treks.service'; import state from 'store/store'; +import { Trek } from 'types/types'; +import { imagesRegExp, setFilesFromStore } from 'utils/utils'; @Component({ tag: 'grw-trek-provider', @@ -8,8 +14,16 @@ import state from 'store/store'; export class GrwTrekProvider { @Prop() languages = 'fr'; @Prop() api: string; - @Prop() trekId: string; + @Prop() trekId: number; + @Prop() portals: string; + @Prop() inBbox: string; + @Prop() cities: string; + @Prop() districts: string; + @Prop() structures: string; + @Prop() themes: string; + @Prop() routes: string; + @Prop() practices: string; controller = new AbortController(); signal = this.controller.signal; @@ -21,10 +35,131 @@ export class GrwTrekProvider { state.languages = this.languages.split(','); state.language = state.languages[0]; } + + state.portalsFromProviders = this.portals; + state.inBboxFromProviders = this.inBbox; + state.citiesFromProviders = this.cities; + state.districtsFromProviders = this.districts; + state.structuresFromProviders = this.structures; + state.themesFromProviders = this.themes; + state.routesFromProviders = this.routes; + state.practicesFromProviders = this.practices; + this.handleTrek(); } - handleTrek() { + disconnectedCallback() { + this.controller.abort(); + state.networkError = false; + } + + async handleTrek() { + const trekInStore: Trek = await getDataInStore('treks', this.trekId); + if (trekInStore && trekInStore.offline) { + this.handleOfflineTrek(trekInStore); + } else { + this.handleOnlineTrek(); + } + } + + async handleOfflineTrek(trek: Trek) { + if ((trek && trek.children.length > 0) || state.parentTrekId) { + const steps: number[] = []; + if (trek.children.length > 0) { + state.parentTrekId = trek.id; + state.parentTrek = trek; + steps.push(...trek.children); + } else { + const parentTrek = await getDataInStore('treks', state.parentTrekId); + state.parentTrek = parentTrek; + steps.push(...parentTrek.children); + } + const stepRequests = []; + steps.forEach(stepId => { + stepRequests.push(getDataInStore('treks', stepId)); + }); + state.currentTrekSteps = await Promise.all([...stepRequests]).then(responses => responses); + } + + if (!state.difficulties) { + const difficulties = await getAllDataInStore('difficulties'); + setFilesFromStore(difficulties, imagesRegExp); + state.difficulties = difficulties; + } + if (!state.routes) { + state.routes = await getAllDataInStore('routes'); + } + if (!state.practices) { + state.practices = await getAllDataInStore('practices'); + } + if (!state.themes) { + state.themes = await getAllDataInStore('themes'); + } + if (!state.cities) { + state.cities = await getAllDataInStore('cities'); + } + if (!state.accessibilities) { + state.accessibilities = await getAllDataInStore('accessibilities'); + } + if (!state.ratings) { + state.ratings = await getAllDataInStore('ratings'); + } + if (!state.ratingsScale) { + state.ratingsScale = await getAllDataInStore('ratingsScale'); + } + if (!state.currentSensitiveAreas) { + // filter trek items + state.currentSensitiveAreas = await getAllDataInStore('sensitiveAreas'); + } + if (!state.labels) { + state.labels = await getAllDataInStore('labels'); + } + if (!state.sources) { + state.sources = await getAllDataInStore('sources'); + } + if (!state.accessibilitiesLevel) { + state.accessibilitiesLevel = await getAllDataInStore('accessibilitiesLevel'); + } + if (!state.trekTouristicContents) { + // filter trek items + const touristicContents = await getAllDataInStore('touristicContents'); + setFilesFromStore(touristicContents, imagesRegExp); + state.trekTouristicContents = touristicContents; + } + if (!state.touristicContentCategories) { + state.touristicContentCategories = await getAllDataInStore('touristicContentCategories'); + } + if (!state.trekTouristicEvents) { + // filter trek items + state.trekTouristicEvents = await getAllDataInStore('touristicEvents'); + } + if (!state.touristicEventTypes) { + state.touristicEventTypes = await getAllDataInStore('touristicEventTypes'); + } + if (!state.networks) { + state.networks = await getAllDataInStore('networks'); + } + if (!state.currentPois) { + // filter trek items + const pois = await getAllDataInStore('pois'); + setFilesFromStore(pois, imagesRegExp); + state.currentPois = pois; + } + + if (!state.poiTypes) { + state.poiTypes = await getAllDataInStore('poiTypes'); + } + if (!state.currentInformationDesks) { + // filter trek items + state.currentInformationDesks = await getAllDataInStore('informationDesks'); + } + + // handle images + setFilesFromStore(trek, imagesRegExp); + state.currentTrek = trek; + } + + handleOnlineTrek() { const requests = []; requests.push( !state.difficulties @@ -85,18 +220,12 @@ export class GrwTrekProvider { `${state.api}informationdesk/?language=${state.language}&fields=id,name,description,type,phone,email,website,municipality,postal_code,street,photo_url,latitude,longitude&page_size=999`, this.init, ), - fetch( - `${state.api}touristiccontent/?language=${state.language}&near_trek=${this.trekId}&published=true&fields=id,name,attachments,category,geometry&page_size=999`, - this.init, - ), + getTouristicContentsNearTrek(state.api, state.language, this.trekId, this.init), fetch(`${state.api}touristiccontent_category/?language=${state.language}&published=true&fields=id,label,pictogram&page_size=999`, this.init), - fetch(`${state.api}touristicevent/?language=${state.language}&near_trek=${this.trekId}&published=true&fields=id,name,attachments,type,geometry&page_size=999`, this.init), + getTouristicEventsNearTrek(state.api, state.language, this.trekId, this.init), fetch(`${state.api}touristicevent_type/?language=${state.language}&published=true&fields=id,type,pictogram&page_size=999`, this.init), fetch(`${state.api}trek_network/?language=${state.language}&published=true&fields=id,label,pictogram&page_size=999`, this.init), - fetch( - `${state.api}trek/${this.trekId}/?language=${state.language}&published=true&fields=id,name,attachments,description,description_teaser,difficulty,duration,ascent,descent,length_2d,practice,themes,route,geometry,gpx,kml,pdf,parking_location,departure,departure_city,arrival,cities,ambiance,access,public_transport,advice,advised_parking,gear,labels,source,points_reference,disabled_infrastructure,accessibility_level,accessibility_slope,accessibility_width,accessibility_signage,accessibility_covering,accessibility_exposure,accessibility_advice,accessibilities,ratings,ratings_description,information_desks,children,networks,web_links`, - this.init, - ), + getTrek(state.api, state.language, this.trekId, this.init), ]) .then(responses => Promise.all(responses.map(response => response.json()))) .then( @@ -124,7 +253,7 @@ export class GrwTrekProvider { trek, ]) => { state.networkError = false; - if (trek.children.length > 0 || state.parentTrekId) { + if ((trek && trek.children.length > 0) || state.parentTrekId) { const steps: number[] = []; if (trek.children.length > 0) { state.parentTrekId = trek.id; @@ -139,11 +268,7 @@ export class GrwTrekProvider { } const stepRequests = []; steps.forEach(stepId => { - stepRequests.push( - fetch( - `${state.api}trek/${stepId}/?language=${state.language}&published=true&fields=id,name,attachments,description_teaser,difficulty,duration,ascent,descent,length_2d,practice,themes,route,departure,departure_city,departure_geom,cities,accessibilities,labels,districts,networks,web_links`, - ), - ); + stepRequests.push(getTrek(state.api, state.language, stepId, this.init)); }); state.currentTrekSteps = await Promise.all([...stepRequests]).then(responses => Promise.all(responses.map(response => response.json()))); } @@ -163,24 +288,24 @@ export class GrwTrekProvider { if (cities) { state.cities = cities.results; } - if (accessibilities) { state.accessibilities = accessibilities.results; } - if (ratings) { state.ratings = ratings.results; } - if (ratingsScale) { state.ratingsScale = ratingsScale.results; } - if (sensitiveAreas) { state.currentSensitiveAreas = sensitiveAreas.results; } - state.labels = labels.results; - state.sources = sources.results; + if (labels) { + state.labels = labels.results; + } + if (sources) { + state.sources = sources.results; + } if (accessibilitiesLevel) { state.accessibilitiesLevel = accessibilitiesLevel.results; } @@ -199,10 +324,18 @@ export class GrwTrekProvider { if (networks) { state.networks = networks.results; } - state.currentPois = pois.results; - state.poiTypes = poiTypes.results; - state.currentInformationDesks = informationDesks.results; - state.currentTrek = trek; + if (pois) { + state.currentPois = pois.results; + } + if (poiTypes) { + state.poiTypes = poiTypes.results; + } + if (informationDesks) { + state.currentInformationDesks = informationDesks.results; + } + if (trek) { + state.currentTrek = trek; + } }, ); } catch (error) { @@ -212,11 +345,6 @@ export class GrwTrekProvider { } } - disconnectedCallback() { - this.controller.abort(); - state.networkError = false; - } - render() { return ; } diff --git a/src/store/grw-treks-provider.tsx b/src/store/grw-treks-provider.tsx index 7b027bd..da44b82 100644 --- a/src/store/grw-treks-provider.tsx +++ b/src/store/grw-treks-provider.tsx @@ -1,5 +1,9 @@ import { Build, Component, h, Host, Prop } from '@stencil/core'; +import { getAllDataInStore } from 'services/grw-db.service'; +import { getDistricts, getTreksList, trekIsAvailableOffline } from 'services/treks.service'; import state from 'store/store'; +import { Treks } from 'types/types'; +import { durations, elevations, lengths } from 'utils/utils'; @Component({ tag: 'grw-treks-provider', @@ -27,6 +31,16 @@ export class GrwTreksProvider { state.languages = this.languages.split(','); state.language = state.languages[0]; } + + state.portalsFromProviders = this.portals; + state.inBboxFromProviders = this.inBbox; + state.citiesFromProviders = this.cities; + state.districtsFromProviders = this.districts; + state.structuresFromProviders = this.structures; + state.themesFromProviders = this.themes; + state.routesFromProviders = this.routes; + state.practicesFromProviders = this.practices; + this.handleTreks(); } @@ -35,75 +49,113 @@ export class GrwTreksProvider { state.networkError = false; } - handleTreks() { - let treksRequest = `${this.api}trek/?language=${state.language}&published=true`; + async handleTreks() { + this.handleOnlineTreks(); + } - this.inBbox && (treksRequest += `&in_bbox=${this.inBbox}`); - this.cities && (treksRequest += `&cities=${this.cities}`); - this.districts && (treksRequest += `&districts=${this.districts}`); - this.structures && (treksRequest += `&structures=${this.structures}`); - this.themes && (treksRequest += `&themes=${this.themes}`); - this.portals && (treksRequest += `&portals=${this.portals}`); - this.routes && (treksRequest += `&routes=${this.routes}`); - this.practices && (treksRequest += `&practices=${this.practices}`); + async handleOfflineTreks(treks: Treks) { + if (!state.difficulties) { + state.difficulties = await getAllDataInStore('difficulties'); + } + if (!state.routes) { + state.routes = await getAllDataInStore('routes'); + } + if (!state.practices) { + state.practices = await getAllDataInStore('practices'); + } + if (!state.themes) { + state.themes = await getAllDataInStore('themes'); + } + if (!state.cities) { + state.cities = await getAllDataInStore('cities'); + } + if (!state.accessibilities) { + state.accessibilities = await getAllDataInStore('accessibilities'); + } + if (!state.labels) { + state.labels = await getAllDataInStore('labels'); + } + if (!state.districts) { + state.districts = await getAllDataInStore('districts'); + } + if (!state.durations) { + state.durations = durations; + } + if (!state.lengths) { + state.lengths = lengths; + } + if (!state.elevations) { + state.elevations = elevations; + } + this.sortTreks(treks); + state.treks = treks; + state.currentTreks = treks; + } - treksRequest += `&fields=id,name,attachments,description_teaser,difficulty,duration,ascent,length_2d,practice,themes,route,departure,departure_city,departure_geom,cities,accessibilities,labels,districts&page_size=999`; + sortTreks(treks) { + treks.sort((a, b) => { + return a.name.localeCompare(b.name, undefined, { + numeric: true, + sensitivity: 'base', + }); + }); + } - try { - Promise.all([ - fetch(`${state.api}trek_difficulty/?language=${state.language}${this.portals ? '&portals='.concat(this.portals) : ''}&fields=id,label,pictogram`, this.init), - fetch(`${state.api}trek_route/?language=${state.language}${this.portals ? '&portals='.concat(this.portals) : ''}&fields=id,route,pictogram`, this.init), - fetch(`${state.api}trek_practice/?language=${state.language}${this.portals ? '&portals='.concat(this.portals) : ''}&fields=id,name,pictogram`, this.init), - fetch(`${state.api}theme/?language=${state.language}${this.portals ? '&portals='.concat(this.portals) : ''}&fields=id,label,pictogram`, this.init), - fetch(`${state.api}city/?language=${state.language}&fields=id,name&published=true&page_size=999`, this.init), - fetch( - `${state.api}trek_accessibility/?language=${state.language}${this.portals ? '&portals='.concat(this.portals) : ''}&fields=id,name,pictogram&published=true&page_size=999`, - this.init, - ), - fetch( - `${state.api}label/?language=${state.language}${ - this.portals ? '&portals='.concat(this.portals) : '' - }&fields=id,name,advice,pictogram,filter&published=true&page_size=999`, - this.init, - ), - fetch(`${state.api}district/?language=${state.language}&fields=id,name&published=true&page_size=999`, this.init), - fetch(treksRequest, this.init), - ]) - .then(responses => Promise.all(responses.map(response => response.json()))) - .then(([difficulties, routes, practices, themes, cities, accessibility, labels, districts, treks]) => { - state.networkError = false; - state.difficulties = difficulties.results; - state.routes = routes.results; - state.practices = practices.results.map(practice => ({ ...practice, selected: false })); - state.themes = themes.results; - state.cities = cities.results; - state.accessibilities = accessibility.results; - state.durations = [ - { id: 1, name: '0 - 1h', minValue: 0, maxValue: 1, selected: false }, - { id: 2, name: '1 - 2h', minValue: 1, maxValue: 2, selected: false }, - { id: 3, name: '2 - 5h', minValue: 2, maxValue: 5, selected: false }, - { id: 4, name: '5 - 10h', minValue: 5, maxValue: 10, selected: false }, - ]; - state.lengths = [ - { id: 1, name: '0 - 5km', minValue: 0, maxValue: 5000, selected: false }, - { id: 2, name: '5 - 10km', minValue: 5000, maxValue: 10000, selected: false }, - { id: 3, name: '10 - 15km', minValue: 10000, maxValue: 15000, selected: false }, - { id: 4, name: '15 - 50km', minValue: 15000, maxValue: 50000, selected: false }, - ]; - state.elevations = [ - { id: 1, name: '0 - 500m', minValue: 0, maxValue: 500, selected: false }, - { id: 2, name: '500m - 1km', minValue: 500, maxValue: 1000, selected: false }, - ]; - state.labels = labels.results; - state.districts = districts.results; - state.treks = treks.results; - state.currentTreks = treks.results; + handleOnlineTreks() { + Promise.all([ + fetch(`${state.api}trek_difficulty/?language=${state.language}${this.portals ? '&portals='.concat(this.portals) : ''}&fields=id,label,pictogram`, this.init), + fetch(`${state.api}trek_route/?language=${state.language}${this.portals ? '&portals='.concat(this.portals) : ''}&fields=id,route,pictogram`, this.init), + fetch(`${state.api}trek_practice/?language=${state.language}${this.portals ? '&portals='.concat(this.portals) : ''}&fields=id,name,pictogram`, this.init), + fetch(`${state.api}theme/?language=${state.language}${this.portals ? '&portals='.concat(this.portals) : ''}&fields=id,label,pictogram`, this.init), + fetch(`${state.api}city/?language=${state.language}&fields=id,name&published=true&page_size=999`, this.init), + fetch( + `${state.api}trek_accessibility/?language=${state.language}${this.portals ? '&portals='.concat(this.portals) : ''}&fields=id,name,pictogram&published=true&page_size=999`, + this.init, + ), + fetch( + `${state.api}label/?language=${state.language}${this.portals ? '&portals='.concat(this.portals) : ''}&fields=id,name,advice,pictogram,filter&published=true&page_size=999`, + this.init, + ), + getDistricts(state.api, state.language, this.init), + getTreksList(this.api, state.language, this.inBbox, this.cities, this.districts, this.structures, this.themes, this.portals, this.routes, this.practices, this.init), + ]) + .then(responses => { + responses.forEach(response => { + if (response.status !== 200) { + throw new Error('network error'); + } }); - } catch (error) { - if (!(error.code === DOMException.ABORT_ERR)) { - state.networkError = true; - } - } + return Promise.all(responses.map(response => response.json())); + }) + .then(async ([difficulties, routes, practices, themes, cities, accessibility, labels, districts, treks]) => { + state.networkError = false; + state.difficulties = difficulties.results; + state.routes = routes.results; + state.practices = practices.results.map(practice => ({ ...practice, selected: false })); + state.themes = themes.results; + state.cities = cities.results; + state.accessibilities = accessibility.results; + state.labels = labels.results; + state.districts = districts.results; + state.durations = durations; + state.lengths = lengths; + state.elevations = elevations; + const treksWithOfflineValue = []; + for (let index = 0; index < treks.results.length; index++) { + const offline = await trekIsAvailableOffline(treks.results[index].id); + treksWithOfflineValue.push({ ...treks.results[index], offline }); + } + state.treks = treksWithOfflineValue; + state.currentTreks = treksWithOfflineValue; + }) + .catch(async () => { + const treksInStore: Treks = await getAllDataInStore('treks'); + if (treksInStore && treksInStore.length > 0) { + this.handleOfflineTreks(treksInStore); + } else { + state.networkError = true; + } + }); } render() { diff --git a/src/store/store.ts b/src/store/store.ts index 11aa149..781be74 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -2,7 +2,7 @@ import { createStore } from '@stencil/store'; import { LatLngBounds } from 'leaflet'; import { Accessibilities, - accessibilitiesLevel, + AccessibilitiesLevel, Difficulties, Durations, InformationDesks, @@ -54,7 +54,7 @@ const { state, onChange, reset } = createStore<{ ratings: Ratings; ratingsScale: RatingsScale; accessibilities: Accessibilities; - accessibilitiesLevel: accessibilitiesLevel; + accessibilitiesLevel: AccessibilitiesLevel; poiTypes: PoiTypes; currentTrek: Trek; currentSensitiveAreas: SensitiveAreas; @@ -85,6 +85,15 @@ const { state, onChange, reset } = createStore<{ selectedTouristicContentId: number; selectedTouristicEventId: number; networks: Networks; + portalsFromProviders: string; + inBboxFromProviders: string; + citiesFromProviders: string; + districtsFromProviders: string; + structuresFromProviders: string; + themesFromProviders: string; + routesFromProviders: string; + practicesFromProviders: string; + offlineTreks: boolean; }>({ mode: null, api: null, @@ -139,6 +148,15 @@ const { state, onChange, reset } = createStore<{ selectedTouristicContentId: null, selectedTouristicEventId: null, networks: null, + portalsFromProviders: null, + inBboxFromProviders: null, + citiesFromProviders: null, + districtsFromProviders: null, + structuresFromProviders: null, + themesFromProviders: null, + routesFromProviders: null, + practicesFromProviders: null, + offlineTreks: false, }); export { onChange, reset }; diff --git a/src/types/types.ts b/src/types/types.ts index 104705d..f2292b2 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -51,6 +51,8 @@ export type Trek = { ratings_description?: string; networks: number[]; web_links: Weblink[]; + update_datetime: string; + offline?: boolean; }; export type Attachments = Attachment[]; @@ -141,7 +143,7 @@ export type Accessibility = { pictogram: string; }; -export type accessibilitiesLevel = AccessibilityLevel[]; +export type AccessibilitiesLevel = AccessibilityLevel[]; export type AccessibilityLevel = { id: number; @@ -248,6 +250,7 @@ export type TouristicContent = { contact?: string; email?: string; website?: string; + offline?: boolean; }; export type TouristicContents = TouristicContent[]; @@ -277,6 +280,7 @@ export type TouristicEvent = { website?: string; begin_date?: string; end_date?: string; + offline?: boolean; }; export type TouristicEvents = TouristicEvent[]; @@ -356,3 +360,5 @@ export type WeblinkCategory = { label: string; pictogram: string; }; + +export type ImageInStore = { url: string; data: string | ArrayBuffer; type: string }; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index ee51c66..7659bbf 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,3 +1,4 @@ +import { getDataInStore } from 'services/grw-db.service'; import state from 'store/store'; import { TouristicContents, TouristicContentsFilters, TouristicEvents, TouristicEventsFilters, TrekFilters, Treks } from 'types/types'; @@ -108,7 +109,9 @@ export function handleTreksFiltersAndSearch(): Treks { } } - const searchTreks = isUsingFilter ? filtersTreks : state.treks; + let searchTreks = isUsingFilter ? filtersTreks : state.treks; + searchTreks = searchTreks.filter(trek => !state.offlineTreks || (state.offlineTreks && trek.offline)); + return Boolean(state.searchValue) ? searchTreks.filter(currentTrek => currentTrek.name.toLowerCase().includes(state.searchValue.toLowerCase())) : searchTreks; } @@ -179,7 +182,6 @@ export function handleTouristicContentsFiltersAndSearch(): TouristicContents { } } const searchTouristicContents = isUsingFilter ? filtersTouristicContents : state.touristicContents; - return Boolean(state.searchValue) ? searchTouristicContents.filter(currentTouristicContents => currentTouristicContents.name.toLowerCase().includes(state.searchValue.toLowerCase())) : searchTouristicContents; @@ -257,3 +259,71 @@ export function handleTouristicEventsFiltersAndSearch(): TouristicEvents { ? searchTouristicEvents.filter(currentTouristicEvent => currentTouristicEvent.name.toLowerCase().includes(state.searchValue.toLowerCase())) : searchTouristicEvents; } + +export const durations = [ + { id: 1, name: '0 - 1h', minValue: 0, maxValue: 1, selected: false }, + { id: 2, name: '1 - 2h', minValue: 1, maxValue: 2, selected: false }, + { id: 3, name: '2 - 5h', minValue: 2, maxValue: 5, selected: false }, + { id: 4, name: '5 - 10h', minValue: 5, maxValue: 10, selected: false }, +]; + +export const lengths = [ + { id: 1, name: '0 - 5km', minValue: 0, maxValue: 5000, selected: false }, + { id: 2, name: '5 - 10km', minValue: 5000, maxValue: 10000, selected: false }, + { id: 3, name: '10 - 15km', minValue: 10000, maxValue: 15000, selected: false }, + { id: 4, name: '15 - 50km', minValue: 15000, maxValue: 50000, selected: false }, +]; + +export const elevations = [ + { id: 1, name: '0 - 500m', minValue: 0, maxValue: 500, selected: false }, + { id: 2, name: '500m - 1km', minValue: 500, maxValue: 1000, selected: false }, +]; + +export function blobToArrayBuffer(blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener('loadend', () => { + resolve(reader.result); + }); + reader.addEventListener('error', reject); + reader.readAsArrayBuffer(blob); + }); +} + +export function arrayBufferToBlob(buffer, type) { + return new Blob([buffer], { type: type }); +} + +export function getFilesToStore(value: Object, regExp: RegExp, onlyFirstArrayFile = false): string[] { + const filesToStore: any[] = []; + for (const keyValue of Object.keys(value)) { + if (value[keyValue]) { + if (typeof value[keyValue] === 'object' && (!Array.isArray(value[keyValue]) || !onlyFirstArrayFile)) { + filesToStore.push(...getFilesToStore(value[keyValue], regExp)); + } else if (typeof value[keyValue] === 'string' && regExp.test(value[keyValue].toLowerCase())) { + filesToStore.push(value[keyValue]); + } else if (typeof value[keyValue] === 'object' && Array.isArray(value[keyValue]) && onlyFirstArrayFile && value[keyValue].length > 0) { + filesToStore.push(...getFilesToStore(value[keyValue][0], regExp)); + } + } + } + + return [...new Set(filesToStore)]; +} + +export async function setFilesFromStore(value: Object, regExp: RegExp) { + for (const keyValue of Object.keys(value)) { + if (value[keyValue]) { + if (typeof value[keyValue] === 'object') { + setFilesFromStore(value[keyValue], regExp); + } else if (typeof value[keyValue] === 'string' && regExp.test(value[keyValue].toLowerCase())) { + const image = await getDataInStore('images', value[keyValue]); + if (image) { + value[keyValue] = window.URL.createObjectURL(arrayBufferToBlob(image.data, image.type)); + } + } + } + } +} + +export const imagesRegExp = new RegExp('^(http(s)?://|www.).*(.png|.jpg|.jpeg|.svg)$');