From 73a46477b184a2aac6d3ee86bbc75fabc0b51a0d Mon Sep 17 00:00:00 2001 From: Kyrylo Petrov Date: Tue, 6 May 2025 13:35:46 +0300 Subject: [PATCH 1/2] chore(my-profile): api, uiux --- src/app/app.routes.ts | 7 + .../services/json-api/json-api.service.ts | 3 +- .../underscore-entites/user/user-us.entity.ts | 36 ++++- .../services/mappers/users/users.mapper.ts | 23 +++ src/app/core/services/user/user.entity.ts | 27 ++++ .../my-profile/my-profile.component.html | 145 ++++++++++++++++++ .../my-profile/my-profile.component.scss | 127 +++++++++++++++ .../my-profile/my-profile.component.spec.ts | 22 +++ .../my-profile/my-profile.component.ts | 27 ++++ src/assets/icons/source/cos-shield.svg | 9 ++ src/assets/icons/source/institution.svg | 20 +++ src/assets/icons/source/orcid.svg | 9 ++ src/assets/styles/overrides/accordion.scss | 41 +++++ 13 files changed, 493 insertions(+), 3 deletions(-) create mode 100644 src/app/features/my-profile/my-profile.component.html create mode 100644 src/app/features/my-profile/my-profile.component.scss create mode 100644 src/app/features/my-profile/my-profile.component.spec.ts create mode 100644 src/app/features/my-profile/my-profile.component.ts create mode 100644 src/assets/icons/source/cos-shield.svg create mode 100644 src/assets/icons/source/institution.svg create mode 100644 src/assets/icons/source/orcid.svg diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 92eefa145..1b268da2f 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -145,6 +145,13 @@ export const routes: Routes = [ provideStates([ResourceFiltersState, ResourceFiltersOptionsState]), ], }, + { + path: 'my-profile', + loadComponent: () => + import('./features/my-profile/my-profile.component').then( + (mod) => mod.MyProfileComponent, + ), + }, ], }, ]; diff --git a/src/app/core/services/json-api/json-api.service.ts b/src/app/core/services/json-api/json-api.service.ts index b5eb8e6ac..522c03304 100644 --- a/src/app/core/services/json-api/json-api.service.ts +++ b/src/app/core/services/json-api/json-api.service.ts @@ -9,8 +9,7 @@ import { JsonApiResponse } from '@core/services/json-api/json-api.entity'; export class JsonApiService { http: HttpClient = inject(HttpClient); readonly #token = - 'Bearer 2rjFZwmdDG4rtKj7hGkEMO6XyHBM2lN7XBbsA1e8OqcFhOWu6Z7fQZiheu9RXtzSeVrgOt'; - // OBJoUomBgbUuDgQo5JoaSKNya6XaYcd0ojAX1XOLmWi6J2arQPzByxyEi81fHE60drQUWv + 'Bearer UlO9O9GNKgVzJD7pUeY53jiQTKJ4U2znXVWNvh0KZQruoENuILx0IIYf9LoDz7Duq72EIm'; readonly #headers = new HttpHeaders({ Authorization: this.#token, Accept: 'application/vnd.api+json', diff --git a/src/app/core/services/json-api/underscore-entites/user/user-us.entity.ts b/src/app/core/services/json-api/underscore-entites/user/user-us.entity.ts index 73b511b9b..078c60b0a 100644 --- a/src/app/core/services/json-api/underscore-entites/user/user-us.entity.ts +++ b/src/app/core/services/json-api/underscore-entites/user/user-us.entity.ts @@ -6,7 +6,41 @@ export interface UserUS { given_name: string; family_name: string; email: string; + date_registered: string; + social: { + orcid: string; + github: string; + scholar: string; + twitter: string; + linkedIn: string; + impactStory: string; + researcherId: string; + }; + employment: { + title: string; + endYear: number; + ongoing: boolean; + endMonth: number; + startYear: number; + department: string; + startMonth: number; + institution: string; + }[]; + education: { + degree: string; + department: string; + institution: string; + startYear: number; + startMonth: number; + endYear: number; + endMonth: number; + ongoing: boolean; + }[]; }; relationships: Record; - links: Record; + links: { + html: string; + profile_image: string; + iri: string; + }; } diff --git a/src/app/core/services/mappers/users/users.mapper.ts b/src/app/core/services/mappers/users/users.mapper.ts index 4d5ecc1e6..3fe2aca12 100644 --- a/src/app/core/services/mappers/users/users.mapper.ts +++ b/src/app/core/services/mappers/users/users.mapper.ts @@ -8,5 +8,28 @@ export function mapUserUStoUser(user: UserUS): User { givenName: user.attributes.given_name, familyName: user.attributes.family_name, email: user.attributes.email, + dateRegistered: new Date(user.attributes.date_registered), + link: user.links.html, + socials: user.attributes.social, + employment: user.attributes.employment?.map((emp) => ({ + title: emp.title, + department: emp.department, + institution: emp.institution, + startDate: new Date(emp.startYear, emp.startMonth - 1), + endDate: emp.ongoing + ? new Date() + : new Date(emp.endYear, emp.endMonth - 1), + ongoing: emp.ongoing, + })), + education: user.attributes.education?.map((edu) => ({ + degree: edu.degree, + department: edu.department, + institution: edu.institution, + startDate: new Date(edu.startYear, edu.startMonth - 1), + endDate: edu.ongoing + ? new Date() + : new Date(edu.endYear, edu.endMonth - 1), + ongoing: edu.ongoing, + })), }; } diff --git a/src/app/core/services/user/user.entity.ts b/src/app/core/services/user/user.entity.ts index cb2410561..c53b357af 100644 --- a/src/app/core/services/user/user.entity.ts +++ b/src/app/core/services/user/user.entity.ts @@ -4,4 +4,31 @@ export interface User { givenName: string; familyName: string; email?: string; + dateRegistered: Date; + link?: string; + socials?: { + orcid?: string; + github?: string; + scholar?: string; + twitter?: string; + linkedIn?: string; + impactStory?: string; + researcherId?: string; + }; + employment?: { + title: string; + department: string; + institution: string; + startDate: Date; + endDate: Date; + ongoing: boolean; + }[]; + education?: { + degree: string; + department: string; + institution: string; + startDate: Date; + endDate: Date; + ongoing: boolean; + }[]; } diff --git a/src/app/features/my-profile/my-profile.component.html b/src/app/features/my-profile/my-profile.component.html new file mode 100644 index 000000000..a337a6266 --- /dev/null +++ b/src/app/features/my-profile/my-profile.component.html @@ -0,0 +1,145 @@ + + +
+
+

Employment

+ + @for (employment of currentUser()?.employment; track $index) { + + +
+

{{ employment.institution }}

+

+ {{ employment.startDate | date: "MMM yyyy" }} - + {{ employment.endDate | date: "MMM yyyy" }} +

+
+
+ +

Job title: {{ employment.title }}

+

+ Department / Institute (optional): {{ employment.institution }} +

+
+
+ } +
+
+
+

Education

+ + @for (education of currentUser()?.education; track $index) { + + +
+

{{ education.institution }}

+

+ {{ education.startDate | date: "MMM yyyy" }} - + @if (education.ongoing) { + ongoing + } @else { + {{ education.endDate | date: "MMM yyyy" }} + } +

+
+
+ +

Degree: {{ education.degree }}

+

+ Department / Institute (optional): {{ education.institution }} +

+
+
+ } +
+
+
diff --git a/src/app/features/my-profile/my-profile.component.scss b/src/app/features/my-profile/my-profile.component.scss new file mode 100644 index 000000000..f0b38809d --- /dev/null +++ b/src/app/features/my-profile/my-profile.component.scss @@ -0,0 +1,127 @@ +@use "../../../assets/styles/variables" as var; + +:host { + .user-info { + width: 100%; + padding: 4.5rem 1.7rem 1.7rem 1.7rem; + + .name-container { + display: flex; + width: 100%; + padding-bottom: 1.7rem; + } + + .description-container { + display: flex; + flex-direction: row; + row-gap: 1.7rem; + + .title-date-wrapper { + display: flex; + flex-direction: column; + width: 50%; + row-gap: 1.7rem; + } + } + + .data-wrapper { + display: flex; + flex-direction: column; + width: 50%; + row-gap: 1.7rem; + + .user-fields-wrapper { + display: flex; + flex-direction: row; + align-items: center; + column-gap: 1.7rem; + } + } + + a:hover { + cursor: pointer; + } + } + + .cards { + display: flex; + padding: 1.7rem; + column-gap: 1.7rem; + background-color: var.$white; + + .card { + border: 1px solid var.$grey-2; + border-radius: 12px; + padding: 1.7rem; + row-gap: 1rem; + width: 50%; + height: fit-content; + + .title-date { + display: flex; + flex-direction: row; + column-gap: 1.7rem; + width: 100%; + } + } + } + + @media (max-width: 1000px) { + .user-info { + padding: 4.5rem 2.5rem 2.5rem 2.5rem; + + .description-container { + flex-direction: column; + row-gap: 2.5rem; + + .title-date-wrapper { + width: 100%; + } + } + + .data-wrapper { + flex-direction: column; + width: 100%; + row-gap: 2.5rem; + } + } + + .cards { + flex-direction: column; + padding: 1.7rem 2.5rem 1.7rem 2.5rem; + row-gap: 1.7rem; + + .card { + width: 100%; + } + } + } + + @media (max-width: 600px) { + .user-info { + padding: 4.5rem 1.1rem 1.1rem 1.1rem; + .data-wrapper { + .user-fields-wrapper { + flex-direction: column; + align-items: start; + row-gap: 1.1rem; + } + } + } + + .cards { + padding: 1.7rem 1.1rem 1.1rem 1.1rem; + .card { + padding: 1.1rem; + .title-date { + flex-direction: column; + row-gap: 0.4rem; + + p { + margin: 0 !important; + } + } + } + } + } +} diff --git a/src/app/features/my-profile/my-profile.component.spec.ts b/src/app/features/my-profile/my-profile.component.spec.ts new file mode 100644 index 000000000..e10b0392f --- /dev/null +++ b/src/app/features/my-profile/my-profile.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MyProfileComponent } from './my-profile.component'; + +describe('MyProfileComponent', () => { + let component: MyProfileComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MyProfileComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(MyProfileComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/my-profile/my-profile.component.ts b/src/app/features/my-profile/my-profile.component.ts new file mode 100644 index 000000000..1851497bc --- /dev/null +++ b/src/app/features/my-profile/my-profile.component.ts @@ -0,0 +1,27 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { Store } from '@ngxs/store'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; +import { Button } from 'primeng/button'; +import { DatePipe, NgOptimizedImage } from '@angular/common'; +import { AccordionModule } from 'primeng/accordion'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { IS_XSMALL } from '@osf/shared/utils/breakpoints.tokens'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'osf-my-profile', + imports: [Button, DatePipe, NgOptimizedImage, AccordionModule], + templateUrl: './my-profile.component.html', + styleUrl: './my-profile.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MyProfileComponent { + readonly #store = inject(Store); + readonly #router = inject(Router); + readonly currentUser = this.#store.selectSignal(UserSelectors.getCurrentUser); + readonly isMobile = toSignal(inject(IS_XSMALL)); + + toProfileSettings() { + this.#router.navigate(['settings/profile-settings']); + } +} diff --git a/src/assets/icons/source/cos-shield.svg b/src/assets/icons/source/cos-shield.svg new file mode 100644 index 000000000..28fde1b59 --- /dev/null +++ b/src/assets/icons/source/cos-shield.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icons/source/institution.svg b/src/assets/icons/source/institution.svg new file mode 100644 index 000000000..7da93f18d --- /dev/null +++ b/src/assets/icons/source/institution.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/source/orcid.svg b/src/assets/icons/source/orcid.svg new file mode 100644 index 000000000..f573f34e0 --- /dev/null +++ b/src/assets/icons/source/orcid.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/styles/overrides/accordion.scss b/src/assets/styles/overrides/accordion.scss index 85a3d055d..1bbdfc52e 100644 --- a/src/assets/styles/overrides/accordion.scss +++ b/src/assets/styles/overrides/accordion.scss @@ -76,3 +76,44 @@ } } } + +.card { + .p-accordion { + .p-accordioncontent-content { + display: flex; + flex-direction: column; + row-gap: 1rem; + padding: 0.4rem 0 0.1rem 0; + border: none; + } + + .p-accordionpanel { + border: none; + } + + .p-accordionheader { + height: 3.5rem; + } + + .p-iconwrapper { + transform: rotate(180deg); + background: var.$bg-blue-3; + height: 2.3rem; + min-width: 2.3rem; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + + svg { + color: var.$pr-blue-1; + } + } + + @media (max-width: 600px) { + .p-accordion { + row-gap: 1.7rem; + } + } + } +} From e39df79a4c609ce216c12d8136a566a0a1115336 Mon Sep 17 00:00:00 2001 From: Kyrylo Petrov Date: Tue, 6 May 2025 13:50:58 +0300 Subject: [PATCH 2/2] chore(my-profile): add search, responsiveness, breadcrumb --- src/app/app.routes.ts | 3 + .../breadcrumb/breadcrumb.component.html | 18 +- .../breadcrumb/breadcrumb.component.ts | 7 +- .../components/header/header.component.html | 11 +- .../components/header/header.component.scss | 2 +- .../components/header/header.component.ts | 19 +- .../core/components/root/root.component.html | 3 + .../core/components/root/root.component.scss | 4 + .../core/components/root/root.component.ts | 8 +- .../services/mappers/users/users.mapper.ts | 1 + src/app/core/services/user/user.entity.ts | 1 + .../my-profile/my-profile.component.html | 6 +- .../my-profile/my-profile.component.scss | 54 +++- .../my-profile/my-profile.component.ts | 53 ++- src/app/features/search/search.component.html | 21 +- src/app/features/search/search.component.scss | 22 +- src/app/features/search/search.component.ts | 29 +- .../features/search/store/search.actions.ts | 10 + src/app/features/search/store/search.model.ts | 1 + .../features/search/store/search.selectors.ts | 5 + src/app/features/search/store/search.state.ts | 25 +- src/app/features/search/utils/data.ts | 13 + .../filter-chips/filter-chips.component.html | 2 +- .../filter-chips/filter-chips.component.scss | 10 +- .../filter-chips/filter-chips.component.ts | 5 + .../resource-card.component.html | 20 +- .../resource-card.component.scss | 21 +- .../resource-card/resource-card.component.ts | 3 + .../resource-filters-options.selectors.ts | 9 + .../resource-filters.component.html | 152 ++++----- .../resource-filters.component.scss | 17 +- .../resource-filters.component.ts | 287 +--------------- .../store/resource-filters.actions.ts | 4 + .../store/resource-filters.selectors.ts | 10 +- .../store/resource-filters.state.ts | 55 +--- .../resources/resource-filters/utils/data.ts | 49 +++ .../resources-wrapper.component.html | 1 + .../resources-wrapper.component.scss | 0 .../resources-wrapper.component.spec.ts | 22 ++ .../resources-wrapper.component.ts | 305 ++++++++++++++++++ .../resources/resources.component.html | 209 +++++++----- .../resources/resources.component.scss | 59 ++-- .../resources/resources.component.ts | 77 ++++- src/assets/icons/source/filter.svg | 7 +- src/assets/icons/source/sort.svg | 11 + src/assets/styles/overrides/accordion.scss | 14 +- src/assets/styles/overrides/button.scss | 15 + src/assets/styles/overrides/dataview.scss | 5 + src/assets/styles/overrides/menu.scss | 24 ++ src/assets/styles/overrides/select.scss | 13 + src/assets/styles/overrides/tabs.scss | 9 + src/assets/styles/styles.scss | 3 +- 52 files changed, 1162 insertions(+), 572 deletions(-) create mode 100644 src/app/features/search/utils/data.ts create mode 100644 src/app/shared/components/resources/resource-filters/utils/data.ts create mode 100644 src/app/shared/components/resources/resources-wrapper/resources-wrapper.component.html create mode 100644 src/app/shared/components/resources/resources-wrapper/resources-wrapper.component.scss create mode 100644 src/app/shared/components/resources/resources-wrapper/resources-wrapper.component.spec.ts create mode 100644 src/app/shared/components/resources/resources-wrapper/resources-wrapper.component.ts create mode 100644 src/assets/icons/source/sort.svg create mode 100644 src/assets/styles/overrides/dataview.scss create mode 100644 src/assets/styles/overrides/menu.scss diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 1b268da2f..a00ca439c 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -151,6 +151,9 @@ export const routes: Routes = [ import('./features/my-profile/my-profile.component').then( (mod) => mod.MyProfileComponent, ), + providers: [ + provideStates([ResourceFiltersState, ResourceFiltersOptionsState]), + ], }, ], }, diff --git a/src/app/core/components/breadcrumb/breadcrumb.component.html b/src/app/core/components/breadcrumb/breadcrumb.component.html index 958fa3790..5cf0b7aca 100644 --- a/src/app/core/components/breadcrumb/breadcrumb.component.html +++ b/src/app/core/components/breadcrumb/breadcrumb.component.html @@ -1,8 +1,12 @@ - +} diff --git a/src/app/core/components/breadcrumb/breadcrumb.component.ts b/src/app/core/components/breadcrumb/breadcrumb.component.ts index 371a0442b..af57bd073 100644 --- a/src/app/core/components/breadcrumb/breadcrumb.component.ts +++ b/src/app/core/components/breadcrumb/breadcrumb.component.ts @@ -13,8 +13,11 @@ export class BreadcrumbComponent { #destroyRef = inject(DestroyRef); protected readonly url = signal(this.#router.url); protected readonly parsedUrl = computed(() => { - const cleanUrl = this.url().split('?')[0].split('#')[0]; - return cleanUrl.replace('-', ' ').split('/').filter(Boolean); + return this.url() + .split('?')[0] + .split('/') + .filter(Boolean) + .map((segment) => segment.replace(/-/g, ' ')); }); constructor() { diff --git a/src/app/core/components/header/header.component.html b/src/app/core/components/header/header.component.html index f73896f19..3a44fcb72 100644 --- a/src/app/core/components/header/header.component.html +++ b/src/app/core/components/header/header.component.html @@ -1,2 +1,11 @@ -{{ authButtonText() }} + + + diff --git a/src/app/core/components/header/header.component.scss b/src/app/core/components/header/header.component.scss index 42cacb134..6d9819938 100644 --- a/src/app/core/components/header/header.component.scss +++ b/src/app/core/components/header/header.component.scss @@ -2,7 +2,7 @@ :host { position: absolute; - height: 4.5rem; + height: 4rem; padding: 0 1.7rem; width: 100%; @include mix.flex-center-between; diff --git a/src/app/core/components/header/header.component.ts b/src/app/core/components/header/header.component.ts index ec7ea6762..d4d5bd91d 100644 --- a/src/app/core/components/header/header.component.ts +++ b/src/app/core/components/header/header.component.ts @@ -8,17 +8,32 @@ import { Router, RouterLink } from '@angular/router'; import { toSignal } from '@angular/core/rxjs-interop'; import { map } from 'rxjs'; import { BreadcrumbComponent } from '@core/components/breadcrumb/breadcrumb.component'; +import { MenuModule } from 'primeng/menu'; +import { ButtonModule } from 'primeng/button'; +import { Store } from '@ngxs/store'; +import { UserSelectors } from '@core/store/user/user.selectors'; @Component({ standalone: true, selector: 'osf-header', - imports: [RouterLink, BreadcrumbComponent], + imports: [RouterLink, BreadcrumbComponent, MenuModule, ButtonModule], templateUrl: './header.component.html', styleUrl: './header.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class HeaderComponent { - #router = inject(Router); + readonly #store = inject(Store); + readonly #router = inject(Router); + readonly currentUser = this.#store.selectSignal(UserSelectors.getCurrentUser); + + items = [ + { + label: 'My profile', + command: () => this.#router.navigate(['my-profile']), + }, + { label: 'Settings', command: () => console.log('Settings') }, + { label: 'Log out', command: () => console.log('Log out') }, + ]; #currentUrl = toSignal(this.#router.events.pipe(map(() => this.#router.url))); diff --git a/src/app/core/components/root/root.component.html b/src/app/core/components/root/root.component.html index 69d6a5bed..5d0737896 100644 --- a/src/app/core/components/root/root.component.html +++ b/src/app/core/components/root/root.component.html @@ -14,6 +14,9 @@
+ @if (!isMobile()) { + + }
diff --git a/src/app/core/components/root/root.component.scss b/src/app/core/components/root/root.component.scss index 7090691b3..4fe3b4a91 100644 --- a/src/app/core/components/root/root.component.scss +++ b/src/app/core/components/root/root.component.scss @@ -46,5 +46,9 @@ flex: 1; } } + + .breadcrumb { + padding: 2.5rem 2.5rem 0 2.5rem; + } } } diff --git a/src/app/core/components/root/root.component.ts b/src/app/core/components/root/root.component.ts index 724cd5c61..eb4e806d1 100644 --- a/src/app/core/components/root/root.component.ts +++ b/src/app/core/components/root/root.component.ts @@ -5,9 +5,10 @@ import { HeaderComponent } from '@core/components/header/header.component'; import { MainContentComponent } from '@core/components/main-content/main-content.component'; import { FooterComponent } from '@core/components/footer/footer.component'; import { TopnavComponent } from '@core/components/topnav/topnav.component'; -import { IS_WEB } from '@shared/utils/breakpoints.tokens'; +import { IS_WEB, IS_XSMALL } from '@shared/utils/breakpoints.tokens'; import { toSignal } from '@angular/core/rxjs-interop'; import { ConfirmDialog } from 'primeng/confirmdialog'; +import { BreadcrumbComponent } from '@core/components/breadcrumb/breadcrumb.component'; @Component({ selector: 'osf-root', @@ -20,12 +21,13 @@ import { ConfirmDialog } from 'primeng/confirmdialog'; FooterComponent, TopnavComponent, ConfirmDialog, + BreadcrumbComponent, ], templateUrl: './root.component.html', styleUrls: ['./root.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class RootComponent { - #isWeb$ = inject(IS_WEB); - isWeb = toSignal(this.#isWeb$); + isWeb = toSignal(inject(IS_WEB)); + isMobile = toSignal(inject(IS_XSMALL)); } diff --git a/src/app/core/services/mappers/users/users.mapper.ts b/src/app/core/services/mappers/users/users.mapper.ts index 3fe2aca12..7f5a08e8f 100644 --- a/src/app/core/services/mappers/users/users.mapper.ts +++ b/src/app/core/services/mappers/users/users.mapper.ts @@ -10,6 +10,7 @@ export function mapUserUStoUser(user: UserUS): User { email: user.attributes.email, dateRegistered: new Date(user.attributes.date_registered), link: user.links.html, + iri: user.links.iri, socials: user.attributes.social, employment: user.attributes.employment?.map((emp) => ({ title: emp.title, diff --git a/src/app/core/services/user/user.entity.ts b/src/app/core/services/user/user.entity.ts index c53b357af..b5e678964 100644 --- a/src/app/core/services/user/user.entity.ts +++ b/src/app/core/services/user/user.entity.ts @@ -6,6 +6,7 @@ export interface User { email?: string; dateRegistered: Date; link?: string; + iri?: string; socials?: { orcid?: string; github?: string; diff --git a/src/app/features/my-profile/my-profile.component.html b/src/app/features/my-profile/my-profile.component.html index a337a6266..c9e91bc01 100644 --- a/src/app/features/my-profile/my-profile.component.html +++ b/src/app/features/my-profile/my-profile.component.html @@ -94,7 +94,7 @@

Employment

@for (employment of currentUser()?.employment; track $index) { - +

{{ employment.institution }}

@@ -118,7 +118,7 @@

Employment

Education

@for (education of currentUser()?.education; track $index) { - +

{{ education.institution }}

@@ -143,3 +143,5 @@

Education

+ + diff --git a/src/app/features/my-profile/my-profile.component.scss b/src/app/features/my-profile/my-profile.component.scss index f0b38809d..eae7fd4ef 100644 --- a/src/app/features/my-profile/my-profile.component.scss +++ b/src/app/features/my-profile/my-profile.component.scss @@ -3,7 +3,7 @@ :host { .user-info { width: 100%; - padding: 4.5rem 1.7rem 1.7rem 1.7rem; + padding: 4rem 1.7rem 1.7rem 1.7rem; .name-container { display: flex; @@ -66,6 +66,54 @@ } } + .search-container { + margin: 6.2rem 1.7rem 0.4rem 1.7rem; + position: relative; + + img { + position: absolute; + right: 0.3rem; + top: 0.3rem; + z-index: 1; + } + } + + .resources { + position: relative; + background: white; + padding: 2rem; + } + + .stepper { + position: absolute; + padding: 1.7rem; + width: 32rem; + display: flex; + flex-direction: column; + row-gap: 1.7rem; + background: white; + border: 1px solid var.$grey-2; + border-radius: 12px; + h3 { + font-size: 1.3rem; + } + } + + .first-stepper { + top: 2rem; + left: 1.7rem; + } + + .second-stepper { + top: calc(2rem + 42px); + left: calc(1.5rem + 30%); + } + + .third-stepper { + top: calc(5rem + 42px); + left: calc(0.4rem + 30%); + } + @media (max-width: 1000px) { .user-info { padding: 4.5rem 2.5rem 2.5rem 2.5rem; @@ -124,4 +172,8 @@ } } } + + .search-container { + margin: 3.5rem 1.7rem 0.4rem 1.7rem; + } } diff --git a/src/app/features/my-profile/my-profile.component.ts b/src/app/features/my-profile/my-profile.component.ts index 1851497bc..fc0205fd2 100644 --- a/src/app/features/my-profile/my-profile.component.ts +++ b/src/app/features/my-profile/my-profile.component.ts @@ -1,4 +1,11 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + effect, + inject, + OnDestroy, + signal, +} from '@angular/core'; import { Store } from '@ngxs/store'; import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { Button } from 'primeng/button'; @@ -7,21 +14,61 @@ import { AccordionModule } from 'primeng/accordion'; import { toSignal } from '@angular/core/rxjs-interop'; import { IS_XSMALL } from '@osf/shared/utils/breakpoints.tokens'; import { Router } from '@angular/router'; +import { ResourceTab } from '../search/models/resource-tab.enum'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { SearchComponent } from '../search/search.component'; +import { + ResetFiltersState, + SetCreator, +} from '@osf/shared/components/resources/resource-filters/store/resource-filters.actions'; +import { ResetSearchState, SetIsMyProfile } from '@osf/features/search/store'; @Component({ selector: 'osf-my-profile', - imports: [Button, DatePipe, NgOptimizedImage, AccordionModule], + standalone: true, + imports: [ + Button, + DatePipe, + NgOptimizedImage, + AccordionModule, + FormsModule, + ReactiveFormsModule, + SearchComponent, + ], templateUrl: './my-profile.component.html', styleUrl: './my-profile.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MyProfileComponent { +export class MyProfileComponent implements OnDestroy { readonly #store = inject(Store); readonly #router = inject(Router); readonly currentUser = this.#store.selectSignal(UserSelectors.getCurrentUser); readonly isMobile = toSignal(inject(IS_XSMALL)); + protected searchValue = signal(''); + protected selectedTab: ResourceTab = ResourceTab.All; + protected readonly ResourceTab = ResourceTab; + toProfileSettings() { this.#router.navigate(['settings/profile-settings']); } + + constructor() { + this.#store.dispatch(new SetIsMyProfile(true)); + + effect(() => { + this.#store.dispatch( + new SetCreator( + this.currentUser()?.fullName ?? '', + this.currentUser()?.iri ?? '', + ), + ); + }); + } + + ngOnDestroy(): void { + this.#store.dispatch(ResetFiltersState); + this.#store.dispatch(ResetSearchState); + this.#store.dispatch(new SetIsMyProfile(false)); + } } diff --git a/src/app/features/search/search.component.html b/src/app/features/search/search.component.html index 63e198754..5845440d6 100644 --- a/src/app/features/search/search.component.html +++ b/src/app/features/search/search.component.html @@ -1,4 +1,7 @@ -
+
better-research
-
+
@if (!isMobile()) { - + All Projects Registrations Preprints Files - Users + @if (!isMyProfilePage()) { + Users + } } @@ -52,13 +57,15 @@ > - - + @if (!isMyProfilePage()) { + + + }
- + @if (currentStep === 1) {
diff --git a/src/app/features/search/search.component.scss b/src/app/features/search/search.component.scss index 9d28d34d9..ddd15a54e 100644 --- a/src/app/features/search/search.component.scss +++ b/src/app/features/search/search.component.scss @@ -2,7 +2,7 @@ :host { .search-container { - margin: 6.2rem 1.7rem 0.4rem 1.7rem; + margin: 3.4rem 1.7rem 0.4rem 1.7rem; position: relative; img { @@ -48,4 +48,24 @@ top: calc(5rem + 42px); left: calc(0.4rem + 30%); } + + @media (max-width: 1000px) { + .search-container { + margin: 3.4rem 2.5rem 0.4rem 2.5rem; + } + + .resources { + padding: 2.5rem; + } + } + + @media (max-width: 600px) { + .search-container { + margin: 2.5rem 1.1rem 2.5rem 1.1rem; + } + + .resources { + padding: 1.1rem; + } + } } diff --git a/src/app/features/search/search.component.ts b/src/app/features/search/search.component.ts index a82436ed4..8f5b3cd9b 100644 --- a/src/app/features/search/search.component.ts +++ b/src/app/features/search/search.component.ts @@ -3,6 +3,7 @@ import { Component, effect, inject, + OnDestroy, signal, untracked, } from '@angular/core'; @@ -17,19 +18,23 @@ import { AutoCompleteModule } from 'primeng/autocomplete'; import { AccordionModule } from 'primeng/accordion'; import { TableModule } from 'primeng/table'; import { DataViewModule } from 'primeng/dataview'; -import { ResourcesComponent } from '@shared/components/resources/resources.component'; import { ResourceTab } from '@osf/features/search/models/resource-tab.enum'; import { Store } from '@ngxs/store'; import { GetResources, + ResetSearchState, SearchSelectors, SetResourceTab, SetSearchText, } from '@osf/features/search/store'; -import { ResourceFiltersSelectors } from '@shared/components/resources/resource-filters/store'; +import { + ResetFiltersState, + ResourceFiltersSelectors, +} from '@shared/components/resources/resource-filters/store'; import { debounceTime } from 'rxjs'; import { GetAllOptions } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.actions'; import { Button } from 'primeng/button'; +import { ResourcesWrapperComponent } from '@shared/components/resources/resources-wrapper/resources-wrapper.component'; @Component({ selector: 'osf-search', @@ -48,14 +53,14 @@ import { Button } from 'primeng/button'; AccordionModule, TableModule, DataViewModule, - ResourcesComponent, Button, + ResourcesWrapperComponent, ], templateUrl: './search.component.html', styleUrl: './search.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SearchComponent { +export class SearchComponent implements OnDestroy { readonly #store = inject(Store); protected searchValue = signal(''); @@ -97,6 +102,9 @@ export class SearchComponent { protected sortByStoreValue = this.#store.selectSignal( SearchSelectors.getSortBy, ); + readonly isMyProfilePage = this.#store.selectSignal( + SearchSelectors.getIsMyProfile, + ); protected selectedTab: ResourceTab = ResourceTab.All; protected readonly ResourceTab = ResourceTab; @@ -137,12 +145,9 @@ export class SearchComponent { } }); - // sync resource tabs with query parameters + // sync resource tabs with store effect(() => { - if ( - !this.selectedTab && - this.selectedTab !== this.resourcesTabStoreValue() - ) { + if (this.selectedTab !== this.resourcesTabStoreValue()) { this.selectedTab = this.resourcesTabStoreValue(); } }); @@ -151,5 +156,11 @@ export class SearchComponent { onTabChange(index: ResourceTab): void { this.#store.dispatch(new SetResourceTab(index)); this.selectedTab = index; + this.#store.dispatch(GetAllOptions); + } + + ngOnDestroy(): void { + this.#store.dispatch(ResetFiltersState); + this.#store.dispatch(ResetSearchState); } } diff --git a/src/app/features/search/store/search.actions.ts b/src/app/features/search/store/search.actions.ts index bf55a3603..f04dc736a 100644 --- a/src/app/features/search/store/search.actions.ts +++ b/src/app/features/search/store/search.actions.ts @@ -31,3 +31,13 @@ export class SetResourceTab { constructor(public resourceTab: ResourceTab) {} } + +export class SetIsMyProfile { + static readonly type = '[Search] Set IsMyProfile'; + + constructor(public isMyProfile: boolean) {} +} + +export class ResetSearchState { + static readonly type = '[Search] Reset State'; +} diff --git a/src/app/features/search/store/search.model.ts b/src/app/features/search/store/search.model.ts index f8c128bde..7155f2c63 100644 --- a/src/app/features/search/store/search.model.ts +++ b/src/app/features/search/store/search.model.ts @@ -10,4 +10,5 @@ export interface SearchStateModel { first: string; next: string; previous: string; + isMyProfile: boolean; } diff --git a/src/app/features/search/store/search.selectors.ts b/src/app/features/search/store/search.selectors.ts index 929ade1b8..8d7f9390f 100644 --- a/src/app/features/search/store/search.selectors.ts +++ b/src/app/features/search/store/search.selectors.ts @@ -44,4 +44,9 @@ export class SearchSelectors { static getPrevious(state: SearchStateModel): string { return state.previous; } + + @Selector([SearchState]) + static getIsMyProfile(state: SearchStateModel): boolean { + return state.isMyProfile; + } } diff --git a/src/app/features/search/store/search.state.ts b/src/app/features/search/store/search.state.ts index db69e3546..690fd5358 100644 --- a/src/app/features/search/store/search.state.ts +++ b/src/app/features/search/store/search.state.ts @@ -5,6 +5,8 @@ import { Action, State, StateContext, Store } from '@ngxs/store'; import { GetResources, GetResourcesByLink, + ResetSearchState, + SetIsMyProfile, SetResourceTab, SetSearchText, SetSortBy, @@ -13,22 +15,13 @@ import { tap } from 'rxjs'; import { ResourceFiltersSelectors } from '@shared/components/resources/resource-filters/store'; import { addFiltersParams } from '@shared/components/resources/resource-filters/utils/add-filters-params.helper'; import { SearchSelectors } from '@osf/features/search/store/search.selectors'; -import { ResourceTab } from '@osf/features/search/models/resource-tab.enum'; import { getResourceTypes } from '@osf/features/search/utils/helpers/get-resource-types.helper'; +import { searchStateDefaults } from '@osf/features/search/utils/data'; @Injectable() @State({ name: 'search', - defaults: { - resources: [], - resourcesCount: 0, - searchText: '', - sortBy: '-relevance', - resourceTab: ResourceTab.All, - first: '', - next: '', - previous: '', - }, + defaults: searchStateDefaults, }) export class SearchState { searchService = inject(SearchService); @@ -90,4 +83,14 @@ export class SearchState { setResourceTab(ctx: StateContext, action: SetResourceTab) { ctx.patchState({ resourceTab: action.resourceTab }); } + + @Action(SetIsMyProfile) + setIsMyProfile(ctx: StateContext, action: SetIsMyProfile) { + ctx.patchState({ isMyProfile: action.isMyProfile }); + } + + @Action(ResetSearchState) + resetState(ctx: StateContext) { + ctx.patchState(searchStateDefaults); + } } diff --git a/src/app/features/search/utils/data.ts b/src/app/features/search/utils/data.ts new file mode 100644 index 000000000..aaade4442 --- /dev/null +++ b/src/app/features/search/utils/data.ts @@ -0,0 +1,13 @@ +import { ResourceTab } from '@osf/features/search/models/resource-tab.enum'; + +export const searchStateDefaults = { + resources: [], + resourcesCount: 0, + searchText: '', + sortBy: '-relevance', + resourceTab: ResourceTab.All, + first: '', + next: '', + previous: '', + isMyProfile: false, +}; diff --git a/src/app/shared/components/resources/filter-chips/filter-chips.component.html b/src/app/shared/components/resources/filter-chips/filter-chips.component.html index 9e794fe9e..4d6ec44a7 100644 --- a/src/app/shared/components/resources/filter-chips/filter-chips.component.html +++ b/src/app/shared/components/resources/filter-chips/filter-chips.component.html @@ -1,4 +1,4 @@ -@if (filters().creator.value) { +@if (filters().creator.value && !isMyProfilePage()) { @let creator = filters().creator.filterName + ": " + filters().creator.label; diff --git a/src/app/shared/components/resources/filter-chips/filter-chips.component.scss b/src/app/shared/components/resources/filter-chips/filter-chips.component.scss index 442a7a495..97920b501 100644 --- a/src/app/shared/components/resources/filter-chips/filter-chips.component.scss +++ b/src/app/shared/components/resources/filter-chips/filter-chips.component.scss @@ -1,5 +1,13 @@ :host { display: flex; flex-direction: column; - row-gap: 0.4rem; + gap: 0.4rem; + + @media (max-width: 1200px) { + flex-direction: row; + } + + @media (max-width: 600px) { + flex-direction: column; + } } diff --git a/src/app/shared/components/resources/filter-chips/filter-chips.component.ts b/src/app/shared/components/resources/filter-chips/filter-chips.component.ts index 953c9ac3b..68573b549 100644 --- a/src/app/shared/components/resources/filter-chips/filter-chips.component.ts +++ b/src/app/shared/components/resources/filter-chips/filter-chips.component.ts @@ -16,6 +16,7 @@ import { Chip } from 'primeng/chip'; import { PrimeTemplate } from 'primeng/api'; import { GetAllOptions } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.actions'; import { FilterType } from '@shared/components/resources/resource-filters/models/filter-type.enum'; +import { SearchSelectors } from '@osf/features/search/store'; @Component({ selector: 'osf-filter-chips', @@ -31,6 +32,10 @@ export class FilterChipsComponent { ResourceFiltersSelectors.getAllFilters, ); + readonly isMyProfilePage = this.#store.selectSignal( + SearchSelectors.getIsMyProfile, + ); + clearFilter(filter: FilterType) { switch (filter) { case FilterType.Creator: diff --git a/src/app/shared/components/resources/resource-card/resource-card.component.html b/src/app/shared/components/resources/resource-card/resource-card.component.html index d7d9feca1..9d2e77381 100644 --- a/src/app/shared/components/resources/resource-card/resource-card.component.html +++ b/src/app/shared/components/resources/resource-card/resource-card.component.html @@ -62,13 +62,25 @@ @if (item()?.dateCreated && item()?.dateModified) {

- Date created: {{ item()?.dateCreated | date: "MMMM d, y" }} | Date - modified: {{ item()?.dateModified | date: "MMMM d, y" }} + @if (!isSmall()) { + Date created: {{ item()?.dateCreated | date: "MMMM d, y" }} | + Date modified: {{ item()?.dateModified | date: "MMMM d, y" }} + } @else { +

+

+ Date created: {{ item()?.dateCreated | date: "MMMM d, y" }} +

+

+ Date modified: + {{ item()?.dateModified | date: "MMMM d, y" }} +

+
+ }

} @if (item()?.resourceType === ResourceType.Registration) { -
+ -
+
@if (item()?.description) {

Description: {{ item()?.description }}

} diff --git a/src/app/shared/components/resources/resource-card/resource-card.component.scss b/src/app/shared/components/resources/resource-card/resource-card.component.scss index d7aae7722..6f9d51825 100644 --- a/src/app/shared/components/resources/resource-card/resource-card.component.scss +++ b/src/app/shared/components/resources/resource-card/resource-card.component.scss @@ -19,9 +19,19 @@ } } + span { + display: inline; + } + a { font-weight: bold; display: inline; + overflow: hidden; + text-overflow: clip; + white-space: wrap; + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; &:hover { text-decoration: underline; @@ -44,6 +54,11 @@ color: var.$dark-blue-1; font-weight: 400; display: inline; + overflow: hidden; + white-space: wrap; + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; } .icon-container { @@ -59,14 +74,16 @@ } .description { - display: block; line-height: 2rem; + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; } .content { display: flex; flex-direction: column; - row-gap: 1rem; + row-gap: 1.7rem; border-top: 1px solid var.$grey-2; padding-top: 1rem; } diff --git a/src/app/shared/components/resources/resource-card/resource-card.component.ts b/src/app/shared/components/resources/resource-card/resource-card.component.ts index eb10edbc7..e021e6275 100644 --- a/src/app/shared/components/resources/resource-card/resource-card.component.ts +++ b/src/app/shared/components/resources/resource-card/resource-card.component.ts @@ -16,6 +16,8 @@ import { Resource } from '@osf/features/search/models/resource.entity'; import { ResourceCardService } from '@shared/components/resources/resource-card/resource-card.service'; import { finalize } from 'rxjs'; import { Skeleton } from 'primeng/skeleton'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { IS_XSMALL } from '@osf/shared/utils/breakpoints.tokens'; @Component({ selector: 'osf-resource-card', @@ -37,6 +39,7 @@ export class ResourceCardComponent { readonly #resourceCardService = inject(ResourceCardService); loading = false; dataIsLoaded = false; + isSmall = toSignal(inject(IS_XSMALL)); protected readonly ResourceType = ResourceType; diff --git a/src/app/shared/components/resources/resource-filters/filters/store/resource-filters-options.selectors.ts b/src/app/shared/components/resources/resource-filters/filters/store/resource-filters-options.selectors.ts index 54d87a027..73fe93363 100644 --- a/src/app/shared/components/resources/resource-filters/filters/store/resource-filters-options.selectors.ts +++ b/src/app/shared/components/resources/resource-filters/filters/store/resource-filters-options.selectors.ts @@ -66,4 +66,13 @@ export class ResourceFiltersOptionsSelectors { ): PartOfCollectionFilter[] { return state.partOfCollection; } + + @Selector([ResourceFiltersOptionsState]) + static getAllOptions( + state: ResourceFiltersOptionsStateModel, + ): ResourceFiltersOptionsStateModel { + return { + ...state, + }; + } } diff --git a/src/app/shared/components/resources/resource-filters/resource-filters.component.html b/src/app/shared/components/resources/resource-filters/resource-filters.component.html index 2371c14fe..59d2586c2 100644 --- a/src/app/shared/components/resources/resource-filters/resource-filters.component.html +++ b/src/app/shared/components/resources/resource-filters/resource-filters.component.html @@ -1,82 +1,86 @@ -
- - - Creator - - - - +@if (anyOptionsCount()) { +
+ + @if (!isMyProfilePage()) { + + Creator + + + + + } - @if (datesOptionsCount() > 0) { - - Date Created - - - - - } + @if (datesOptionsCount() > 0) { + + Date Created + + + + + } - @if (funderOptionsCount() > 0) { - - Funder - - - - - } + @if (funderOptionsCount() > 0) { + + Funder + + + + + } - @if (subjectOptionsCount() > 0) { - - Subject - - - - - } + @if (subjectOptionsCount() > 0) { + + Subject + + + + + } - @if (licenseOptionsCount() > 0) { - - License - - - - - } + @if (licenseOptionsCount() > 0) { + + License + + + + + } - @if (resourceTypeOptionsCount() > 0) { - - Resource Type - - - - - } + @if (resourceTypeOptionsCount() > 0) { + + Resource Type + + + + + } - @if (institutionOptionsCount() > 0) { - - Institution - - - - - } + @if (institutionOptionsCount() > 0) { + + Institution + + + + + } - @if (providerOptionsCount() > 0) { - - Provider - - - - - } + @if (providerOptionsCount() > 0) { + + Provider + + + + + } - @if (partOfCollectionOptionsCount() > 0) { - - Part of Collection - - - - - } - -
+ @if (partOfCollectionOptionsCount() > 0) { + + Part of Collection + + + + + } +
+
+} diff --git a/src/app/shared/components/resources/resource-filters/resource-filters.component.scss b/src/app/shared/components/resources/resource-filters/resource-filters.component.scss index e73580030..74ec4adc2 100644 --- a/src/app/shared/components/resources/resource-filters/resource-filters.component.scss +++ b/src/app/shared/components/resources/resource-filters/resource-filters.component.scss @@ -1,8 +1,15 @@ -@use "../../../../../assets/styles/variables" as var; +@use "assets/styles/variables" as var; :host { - border: 1px solid var.$grey-2; - border-radius: 12px; - padding: 0 1.7rem 0 1.7rem; - height: fit-content; + width: 30%; + + .filters { + border: 1px solid var.$grey-2; + border-radius: 12px; + padding: 0 1.7rem 0 1.7rem; + display: flex; + flex-direction: column; + row-gap: 0.8rem; + height: fit-content; + } } diff --git a/src/app/shared/components/resources/resource-filters/resource-filters.component.ts b/src/app/shared/components/resources/resource-filters/resource-filters.component.ts index 335f592b1..a281c5995 100644 --- a/src/app/shared/components/resources/resource-filters/resource-filters.component.ts +++ b/src/app/shared/components/resources/resource-filters/resource-filters.component.ts @@ -2,9 +2,7 @@ import { ChangeDetectionStrategy, Component, computed, - effect, inject, - OnInit, } from '@angular/core'; import { Accordion, @@ -24,31 +22,8 @@ import { ProviderFilterComponent } from '@shared/components/resources/resource-f import { PartOfCollectionFilterComponent } from '@shared/components/resources/resource-filters/filters/part-of-collection-filter/part-of-collection-filter.component'; import { Store } from '@ngxs/store'; import { ResourceFiltersOptionsSelectors } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.selectors'; -import { GetAllOptions } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.actions'; import { InstitutionFilterComponent } from '@shared/components/resources/resource-filters/filters/institution-filter/institution-filter.component'; -import { ActivatedRoute, Router } from '@angular/router'; -import { - ResourceFilterLabel, - ResourceFiltersSelectors, - SetCreator, - SetDateCreated, - SetFunder, - SetInstitution, - SetLicense, - SetPartOfCollection, - SetProvider, - SetResourceType, - SetSubject, -} from '@shared/components/resources/resource-filters/store'; -import { - SearchSelectors, - SetResourceTab, - SetSearchText, - SetSortBy, -} from '@osf/features/search/store'; -import { take } from 'rxjs'; -import { ResourceTab } from '@osf/features/search/models/resource-tab.enum'; -import { FilterLabels } from '@shared/components/resources/resource-filters/models/filter-labels'; +import { SearchSelectors } from '@osf/features/search/store'; @Component({ selector: 'osf-resource-filters', @@ -73,10 +48,8 @@ import { FilterLabels } from '@shared/components/resources/resource-filters/mode styleUrl: './resource-filters.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ResourceFiltersComponent implements OnInit { +export class ResourceFiltersComponent { readonly #store = inject(Store); - readonly #router = inject(Router); - readonly #activeRoute = inject(ActivatedRoute); readonly datesOptionsCount = computed(() => { return this.#store @@ -126,249 +99,21 @@ export class ResourceFiltersComponent implements OnInit { .reduce((acc, item) => acc + item.count, 0), ); - creatorSelected = this.#store.selectSignal( - ResourceFiltersSelectors.getCreator, - ); - dateCreatedSelected = this.#store.selectSignal( - ResourceFiltersSelectors.getDateCreated, - ); - funderSelected = this.#store.selectSignal(ResourceFiltersSelectors.getFunder); - subjectSelected = this.#store.selectSignal( - ResourceFiltersSelectors.getSubject, - ); - licenseSelected = this.#store.selectSignal( - ResourceFiltersSelectors.getLicense, - ); - resourceTypeSelected = this.#store.selectSignal( - ResourceFiltersSelectors.getResourceType, - ); - institutionSelected = this.#store.selectSignal( - ResourceFiltersSelectors.getInstitution, - ); - providerSelected = this.#store.selectSignal( - ResourceFiltersSelectors.getProvider, - ); - partOfCollectionSelected = this.#store.selectSignal( - ResourceFiltersSelectors.getPartOfCollection, - ); - sortSelected = this.#store.selectSignal(SearchSelectors.getSortBy); - searchInput = this.#store.selectSignal(SearchSelectors.getSearchText); - resourceTabSelected = this.#store.selectSignal( - SearchSelectors.getResourceTab, + readonly isMyProfilePage = this.#store.selectSignal( + SearchSelectors.getIsMyProfile, ); - ngOnInit() { - // set all query parameters from route to store when page is loaded - this.#activeRoute.queryParamMap.pipe(take(1)).subscribe((params) => { - const activeFilters = params.get('activeFilters'); - const filters = activeFilters ? JSON.parse(activeFilters) : []; - const sortBy = params.get('sortBy'); - const search = params.get('search'); - const resourceTab = params.get('resourceTab'); - - const creator = filters.find( - (p: ResourceFilterLabel) => p.filterName === FilterLabels.creator, - ); - const dateCreated = filters.find( - (p: ResourceFilterLabel) => p.filterName === 'DateCreated', - ); - const funder = filters.find( - (p: ResourceFilterLabel) => p.filterName === FilterLabels.funder, - ); - const subject = filters.find( - (p: ResourceFilterLabel) => p.filterName === FilterLabels.subject, - ); - const license = filters.find( - (p: ResourceFilterLabel) => p.filterName === FilterLabels.license, - ); - const resourceType = filters.find( - (p: ResourceFilterLabel) => p.filterName === 'ResourceType', - ); - const institution = filters.find( - (p: ResourceFilterLabel) => p.filterName === FilterLabels.institution, - ); - const provider = filters.find( - (p: ResourceFilterLabel) => p.filterName === FilterLabels.provider, - ); - const partOfCollection = filters.find( - (p: ResourceFilterLabel) => p.filterName === 'PartOfCollection', - ); - - if (creator) { - this.#store.dispatch(new SetCreator(creator.label, creator.value)); - } - if (dateCreated) { - this.#store.dispatch(new SetDateCreated(dateCreated.value)); - } - if (funder) { - this.#store.dispatch(new SetFunder(funder.label, funder.value)); - } - if (subject) { - this.#store.dispatch(new SetSubject(subject.label, subject.value)); - } - if (license) { - this.#store.dispatch(new SetLicense(license.label, license.value)); - } - if (resourceType) { - this.#store.dispatch( - new SetResourceType(resourceType.label, resourceType.value), - ); - } - if (institution) { - this.#store.dispatch( - new SetInstitution(institution.label, institution.value), - ); - } - if (provider) { - this.#store.dispatch(new SetProvider(provider.label, provider.value)); - } - if (partOfCollection) { - this.#store.dispatch( - new SetPartOfCollection( - partOfCollection.label, - partOfCollection.value, - ), - ); - } - - if (sortBy) { - this.#store.dispatch(new SetSortBy(sortBy)); - } - if (search) { - this.#store.dispatch(new SetSearchText(search)); - } - if (resourceTab) { - this.#store.dispatch(new SetResourceTab(+resourceTab)); - } - - this.#store.dispatch(GetAllOptions); - }); - } - - constructor() { - // if new value for some filter was put in store, add it to route - effect(() => this.syncFilterToQuery('Creator', this.creatorSelected())); - effect(() => - this.syncFilterToQuery('DateCreated', this.dateCreatedSelected()), + readonly anyOptionsCount = computed(() => { + return ( + this.datesOptionsCount() > 0 || + this.funderOptionsCount() > 0 || + this.subjectOptionsCount() > 0 || + this.licenseOptionsCount() > 0 || + this.resourceTypeOptionsCount() > 0 || + this.institutionOptionsCount() > 0 || + this.providerOptionsCount() > 0 || + this.partOfCollectionOptionsCount() > 0 || + !this.isMyProfilePage() ); - effect(() => this.syncFilterToQuery('Funder', this.funderSelected())); - effect(() => this.syncFilterToQuery('Subject', this.subjectSelected())); - effect(() => this.syncFilterToQuery('License', this.licenseSelected())); - effect(() => - this.syncFilterToQuery('ResourceType', this.resourceTypeSelected()), - ); - effect(() => - this.syncFilterToQuery('Institution', this.institutionSelected()), - ); - effect(() => this.syncFilterToQuery('Provider', this.providerSelected())); - effect(() => - this.syncFilterToQuery( - 'PartOfCollection', - this.partOfCollectionSelected(), - ), - ); - effect(() => this.syncSortingToQuery(this.sortSelected())); - effect(() => this.syncSearchToQuery(this.searchInput())); - effect(() => this.syncResourceTabToQuery(this.resourceTabSelected())); - } - - syncFilterToQuery(filterName: string, filterValue: ResourceFilterLabel) { - const paramMap = this.#activeRoute.snapshot.queryParamMap; - const currentParams = { ...this.#activeRoute.snapshot.queryParams }; - - // Read existing parameters - const currentFiltersRaw = paramMap.get('activeFilters'); - - let filters: ResourceFilterLabel[] = []; - - try { - filters = currentFiltersRaw - ? (JSON.parse(currentFiltersRaw) as ResourceFilterLabel[]) - : []; - } catch (e) { - console.error('Invalid activeFilters format in query params', e); - } - - const index = filters.findIndex((f) => f.filterName === filterName); - - const hasValue = !!filterValue?.value; - - // Update activeFilters array - if (!hasValue && index !== -1) { - filters.splice(index, 1); - } else if (hasValue && filterValue?.label && filterValue.value) { - const newFilter = { - filterName, - label: filterValue.label, - value: filterValue.value, - }; - - if (index !== -1) { - filters[index] = newFilter; - } else { - filters.push(newFilter); - } - } - - if (filters.length > 0) { - currentParams['activeFilters'] = JSON.stringify(filters); - } else { - delete currentParams['activeFilters']; - } - - // Navigation - this.#router.navigate([], { - relativeTo: this.#activeRoute, - queryParams: currentParams, - replaceUrl: true, - }); - } - - syncSortingToQuery(sortBy: string) { - const currentParams = { ...this.#activeRoute.snapshot.queryParams }; - - if (sortBy && sortBy !== '-relevance') { - currentParams['sortBy'] = sortBy; - } else if (sortBy && sortBy === '-relevance') { - delete currentParams['sortBy']; - } - - this.#router.navigate([], { - relativeTo: this.#activeRoute, - queryParams: currentParams, - replaceUrl: true, - }); - } - - syncSearchToQuery(search: string) { - const currentParams = { ...this.#activeRoute.snapshot.queryParams }; - - if (search) { - currentParams['search'] = search; - } else { - delete currentParams['search']; - } - - this.#router.navigate([], { - relativeTo: this.#activeRoute, - queryParams: currentParams, - replaceUrl: true, - }); - } - - syncResourceTabToQuery(resourceTab: ResourceTab) { - const currentParams = { ...this.#activeRoute.snapshot.queryParams }; - - if (resourceTab) { - currentParams['resourceTab'] = resourceTab; - } else { - delete currentParams['resourceTab']; - } - - this.#router.navigate([], { - relativeTo: this.#activeRoute, - queryParams: currentParams, - replaceUrl: true, - }); - } + }); } diff --git a/src/app/shared/components/resources/resource-filters/store/resource-filters.actions.ts b/src/app/shared/components/resources/resource-filters/store/resource-filters.actions.ts index 547df8280..1b371a84d 100644 --- a/src/app/shared/components/resources/resource-filters/store/resource-filters.actions.ts +++ b/src/app/shared/components/resources/resource-filters/store/resource-filters.actions.ts @@ -66,3 +66,7 @@ export class SetPartOfCollection { public id: string, ) {} } + +export class ResetFiltersState { + static readonly type = '[Resource Filters] Reset State'; +} diff --git a/src/app/shared/components/resources/resource-filters/store/resource-filters.selectors.ts b/src/app/shared/components/resources/resource-filters/store/resource-filters.selectors.ts index 6d6ce979e..8ea486ec3 100644 --- a/src/app/shared/components/resources/resource-filters/store/resource-filters.selectors.ts +++ b/src/app/shared/components/resources/resource-filters/store/resource-filters.selectors.ts @@ -11,15 +11,7 @@ export class ResourceFiltersSelectors { state: ResourceFiltersStateModel, ): ResourceFiltersStateModel { return { - creator: state.creator, - dateCreated: state.dateCreated, - funder: state.funder, - subject: state.subject, - license: state.license, - resourceType: state.resourceType, - institution: state.institution, - provider: state.provider, - partOfCollection: state.partOfCollection, + ...state, }; } diff --git a/src/app/shared/components/resources/resource-filters/store/resource-filters.state.ts b/src/app/shared/components/resources/resource-filters/store/resource-filters.state.ts index 9c84ca3ad..a64e0a090 100644 --- a/src/app/shared/components/resources/resource-filters/store/resource-filters.state.ts +++ b/src/app/shared/components/resources/resource-filters/store/resource-filters.state.ts @@ -3,6 +3,7 @@ import { Action, State, StateContext } from '@ngxs/store'; import { Injectable } from '@angular/core'; import { + ResetFiltersState, SetCreator, SetDateCreated, SetFunder, @@ -14,57 +15,12 @@ import { SetSubject, } from '@shared/components/resources/resource-filters/store/resource-filters.actions'; import { FilterLabels } from '@shared/components/resources/resource-filters/models/filter-labels'; +import { resourceFiltersDefaults } from '@shared/components/resources/resource-filters/utils/data'; // Store for user selected filters values @State({ name: 'resourceFilters', - defaults: { - creator: { - filterName: FilterLabels.creator, - label: undefined, - value: undefined, - }, - dateCreated: { - filterName: FilterLabels.dateCreated, - label: undefined, - value: undefined, - }, - funder: { - filterName: FilterLabels.funder, - label: undefined, - value: undefined, - }, - subject: { - filterName: FilterLabels.subject, - label: undefined, - value: undefined, - }, - license: { - filterName: FilterLabels.license, - label: undefined, - value: undefined, - }, - resourceType: { - filterName: FilterLabels.resourceType, - label: undefined, - value: undefined, - }, - institution: { - filterName: FilterLabels.institution, - label: undefined, - value: undefined, - }, - provider: { - filterName: FilterLabels.provider, - label: undefined, - value: undefined, - }, - partOfCollection: { - filterName: FilterLabels.partOfCollection, - label: undefined, - value: undefined, - }, - }, + defaults: resourceFiltersDefaults, }) @Injectable() export class ResourceFiltersState { @@ -181,4 +137,9 @@ export class ResourceFiltersState { }, }); } + + @Action(ResetFiltersState) + resetState(ctx: StateContext) { + ctx.patchState(resourceFiltersDefaults); + } } diff --git a/src/app/shared/components/resources/resource-filters/utils/data.ts b/src/app/shared/components/resources/resource-filters/utils/data.ts new file mode 100644 index 000000000..4f4d3e0c7 --- /dev/null +++ b/src/app/shared/components/resources/resource-filters/utils/data.ts @@ -0,0 +1,49 @@ +import { FilterLabels } from '@shared/components/resources/resource-filters/models/filter-labels'; + +export const resourceFiltersDefaults = { + creator: { + filterName: FilterLabels.creator, + label: undefined, + value: undefined, + }, + dateCreated: { + filterName: FilterLabels.dateCreated, + label: undefined, + value: undefined, + }, + funder: { + filterName: FilterLabels.funder, + label: undefined, + value: undefined, + }, + subject: { + filterName: FilterLabels.subject, + label: undefined, + value: undefined, + }, + license: { + filterName: FilterLabels.license, + label: undefined, + value: undefined, + }, + resourceType: { + filterName: FilterLabels.resourceType, + label: undefined, + value: undefined, + }, + institution: { + filterName: FilterLabels.institution, + label: undefined, + value: undefined, + }, + provider: { + filterName: FilterLabels.provider, + label: undefined, + value: undefined, + }, + partOfCollection: { + filterName: FilterLabels.partOfCollection, + label: undefined, + value: undefined, + }, +}; diff --git a/src/app/shared/components/resources/resources-wrapper/resources-wrapper.component.html b/src/app/shared/components/resources/resources-wrapper/resources-wrapper.component.html new file mode 100644 index 000000000..20b02cc4c --- /dev/null +++ b/src/app/shared/components/resources/resources-wrapper/resources-wrapper.component.html @@ -0,0 +1 @@ + diff --git a/src/app/shared/components/resources/resources-wrapper/resources-wrapper.component.scss b/src/app/shared/components/resources/resources-wrapper/resources-wrapper.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/components/resources/resources-wrapper/resources-wrapper.component.spec.ts b/src/app/shared/components/resources/resources-wrapper/resources-wrapper.component.spec.ts new file mode 100644 index 000000000..7e2ac76aa --- /dev/null +++ b/src/app/shared/components/resources/resources-wrapper/resources-wrapper.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ResourcesWrapperComponent } from './resources-wrapper.component'; + +describe('ResourcesWrapperComponent', () => { + let component: ResourcesWrapperComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ResourcesWrapperComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ResourcesWrapperComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/resources/resources-wrapper/resources-wrapper.component.ts b/src/app/shared/components/resources/resources-wrapper/resources-wrapper.component.ts new file mode 100644 index 000000000..536d70cf2 --- /dev/null +++ b/src/app/shared/components/resources/resources-wrapper/resources-wrapper.component.ts @@ -0,0 +1,305 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + inject, + OnInit, +} from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + ResourceFilterLabel, + ResourceFiltersSelectors, + SetCreator, + SetDateCreated, + SetFunder, + SetInstitution, + SetLicense, + SetPartOfCollection, + SetProvider, + SetResourceType, + SetSubject, +} from '@shared/components/resources/resource-filters/store'; +import { + SearchSelectors, + SetResourceTab, + SetSearchText, + SetSortBy, +} from '@osf/features/search/store'; +import { Store } from '@ngxs/store'; +import { take } from 'rxjs'; +import { FilterLabels } from '@shared/components/resources/resource-filters/models/filter-labels'; +import { GetAllOptions } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.actions'; +import { ResourceTab } from '@osf/features/search/models/resource-tab.enum'; +import { ResourcesComponent } from '@shared/components/resources/resources.component'; + +@Component({ + selector: 'osf-resources-wrapper', + imports: [ResourcesComponent], + templateUrl: './resources-wrapper.component.html', + styleUrl: './resources-wrapper.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ResourcesWrapperComponent implements OnInit { + readonly #store = inject(Store); + readonly #activeRoute = inject(ActivatedRoute); + readonly #router = inject(Router); + + creatorSelected = this.#store.selectSignal( + ResourceFiltersSelectors.getCreator, + ); + dateCreatedSelected = this.#store.selectSignal( + ResourceFiltersSelectors.getDateCreated, + ); + funderSelected = this.#store.selectSignal(ResourceFiltersSelectors.getFunder); + subjectSelected = this.#store.selectSignal( + ResourceFiltersSelectors.getSubject, + ); + licenseSelected = this.#store.selectSignal( + ResourceFiltersSelectors.getLicense, + ); + resourceTypeSelected = this.#store.selectSignal( + ResourceFiltersSelectors.getResourceType, + ); + institutionSelected = this.#store.selectSignal( + ResourceFiltersSelectors.getInstitution, + ); + providerSelected = this.#store.selectSignal( + ResourceFiltersSelectors.getProvider, + ); + partOfCollectionSelected = this.#store.selectSignal( + ResourceFiltersSelectors.getPartOfCollection, + ); + sortSelected = this.#store.selectSignal(SearchSelectors.getSortBy); + searchInput = this.#store.selectSignal(SearchSelectors.getSearchText); + resourceTabSelected = this.#store.selectSignal( + SearchSelectors.getResourceTab, + ); + isMyProfilePage = this.#store.selectSignal(SearchSelectors.getIsMyProfile); + + constructor() { + // if new value for some filter was put in store, add it to route + effect(() => this.syncFilterToQuery('Creator', this.creatorSelected())); + effect(() => + this.syncFilterToQuery('DateCreated', this.dateCreatedSelected()), + ); + effect(() => this.syncFilterToQuery('Funder', this.funderSelected())); + effect(() => this.syncFilterToQuery('Subject', this.subjectSelected())); + effect(() => this.syncFilterToQuery('License', this.licenseSelected())); + effect(() => + this.syncFilterToQuery('ResourceType', this.resourceTypeSelected()), + ); + effect(() => + this.syncFilterToQuery('Institution', this.institutionSelected()), + ); + effect(() => this.syncFilterToQuery('Provider', this.providerSelected())); + effect(() => + this.syncFilterToQuery( + 'PartOfCollection', + this.partOfCollectionSelected(), + ), + ); + effect(() => this.syncSortingToQuery(this.sortSelected())); + effect(() => this.syncSearchToQuery(this.searchInput())); + effect(() => this.syncResourceTabToQuery(this.resourceTabSelected())); + } + + ngOnInit() { + // set all query parameters from route to store when page is loaded + this.#activeRoute.queryParamMap.pipe(take(1)).subscribe((params) => { + const activeFilters = params.get('activeFilters'); + const filters = activeFilters ? JSON.parse(activeFilters) : []; + const sortBy = params.get('sortBy'); + const search = params.get('search'); + const resourceTab = params.get('resourceTab'); + + const creator = filters.find( + (p: ResourceFilterLabel) => p.filterName === FilterLabels.creator, + ); + const dateCreated = filters.find( + (p: ResourceFilterLabel) => p.filterName === 'DateCreated', + ); + const funder = filters.find( + (p: ResourceFilterLabel) => p.filterName === FilterLabels.funder, + ); + const subject = filters.find( + (p: ResourceFilterLabel) => p.filterName === FilterLabels.subject, + ); + const license = filters.find( + (p: ResourceFilterLabel) => p.filterName === FilterLabels.license, + ); + const resourceType = filters.find( + (p: ResourceFilterLabel) => p.filterName === 'ResourceType', + ); + const institution = filters.find( + (p: ResourceFilterLabel) => p.filterName === FilterLabels.institution, + ); + const provider = filters.find( + (p: ResourceFilterLabel) => p.filterName === FilterLabels.provider, + ); + const partOfCollection = filters.find( + (p: ResourceFilterLabel) => p.filterName === 'PartOfCollection', + ); + + if (creator) { + this.#store.dispatch(new SetCreator(creator.label, creator.value)); + } + if (dateCreated) { + this.#store.dispatch(new SetDateCreated(dateCreated.value)); + } + if (funder) { + this.#store.dispatch(new SetFunder(funder.label, funder.value)); + } + if (subject) { + this.#store.dispatch(new SetSubject(subject.label, subject.value)); + } + if (license) { + this.#store.dispatch(new SetLicense(license.label, license.value)); + } + if (resourceType) { + this.#store.dispatch( + new SetResourceType(resourceType.label, resourceType.value), + ); + } + if (institution) { + this.#store.dispatch( + new SetInstitution(institution.label, institution.value), + ); + } + if (provider) { + this.#store.dispatch(new SetProvider(provider.label, provider.value)); + } + if (partOfCollection) { + this.#store.dispatch( + new SetPartOfCollection( + partOfCollection.label, + partOfCollection.value, + ), + ); + } + + if (sortBy) { + this.#store.dispatch(new SetSortBy(sortBy)); + } + if (search) { + this.#store.dispatch(new SetSearchText(search)); + } + if (resourceTab) { + this.#store.dispatch(new SetResourceTab(+resourceTab)); + } + + this.#store.dispatch(GetAllOptions); + }); + } + + syncFilterToQuery(filterName: string, filterValue: ResourceFilterLabel) { + if (this.isMyProfilePage()) { + return; + } + const paramMap = this.#activeRoute.snapshot.queryParamMap; + const currentParams = { ...this.#activeRoute.snapshot.queryParams }; + + // Read existing parameters + const currentFiltersRaw = paramMap.get('activeFilters'); + + let filters: ResourceFilterLabel[] = []; + + try { + filters = currentFiltersRaw + ? (JSON.parse(currentFiltersRaw) as ResourceFilterLabel[]) + : []; + } catch (e) { + console.error('Invalid activeFilters format in query params', e); + } + + const index = filters.findIndex((f) => f.filterName === filterName); + + const hasValue = !!filterValue?.value; + + // Update activeFilters array + if (!hasValue && index !== -1) { + filters.splice(index, 1); + } else if (hasValue && filterValue?.label && filterValue.value) { + const newFilter = { + filterName, + label: filterValue.label, + value: filterValue.value, + }; + + if (index !== -1) { + filters[index] = newFilter; + } else { + filters.push(newFilter); + } + } + + if (filters.length > 0) { + currentParams['activeFilters'] = JSON.stringify(filters); + } else { + delete currentParams['activeFilters']; + } + + // Navigation + this.#router.navigate([], { + relativeTo: this.#activeRoute, + queryParams: currentParams, + replaceUrl: true, + }); + } + + syncSortingToQuery(sortBy: string) { + if (this.isMyProfilePage()) { + return; + } + const currentParams = { ...this.#activeRoute.snapshot.queryParams }; + + if (sortBy && sortBy !== '-relevance') { + currentParams['sortBy'] = sortBy; + } else if (sortBy && sortBy === '-relevance') { + delete currentParams['sortBy']; + } + + this.#router.navigate([], { + relativeTo: this.#activeRoute, + queryParams: currentParams, + replaceUrl: true, + }); + } + + syncSearchToQuery(search: string) { + if (this.isMyProfilePage()) { + return; + } + const currentParams = { ...this.#activeRoute.snapshot.queryParams }; + + if (search) { + currentParams['search'] = search; + } else { + delete currentParams['search']; + } + + this.#router.navigate([], { + relativeTo: this.#activeRoute, + queryParams: currentParams, + replaceUrl: true, + }); + } + + syncResourceTabToQuery(resourceTab: ResourceTab) { + if (this.isMyProfilePage()) { + return; + } + const currentParams = { ...this.#activeRoute.snapshot.queryParams }; + + if (resourceTab) { + currentParams['resourceTab'] = resourceTab; + } else { + delete currentParams['resourceTab']; + } + + this.#router.navigate([], { + relativeTo: this.#activeRoute, + queryParams: currentParams, + replaceUrl: true, + }); + } +} diff --git a/src/app/shared/components/resources/resources.component.html b/src/app/shared/components/resources/resources.component.html index 667cbc7aa..5940fa633 100644 --- a/src/app/shared/components/resources/resources.component.html +++ b/src/app/shared/components/resources/resources.component.html @@ -1,94 +1,157 @@ -
- @if (searchCount() > 10000) { -

10 000+ results

- } @else if (searchCount() > 0) { -

{{ searchCount() }} results

- } @else { -

0 results

- } +
+
+ @if (isMobile()) { + + + } + @if (searchCount() > 10000) { +

10 000+ results

+ } @else if (searchCount() > 0) { +

{{ searchCount() }} results

+ } @else { +

0 results

+ } +
-

Sort by:

- + @if (isWeb()) { +

Sort by:

+ + } @else { + @if (isAnyFilterOptions()) { + filter by + } + sort by + }
-
-
- @if (isAnyFilterSelected()) { - - } +@if (isFiltersOpen()) { +
+} @else if (isSortingOpen()) { +
+ @for (option of sortTabOptions; track option.value) { +
+ {{ option.label }} +
+ } +
+} @else { + @if (isAnyFilterSelected()) { +
+ +
+ } - - -
- @if (items.length > 0) { - @for (item of items; track item.id) { - - } +
+ @if (isWeb() && isAnyFilterOptions()) { + + } + + + +
+ @if (items.length > 0) { + @for (item of items; track item.id) { + + } + +
+ @if (first() && prev()) { + + + + } -
- @if (first() && prev()) { + + + + - } - - - - - - - - -
- } -
- - -
+
+ } +
+
+
+
+} diff --git a/src/app/shared/components/resources/resources.component.scss b/src/app/shared/components/resources/resources.component.scss index 5a8586f3d..c637cba9b 100644 --- a/src/app/shared/components/resources/resources.component.scss +++ b/src/app/shared/components/resources/resources.component.scss @@ -17,36 +17,51 @@ } } - .filters-container { + .filter-full-size { + flex: 1; + } + + .sort-card { display: flex; - flex-direction: column; - width: 30%; - row-gap: 0.8rem; + align-items: center; + justify-content: center; + width: 100%; + height: 44px; + border: 1px solid var.$grey-2; + border-radius: 12px; + padding: 0 1.7rem 0 1.7rem; + cursor: pointer; } - .resources-container { - width: 70%; + .card-selected { + background: var.$bg-blue-2; + } - .resources-list { - width: 100%; - display: flex; - flex-direction: column; - row-gap: 0.85rem; - } + .filters-resources-web { + .resources-container { + flex: 1; - .switch-icon { - &:hover { - cursor: pointer; + .resources-list { + width: 100%; + display: flex; + flex-direction: column; + row-gap: 0.85rem; } - } - .icon-disabled { - opacity: 0.5; - cursor: none; - } + .switch-icon { + &:hover { + cursor: pointer; + } + } - .icon-active { - fill: var.$grey-1; + .icon-disabled { + opacity: 0.5; + cursor: none; + } + + .icon-active { + fill: var.$grey-1; + } } } } diff --git a/src/app/shared/components/resources/resources.component.ts b/src/app/shared/components/resources/resources.component.ts index 1dde54021..e5d2eb756 100644 --- a/src/app/shared/components/resources/resources.component.ts +++ b/src/app/shared/components/resources/resources.component.ts @@ -4,12 +4,11 @@ import { computed, effect, inject, - input, signal, untracked, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; +import { IS_XSMALL, IS_WEB } from '@shared/utils/breakpoints.tokens'; import { DropdownModule } from 'primeng/dropdown'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ResourceFiltersComponent } from '@shared/components/resources/resource-filters/resource-filters.component'; @@ -23,11 +22,14 @@ import { ResourceTab } from '@osf/features/search/models/resource-tab.enum'; import { GetResourcesByLink, SearchSelectors, + SetResourceTab, SetSortBy, } from '@osf/features/search/store'; import { FilterChipsComponent } from '@shared/components/resources/filter-chips/filter-chips.component'; import { ResourceFiltersSelectors } from '@shared/components/resources/resource-filters/store'; import { Select } from 'primeng/select'; +import { NgOptimizedImage } from '@angular/common'; +import { ResourceFiltersOptionsSelectors } from '@shared/components/resources/resource-filters/filters/store/resource-filters-options.selectors'; @Component({ selector: 'osf-resources', @@ -43,6 +45,7 @@ import { Select } from 'primeng/select'; ResourceCardComponent, FilterChipsComponent, Select, + NgOptimizedImage, ], templateUrl: './resources.component.html', styleUrl: './resources.component.scss', @@ -50,17 +53,27 @@ import { Select } from 'primeng/select'; }) export class ResourcesComponent { readonly #store = inject(Store); - selectedTab = input.required(); + + selectedTabStore = this.#store.selectSignal(SearchSelectors.getResourceTab); searchCount = this.#store.selectSignal(SearchSelectors.getResourcesCount); resources = this.#store.selectSignal(SearchSelectors.getResources); sortBy = this.#store.selectSignal(SearchSelectors.getSortBy); first = this.#store.selectSignal(SearchSelectors.getFirst); next = this.#store.selectSignal(SearchSelectors.getNext); prev = this.#store.selectSignal(SearchSelectors.getPrevious); + isMyProfilePage = this.#store.selectSignal(SearchSelectors.getIsMyProfile); + + isWeb = toSignal(inject(IS_WEB)); + + isFiltersOpen = signal(false); + isSortingOpen = signal(false); protected filters = this.#store.selectSignal( ResourceFiltersSelectors.getAllFilters, ); + protected filtersOptions = this.#store.selectSignal( + ResourceFiltersOptionsSelectors.getAllOptions, + ); protected isAnyFilterSelected = computed(() => { return ( this.filters().creator.value || @@ -74,11 +87,24 @@ export class ResourcesComponent { this.filters().partOfCollection.value ); }); + protected isAnyFilterOptions = computed(() => { + return ( + this.filtersOptions().datesCreated.length > 0 || + this.filtersOptions().creators.length > 0 || + this.filtersOptions().funders.length > 0 || + this.filtersOptions().subjects.length > 0 || + this.filtersOptions().licenses.length > 0 || + this.filtersOptions().resourceTypes.length > 0 || + this.filtersOptions().institutions.length > 0 || + this.filtersOptions().providers.length > 0 || + this.filtersOptions().partOfCollection.length > 0 || + !this.isMyProfilePage() + ); + }); protected readonly isMobile = toSignal(inject(IS_XSMALL)); protected selectedSort = signal(''); - protected readonly sortTabOptions = [ { label: 'Relevance', value: '-relevance' }, { label: 'Date created (newest)', value: '-dateCreated' }, @@ -87,6 +113,16 @@ export class ResourcesComponent { { label: 'Date modified (oldest)', value: 'dateModified' }, ]; + protected selectedTab = signal(ResourceTab.All); + protected readonly tabsOptions = [ + { label: 'All', value: ResourceTab.All }, + { label: 'Projects', value: ResourceTab.Projects }, + { label: 'Registrations', value: ResourceTab.Registrations }, + { label: 'Preprints', value: ResourceTab.Preprints }, + { label: 'Files', value: ResourceTab.Files }, + { label: 'Users', value: ResourceTab.Users }, + ]; + constructor() { // if new value for sorting in store, update value in dropdown effect(() => { @@ -107,10 +143,43 @@ export class ResourcesComponent { this.#store.dispatch(new SetSortBy(chosenValue)); } }); + + effect(() => { + const storeValue = this.selectedTabStore(); + const currentInput = untracked(() => this.selectedTab()); + + if (storeValue && currentInput !== storeValue) { + this.selectedTab.set(storeValue); + } + }); + + effect(() => { + const chosenValue = this.selectedTab(); + const storeValue = untracked(() => this.selectedTabStore()); + + if (chosenValue !== storeValue) { + this.#store.dispatch(new SetResourceTab(chosenValue)); + } + }); } // pagination switchPage(link: string) { this.#store.dispatch(new GetResourcesByLink(link)); } + + openFilters() { + this.isFiltersOpen.set(!this.isFiltersOpen()); + this.isSortingOpen.set(false); + } + + openSorting() { + this.isSortingOpen.set(!this.isSortingOpen()); + this.isFiltersOpen.set(false); + } + + selectSort(value: string) { + this.selectedSort.set(value); + this.openSorting(); + } } diff --git a/src/assets/icons/source/filter.svg b/src/assets/icons/source/filter.svg index 85dd7f4ed..d0e579666 100644 --- a/src/assets/icons/source/filter.svg +++ b/src/assets/icons/source/filter.svg @@ -1,5 +1,4 @@ - - - + + + - diff --git a/src/assets/icons/source/sort.svg b/src/assets/icons/source/sort.svg new file mode 100644 index 000000000..31d57840e --- /dev/null +++ b/src/assets/icons/source/sort.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/styles/overrides/accordion.scss b/src/assets/styles/overrides/accordion.scss index 1bbdfc52e..542247913 100644 --- a/src/assets/styles/overrides/accordion.scss +++ b/src/assets/styles/overrides/accordion.scss @@ -109,11 +109,17 @@ color: var.$pr-blue-1; } } + } - @media (max-width: 600px) { - .p-accordion { - row-gap: 1.7rem; - } + @media (max-width: 1000px) { + .p-accordion { + row-gap: 0.5rem; + } + } + + @media (max-width: 600px) { + .p-accordion { + row-gap: 1.7rem; } } } diff --git a/src/assets/styles/overrides/button.scss b/src/assets/styles/overrides/button.scss index 4d2203936..b8de2901f 100644 --- a/src/assets/styles/overrides/button.scss +++ b/src/assets/styles/overrides/button.scss @@ -219,3 +219,18 @@ color: var.$red-1; } } + +.dropdown-button { + position: relative; + display: flex; + flex-direction: column; + justify-content: end; + .p-button { + background: transparent; + color: var.$dark-blue-2; + padding: 0; + } + i { + color: var.$grey-1; + } +} diff --git a/src/assets/styles/overrides/dataview.scss b/src/assets/styles/overrides/dataview.scss new file mode 100644 index 000000000..1e6279f87 --- /dev/null +++ b/src/assets/styles/overrides/dataview.scss @@ -0,0 +1,5 @@ +.p-dataview { + .p-dataview-emptymessage { + text-align: center; + } +} diff --git a/src/assets/styles/overrides/menu.scss b/src/assets/styles/overrides/menu.scss new file mode 100644 index 000000000..2ae6d2fb7 --- /dev/null +++ b/src/assets/styles/overrides/menu.scss @@ -0,0 +1,24 @@ +@use "../variables" as var; + +.p-menu-overlay { + top: 2rem !important; + left: 0 !important; +} + +.p-menu-item-content { + a { + color: var.$dark-blue-2; + } + a:hover { + text-decoration: none; + } + &:hover { + text-decoration: none; + background: var.$bg-blue-3; + } +} + +.p-menu { + min-width: 7rem; + width: 100%; +} diff --git a/src/assets/styles/overrides/select.scss b/src/assets/styles/overrides/select.scss index 76016df54..0a6c0ec10 100644 --- a/src/assets/styles/overrides/select.scss +++ b/src/assets/styles/overrides/select.scss @@ -92,6 +92,7 @@ .p-select-clear-icon { right: 1rem; + margin-left: 0.5rem; } } } @@ -103,3 +104,15 @@ } } } + +.centered-select { + .p-select { + .p-select-label { + justify-content: center; + } + + .p-select-option { + justify-content: center; + } + } +} diff --git a/src/assets/styles/overrides/tabs.scss b/src/assets/styles/overrides/tabs.scss index 155348968..fc949a9c5 100644 --- a/src/assets/styles/overrides/tabs.scss +++ b/src/assets/styles/overrides/tabs.scss @@ -39,4 +39,13 @@ .p-tabpanels { padding: 0 !important; } + .p-tablist { + padding: 3.4rem 1.7rem 0 1.7rem; + } + + @media (max-width: 1000px) { + .p-tablist { + padding: 2rem 2.5rem 0 2.5rem; + } + } } diff --git a/src/assets/styles/styles.scss b/src/assets/styles/styles.scss index 0f9874834..bca78dfda 100644 --- a/src/assets/styles/styles.scss +++ b/src/assets/styles/styles.scss @@ -26,7 +26,8 @@ @use "./overrides/paginator"; @use "./overrides/chip"; @use "./overrides/tag"; - +@use "./overrides/dataview"; +@use "./overrides/menu"; @layer base, primeng, reset; @layer base {