From b8d34a42a003813e947d0f19b317bc6d53348496 Mon Sep 17 00:00:00 2001 From: Kai Niebes Date: Wed, 22 Jul 2026 18:10:46 +0200 Subject: [PATCH] Add remaining profile page filter and search options --- .../pagination/pagination.component.html | 28 +++ .../pagination/pagination.component.scss | 8 + .../pagination/pagination.component.ts | 49 +++++ ...plore-filter-sidenav-toggle.component.html | 5 + ...plore-filter-sidenav-toggle.component.scss | 20 ++ ...explore-filter-sidenav-toggle.component.ts | 13 ++ .../explore-filter-sidenav.component.html | 178 +++++++++--------- .../explore-filter-sidenav.component.ts | 29 ++- src/app/pages/explore/explore.component.html | 12 +- src/app/pages/explore/explore.component.scss | 21 --- src/app/pages/explore/explore.component.ts | 2 + src/app/pages/explore/shared-types.ts | 95 ++++++---- .../compilations/compilations.component.html | 24 +-- .../compilations/compilations.component.ts | 137 ++++++++++++-- .../entities/entities.component.html | 18 +- .../entities/entities.component.ts | 164 +++++++++++----- .../profile-page/profile-page.component.html | 13 ++ .../profile-page/profile-page.component.scss | 6 + .../profile-page/profile-page.component.ts | 95 +++++++++- 19 files changed, 663 insertions(+), 254 deletions(-) create mode 100644 src/app/components/pagination/pagination.component.html create mode 100644 src/app/components/pagination/pagination.component.scss create mode 100644 src/app/components/pagination/pagination.component.ts create mode 100644 src/app/pages/explore/explore-filter-sidenav-toggle/explore-filter-sidenav-toggle.component.html create mode 100644 src/app/pages/explore/explore-filter-sidenav-toggle/explore-filter-sidenav-toggle.component.scss create mode 100644 src/app/pages/explore/explore-filter-sidenav-toggle/explore-filter-sidenav-toggle.component.ts diff --git a/src/app/components/pagination/pagination.component.html b/src/app/components/pagination/pagination.component.html new file mode 100644 index 00000000..12c4ffea --- /dev/null +++ b/src/app/components/pagination/pagination.component.html @@ -0,0 +1,28 @@ +@if (pagination().totalItemCount >= 0) { + + {{ 'Page' | translate }} {{ pagination().pageIndex + 1 }} {{ 'of' | translate }} + {{ pagination().pageCount }} + + + {{ 'Results' | translate }} + {{ pagination().pageIndex * pagination().pageSize + 1 }} + - + {{ + [(pagination().pageIndex + 1) * pagination().pageSize, pagination().totalItemCount] + | math: 'min' + }} + {{ 'of' | translate }} {{ pagination().totalItemCount }} + +} + + + + diff --git a/src/app/components/pagination/pagination.component.scss b/src/app/components/pagination/pagination.component.scss new file mode 100644 index 00000000..56d76c74 --- /dev/null +++ b/src/app/components/pagination/pagination.component.scss @@ -0,0 +1,8 @@ +:host { + grid-row: 3; + grid-column: 1 / -1; + flex-direction: row; + display: flex; + justify-content: flex-end; + align-items: center; +} diff --git a/src/app/components/pagination/pagination.component.ts b/src/app/components/pagination/pagination.component.ts new file mode 100644 index 00000000..fbdd6aa1 --- /dev/null +++ b/src/app/components/pagination/pagination.component.ts @@ -0,0 +1,49 @@ +import { Component, computed, model } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslatePipe } from 'src/app/pipes'; +import { MathPipe } from 'src/app/pipes/math.pipe'; + +export type Pagination = { + pageCount: number; + pageSize: number; + pageIndex: number; + totalItemCount: number; +}; + +@Component({ + selector: 'app-pagination', + imports: [MatIconModule, MatButtonModule, TranslatePipe, MathPipe], + templateUrl: './pagination.component.html', + styleUrl: './pagination.component.scss', +}) +export class PaginationComponent { + pagination = model.required(); + + canNavigatePrevious = computed(() => this.pagination().pageIndex > 0); + canNavigateFirst = computed(() => this.pagination().pageIndex > 0); + canNavigateNext = computed(() => { + const { pageIndex, pageCount } = this.pagination(); + return pageIndex + 1 < pageCount; + }); + canNavigateLast = computed(() => { + const { pageIndex, pageCount } = this.pagination(); + return pageIndex + 1 < pageCount; + }); + + public firstPage() { + this.pagination.update(state => ({ ...state, pageIndex: 0 })); + } + public previousPage() { + this.pagination.update(state => ({ + ...state, + pageIndex: Math.max(0, state.pageIndex - 1), + })); + } + public nextPage() { + this.pagination.update(state => ({ ...state, pageIndex: state.pageIndex + 1 })); + } + public lastPage() { + this.pagination.update(state => ({ ...state, pageIndex: state.pageCount - 1 })); + } +} diff --git a/src/app/pages/explore/explore-filter-sidenav-toggle/explore-filter-sidenav-toggle.component.html b/src/app/pages/explore/explore-filter-sidenav-toggle/explore-filter-sidenav-toggle.component.html new file mode 100644 index 00000000..a4ca0e2c --- /dev/null +++ b/src/app/pages/explore/explore-filter-sidenav-toggle/explore-filter-sidenav-toggle.component.html @@ -0,0 +1,5 @@ +tune + +@if (numFilterOptions() > 0) { + {{ numFilterOptions() }} +} diff --git a/src/app/pages/explore/explore-filter-sidenav-toggle/explore-filter-sidenav-toggle.component.scss b/src/app/pages/explore/explore-filter-sidenav-toggle/explore-filter-sidenav-toggle.component.scss new file mode 100644 index 00000000..07ccfa5a --- /dev/null +++ b/src/app/pages/explore/explore-filter-sidenav-toggle/explore-filter-sidenav-toggle.component.scss @@ -0,0 +1,20 @@ +:host { + display: flex; + flex-direction: row; + align-items: center; + padding: 8px; + gap: 8px; + border-radius: 24px; + background-color: var(--brand-color); + color: white; + + &:hover, + &:focus, + &:focus-within { + cursor: pointer; + } +} + +:host.has-active-filters { + padding: 8px 12px; +} diff --git a/src/app/pages/explore/explore-filter-sidenav-toggle/explore-filter-sidenav-toggle.component.ts b/src/app/pages/explore/explore-filter-sidenav-toggle/explore-filter-sidenav-toggle.component.ts new file mode 100644 index 00000000..2caede2b --- /dev/null +++ b/src/app/pages/explore/explore-filter-sidenav-toggle/explore-filter-sidenav-toggle.component.ts @@ -0,0 +1,13 @@ +import { Component, input } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +@Component({ + selector: 'app-explore-filter-sidenav-toggle', + imports: [MatIconModule, MatTooltipModule], + templateUrl: './explore-filter-sidenav-toggle.component.html', + styleUrl: './explore-filter-sidenav-toggle.component.scss', +}) +export class ExploreFilterSidenavToggleComponent { + numFilterOptions = input(0); +} diff --git a/src/app/pages/explore/explore-filter-sidenav/explore-filter-sidenav.component.html b/src/app/pages/explore/explore-filter-sidenav/explore-filter-sidenav.component.html index 58060f89..e1228426 100644 --- a/src/app/pages/explore/explore-filter-sidenav/explore-filter-sidenav.component.html +++ b/src/app/pages/explore/explore-filter-sidenav/explore-filter-sidenav.component.html @@ -1,91 +1,93 @@ - +@if (filterOptions(); as filterOptions) { + @if (filterOptions.sortBy && filterOptions.sortBy.length > 0) { + + + } + @if (filterOptions.mediaType && filterOptions.mediaType.length > 0) { + + + } + @if (filterOptions.annotation && filterOptions.annotation.length > 0) { + + + } + @if (isAuthenticated() && filterOptions.access && filterOptions.access.length > 0) { + + + } + @if (filterOptions.licence && filterOptions.licence.length > 0) { + + + } + @if (filterOptions.misc && filterOptions.misc.length > 0) { + + } - +
+ - - - - - - - - -@if (isAuthenticated()) { - - - + +
} - - - - - - - -
- - - -
diff --git a/src/app/pages/explore/explore-filter-sidenav/explore-filter-sidenav.component.ts b/src/app/pages/explore/explore-filter-sidenav/explore-filter-sidenav.component.ts index f24a5507..0d11d75f 100644 --- a/src/app/pages/explore/explore-filter-sidenav/explore-filter-sidenav.component.ts +++ b/src/app/pages/explore/explore-filter-sidenav/explore-filter-sidenav.component.ts @@ -32,6 +32,23 @@ import { toSignal } from '@angular/core/rxjs-interop'; export type ExploreFilterSidenavData = { options: ExploreFilterOption[]; category: ExploreCategory; + filterOptions?: { + sortBy?: ExploreFilterOption[]; + mediaType?: ExploreFilterOption[]; + annotation?: ExploreFilterOption[]; + access?: ExploreFilterOption[]; + licence?: ExploreFilterOption[]; + misc?: ExploreFilterOption[]; + }; +}; + +const DEFAULT_FILTER_OPTIONS: NonNullable = { + sortBy: SortByOptions, + mediaType: MediaTypeOptions, + annotation: AnnotationOptions, + access: AccessOptions, + licence: LicenceOptions, + misc: MiscOptions, }; @Injectable({ providedIn: 'root' }) @@ -105,20 +122,14 @@ export class ExploreFilterSidenavComponent implements SidenavComponent { }); } - public filterOptions = { - sortBy: SortByOptions, - mediaType: MediaTypeOptions, - annotation: AnnotationOptions, - access: AccessOptions, - licence: LicenceOptions, - misc: MiscOptions, - }; + filterOptions = computed(() => this.dataInput()?.filterOptions ?? DEFAULT_FILTER_OPTIONS); selectedSortByOption = computed(() => { const selectedOptions = this.selectedFilterOptions(); + const availableOptions = this.filterOptions(); const selected = selectedOptions.find(option => option.category === 'sortBy'); if (selected) return [selected]; - const defaultOption = SortByOptions.find(option => option.default); + const defaultOption = (availableOptions.sortBy ?? SortByOptions).find(option => option.default); return defaultOption ? [defaultOption] : []; }); diff --git a/src/app/pages/explore/explore.component.html b/src/app/pages/explore/explore.component.html index 73732a69..7145badc 100644 --- a/src/app/pages/explore/explore.component.html +++ b/src/app/pages/explore/explore.component.html @@ -74,8 +74,8 @@

{{ 'Explore' | translate }}

[initialValue]="searchText()" /> -
{{ 'Explore' | translate }} : '' " (click)="openFilterSidenav()" - > - tune - - @if (numFilterOptions() > 0) { - {{ numFilterOptions() }} - } -
+ /> diff --git a/src/app/pages/explore/explore.component.scss b/src/app/pages/explore/explore.component.scss index 857dab00..1305fbe0 100644 --- a/src/app/pages/explore/explore.component.scss +++ b/src/app/pages/explore/explore.component.scss @@ -36,27 +36,6 @@ app-tabs { } } -div.filter-sidenav-toggle { - display: flex; - flex-direction: row; - align-items: center; - padding: 8px; - gap: 8px; - border-radius: 24px; - background-color: var(--brand-color); - color: white; - - &:hover, - &:focus, - &:focus-within { - cursor: pointer; - } - - &.has-active-filters { - padding: 8px 12px; - } -} - .paginator-container { grid-row: 3; grid-column: 1 / -1; diff --git a/src/app/pages/explore/explore.component.ts b/src/app/pages/explore/explore.component.ts index 3b83d26b..de2a2dff 100644 --- a/src/app/pages/explore/explore.component.ts +++ b/src/app/pages/explore/explore.component.ts @@ -46,6 +46,7 @@ import { SidenavService } from 'src/app/services/sidenav.service'; import { ICompilation, IEntity, isCompilation, isEntity } from '@kompakkt/common'; import { GridElementComponent } from '../../components/grid-element/grid-element.component'; import { ExploreFilterOption } from './explore-filter-option/explore-filter-option.component'; +import { ExploreFilterSidenavToggleComponent } from './explore-filter-sidenav-toggle/explore-filter-sidenav-toggle.component'; import { ExploreFilterSidenavComponent, ExploreFilterSidenavData, @@ -95,6 +96,7 @@ type Pagination = { SelectionContainerComponent, SelectionTab, MatCheckboxModule, + ExploreFilterSidenavToggleComponent, ], }) export class ExploreComponent implements OnInit { diff --git a/src/app/pages/explore/shared-types.ts b/src/app/pages/explore/shared-types.ts index 5b29a0b3..adf30c2b 100644 --- a/src/app/pages/explore/shared-types.ts +++ b/src/app/pages/explore/shared-types.ts @@ -17,52 +17,55 @@ export const SortOrderDirection: Record = { [SortOrder.newest]: ['Newest', 'Oldest'], }; -export const SortByOptions: ExploreFilterOption[] = [ - { label: 'Most recent', value: SortOrder.newest, default: true }, - { label: 'Most popular', value: SortOrder.popularity }, - { label: 'Most annotations', value: SortOrder.annotations }, - { label: 'Alphabetical (A-Z)', value: SortOrder.name }, - { label: 'Alphabetical (Z-A)', value: SortOrder.name + '-reversed' }, - // TODO: Decide if these should be added back in - // { label: 'Usage in collections', value: SortOrder.usage }, -].map(v => ({ ...v, exclusive: true, category: 'sortBy' })); +export const AvailableSortByOptions = { + newest: { label: 'Most recent', value: SortOrder.newest, default: true, exclusive: true, category: 'sortBy' }, + popularity: { label: 'Most popular', value: SortOrder.popularity, exclusive: true, category: 'sortBy' }, + annotations: { label: 'Most annotations', value: SortOrder.annotations, exclusive: true, category: 'sortBy' }, + nameAsc: { label: 'Alphabetical (A-Z)', value: SortOrder.name, exclusive: true, category: 'sortBy' }, + nameDesc: { label: 'Alphabetical (Z-A)', value: SortOrder.name + '-reversed', exclusive: true, category: 'sortBy' }, +} satisfies Record; +export const SortByOptions: ExploreFilterOption[] = Object.values(AvailableSortByOptions); -export const MediaTypeOptions: ExploreFilterOption[] = [ - { label: '3D models', value: 'model' }, - { label: 'Point clouds', value: 'cloud' }, - { label: '3D Gaussian splats', value: 'splat' }, - { label: 'Images', value: 'image' }, - { label: 'Videos', value: 'video' }, - { label: 'Audio', value: 'audio' }, -].map(v => ({ ...v, exclusive: false, category: 'mediaType' })); +export const AvailableMediaTypeOptions = { + model: { label: '3D models', value: 'model', exclusive: false, category: 'mediaType' }, + cloud: { label: 'Point clouds', value: 'cloud', exclusive: false, category: 'mediaType' }, + splat: { label: '3D Gaussian splats', value: 'splat', exclusive: false, category: 'mediaType' }, + image: { label: 'Images', value: 'image', exclusive: false, category: 'mediaType' }, + video: { label: 'Videos', value: 'video', exclusive: false, category: 'mediaType' }, + audio: { label: 'Audio', value: 'audio', exclusive: false, category: 'mediaType' }, +} satisfies Record; +export const MediaTypeOptions: ExploreFilterOption[] = Object.values(AvailableMediaTypeOptions); -export const AnnotationOptions: ExploreFilterOption[] = [ - { label: 'With annotations', value: 'with-annotations' }, - { label: 'Without annotations', value: 'without-annotations' }, -].map(v => ({ ...v, category: 'annotation', exclusive: false })); +export const AvailableAnnotationOptions = { + withAnnotations: { label: 'With annotations', value: 'with-annotations', category: 'annotation', exclusive: false }, + withoutAnnotations: { label: 'Without annotations', value: 'without-annotations', category: 'annotation', exclusive: false }, +} satisfies Record; +export const AnnotationOptions: ExploreFilterOption[] = Object.values(AvailableAnnotationOptions); -export const AccessOptions: ExploreFilterOption[] = [ - { label: 'Owner', value: 'owner' }, - { label: 'Editor', value: 'editor' }, - { label: 'Viewer', value: 'viewer' }, -].map(v => ({ ...v, category: 'access' })); +export const AvailableAccessOptions = { + owner: { label: 'Owner', value: 'owner', category: 'access' }, + editor: { label: 'Editor', value: 'editor', category: 'access' }, + viewer: { label: 'Viewer', value: 'viewer', category: 'access' }, +} satisfies Record; +export const AccessOptions: ExploreFilterOption[] = Object.values(AvailableAccessOptions); -export const MiscOptions: ExploreFilterOption[] = [ - { label: 'Downloadable', value: 'downloadable' }, - // TODO: { label: 'Animated', value: 'animated' }, -].map(v => ({ ...v, category: 'misc' })); +export const AvailableMiscOptions = { + downloadable: { label: 'Downloadable', value: 'downloadable', category: 'misc' }, +} satisfies Record; +export const MiscOptions: ExploreFilterOption[] = Object.values(AvailableMiscOptions); -export const LicenceOptions: ExploreFilterOption[] = [ - { label: 'CC0', value: 'CC0' }, - { label: 'PDM 1.0', value: 'PDM' }, - { label: 'CC BY 4.0', value: 'BY' }, - { label: 'CC BY-SA 4.0', value: 'BYSA' }, - { label: 'CC BY-ND 4.0', value: 'BYND' }, - { label: 'CC BY-NC 4.0', value: 'BYNC' }, - { label: 'CC BY-NC-SA 4.0', value: 'BYNCSA' }, - { label: 'CC BY-NC-ND 4.0', value: 'BYNCND' }, - { label: 'All rights reserved', value: 'AR' }, -].map(v => ({ ...v, category: 'licence' })); +export const AvailableLicenceOptions = { + cc0: { label: 'CC0', value: 'CC0', category: 'licence' }, + pdm: { label: 'PDM 1.0', value: 'PDM', category: 'licence' }, + by4: { label: 'CC BY 4.0', value: 'BY', category: 'licence' }, + bysa4: { label: 'CC BY-SA 4.0', value: 'BYSA', category: 'licence' }, + bynd4: { label: 'CC BY-ND 4.0', value: 'BYND', category: 'licence' }, + bync4: { label: 'CC BY-NC 4.0', value: 'BYNC', category: 'licence' }, + byncsa4: { label: 'CC BY-NC-SA 4.0', value: 'BYNCSA', category: 'licence' }, + byncnd4: { label: 'CC BY-NC-ND 4.0', value: 'BYNCND', category: 'licence' }, + ar: { label: 'All rights reserved', value: 'AR', category: 'licence' }, +} satisfies Record; +export const LicenceOptions: ExploreFilterOption[] = Object.values(AvailableLicenceOptions); export const CombinedOptions = [ ...SortByOptions, @@ -72,3 +75,13 @@ export const CombinedOptions = [ ...MiscOptions, ...LicenceOptions, ]; + +export const reduceExploreFilterOptions = (arr: ExploreFilterOption[]) => + arr.reduce( + (acc, val) => { + if (!acc[val.category]) acc[val.category] = []; + acc[val.category]!.push(val.value); + return acc; + }, + {} as Record, + ); diff --git a/src/app/pages/profile-page/compilations/compilations.component.html b/src/app/pages/profile-page/compilations/compilations.component.html index bc6413b4..6e14e94b 100644 --- a/src/app/pages/profile-page/compilations/compilations.component.html +++ b/src/app/pages/profile-page/compilations/compilations.component.html @@ -66,10 +66,10 @@ - @if (filteredCompilations$ | async; as compilations) { - @if (compilations.results.length > 0) { + @if (paginatorCompilations$ | async; as compilations) { + @if (compilations.length > 0) {
- @for (element of compilations.results; track element._id) { + @for (element of compilations; track element._id) {
}
+ + } @else { - @if (compilations.empty) { -

- {{ 'You do not have any collections' | translate }} -

- } @else { -

- {{ 'No collections found matching your filter criteria' | translate }} -

- } +
+ @if ((compilationsSignal()?.length ?? 0) > 0) { +

{{ 'No collections found matching your filter criteria' | translate }}

+ } @else { +

{{ 'You do not have any collections and are not participating in any collections by others' | translate }}

+ } +
} }
diff --git a/src/app/pages/profile-page/compilations/compilations.component.ts b/src/app/pages/profile-page/compilations/compilations.component.ts index d23c7235..ac1aad57 100644 --- a/src/app/pages/profile-page/compilations/compilations.component.ts +++ b/src/app/pages/profile-page/compilations/compilations.component.ts @@ -7,6 +7,7 @@ import { inject, input, output, + signal, TemplateRef, viewChild, viewChildren, @@ -25,6 +26,7 @@ import { RouterLink } from '@angular/router'; import { combineLatest, firstValueFrom, map } from 'rxjs'; import { GridElementComponent } from 'src/app/components/grid-element/grid-element.component'; +import { Pagination, PaginationComponent } from 'src/app/components/pagination/pagination.component'; import { SelectionContainerComponent } from 'src/app/components/selection/selection-container.component'; import { ManageOwnershipComponent } from 'src/app/dialogs/manage-ownership/manage-ownership.component'; import { TranslatePipe } from 'src/app/pipes'; @@ -38,6 +40,14 @@ import { SelectionService } from 'src/app/services/selection.service'; import { Collection, EntityAccessRole, ICompilation, isCompilation } from '@kompakkt/common'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { IsUserOfRolePipe } from 'src/app/pipes/is-user-of-role.pipe'; +import { ExploreFilterOption } from '../../explore/explore-filter-option/explore-filter-option.component'; +import { + AvailableAnnotationOptions, + AvailableMiscOptions, + reduceExploreFilterOptions, + SortOrder, +} from '../../explore/shared-types'; +import { ExploreFilterSidenavOptionsService } from '../../explore/explore-filter-sidenav/explore-filter-sidenav.component'; @Component({ selector: 'app-profile-compilations', @@ -60,6 +70,7 @@ import { IsUserOfRolePipe } from 'src/app/pipes/is-user-of-role.pipe'; IsUserOfRolePipe, SelectionContainerComponent, MatCheckboxModule, + PaginationComponent, ], }) export class ProfileCompilationsComponent implements AfterViewInit { @@ -69,6 +80,7 @@ export class ProfileCompilationsComponent implements AfterViewInit { #dialogHelper = inject(DialogHelperService); #rootSelectionService = inject(SelectionService); #snackbar = inject(SnackbarService); + #sidenavOptionsService = inject(ExploreFilterSidenavOptionsService); gridItems = viewChildren('gridItem'); @@ -78,6 +90,8 @@ export class ProfileCompilationsComponent implements AfterViewInit { searchText = input(''); searchText$ = toObservable(this.searchText); + selectedFilterOptions = input([]); + selectedFilterOptions$ = toObservable(this.selectedFilterOptions); user = toSignal(this.#account.user$); @@ -106,29 +120,120 @@ export class ProfileCompilationsComponent implements AfterViewInit { compilations$ = this.#account.compilationsWithEntities$.pipe( map(compilations => compilations.filter(c => isCompilation(c))), ); - - filteredCompilations$ = combineLatest([this.searchText$, this.compilations$]).pipe( - map(([text, compilations]) => { - if (!compilations || compilations.length === 0) return { empty: true, results: [] }; - if (!text || text.trim().length === 0) return { empty: false, results: compilations }; - text = text.trim().toLowerCase(); - return { - empty: false, - results: compilations.filter(c => - ((c.__normalizedName || c.name) + c.description) - .toLowerCase() - .includes(text.toLowerCase()), - ), - }; + compilationsSignal = toSignal(this.compilations$); + + filteredCompilations$ = combineLatest([ + this.#account.user$, + this.compilations$, + this.searchText$.pipe(map(text => text.trim().toLowerCase())), + this.selectedFilterOptions$.pipe(map(options => reduceExploreFilterOptions(options))), + ]).pipe( + map(([userdata, compilations, searchText, filterOptions]) => { + if (!compilations) return []; + const sortOrder = (filterOptions.sortBy as SortOrder[] | undefined)?.at(0) ?? SortOrder.newest; + return compilations + .filter(compilation => { + if (filterOptions.annotation) { + const withAnnotations = filterOptions.annotation.includes( + AvailableAnnotationOptions.withAnnotations.value, + ); + const withoutAnnotations = filterOptions.annotation.includes( + AvailableAnnotationOptions.withoutAnnotations.value, + ); + const count = + compilation.__annotationCount ?? + Object.keys(compilation.annotations || {}).length; + if (withAnnotations && withoutAnnotations) { + // no-op + } else { + if (withAnnotations && count === 0) return false; + if (withoutAnnotations && count > 0) return false; + } + } + if (filterOptions.access) { + if (!userdata) return false; + const userAccess = compilation.access.find(u => u._id === userdata._id)?.role; + if (!userAccess || !filterOptions.access.includes(userAccess)) return false; + } + if (filterOptions.misc) { + if ( + filterOptions.misc.includes(AvailableMiscOptions.downloadable.value) && + !compilation.__downloadable + ) + return false; + } + if (filterOptions.licence) { + if ( + !compilation.__licenses || + !filterOptions.licence.some(l => compilation.__licenses?.includes(l)) + ) + return false; + } + if (searchText.length > 0) { + const content = + (compilation.__normalizedName || compilation.name) + compilation.description; + if (!content.toLowerCase().includes(searchText)) return false; + } + return true; + }) + .sort((a, b) => { + if (!sortOrder) return 0; + switch (sortOrder) { + case SortOrder.annotations: + return ( + (b.__annotationCount ?? Object.keys(b.annotations || {}).length) - + (a.__annotationCount ?? Object.keys(a.annotations || {}).length) + ); + case SortOrder.name: + return (a.__normalizedName || a.name).localeCompare( + b.__normalizedName || b.name, + ); + case SortOrder.popularity: + return (b.__hits ?? 0) - (a.__hits ?? 0); + case SortOrder.newest: + return (b.__createdAt ?? 0) - (a.__createdAt ?? 0); + default: + return (b.__hits ?? 0) - (a.__hits ?? 0); + } + }); }), ); filteredCompilationsSignal = toSignal(this.filteredCompilations$); + public paginator = signal({ + pageCount: Number.POSITIVE_INFINITY, + pageSize: 24, + totalItemCount: -1, + pageIndex: 0, + }); + #paginator$ = toObservable(this.paginator); + + paginatorCompilations$ = combineLatest([this.filteredCompilations$, this.#paginator$]).pipe( + map(([compilations, { pageSize, pageIndex }]) => { + const start = pageSize * pageIndex; + return compilations.slice(start, start + pageSize); + }), + ); + + paginatorCompilationsSignal = toSignal(this.paginatorCompilations$); + readonly singleSelectedCompilation = computed(() => this.selectionService().singleSelectedCompilation(), ); + constructor() { + this.filteredCompilations$.subscribe(compilations => { + this.#sidenavOptionsService.setResultCount(compilations.length); + this.paginator.update(paginator => ({ + ...paginator, + pageIndex: 0, + pageCount: Math.ceil(compilations.length / paginator.pageSize), + totalItemCount: compilations.length, + })); + }); + } + public openRemoveCompilationDialog(compilation: ICompilation) { this.#dialogHelper.removeFromCompilation(compilation); } @@ -215,7 +320,7 @@ export class ProfileCompilationsComponent implements AfterViewInit { } public addCompilationToSelection(compilation: ICompilation, event: MouseEvent) { - const allElements = this.filteredCompilationsSignal()?.results ?? []; + const allElements = this.filteredCompilationsSignal() ?? []; if (event.shiftKey) { this.selectionService().updateSelectionWithRange(compilation, allElements); } else { @@ -264,7 +369,7 @@ export class ProfileCompilationsComponent implements AfterViewInit { } const compElementPairs = - this.filteredCompilationsSignal()?.results.map((element, index) => ({ + this.paginatorCompilationsSignal()?.map((element, index) => ({ element, htmlElement: this.gridItems()[index].nativeElement as HTMLElement, })) || []; diff --git a/src/app/pages/profile-page/entities/entities.component.html b/src/app/pages/profile-page/entities/entities.component.html index fdcdeff3..94b854e0 100644 --- a/src/app/pages/profile-page/entities/entities.component.html +++ b/src/app/pages/profile-page/entities/entities.component.html @@ -8,7 +8,11 @@ @if (filteredEntities$ | async; as filteredEntities) { @if (filteredEntities.length === 0) {
-

{{ 'No matches' | translate }}

+ @if ((entitiesByEntityTypeSignal()?.length ?? 0) > 0) { +

{{ 'No objects found matching your filter criteria' | translate }}

+ } @else { +

{{ 'You have no objects yet' | translate }}

+ }
} } @@ -203,16 +207,6 @@

{{ 'No matches' | translate }}

} - + diff --git a/src/app/pages/profile-page/entities/entities.component.ts b/src/app/pages/profile-page/entities/entities.component.ts index 068072c2..377ea488 100644 --- a/src/app/pages/profile-page/entities/entities.component.ts +++ b/src/app/pages/profile-page/entities/entities.component.ts @@ -3,35 +3,32 @@ import { AfterViewInit, Component, computed, - effect, ElementRef, inject, input, output, - QueryList, + signal, TemplateRef, viewChild, viewChildren, - ViewChildren, } from '@angular/core'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatChipsModule } from '@angular/material/chips'; -import { MatDialog } from '@angular/material/dialog'; import { MatDividerModule } from '@angular/material/divider'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatMenuModule } from '@angular/material/menu'; -import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { MatRadioModule } from '@angular/material/radio'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; -import { BehaviorSubject, combineLatest, map, switchMap } from 'rxjs'; +import { combineLatest, map, switchMap } from 'rxjs'; import { GridElementComponent } from 'src/app/components'; +import { Pagination, PaginationComponent } from 'src/app/components/pagination/pagination.component'; import { TranslatePipe } from 'src/app/pipes'; import { AccountService, @@ -50,6 +47,21 @@ import { import { SelectionContainerComponent } from 'src/app/components/selection/selection-container.component'; import { IsUserOfRolePipe } from 'src/app/pipes/is-user-of-role.pipe'; import { MatCheckboxModule } from '@angular/material/checkbox'; +import { ExploreFilterOption } from '../../explore/explore-filter-option/explore-filter-option.component'; +import { + AvailableAnnotationOptions, + AvailableMiscOptions, + reduceExploreFilterOptions, + SortOrder, +} from '../../explore/shared-types'; +import { ExploreFilterSidenavOptionsService } from '../../explore/explore-filter-sidenav/explore-filter-sidenav.component'; + +const getAnnotationCount = (entity: IEntity): number => { + if (entity.__annotationCount !== undefined) { + return entity.__annotationCount; + } + return Object.keys(entity.annotations || {}).length; +}; @Component({ selector: 'app-profile-entities', @@ -62,7 +74,6 @@ import { MatCheckboxModule } from '@angular/material/checkbox'; MatTooltipModule, MatInputModule, MatFormFieldModule, - MatPaginatorModule, MatButtonModule, MatRadioModule, MatMenuModule, @@ -78,6 +89,7 @@ import { MatCheckboxModule } from '@angular/material/checkbox'; SelectionContainerComponent, IsUserOfRolePipe, MatCheckboxModule, + PaginationComponent, ], }) export class ProfileEntitiesComponent implements AfterViewInit { @@ -86,13 +98,15 @@ export class ProfileEntitiesComponent implements AfterViewInit { private helper = inject(DialogHelperService); private _rootSelectionService = inject(SelectionService); private snackbar = inject(SnackbarService); + #sidenavOptionsService = inject(ExploreFilterSidenavOptionsService); searchText = input(''); searchText$ = toObservable(this.searchText); entityType = input.required<'finished' | 'unfinished'>(); isDraft = computed(() => this.entityType() === 'unfinished'); entityType$ = toObservable(this.entityType); - paginator = viewChild(MatPaginator); + selectedFilterOptions = input([]); + selectedFilterOptions$ = toObservable(this.selectedFilterOptions); gridItems = viewChildren('gridItem'); @@ -118,12 +132,13 @@ export class ProfileEntitiesComponent implements AfterViewInit { readonly singleSelectedEntity = computed(() => this.selectionService().singleSelectedEntity()); - public pageEvent$ = new BehaviorSubject({ - previousPageIndex: 0, + public paginator = signal({ + pageCount: Number.POSITIVE_INFINITY, + pageSize: 24, + totalItemCount: -1, pageIndex: 0, - pageSize: 20, - length: Number.POSITIVE_INFINITY, }); + #paginator$ = toObservable(this.paginator); public userCompilations = toSignal(this.account.compilations$, { initialValue: null, @@ -137,52 +152,111 @@ export class ProfileEntitiesComponent implements AfterViewInit { return userAccess?.role === EntityAccessRole.owner; } - constructor() { - this.filteredEntities$.subscribe(entities => { - const pageEvent = this.pageEvent$.getValue(); - this.pageEvent$.next({ ...pageEvent, length: entities.length }); - }); - - effect(() => { - const searchText = this.searchText(); - this.paginator()?.firstPage(); - }); - } - - filteredEntities$ = this.entityType$.pipe( + entitiesByEntityType$ = this.entityType$.pipe( switchMap(type => type === 'finished' ? this.account.finishedEntities$ : this.account.draftEntities$, ), ); + entitiesByEntityTypeSignal = toSignal(this.entitiesByEntityType$); - filteredEntitiesSignal = toSignal(this.filteredEntities$); - - filteredLength$ = combineLatest([this.filteredEntities$, this.searchText$]).pipe( - map(([arr, searchInput]) => this.filterEntities(arr, searchInput).length), + filteredEntities$ = combineLatest([ + this.account.user$, + this.entitiesByEntityType$, + this.searchText$.pipe(map(text => text.trim().toLowerCase())), + this.selectedFilterOptions$.pipe(map(options => reduceExploreFilterOptions(options))), + ]).pipe( + map(([userdata, entities, searchText, filterOptions]) => { + if (!entities) return []; + const sortOrder = (filterOptions.sortBy as SortOrder[] | undefined)?.at(0) ?? SortOrder.newest; + return entities + .filter(entity => { + if (filterOptions.mediaType) { + if (!filterOptions.mediaType.includes(entity.mediaType)) return false; + } + if (filterOptions.annotation) { + const withAnnotations = filterOptions.annotation.includes( + AvailableAnnotationOptions.withAnnotations.value, + ); + const withoutAnnotations = filterOptions.annotation.includes( + AvailableAnnotationOptions.withoutAnnotations.value, + ); + const count = getAnnotationCount(entity); + if (withAnnotations && withoutAnnotations) { + // both selected → no-op + } else { + if (withAnnotations && count === 0) return false; + if (withoutAnnotations && count > 0) return false; + } + } + if (filterOptions.access) { + if (!userdata) return false; + const userAccess = entity.access.find(u => u._id === userdata._id)?.role; + if (!userAccess || !filterOptions.access.includes(userAccess)) return false; + } + if (filterOptions.misc) { + if ( + filterOptions.misc.includes(AvailableMiscOptions.downloadable.value) && + !entity.__downloadable + ) + return false; + } + if (filterOptions.licence) { + if ( + !entity.__licenses || + !filterOptions.licence.some(l => entity.__licenses?.includes(l)) + ) + return false; + } + if (searchText.length > 0) { + let content = entity.name; + if (isMetadataEntity(entity.relatedDigitalEntity)) { + content += entity.relatedDigitalEntity.title; + content += entity.relatedDigitalEntity.description; + } + if (!content.toLowerCase().includes(searchText)) return false; + } + return true; + }) + .sort((a, b) => { + if (!sortOrder) return 0; + switch (sortOrder) { + case SortOrder.annotations: + return getAnnotationCount(b) - getAnnotationCount(a); + case SortOrder.name: + return (a.__normalizedName || a.name).localeCompare( + b.__normalizedName || b.name, + ); + case SortOrder.popularity: + return (b.__hits ?? 0) - (a.__hits ?? 0); + case SortOrder.newest: + return (b.__createdAt ?? 0) - (a.__createdAt ?? 0); + default: + return (b.__hits ?? 0) - (a.__hits ?? 0); + } + }); + }), ); - paginatorEntities$ = combineLatest([ - this.filteredEntities$, - this.searchText$, - this.pageEvent$, - ]).pipe( - map(([arr, searchInput, { pageSize, pageIndex }]) => { - const filtered = this.filterEntities(arr, searchInput); + filteredEntitiesSignal = toSignal(this.filteredEntities$); + + paginatorEntities$ = combineLatest([this.filteredEntities$, this.#paginator$]).pipe( + map(([entities, { pageSize, pageIndex }]) => { const start = pageSize * pageIndex; - return filtered.slice(start, start + pageSize); + return entities.slice(start, start + pageSize); }), ); paginatorEntitiesSignal = toSignal(this.paginatorEntities$); - private filterEntities(arr: IEntity[], searchInput: string): IEntity[] { - return arr.filter(_e => { - let content = _e.name; - if (isMetadataEntity(_e.relatedDigitalEntity)) { - content += _e.relatedDigitalEntity.title; - content += _e.relatedDigitalEntity.description; - } - return content.toLowerCase().includes(searchInput); + constructor() { + this.filteredEntities$.subscribe(entities => { + this.#sidenavOptionsService.setResultCount(entities.length); + this.paginator.update(paginator => ({ + ...paginator, + pageIndex: 0, + pageCount: Math.ceil(entities.length / paginator.pageSize), + totalItemCount: entities.length, + })); }); } diff --git a/src/app/pages/profile-page/profile-page.component.html b/src/app/pages/profile-page/profile-page.component.html index cd29adea..77879059 100644 --- a/src/app/pages/profile-page/profile-page.component.html +++ b/src/app/pages/profile-page/profile-page.component.html @@ -14,12 +14,23 @@ @switch (tabs.selectedTab()?.value) { @case ('objects') { } @@ -27,12 +38,14 @@ } @case ('collections') { } diff --git a/src/app/pages/profile-page/profile-page.component.scss b/src/app/pages/profile-page/profile-page.component.scss index 3a8a7459..ede75bad 100644 --- a/src/app/pages/profile-page/profile-page.component.scss +++ b/src/app/pages/profile-page/profile-page.component.scss @@ -87,4 +87,10 @@ span.mat-content { div.tab-content { padding: 16px 0; } + + aside { + display: flex; + align-items: center; + gap: 16px; + } } diff --git a/src/app/pages/profile-page/profile-page.component.ts b/src/app/pages/profile-page/profile-page.component.ts index df8afb19..67b3068e 100644 --- a/src/app/pages/profile-page/profile-page.component.ts +++ b/src/app/pages/profile-page/profile-page.component.ts @@ -1,4 +1,12 @@ -import { Component, effect, inject, OnInit, signal, TemplateRef } from '@angular/core'; +import { + Component, + computed, + effect, + inject, + OnInit, + signal, + TemplateRef, +} from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; @@ -21,8 +29,26 @@ import { ProfileCompilationsComponent } from './compilations/compilations.compon import { ProfileEntitiesComponent } from './entities/entities.component'; import { ProfilePageHeaderComponent } from './profile-page-header/profile-page-header.component'; import { ProfilePageHelpComponent } from './profile-page-help.component'; +import { MatIconModule } from '@angular/material/icon'; import { SelectionTab } from 'src/app/components/selection/selection-tab/selection-tab.component'; import { SelectionService } from 'src/app/services/selection.service'; +import { SidenavService } from 'src/app/services/sidenav.service'; +import { ExploreFilterOption } from '../explore/explore-filter-option/explore-filter-option.component'; +import { ExploreFilterSidenavToggleComponent } from '../explore/explore-filter-sidenav-toggle/explore-filter-sidenav-toggle.component'; +import { + ExploreFilterSidenavComponent, + ExploreFilterSidenavData, + ExploreFilterSidenavOptionsService, +} from '../explore/explore-filter-sidenav/explore-filter-sidenav.component'; +import { + AccessOptions, + AnnotationOptions, + ExploreCategory, + LicenceOptions, + MediaTypeOptions, + MiscOptions, + SortByOptions, +} from '../explore/shared-types'; @Component({ selector: 'app-profile-page', @@ -45,6 +71,8 @@ import { SelectionService } from 'src/app/services/selection.service'; TabsComponent, SearchBarComponent, SelectionTab, + ExploreFilterSidenavToggleComponent, + MatIconModule, ], }) export class ProfilePageComponent implements OnInit { @@ -56,6 +84,13 @@ export class ProfilePageComponent implements OnInit { #helper = inject(DialogHelperService); #titleService = inject(Title); #selectionService = inject(SelectionService); + #sidenavService = inject(SidenavService); + #sidenavOptionsService = inject(ExploreFilterSidenavOptionsService); + + selectedFilterOptions = signal([]); + numFilterOptions = computed( + () => this.selectedFilterOptions().filter(option => option.category !== 'sortBy').length, + ); #routeParams = toSignal( this.#activatedRoute.paramMap.pipe( @@ -71,6 +106,17 @@ export class ProfilePageComponent implements OnInit { effect(() => { console.log('ProfilePageComponent: params changed to', this.#routeParams()); }); + + let isInitialChange = true; + effect(() => { + const updatedOptions = this.#sidenavOptionsService.selectedOptions(); + console.log('Sidenav options updated in background:', updatedOptions, isInitialChange); + if (isInitialChange) { + isInitialChange = false; + return; + } + this.selectedFilterOptions.set(updatedOptions); + }); } userData = toSignal(this.#account.user$); @@ -109,6 +155,53 @@ export class ProfilePageComponent implements OnInit { this.#selectionService.clearSelection(); } + public async openFilterSidenav() { + if (this.#sidenavService.state().opened) return; + const tab = this.selectedTab(); + const category: ExploreCategory = + tab === 'collections' ? 'collections' : 'objects'; + + const filterOptions = + tab === 'drafts' + ? ({ + sortBy: SortByOptions, + mediaType: MediaTypeOptions, + annotation: [], + access: [], + licence: [], + misc: [], + } satisfies ExploreFilterSidenavData['filterOptions']) + : tab === 'collections' + ? ({ + sortBy: SortByOptions, + mediaType: [], + annotation: AnnotationOptions, + access: AccessOptions, + licence: LicenceOptions, + misc: MiscOptions, + } satisfies ExploreFilterSidenavData['filterOptions']) + : ({ + sortBy: SortByOptions, + mediaType: MediaTypeOptions, + annotation: AnnotationOptions, + access: AccessOptions, + licence: LicenceOptions, + misc: MiscOptions, + } satisfies ExploreFilterSidenavData['filterOptions']); + + const result = await this.#sidenavService.openWithResult< + ExploreFilterOption[], + ExploreFilterSidenavData + >(ExploreFilterSidenavComponent, { + options: this.selectedFilterOptions(), + category, + filterOptions, + }); + if (result) { + this.selectedFilterOptions.set(result); + } + } + ngOnInit() { this.#titleService.setTitle('Kompakkt – Profile');