diff --git a/frontend/app/src/app/modules/data/list/search-page.component.ts b/frontend/app/src/app/modules/data/list/search-page.component.ts index c9dca4b0..58687d14 100644 --- a/frontend/app/src/app/modules/data/list/search-page.component.ts +++ b/frontend/app/src/app/modules/data/list/search-page.component.ts @@ -15,7 +15,12 @@ import {Component, DestroyRef, inject, Input, OnInit} from '@angular/core'; import {ActivatedRoute, Router} from '@angular/router'; import {Keyboard} from '@capacitor/keyboard'; -import {AlertController, AnimationBuilder, AnimationController} from '@ionic/angular/standalone'; +import { + AlertController, + AnimationBuilder, + AnimationController, + ModalController, +} from '@ionic/angular/standalone'; import {Capacitor} from '@capacitor/core'; import { SCFacet, @@ -36,6 +41,8 @@ import {PositionService} from '../../map/position.service'; import {ConfigProvider} from '../../config/config.provider'; import {searchPageSwitchAnimation} from './search-page-switch-animation'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {ContextMenuModalComponent} from '../../menu/context/context-menu-modal.component'; +import {enterAnimation, leaveAnimation} from '../../menu/context/context-menu-modal.animations'; /** * SearchPageComponent queries things and shows list of things as search results and filter as context menu @@ -173,7 +180,8 @@ export class SearchPageComponent implements OnInit { private readonly route: ActivatedRoute, protected positionService: PositionService, private readonly configProvider: ConfigProvider, - animationController: AnimationController, + protected animationController: AnimationController, + protected modalController: ModalController, ) { this.routeAnimation = searchPageSwitchAnimation(animationController); } @@ -368,4 +376,20 @@ export class SearchPageComponent implements OnInit { this.searchStringChanged(term); } } + + async openContextMenu(): Promise { + const modal = await this.modalController.create({ + component: ContextMenuModalComponent, + cssClass: 'context-menu-modal', + showBackdrop: true, + backdropDismiss: true, + enterAnimation: (baseElement: HTMLElement) => enterAnimation(baseElement, this.animationController), + leaveAnimation: (baseElement: HTMLElement) => leaveAnimation(baseElement, this.animationController), + componentProps: { + contextMenuService: this.contextMenuService, + }, + }); + + await modal.present(); + } } diff --git a/frontend/app/src/app/modules/data/list/search-page.html b/frontend/app/src/app/modules/data/list/search-page.html index 919c589c..11af269e 100644 --- a/frontend/app/src/app/modules/data/list/search-page.html +++ b/frontend/app/src/app/modules/data/list/search-page.html @@ -12,10 +12,6 @@ ~ You should have received a copy of the GNU General Public License along with ~ this program. If not, see . --> - -@if (showContextMenu) { - -} @if (showDrawer && showTopToolbar) { @@ -41,7 +37,7 @@ > @if (showContextMenu) { - + } diff --git a/frontend/app/src/app/modules/favorites/favorites-page.component.ts b/frontend/app/src/app/modules/favorites/favorites-page.component.ts index 15a8378d..ba219155 100644 --- a/frontend/app/src/app/modules/favorites/favorites-page.component.ts +++ b/frontend/app/src/app/modules/favorites/favorites-page.component.ts @@ -13,7 +13,7 @@ * this program. If not, see . */ import {Component, OnInit} from '@angular/core'; -import {AlertController, AnimationController} from '@ionic/angular/standalone'; +import {AlertController, AnimationController, ModalController} from '@ionic/angular/standalone'; import {ActivatedRoute, Router} from '@angular/router'; import {NGXLogger} from 'ngx-logger'; import {debounceTime, distinctUntilChanged, startWith, take} from 'rxjs/operators'; @@ -55,6 +55,7 @@ export class FavoritesPageComponent extends SearchPageComponent implements OnIni private favoritesService: FavoritesService, configProvider: ConfigProvider, animationController: AnimationController, + modalController: ModalController, ) { super( alertController, @@ -68,6 +69,7 @@ export class FavoritesPageComponent extends SearchPageComponent implements OnIni positionService, configProvider, animationController, + modalController, ); } diff --git a/frontend/app/src/app/modules/menu/context/context-menu-modal.animations.ts b/frontend/app/src/app/modules/menu/context/context-menu-modal.animations.ts new file mode 100644 index 00000000..f1c4a226 --- /dev/null +++ b/frontend/app/src/app/modules/menu/context/context-menu-modal.animations.ts @@ -0,0 +1,54 @@ +import {Animation, AnimationController} from '@ionic/angular'; + +/** + * Defines the animation for showing a modal as a right-hand sidebar. + * @param baseElement The root element of the modal (including Shadow DOM). + * @param animationCtrl The Ionic AnimationController. + * @returns The configured Ionic animation. + */ +export const enterAnimation = (baseElement: HTMLElement, animationCtrl: AnimationController): Animation => { + const root = baseElement.shadowRoot; + + const backdrop = root?.querySelector('ion-backdrop'); + const wrapper = root?.querySelector('.modal-wrapper'); + + // The wrapper needs to be positioned on the right side + if (wrapper instanceof HTMLElement) { + Object.assign(wrapper.style, { + position: 'absolute', + top: '0', + right: '0', + height: '100%', + width: '304px', + maxWidth: '75%', + opacity: '1', + }); + } + + const backdropAnimation = animationCtrl + .create() + .addElement(backdrop!) + .fromTo('opacity', '0.01', 'var(--backdrop-opacity)'); + + const wrapperAnimation = animationCtrl + .create() + .addElement(wrapper!) + .fromTo('transform', 'translateX(100%)', 'translateX(0)'); + + return animationCtrl + .create() + .addElement(baseElement) + .duration(400) + .easing('ease-out') + .addAnimation([backdropAnimation, wrapperAnimation]); +}; + +/** + * Defines the animation for hiding a modal by sliding it out to the right. + * @param baseElement The root element of the modal. + * @param animationCtrl The Ionic AnimationController. + * @returns The configured Ionic animation (reverse of enterAnimation). + */ +export const leaveAnimation = (baseElement: HTMLElement, animationCtrl: AnimationController): Animation => { + return enterAnimation(baseElement, animationCtrl).direction('reverse'); +}; diff --git a/frontend/app/src/app/modules/menu/context/context-menu-modal.component.ts b/frontend/app/src/app/modules/menu/context/context-menu-modal.component.ts new file mode 100644 index 00000000..90271521 --- /dev/null +++ b/frontend/app/src/app/modules/menu/context/context-menu-modal.component.ts @@ -0,0 +1,113 @@ +import {LangChangeEvent, TranslateService} from '@ngx-translate/core'; +import {SCLanguage, SCThingTranslator, SCThingType, SCTranslations} from '@openstapps/core'; +import {ContextMenuService} from './context-menu.service'; +import {FilterContext, FilterFacet, SortContext, SortContextOption} from './context-type.js'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {ModalController} from '@ionic/angular/standalone'; +import {Component, Input, OnInit, OnDestroy} from '@angular/core'; +import {Subject, takeUntil} from 'rxjs'; + +@Component({ + selector: 'stapps-context-menu-modal', + templateUrl: './context-menu-modal.html', +}) +export class ContextMenuModalComponent implements OnInit, OnDestroy { + @Input() contextMenuService: ContextMenuService; + + compactFilterOptionCount = 5; + + filterOption: FilterContext; + + sortOption: SortContext; + + language: keyof SCTranslations; + + translator: SCThingTranslator; + + scThingType = SCThingType; + + // Using a subject to manage subscriptions for clean-up + private readonly destroy$ = new Subject(); + + constructor( + private translateService: TranslateService, + private readonly modalCtrl: ModalController, + ) { + this.language = this.translateService.currentLang as keyof SCTranslations; + this.translator = new SCThingTranslator(this.language); + + this.translateService.onLangChange.pipe(takeUntilDestroyed()).subscribe((event: LangChangeEvent) => { + this.language = event.lang as keyof SCTranslations; + this.translator = new SCThingTranslator(this.language); + }); + } + + ngOnInit(): void { + const initialFilter = this.contextMenuService.filterOptions.getValue(); + if (initialFilter) { + this.filterOption = initialFilter; + } + + const initialSort = this.contextMenuService.sortOptions.getValue(); + if (initialSort) { + this.sortOption = initialSort; + } + + // Move the subscription logic here. It's now safe to access this.contextMenuService. + this.contextMenuService.filterContextChanged$.pipe(takeUntil(this.destroy$)).subscribe(fc => { + if (fc) { + this.filterOption = fc; + } + }); + + this.contextMenuService.sortContextChanged$.pipe(takeUntil(this.destroy$)).subscribe(sc => { + if (sc) { + this.sortOption = sc; + } + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + get facets(): FilterFacet[] { + return this.filterOption?.options?.filter(it => it.buckets.length > 0) || []; + } + + resetFilter(option: FilterContext) { + for (const facet of option.options) { + for (const bucket of facet.buckets) { + bucket.checked = false; + } + } + this.contextMenuService.contextFilterChanged(this.filterOption); + } + + filterChanged = () => { + this.contextMenuService.contextFilterChanged(this.filterOption); + }; + + sortChanged(option: SortContext, value: SortContextOption) { + if (option.value === value.value) { + if (value.reversible) { + option.reversed = !option.reversed; + } + } else { + option.value = value.value; + if (value.reversible) { + option.reversed = false; + } + } + this.contextMenuService.contextSortChanged(option); + } + + getTranslatedPropertyValue(onlyForType: SCThingType, field: string, key?: string): string | undefined { + return this.translator.translatedPropertyValue(onlyForType, field, key); + } + + dismiss() { + this.modalCtrl.dismiss(); + } +} diff --git a/frontend/app/src/app/modules/menu/context/context-menu-modal.html b/frontend/app/src/app/modules/menu/context/context-menu-modal.html new file mode 100644 index 00000000..e1bcaa13 --- /dev/null +++ b/frontend/app/src/app/modules/menu/context/context-menu-modal.html @@ -0,0 +1,90 @@ + + + +

{{ 'menu.context.title' | translate | titlecase }}

+
+
+
+ + + + + + + + {{ 'menu.context.sort.title' | translate | titlecase }} + + + + {{ 'menu.context.sort.' + value.value | translate | titlecase }} + + + + + + + + + + +
+ + + {{ 'menu.context.filter.title' | translate | titlecase }} + + + + + + + + + {{ facet.info.onlyOnType | titlecase }} / + + {{ facet.info.field | titlecase }} + + + + + + + ({{ bucket.count }}) + {{ + facet.field === 'type' + ? (getTranslatedPropertyValue($any(bucket.key), 'type') | titlecase) + : (facet.onlyOnType && getTranslatedPropertyValue(facet.onlyOnType, facet.field, bucket.key) + | titlecase) + }} + + + + + + {{ 'menu.context.filter.showAll' | translate }} + + +
+
diff --git a/frontend/app/src/app/modules/menu/context/context-menu.component.ts b/frontend/app/src/app/modules/menu/context/context-menu.component.ts deleted file mode 100644 index 0078a894..00000000 --- a/frontend/app/src/app/modules/menu/context/context-menu.component.ts +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright (C) 2023 StApps - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation, version 3. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ -import {Component, Input} from '@angular/core'; -import {LangChangeEvent, TranslateService} from '@ngx-translate/core'; -import {SCLanguage, SCThingTranslator, SCThingType, SCTranslations} from '@openstapps/core'; -import {ContextMenuService} from './context-menu.service'; -import {FilterContext, FilterFacet, SortContext, SortContextOption} from './context-type.js'; -import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; - -/** - * The context menu - * - * It can be configured with sorting types and filtering on facets - * - * Example:
- * `` - */ -@Component({ - selector: 'stapps-context', - templateUrl: 'context-menu.html', -}) -export class ContextMenuComponent { - /** - * Id of the content the menu is used for - */ - @Input() - contentId: string; - - /** - * Amount of filter options shown on compact view - */ - compactFilterOptionCount = 5; - - /** - * Container for the filter context - */ - filterOption: FilterContext; - - /** - * Picks facets based on the compact filter option and sorts - * them based on - * - * No specific type => Type name alphabetically => Bucket count - */ - get facets(): FilterFacet[] { - return this.filterOption.options.filter(it => it.buckets.length > 0); - } - - /** - * Possible languages to be used for translation - */ - language: keyof SCTranslations; - - /** - * Mapping of SCThingType - */ - scThingType = SCThingType; - - /** - * Container for the sort context - */ - sortOption: SortContext; - - /** - * Core translator - */ - translator: SCThingTranslator; - - constructor( - private translateService: TranslateService, - private readonly contextMenuService: ContextMenuService, - ) { - this.language = this.translateService.currentLang as keyof SCTranslations; - this.translator = new SCThingTranslator(this.language); - - this.translateService.onLangChange.pipe(takeUntilDestroyed()).subscribe((event: LangChangeEvent) => { - this.language = event.lang as keyof SCTranslations; - this.translator = new SCThingTranslator(this.language); - }); - this.contextMenuService.filterContextChanged$.pipe(takeUntilDestroyed()).subscribe(filterContext => { - this.filterOption = filterContext; - }); - this.contextMenuService.sortOptions.pipe(takeUntilDestroyed()).subscribe(sortContext => { - this.sortOption = sortContext; - }); - } - - /** - * Sets selected filter options and updates listener - */ - filterChanged = () => { - this.contextMenuService.contextFilterChanged(this.filterOption); - }; - - /** - * Returns translated property value - */ - getTranslatedPropertyValue(onlyForType: SCThingType, field: string, key?: string): string | undefined { - return this.translator.translatedPropertyValue(onlyForType, field, key); - } - - /** - * Resets filter options - */ - resetFilter = (option: FilterContext) => { - for (const filterFacet of option.options) - for (const filterBucket of filterFacet.buckets) { - filterBucket.checked = false; - } - this.contextMenuService.contextFilterChanged(this.filterOption); - }; - - /** - * Updates selected sort option and updates listener - */ - sortChanged = (option: SortContext, value: SortContextOption) => { - if (option.value === value.value) { - if (value.reversible) { - option.reversed = !option.reversed; - } - } else { - option.value = value.value; - if (value.reversible) { - option.reversed = false; - } - } - this.contextMenuService.contextSortChanged(option); - }; -} diff --git a/frontend/app/src/app/modules/menu/context/context-menu.html b/frontend/app/src/app/modules/menu/context/context-menu.html deleted file mode 100644 index 2986d7d6..00000000 --- a/frontend/app/src/app/modules/menu/context/context-menu.html +++ /dev/null @@ -1,114 +0,0 @@ - - - - - -

{{ 'menu.context.title' | translate | titlecase }}

-
-
- - - - @if (sortOption) { - - - - {{ 'menu.context.sort.title' | translate | titlecase }} - - @for (value of sortOption.values; track value; let i = $index) { - - - {{ 'menu.context.sort.' + value.value | translate | titlecase }} - @if (sortOption.value === value.value && value.reversible) { - - @if (sortOption.reversed) { - - } - @if (!sortOption.reversed) { - - } - - } - - - } - - } - - - @if (filterOption) { -
- - - {{ 'menu.context.filter.title' | translate | titlecase }} - - - - - @for (facet of facets; track facet) { - -
- - - @if (facet.info.onlyOnType) { - {{ facet.info.onlyOnType | titlecase }} / - - } - {{ facet.info.field | titlecase }} - - -
- @for ( - bucket of !facet.compact ? facet.buckets.slice(0, compactFilterOptionCount) : facet.buckets; - track bucket - ) { - - - ({{ bucket.count }}) - {{ - facet.field === 'type' - ? (getTranslatedPropertyValue($any(bucket.key), 'type') | titlecase) - : (facet.onlyOnType && - getTranslatedPropertyValue(facet.onlyOnType, facet.field, bucket.key) - | titlecase) - }} - - - } - @if (!facet.compact && facet.buckets.length > compactFilterOptionCount) { - - {{ 'menu.context.filter.showAll' | translate }} - - } -
-
-
- } -
- } -
-
diff --git a/frontend/app/src/app/modules/menu/context/context-menu.service.ts b/frontend/app/src/app/modules/menu/context/context-menu.service.ts index 804fefce..e124df9c 100644 --- a/frontend/app/src/app/modules/menu/context/context-menu.service.ts +++ b/frontend/app/src/app/modules/menu/context/context-menu.service.ts @@ -21,7 +21,7 @@ import { SCThingType, SCTranslations, } from '@openstapps/core'; -import {Subject} from 'rxjs'; +import {BehaviorSubject} from 'rxjs'; import {FilterBucket, FilterContext, FilterFacet, SortContext, TransformedFacet} from './context-type'; import {TranslateService} from '@ngx-translate/core'; import {ThingTranslateService} from '../../../translation/thing-translate.service'; @@ -40,7 +40,7 @@ export class ContextMenuService { /** * Container for the filter context */ - filterOptions = new Subject(); + filterOptions = new BehaviorSubject(undefined); /** * Observable filterContext streams @@ -50,7 +50,7 @@ export class ContextMenuService { /** * Container for the filter query (SCSearchFilter) */ - filterQuery = new Subject(); + filterQuery = new BehaviorSubject(undefined); /** * Observable filterContext streams @@ -65,7 +65,7 @@ export class ContextMenuService { /** * Container for the sort context */ - sortOptions = new Subject(); + sortOptions = new BehaviorSubject(undefined); /** * Observable SortContext streams @@ -75,7 +75,7 @@ export class ContextMenuService { /** * Container for the sort query */ - sortQuery = new Subject(); + sortQuery = new BehaviorSubject(undefined); /** * Observable SortContext streams diff --git a/frontend/app/src/app/modules/menu/menu.module.ts b/frontend/app/src/app/modules/menu/menu.module.ts index de535593..162d61ef 100644 --- a/frontend/app/src/app/modules/menu/menu.module.ts +++ b/frontend/app/src/app/modules/menu/menu.module.ts @@ -19,12 +19,13 @@ import {RouterModule} from '@angular/router'; import {LayoutModule} from '@angular/cdk/layout'; import {TranslateModule} from '@ngx-translate/core'; import {SettingsModule} from '../settings/settings.module'; -import {ContextMenuComponent} from './context/context-menu.component'; import {ContextMenuService} from './context/context-menu.service'; import { IonButton, + IonButtons, IonCheckbox, IonContent, + IonHeader, IonItem, IonLabel, IonList, @@ -39,13 +40,14 @@ import { IonToolbar, } from '@ionic/angular/standalone'; import {IonIconDirective} from 'src/app/util/ion-icon/ion-icon.directive'; +import {ContextMenuModalComponent} from './context/context-menu-modal.component'; /** * Menu module */ @NgModule({ - declarations: [ContextMenuComponent], - exports: [ContextMenuComponent], + declarations: [ContextMenuModalComponent], + exports: [ContextMenuModalComponent], imports: [ CommonModule, IonIconDirective, @@ -69,6 +71,8 @@ import {IonIconDirective} from 'src/app/util/ion-icon/ion-icon.directive'; IonRadioGroup, IonContent, IonToolbar, + IonButtons, + IonHeader, ], providers: [ContextMenuService], })