diff --git a/.vscode/settings.json b/.vscode/settings.json index d2271d7836..cb8d65a0fa 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,7 @@ "app:ccf-organ-info", "app:ccf-rui", "app:cde-ui", + "app:cns-website", "app:dashboard-ui", "app:docs.humanatlas.io", "app:ftu-ui", diff --git a/apps/cde-ui/src/app/app.component.spec.ts b/apps/cde-ui/src/app/app.component.spec.ts index c1c0167dd9..e92de22f3b 100644 --- a/apps/cde-ui/src/app/app.component.spec.ts +++ b/apps/cde-ui/src/app/app.component.spec.ts @@ -2,11 +2,14 @@ import { HttpClientModule } from '@angular/common/http'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { Shallow } from 'shallow-render'; import { AppComponent } from './app.component'; +import { PrivacyPreferencesService } from '@hra-ui/design-system/privacy'; describe('AppComponent', () => { let shallow: Shallow; beforeEach(async () => { - shallow = new Shallow(AppComponent).import(HttpClientModule, HttpClientTestingModule); + shallow = new Shallow(AppComponent) + .import(HttpClientModule, HttpClientTestingModule) + .mock(PrivacyPreferencesService, { launch: jest.fn() }); }); it(`should have as title 'cde-ui'`, async () => { diff --git a/apps/cns-website/eslint.config.mjs b/apps/cns-website/eslint.config.mjs new file mode 100644 index 0000000000..0ca18d80b0 --- /dev/null +++ b/apps/cns-website/eslint.config.mjs @@ -0,0 +1,27 @@ +import { configs } from '../../eslint.config.mjs'; + +export default [ + ...configs.base, + ...configs.angular, + { + files: ['**/*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'cns', + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'cns', + style: 'kebab-case', + }, + ], + }, + }, +]; diff --git a/apps/cns-website/jest.config.ts b/apps/cns-website/jest.config.ts new file mode 100644 index 0000000000..9b18bb1b95 --- /dev/null +++ b/apps/cns-website/jest.config.ts @@ -0,0 +1,15 @@ +export default { + displayName: 'cns-website', + preset: '../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../coverage/apps/cns-website', + // TODO increase to 85%! + coverageThreshold: { + global: { + statements: 37, + branches: 42, + lines: 38, + functions: 40, + }, + }, +}; diff --git a/apps/cns-website/project.json b/apps/cns-website/project.json new file mode 100644 index 0000000000..472ea7a7d2 --- /dev/null +++ b/apps/cns-website/project.json @@ -0,0 +1,45 @@ +{ + "name": "cns-website", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "prefix": "cns", + "sourceRoot": "apps/cns-website/src", + "tags": ["type:app", "project:cns-website"], + "targets": { + "build": { + "executor": "@nx/angular:application", + "options": { + "assets": [ + { + "glob": "**/*", + "input": "apps/cns-website/public" + }, + { + "input": "libs/design-system/assets", + "glob": "**/*", + "output": "./assets" + } + ], + "plugins": [] + }, + "configurations": { + "production": { + "baseHref": "/cns-website/" + } + } + }, + "serve": { + "executor": "@nx/angular:dev-server", + "continuous": true + }, + "compodoc": { + "executor": "@twittwer/compodoc:compodoc", + "options": { + "tsConfig": "apps/cns-website/tsconfig.app.json" + } + }, + "build-webcomponent": { + "command": "node tools/scripts/bundle-scripts.mjs dist/apps/cns-website/browser/" + } + } +} diff --git a/apps/cns-website/public/assets/cns_footer_logo.svg b/apps/cns-website/public/assets/cns_footer_logo.svg new file mode 100644 index 0000000000..82eb2d6dcb --- /dev/null +++ b/apps/cns-website/public/assets/cns_footer_logo.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/cns-website/public/assets/cns_header_logo.svg b/apps/cns-website/public/assets/cns_header_logo.svg new file mode 100644 index 0000000000..d136494715 --- /dev/null +++ b/apps/cns-website/public/assets/cns_header_logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/apps/cns-website/public/assets/content/2012-ucsdmap/data.yaml b/apps/cns-website/public/assets/content/2012-ucsdmap/data.yaml new file mode 100644 index 0000000000..bd840ce304 --- /dev/null +++ b/apps/cns-website/public/assets/content/2012-ucsdmap/data.yaml @@ -0,0 +1,72 @@ +$schema: ../../../app/schemas/content-page/content-page.schema.json +title: 'Design and Update of a Classification System: The UCSD Map of Science' +subtitle: | + Börner, Katy, Richard Klavans, Michael Patek, Angela Zoss, Joseph R. Biberstine, Robert Light, Vincent Larivière, + and Kevin W. Boyack (2012) Design and Update of a Classification System: The UCSD Map of Science. PLoS ONE 7(7): + e39464. [doi:10.1371/journal.pone.0039464](https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0039464) +breadcrumbs: + - name: Home + route: / + - name: | + Design and Update of a Classification System: The UCSD Map of Science +content: + - component: PageSection + tagline: Team + anchor: team + level: 2 + content: + - component: Markdown + data: | + The project is lead by [SciTech Strategies Inc](https://www.scitech-strategies.com/). in collaboration with the [Cyberinfrastructure + for Network Science Center](https://cns.iu.edu/) at Indiana University. There are subcontracts to different researchers + and one company. The full team comprises: + + Katy Börner1, Richard Klavans2, Michael Patek2, Angela M. Zoss1, Joseph R. Biberstine1, Robert P. Light1, Vincent Larivière1,3, and Kevin W. Boyack5 + + - component: PageSection + tagline: Data + anchor: data + level: 2 + content: + - component: Markdown + data: | + The 2010 UCSD map of science and classification system covering 10 years (2001-2010) of Web of Science data and + 8 years (2001-2008) of Scopus data with subdiscipline assignments by SciTech Strategies. + 1. Data as [MS AccessDB](https://cns.iu.edu/docs/data/2012-UCSDMap/UCSDmapDatabase.accdb) and as + [MS Excel](https://cns.iu.edu/docs/data/2012-UCSDMap/UCSDmapDataTables.xlsx) file (identical info as MS AccessDB) + as well as [data dictionary](https://cns.iu.edu/docs/data/2012-UCSDMap/UCSDmapDataDictionary.xlsx) and + [database schema](https://cns.iu.edu/docs/data/2012-UCSDMap/UCSDmapDBSchema.pdf). + 2. [Network .net](https://cns.iu.edu/docs/data/2012-UCSDMap/UCSDmap.net) file to visually render science map. + Also provided as [.net file](https://cns.iu.edu/docs/data/2012-UCSDMap/UCSDmap_with_disciplines.net) with discipline nodes and names. + + - component: PageSection + tagline: Usage conditions + anchor: usage-conditions + level: 2 + content: + - component: Markdown + data: | + This map is shared under the [Creative Commons, Attribution-NonCommercial-ShareAlike 3.0 Unported (CC BY-NC-SA 3.0) license](http://creativecommons.org/licenses/by-nc-sa/3.0/). + That is, you are free to share, e.g., to copy, distribute and transmit the work, and to remix, i.e., to adapt the work under the following conditions: + - Attribution — You must attribute the work in the following manner (but not in any way that suggests that they endorse you or your use of the work): + Cite the above paper and use the following acknowledgment text: *"The authors wish to acknowledge The Regents of the University of California, + SciTech Strategies, Observatoire des Sciences et des Technologies, and the Cyberinfrastructure for Network Science Center for making the 2010 + UCSD Map of Science and Classification System available for this work."* + - Noncommercial — You may not use this work for commercial purposes. + + - component: PageSection + tagline: Affiliations + anchor: affiliations + level: 2 + content: + - component: Markdown + data: | + 1 Cyberinfrastructure for Network Science Center, School of Library and Information Science, Indiana University, 10th Street & Jordan Avenue, Wells Library, Bloomington, IN 47405, USA +
+ 2 SciTech Strategies, Inc., Berwyn, PA, 19312, USA +
+ 3 École de bibliothéconomie et des sciences de l’information, Université de Montréal, C.P. 6128, Succ. Centre-ville, Montréal QC, H3C 3J7, Canada. +
+ 4 Observatoire des Sciences et des Technologies (OST), Centre Interuniversitaire de Recherche sur la Science et la Technologie (CIRST), Université du Québec à Montréal, C.P. 8888, Succ. Centre-Ville, Montréal, QC H3C 3P8, Canada. +
+ 5 SciTech Strategies, Inc., Albuquerque, NM 87122, USA diff --git a/apps/cns-website/public/assets/content/about-page/data.yaml b/apps/cns-website/public/assets/content/about-page/data.yaml new file mode 100644 index 0000000000..2421853b0e --- /dev/null +++ b/apps/cns-website/public/assets/content/about-page/data.yaml @@ -0,0 +1,89 @@ +$schema: ../../../app/schemas/content-page/content-page.schema.json +title: About the Cyberinfrastructure for Network Science Center +subtitle: | + We are atlas builders and system integrators with a core focus on data visualization literacy. + We publish in high impact journals, our code is high quality, and we communicate our work well to diverse audiences. +breadcrumbs: + - name: Home + route: / + - name: About the Cyberinfrastructure for Network Science Center +content: + - component: PageSection + tagline: Our mission + anchor: our-mission + level: 2 + content: + - component: Markdown + data: | + The [CNS](https://cns.iu.edu/) mission is to advance research, development, teaching, and service in data mining, modeling, and visualization. + Specific foci are increased data visualization literacy and research on multi-level atlases + of the structure and evolution of science and technology (see [Mapping Science Exhibit](https://scimaps.org/)), + mapping the human body at the single cell level (see [HuBMAP](https://portal.hubmapconsortium.org/)), + and the communication of results via static and interactive data visualizations + (see courses like the [Visual Analytics Certificate](https://visanalytics.cns.iu.edu/) + and the [Visible Human MOOC](https://expand.iu.edu/browse/sice/cns/courses/hubmap-visible-human-mooc)). + - component: Image + src: /assets/content/about-page/images/luddy-hall.png + + - component: PageSection + tagline: Our history + anchor: our-history + level: 2 + content: + - component: Markdown + data: | + Before CNS came into existence, Katy Börner created and directed the Information Visualization Lab to provide an active and advanced research environment to conduct research in information visualization. + + + She later broadened the scope of her enterprise and grew it into an organization that performs big data mining and filtering, creates open source tools for analysis and visualization, shares knowledge and + techniques through teaching, exhibitions, and workshops on an international level, and connects people with different expertise for research collaboration across the world—what you know today as the CNS Center. + + + Today, many students and staff members decide to join CNS because its strong focus on information visualization excellence. Almost all projects at CNS aim to advance data visualization design, standards, tools, + or data visualization literacy in general. + - component: Image + styles: + max-width: 640px + max-height: 480px + src: /assets/content/about-page/images/history-2005.png + + - component: PageSection + tagline: Donate + anchor: donate + level: 2 + content: + - component: Markdown + data: | + CNS receives funding from organizations throughout the world plus smaller donations from individuals. These generous gifts allow us to stay on the cutting edge of information science and continue the work of + helping advance big data management and utilization. Thank you for your support! + - component: Button + label: Donate + href: https://give.myiu.org/iu-bloomington/I320004200.html + type: cta + + - component: PageSection + tagline: Contact us + anchor: contact-us + level: 2 + content: + - component: Markdown + data: | + Contact: Traci Smith, Center Assistant +
+ E-mail: cnscntr@iu.edu +
+ Phone: 812-856-4402 +

+ Mailing address: +
+ Cyberinfrastructure for Network Science (CNS) Center +
+ Department of Intelligent Systems Engineering +
+ Luddy School of Informatics, Computing, and Engineering +
+ Indiana University at Bloomington +
+ 700 N. Woodlawn Ave. Luddy Hall, Suite 4018 +
+ Bloomington, IN 47408 diff --git a/apps/cns-website/public/assets/content/about-page/images/history-2005.png b/apps/cns-website/public/assets/content/about-page/images/history-2005.png new file mode 100644 index 0000000000..9b0e436dd8 Binary files /dev/null and b/apps/cns-website/public/assets/content/about-page/images/history-2005.png differ diff --git a/apps/cns-website/public/assets/content/about-page/images/luddy-hall.png b/apps/cns-website/public/assets/content/about-page/images/luddy-hall.png new file mode 100644 index 0000000000..7326b99188 Binary files /dev/null and b/apps/cns-website/public/assets/content/about-page/images/luddy-hall.png differ diff --git a/apps/cns-website/public/assets/content/amatria/2017-iu-summer-camp-dendrite.pdf b/apps/cns-website/public/assets/content/amatria/2017-iu-summer-camp-dendrite.pdf new file mode 100644 index 0000000000..6f3d779a30 Binary files /dev/null and b/apps/cns-website/public/assets/content/amatria/2017-iu-summer-camp-dendrite.pdf differ diff --git a/apps/cns-website/public/assets/content/amatria/data.yaml b/apps/cns-website/public/assets/content/amatria/data.yaml new file mode 100644 index 0000000000..89c764760a --- /dev/null +++ b/apps/cns-website/public/assets/content/amatria/data.yaml @@ -0,0 +1,310 @@ +$schema: ../../../app/schemas/content-page/content-page.schema.json +title: 'Amatria: Sentient Architecture' +subtitle: 'Amatria is a living sculpture that responds to her environment through light, sound, and movement.' +breadcrumbs: + - name: Home + route: / + - name: | + Amatria: Sentient Architecture +headerContent: + - component: Image + src: assets/content/amatria/images/amatria-1.png + alt: Amatria Sentient Architecture installation at Luddy Hall + - component: Markdown + data: Photo by Ann Schertz. + +content: + - component: PageSection + tagline: 'Luddy Hall: Home to Amatria' + anchor: luddy-hall + level: 2 + content: + - component: Markdown + data: | + Indiana University's Luddy Hall is the home of several rather unique Sentient Architecture installations: _Amatria_, dendrites and moths. The installations foreshadow a future where the Internet of Things is omnipresent. They also demonstrate how embedded technologies affect humans that inhabit these spaces. The works were designed and built by members of the Toronto-based [Living Architecture Systems Group](http://livingarchitecturesystems.com/) (LASG) led by Philip Beesley, Philip Beesley Architect Inc. and by members of the Cyberinfrastructure for Network Science Center (CNS) at the School of Informatics, Computing, and Engineering, Indiana University, Bloomington. + - component: Image + src: assets/content/amatria/images/amatria-2.png + alt: About Amatria and architectural elements + + - component: PageSection + tagline: 'About Amatria' + anchor: about + level: 2 + content: + - component: Markdown + data: | + _Amatria_, hanging above the stairs in the 4th floor atrium, is a luminous, forest-inspired landscape of soaring clouds and tangled thickets of 3D-printed formations alive with artificial intelligence that invites visitors into an interactive, ethereal space. 'She' is a living sculpture with a delicate canopy of mesh- and frond-like organic structures suspended from the ceiling in Indiana University's Luddy Hall atrium. She gathers information about her environment using light and motion sensors, responding with atmospheric sounds, undulating movements, and changing colors. She is aware of the people who enter her sphere to gaze upon her visual story of abiogenesis: the emergence of life during the earliest stages of development of the universe. +

+ - Birthdate: April 11, 2018 + - Resources: [Amatria Pictionary](https://cns.iu.edu/docs/handouts/Amatria_Pictionary_hi.pdf) | [Living Architecture Systems Group: Amatria](https://livingarchitecturesystems.com/project/amatria/) + - Pictures: [360 degrees, 3D View of Luddy Hall 4111, Amatria's birth place](https://my.matterport.com/models/eDirQ2tqFMo?section=media&mediasection=showcase) + - Videos: [Installation](https://youtu.be/6sMbNrPsAM0) | [DEAF 2012](https://www.youtube.com/watch?v=DGHEBdkC8AA) + - Publications: Carolyn Beans (2018) [Science and Culture: Sentient architecture promises insight into our evolving relationship with AI](https://doi.org/10.1073/pnas.1809390115). PNAS. 115 (30) 7638-7640 + + - component: PageSection + tagline: New generations and architectural elements + anchor: architectural-elements + level: 2 + content: + - component: GridContainer + styles: + margin-top: 1rem + content: + - component: ActionCard + variant: outlined + image: assets/content/amatria/images/denrites.png + tagline: Dendrites + content: + - component: Markdown + data: Each _Amatria_ Dendrite has one light sensor (the eye) and actuators, such as lights and a strand of shape memory alloy, that makes the sculpture move. Software controls the sensor and actuators. Dendrite fields were built in the [2017 ISE Summer Camp](https://cns.iu.edu/assets/content/amatria/2017-iu-summer-camp-dendrite.pdf). + actionsLeft: + component: TextHyperlink + text: View manual + url: https://cns.iu.edu/docs/handouts/dendrite-kit.pdf + ariaLabel: View manual for Amatria Dendrite kit + actionsRight: + component: TextHyperlink + text: View GitHub + url: https://github.com/pbarch/1714-IU-Summer-Camp + ariaLabel: View GitHub repository for Amatria Dendrite kit + - component: ActionCard + variant: outlined + image: assets/content/amatria/images/moths.png + tagline: Moths + content: + - component: Markdown + data: _Amatria_ Moths, on display in the Luddy Hall Visualization Lab (room 4012), are the newest generation of _Amatria_-related architectural elements. + actionsLeft: + component: TextHyperlink + text: View manual + url: https://cns.iu.edu/docs/research/workshops/amatria/18-amatria-moth-manual.pdf + ariaLabel: View manual for Amatria Moth kit + - component: ActionCard + variant: outlined + image: assets/content/amatria/images/fascinators.png + tagline: Fascinators + content: + - component: Markdown + data: A limited edition personal work of art worn in your hair or elsewhere on your person. Each kit has a pair of mylar fronds that are joined to a tear-drop glass vessel filled with copper sulphate, creating a beautiful contrast of white and silver on blue. + actionsLeft: + component: TextHyperlink + text: View handout + url: https://cns.iu.edu/docs/research/workshops/amatria/Handout-Fascinator.pdf + ariaLabel: View handout for Amatria Fascinator kit + - component: ActionCard + variant: outlined + image: assets/content/amatria/images/amaria.png + tagline: Amaria + content: + - component: Markdown + data: Assembled from different types of materials, this unusual structure will grab the attention of any passerby. Place her on a suitable surface, and she will even go for a walk! + actionsLeft: + component: TextHyperlink + text: View handout + url: https://cns.iu.edu/docs/research/workshops/amatria/Handout-Amaria.pdf + ariaLabel: View handout for Amatria Amaria kit + + - component: PageSection + tagline: 'Tavola app: Making sense of Amatria' + anchor: tavola + level: 2 + content: + - component: Markdown + data: | + Tavola is an application that visualizes data flows in _Amatria_. It helps visitors understand the physical structure of _Amatria_ by showing the location of infrared sensors and actuators such as speakers, lights, motors as well as activation patterns unfolding over time. Tavola reads real-time data streams from _Amatria_ (e.g., values of all 18 infrared sensors) and visualizes them using Unity 3D. It comes with a brief introduction of the sculpture and instructions on how to modify camera angles and the data on display. As _Amatria_ evolves, Tavola evolves to capture new functionalities. Tavola is showcased during tours of _Amatria_; a permanent deployment is planned in the near future. + - component: Markdown + data: | + Resources: [Tavola Overview](https://cns.iu.edu/images/research/workshops/amatria/img-tavola-overview.jpg) | [Tavola Real-time Sensor Visualization](https://cns.iu.edu/images/research/workshops/amatria/img-tavola-sensors.jpg) + - component: Image + src: assets/content/amatria/images/tavola.png + alt: Tavola Real-time Sensor Visualization + + - component: PageSection + tagline: Events + anchor: events + level: 2 + content: + - component: Markdown + data: | + Over the years, CNS has hosted public tours, birthday celebrations, workshops, and special tours showcasing _Amatria_'s interactive architecture. + + - component: PageSection + tagline: 'Amatria’s 8th Birthday Party: April 17, 2026' + anchor: 8th-birthday-party + level: 3 + content: + - component: Markdown + data: | + This is an annual birthday event to commemorate the ‘birth’ (installation) of _Amatria_ in Luddy Hall. Installation was completed in 2018, months after the completion of Luddy Hall. She has been a friendly presence to all who use and visit the building, and represents the ambitions of the Luddy School of Informatics, Computing and Engineering to have human and artificial intelligences live, learn, and innovate together in harmony. This party celebrates the convergence of renaissance engineering, science, and art as they come together in _Amatria_, perhaps the most unique public sculpture on the IU Bloomington campus. + + - **Location:** Luddy Hall, 4th floor lobby area + - **Date:** Friday, April 17, 2026 + - **Time:** 10-11 am ET + - **Cost to attend:** Free + + - component: PageSection + tagline: '2025' + anchor: events-2025 + level: 3 + content: + - component: Markdown + data: | + - **Apr 18** *Amatria* 7th Birthday Celebration (10:00am–11:00am ET), Indiana University, Bloomington, IN [(Photos)](https://photos.app.goo.gl/paJNb2L1WjpnGsz77) ([Slides](https://cns-iu.github.io/workshops/2025-04-18-20y-cns/assets/amatria7th.pdf)) ([Press](https://news.iu.edu/luddy/live/news/45384-amatria-birthday-brings-hope-growth)) + + - component: PageSection + tagline: '2024' + anchor: events-2024 + level: 3 + content: + - component: Markdown + data: | + - **Dec 19** *Amatria* Tour for IU Trustees (11:30 am to 12:00 pm), Indiana University, Bloomington, IN + - **Jun 21** *Amatria* Tour for IU Alumni, Indiana University, Bloomington, IN + - **Apr 11** *Amatria* 6th Birthday Celebration (9:00am–10:00am), Indiana University, Bloomington, IN [(Photos)](https://luddybloomington.smugmug.com/2024-Events/Amatrias-6th-Birthday) ([Slides](https://docs.google.com/presentation/d/1IwGTyVR9_Vohu04YGt96I3ZVgxWW1NGh/edit#slide=id.g2c1aade4c23_1_0)) ([Videos](https://youtu.be/ZJwCq8reHQc?feature=shared)) + + - component: PageSection + tagline: '2023' + anchor: events-2023 + level: 3 + content: + - component: Markdown + data: | + - **Jul 5** *Amatria* Tour for attendees of the [ISSI Conference 2023](https://cns-iu.github.io/workshops/2023-07-02_issi/) + - **Apr 11** *Amatria* 5th Birthday Celebration (9:00am–10:00am), Indiana University, Bloomington, IN [(Photos)](https://photos.app.goo.gl/n7zrro1Pj8gZYfWt6) ([Slides](https://docs.google.com/presentation/d/1sQYGiEX5tpap6WkWdj1olMHkRBbnTqTX/edit#slide=id.g214414a406b_0_0)) ([Video](https://youtu.be/0pPV7Lr2eC0?feature=shared)) + + - component: PageSection + tagline: '2022' + anchor: events-2022 + level: 3 + content: + - component: Markdown + data: | + - **Oct 22** Science Fest (9:00am–3:00pm), Bloomington, IN + - **Oct 5** *Amatria* tour for Campus Visit for Purdue Polytechnic High School Students (12:40pm–1:10pm), Indiana University, Bloomington, IN + - **Sep 29** [IU Bloomington Tech Petting Zoo](https://depi.iu.edu/events/tech-petting-zoo/index.html) (11:00am–2:00pm), Indiana Memorial Union, Bloomington, IN + - **Sep 1** First Thursday Festival (5:00pm–8:00pm), Bloomington, IN + - **Aug 27** [Makevention](http://makevention.org/) (10:00am–4:00pm), Monroe Convention Center, Bloomington, IN + - **Jun 21** *Amatria* tour for Research Experiences for Undergraduates (3:45pm–4:15pm), Indiana University, Bloomington, IN ([Photos](https://photos.app.goo.gl/vkPSyjiZVr7zJQjb9)) + - **Apr 11** *Amatria* 4th Birthday Celebration (9:00am–10:00am), Indiana University, Bloomington, IN ([Photos](https://photos.app.goo.gl/FrRYoCyxFniP5NVC8)) ([Video](https://www.youtube.com/watch?v=MdTZeCgFbio&t=21s)) + - **Apr 7** First Thursday Festival (5:00pm-8:00pm), IU Auditorium, Bloomington, IN ([Photos](https://photos.app.goo.gl/8hYCF2DQzCUuSFff7)) + + - component: PageSection + tagline: '2021' + anchor: events-2021 + level: 3 + content: + - component: Markdown + data: | + - **Oct 22** Science Fest (9:00am–3:00pm), Bloomington, IN + - **Oct 5** *Amatria* tour for Campus Visit for Purdue Polytechnic High School Students (12:40pm–1:10pm), Indiana University, Bloomington, IN + - **Sep 29** [IU Bloomington Tech Petting Zoo](https://depi.iu.edu/events/tech-petting-zoo/index.html) (11:00am–2:00pm), Indiana Memorial Union, Bloomington, IN + - **Sep 1** First Thursday Festival (5:00pm–8:00pm), Bloomington, IN + - **Aug 27** [Makevention](http://makevention.org/) (10:00am–4:00pm), Monroe Convention Center, Bloomington, IN + - **Jun 21** *Amatria* tour for Research Experiences for Undergraduates (3:45pm–4:15pm), Indiana University, Bloomington, IN ([Photos](https://photos.app.goo.gl/vkPSyjiZVr7zJQjb9)) + - **Apr 11** *Amatria* 4th Birthday Celebration (9:00am–10:00am), Indiana University, Bloomington, IN ([Photos](https://photos.app.goo.gl/FrRYoCyxFniP5NVC8)) ([Video](https://www.youtube.com/watch?v=MdTZeCgFbio&t=21s)) + - **Apr 7** First Thursday Festival (5:00pm-8:00pm), IU Auditorium, Bloomington, IN ([Photos](https://photos.app.goo.gl/8hYCF2DQzCUuSFff7)) + + - component: PageSection + tagline: '2020' + anchor: events-2020 + level: 3 + content: + - component: Markdown + data: | + - **Apr 13** *Amatria* 2nd Birthday Celebration (9:00am–10:00am) ([Video](https://youtu.be/w3wIoIAkjL0)) ([Song: A Bloom (for *Amatria*), by Gabriel Lubell](https://soundcloud.com/gabriel-lubell/a-bloom-for-amatria)) + - **Feb 28** *Amatria* tour for Data Science Online students (2:30pm–3:00pm), Indiana University, Bloomington, IN + - **Feb 26** *Amatria* tour for Eskenazi visitors, Indiana University, Bloomington, IN + - **Feb 7** IU ERI talk/Eric Mathis (LASG), Indiana University, Bloomington, IN + + - component: PageSection + tagline: '2019' + anchor: events-2019 + level: 3 + content: + - component: Markdown + data: | + - **Dec 4** Andrew Maple *Amatria* 360 photo shoot, Indiana University, Bloomington, IN + - **Nov 8** Harmony School, Dean Raj, Indiana University, Bloomington, IN + - **Oct 25** Women's colloquium, Indiana University, Bloomington, IN + - **Jun 28** Gov. Eric Holcomb, IU President McRobbie, Dean Raj, Indiana University, Bloomington, IN + - **Jun 20** *Amatria* tour for summer camp participants, Indiana University, Bloomington, IN + - **May 28** CRANE/WestGate, Indiana University, Bloomington, IN + - **May 3** Project School tour, contact: Alexis Caudell, Indiana University, Bloomington, IN + - **Apr 26** *Amatria* \+ Tavola tour for IOTW reception, Indiana University, Bloomington, IN ( + - **Oct 25** *Amatria* tour with Colloquium for Women of IU event, Indiana University, Bloomington, IN + - **Oct 7** Assembling Fascinators, Indiana University, Bloomington, IN + - **May 3** Project School tour (10:00am–10:45am), Indiana University, Bloomington, IN + - **Apr 11** *Amatria* Birthday Celebration\! (3:00pm–5:00pm), Indiana University, Bloomington, IN + - **Apr 2** Tour for ISE job candidate (1:45pm–2:00pm), Indiana University, Bloomington, IN + - **Mar 25** Tour for ISE job candidate (1:45pm–2:00pm), Indiana University, Bloomington, IN + - **Mar 21** *Amatria* Public Tour (12:00pm–1:00pm and 4:00pm–5:00pm), Indiana University, Bloomington, IN + - **Jan 21** MLK Dendrite Workshop (11:00am–2:00pm), SICE LLC Innovative Space, Indiana University, Bloomington, IN ([Photos](https://photos.app.goo.gl/qSuNtpPBrcjYdsi88)) + - **Jan 4** Meditation under *Amatria* with Philip Beesley, David Ebbinghouse, and Bo Choi (11:00am–11:30am), SICE LLC Innovative Space, Indiana University, Bloomington, IN ([Photos](https://photos.app.goo.gl/2YDuqHpiByXfyhgh7)) + + - component: PageSection + tagline: '2018' + anchor: events-2018 + level: 3 + content: + - component: Markdown + data: | + - **Dec 10** *Amatria* Public Tour (4:00pm–5:00pm), Indiana University, Bloomington, IN + - **Oct 26-28** [OurCS Workshop/Conference](http://ourcs.sice.indiana.edu/), Indiana University, Bloomington, IN + - **Oct 22** *Amatria* Public Tour (4:00pm–5:00pm), Indiana University, Bloomington, IN + - **Oct 10** Indiana University Foundation Board of Director’s Welcome Reception, Indiana University, Bloomington, IN + - **Sep 10** *Amatria* Public Tour (4:00pm–5:00pm), Indiana University, Bloomington, IN + - **Aug 25** [Makevention](http://makevention.org/) (10:00am–4:00pm), Monroe Convention Center, Bloomington, IN + - **Aug 1** [NOAC Dendrite Workshop](https://photos.app.goo.gl/ZfZmyGp5Qyyqjmfn6), Indiana University, Bloomington, IN + - **Jul 18** Pathfinders/Infosys Open House, Indiana University, Bloomington, IN + - **Jun 19** *Amatria* Public Tour (2:30pm–3:30pm), Indiana University, Bloomington, IN + - **May 21** *Amatria* Public Tour (4:00pm–5:00pm), Indiana University, Bloomington, IN + - **May 3** *Amatria* Reserved Tour (1:30pm), Indiana University, Bloomington, IN + - **May 1** *Amatria* Reserved Tour (10:30am), Indiana University, Bloomington, IN + - **Apr 30** *Amatria* Reserved Tour (2:00pm), Indiana University, Bloomington, IN + - **Apr 25** *Amatria* Reserved Tour (12:00pm), Indiana University, Bloomington, IN + - **Apr 24 & 26** HON-H 241 Dendrite Building Session, Indiana University, Bloomington, IN + - **Apr 11** VIP Reception before *Amatria* reveal, Indiana University, Bloomington, IN ([Photos](https://photos.app.goo.gl/MR3FuhQJHeuucok17)) + - **Apr 11** Birth of *Amatria* with 'dad' Philip Beesley and his team, Indiana University, Bloomington, IN ([Photos](https://photos.app.goo.gl/EMC1fMeERvsMdJMFA)) + - **Mar 23-24** CEWiT Summit, Indiana University, Bloomington, IN + - **Mar 2-3** Fashion Technology Symposium, Indiana University, Bloomington, IN + + - component: PageSection + tagline: '2017' + anchor: events-2017 + level: 3 + content: + - component: Markdown + data: | + - **Dec 5 & 7** ENGR 101 Dendrite Building Session, Indiana University, Bloomington, IN + - **Nov 29-30** [FTC Conference](http://saiconference.com/FTC), Vancouver, Canada + - **Nov 10-11** [CIS IEEE EnCON 2017](http://cis-ieee.org/EnCON2017/index.html), Indiana University, Bloomington, IN + - **Nov 3** Meet *Amatria's* Creator Philip Beesley, Indiana University, Bloomington, IN + - **Oct 26-28** Society of Woman Engineers + - **Oct 21** [Science Fest 2017](https://scienceoutreach.indiana.edu/news-events/science-fest/index.html), Indiana University, Bloomington, IN + - **Oct 16** E599 Course in ISE, Indiana University, Bloomington, IN + - **Oct 12** Dendrite Building Session 2, Bloomington, IN + - **Oct 10** NACAC STEM Fair + - **Oct 5** Dendrite Building Session 1, Bloomington, IN + - **Oct 5** First Thursday, Bloomington, IN (Christian Mackay) + - **Aug 26** [Makevention 2017](http://makevention.org/), BloomingLabs, Bloomington, IN + - **Aug 5** Robot Roll Call 2017, WonderLab, Bloomington, IN + + - component: PageSection + tagline: References + anchor: references + level: 2 + content: + - component: Markdown + data: | + Beans, Carolyn. 2018. ["Science and Culture: Sentient Architecture Promises Insight into Our Evolving Relationship with AI."](https://doi.org/10.1073/pnas.1809390115) PNAS 115 (30): 7638–7640. + + - component: PageSection + tagline: Acknowledgments + anchor: acknowledgments + level: 2 + content: + - component: Markdown + data: | + _Amatria_, Dendrites, and Moths were designed and developed by the [Living Architecture Systems Group](http://livingarchitecturesystems.com/) in association with Philip Beesley Architect Inc. + + Tavola was designed by Ph.D. student Andreas Bueckle, under the supervision of Katy Börner, Director of the Cyberinfrastructure for Network Science Center at the Luddy School of Informatics, Computing, and Engineering. diff --git a/apps/cns-website/public/assets/content/amatria/images/amaria.png b/apps/cns-website/public/assets/content/amatria/images/amaria.png new file mode 100644 index 0000000000..b6ebec7b5b Binary files /dev/null and b/apps/cns-website/public/assets/content/amatria/images/amaria.png differ diff --git a/apps/cns-website/public/assets/content/amatria/images/amatria-1.png b/apps/cns-website/public/assets/content/amatria/images/amatria-1.png new file mode 100644 index 0000000000..df66d45967 Binary files /dev/null and b/apps/cns-website/public/assets/content/amatria/images/amatria-1.png differ diff --git a/apps/cns-website/public/assets/content/amatria/images/amatria-2.png b/apps/cns-website/public/assets/content/amatria/images/amatria-2.png new file mode 100644 index 0000000000..0a00ba3b94 Binary files /dev/null and b/apps/cns-website/public/assets/content/amatria/images/amatria-2.png differ diff --git a/apps/cns-website/public/assets/content/amatria/images/amatria-unveiling.jpg b/apps/cns-website/public/assets/content/amatria/images/amatria-unveiling.jpg new file mode 100644 index 0000000000..ef16ae7a6e Binary files /dev/null and b/apps/cns-website/public/assets/content/amatria/images/amatria-unveiling.jpg differ diff --git a/apps/cns-website/public/assets/content/amatria/images/denrites.png b/apps/cns-website/public/assets/content/amatria/images/denrites.png new file mode 100644 index 0000000000..374fe78ecb Binary files /dev/null and b/apps/cns-website/public/assets/content/amatria/images/denrites.png differ diff --git a/apps/cns-website/public/assets/content/amatria/images/fascinators.png b/apps/cns-website/public/assets/content/amatria/images/fascinators.png new file mode 100644 index 0000000000..104c7953f0 Binary files /dev/null and b/apps/cns-website/public/assets/content/amatria/images/fascinators.png differ diff --git a/apps/cns-website/public/assets/content/amatria/images/img-amaria.jpg b/apps/cns-website/public/assets/content/amatria/images/img-amaria.jpg new file mode 100644 index 0000000000..43b964f58f Binary files /dev/null and b/apps/cns-website/public/assets/content/amatria/images/img-amaria.jpg differ diff --git a/apps/cns-website/public/assets/content/amatria/images/img-amatria-dendrite.jpg b/apps/cns-website/public/assets/content/amatria/images/img-amatria-dendrite.jpg new file mode 100644 index 0000000000..aed029b111 Binary files /dev/null and b/apps/cns-website/public/assets/content/amatria/images/img-amatria-dendrite.jpg differ diff --git a/apps/cns-website/public/assets/content/amatria/images/img-amatria-fascinator.jpg b/apps/cns-website/public/assets/content/amatria/images/img-amatria-fascinator.jpg new file mode 100644 index 0000000000..c98915090d Binary files /dev/null and b/apps/cns-website/public/assets/content/amatria/images/img-amatria-fascinator.jpg differ diff --git a/apps/cns-website/public/assets/content/amatria/images/img-amatria-header.jpg b/apps/cns-website/public/assets/content/amatria/images/img-amatria-header.jpg new file mode 100644 index 0000000000..6631959e49 Binary files /dev/null and b/apps/cns-website/public/assets/content/amatria/images/img-amatria-header.jpg differ diff --git a/apps/cns-website/public/assets/content/amatria/images/img-amatria-moth-schematic.png b/apps/cns-website/public/assets/content/amatria/images/img-amatria-moth-schematic.png new file mode 100644 index 0000000000..5ea8368bd4 Binary files /dev/null and b/apps/cns-website/public/assets/content/amatria/images/img-amatria-moth-schematic.png differ diff --git a/apps/cns-website/public/assets/content/amatria/images/img-amatria-tavola.jpg b/apps/cns-website/public/assets/content/amatria/images/img-amatria-tavola.jpg new file mode 100644 index 0000000000..c42f667e94 Binary files /dev/null and b/apps/cns-website/public/assets/content/amatria/images/img-amatria-tavola.jpg differ diff --git a/apps/cns-website/public/assets/content/amatria/images/img-luddy-hall.jpg b/apps/cns-website/public/assets/content/amatria/images/img-luddy-hall.jpg new file mode 100644 index 0000000000..1742ee5a94 Binary files /dev/null and b/apps/cns-website/public/assets/content/amatria/images/img-luddy-hall.jpg differ diff --git a/apps/cns-website/public/assets/content/amatria/images/img-tavola-overview.jpg b/apps/cns-website/public/assets/content/amatria/images/img-tavola-overview.jpg new file mode 100644 index 0000000000..361a575be6 Binary files /dev/null and b/apps/cns-website/public/assets/content/amatria/images/img-tavola-overview.jpg differ diff --git a/apps/cns-website/public/assets/content/amatria/images/img-tavola-sensors.jpg b/apps/cns-website/public/assets/content/amatria/images/img-tavola-sensors.jpg new file mode 100644 index 0000000000..2aee71e8ec Binary files /dev/null and b/apps/cns-website/public/assets/content/amatria/images/img-tavola-sensors.jpg differ diff --git a/apps/cns-website/public/assets/content/amatria/images/moths.png b/apps/cns-website/public/assets/content/amatria/images/moths.png new file mode 100644 index 0000000000..21a24c0b9d Binary files /dev/null and b/apps/cns-website/public/assets/content/amatria/images/moths.png differ diff --git a/apps/cns-website/public/assets/content/amatria/images/tavola.png b/apps/cns-website/public/assets/content/amatria/images/tavola.png new file mode 100644 index 0000000000..9f924b40ed Binary files /dev/null and b/apps/cns-website/public/assets/content/amatria/images/tavola.png differ diff --git a/apps/cns-website/public/assets/content/envisioning-intelligences/data.yaml b/apps/cns-website/public/assets/content/envisioning-intelligences/data.yaml new file mode 100644 index 0000000000..d2c83c7554 --- /dev/null +++ b/apps/cns-website/public/assets/content/envisioning-intelligences/data.yaml @@ -0,0 +1,311 @@ +$schema: ../../../app/schemas/content-page/content-page.schema.json +title: Envisioning Intelligences +headerContent: + - component: Image + src: /assets/content/envisioning-intelligences/images/header-images.png + - component: Markdown + data: | + Intelligence has often been considered a uniquely human trait. However, recent developments in the study of plants and animals, + as well as breakthroughs in the realm of machine learning, have expanded our understanding of what is intelligent. + These days, it is not uncommon to read about the language of trees and the cognitive ability of cephalopods on devices we refer to as smart phones. + + What does intelligence look like when exhibited by humans or machines, plants or animals? +
+ How do different types of intelligence interact and cooperate in order to survive and thrive? + + Looking at human intelligence alongside that of microbes, plants, animals, and machines can help us find ways to work together and identify + blind spots in our own ways of thinking. +subtitle: '' +breadcrumbs: + - name: Home + route: / + - name: 30-Year Exhibit + route: /exhibit + - name: Envisioning Intelligences +content: + - component: PageSection + tagline: The Knowledge Cosmos, 2025 + anchor: the-knowledge-cosmos + level: 2 + content: + - component: Image + src: /assets/content/envisioning-intelligences/images/knowledge-cosmos.png + - component: PageSection + tagline: Artists and affiliations + level: 3 + content: + - component: Markdown + data: | + - Nikita Sridhar, IBM Client Engineering + - Alec McGail, NASA + - Rifaa Tajani, Harvard University and IBM + - Jiabao Li, The University of Texas at Austin + - component: PageSection + tagline: Description + level: 3 + content: + - component: Markdown + data: | + The amount of scientific literature humanity produces is growing at an incredible rate, and it is difficult to stay current. + To make knowledge more manageable, it is often divided into disciplinary territories. But this solution prevents knowledge + from crossing borders to encounter the unfamiliar thoughts and practices that may spur new ideas and innovation. + + *The Knowledge Cosmos* visualization is an interactive 3D universe where 17 million academic papers are located based on + similarity of content, not preconceived categories. This arrangement allows for the discovery of unexpected interdisciplinary + connections and under-explored gaps in knowledge. + - component: PageSection + tagline: References + level: 3 + content: + - component: Markdown + data: | + - Börner, Katy. 2010. *Atlas of Science: Visualizing What We Know*. Cambridge, MA: The MIT Press. + - Cui, Weiwei, Shixia Liu, Li Tan, Conglei Shi, Yangqiu Song, and Zekai Gao. XXXX. “TextFlow: Towards Better Understanding + of Evolving Topics in Text.” *IEEE Transactions on Visualization and Computer Graphics* 17 (12): 2412–2421. Accessed February + 23, 2026. https://ieeexplore.ieee.org/document/6065008. + - Davidson, George S., Bruce Hendrickson, David K. Johnson, Charles E. Meyers, and Brian N. Wylie. 1998. “Knowledge Mining + with VxInsight: Discovery through Interaction.” *Journal of Intelligent Information Systems* 11 (3): 259–285. + Accessed February 23, 2026. https://link.springer.com/article/10.1023/A:1008690008856. + - Kraker, Peter, and Najmeh Shaghaei. 2019. “Open Knowledge Maps: A Visual Interface to the World’s Scientific Knowledge. In *Proceedings + of LIBER 2019*. Accessed February 23, 2026. https://zenodo.org/records/3258106. + - Li, Zeyu, Changhong Zhang, Shichao Jia, and Jiawan Zhang. 2019. “Galex: Exploring the Evolution and Intersection of Disciplines.” + *IEEE Transactions on Visualization and Computer Graphics* 26 (1): 1182–1192. Accessed February 23, 2026. + https://ieeexplore.ieee.org/document/8807243. + - McGail, Alec, Rifaa Tajani, Nikita Sridhar, and Jiaobao Li. 2025. “The Knowledge Cosmos.” Accessed February 23, 2026. + https://www.theknowledgecosmos.com. + - McGail, Alec, Rifaa Tajani, Nikita Sridhar, and Jiaobao Li. 2025. *The Knowledge Cosmos*. In *Envisioning Intelligences 3.1* (2025), + edited by Katy Börner, Elizabeth G. Record, and Todd Theriault. + - Rosvall, Martin, and Carl T. Bergstrom. 2008. “Maps of Random Walks on Complex Networks Reveal Community Structure.” *PNAS* 105 (4): 1118–1123. + Accessed February 23, 2026. https://www.pnas.org/doi/full/10.1073/pnas.0706851105. + - Wang, Xitang, Shixia Liu, Junlin Liu, Jianfei Chen, Jun Zhu, and Baining Guo. 2016. “TopicPanorama: A Full Picture of Relevant Topics.” + *IEEE Transactions on Visualization and Computer Graphics* 22 (12): 2508–2521. Accessed February 23, 2026. https://ieeexplore.ieee.org/document/7374750. + + - component: PageSection + tagline: Zoonotic Web, 2024 + anchor: zoonotic-web + level: 2 + content: + - component: Image + src: /assets/content/envisioning-intelligences/images/zoonotic-web.png + - component: PageSection + tagline: Artists and affiliations + level: 3 + content: + - component: Markdown + data: | + - Liuhuaying Yang, Complexity Science Hub + - Amélie Desvars-Larrive, Complexity Science Hub and University of Veterinary Medicine Vienna + - component: PageSection + tagline: Description + level: 3 + content: + - component: Markdown + data: | + A rat carrying a virus is bitten by a tick. That now-infected tick bites a cow on a farm. A farmer slaughters the cow + and processes the meat for human consumption. The path taken by zoonoses—infectious diseases that spread from animal + to human—can be anything but straightforward, making them difficult to fully track, study, and prevent. + + This interactive network shows how tiny pathogens navigate between species and environments. Visualizing otherwise hidden + relationships reveals how microscopic and macroscopic life co-shape disease dynamics. + - component: PageSection + tagline: References + level: 3 + content: + - component: Markdown + data: | + - Amélie Desvars-Larrive, Amélie, Anna Elisabeth Vogl, Gavrila Amadea Puspitarani, Liuhuaying Yang, Anja Joachim, and Annemarie Käsbohrer. 2024. + “A One Health Framework for Exploring Zoonotic Interactions Demonstrated through a Case Study.” *Nature Communications* 15 (5650). Accessed February 20, 2026. + https://www.nature.com/articles/s41467-024-49967-7. + - Yang, Liuhuaying, and Amélie Desvars-Larrive. 2024. “Zoonotic Web.” *Nature Communications* 15 (5650). Accessed February 23, 2026. https://vis.csh.ac.at/zoonotic-web. + - Yang, Liuhuaying, and Amélie Desvars-Larrive. 2024. *Zoonotic Web*. In *Envisioning Intelligences 3.1* (2025), edited by Katy Börner, Elizabeth G. Record, and Todd Theriault. + + - component: PageSection + tagline: The Emotional Echo of Cultural Intelligence, 2021 + anchor: the-emotional-echo-of-cultural-intelligence + level: 2 + content: + - component: Image + src: /assets/content/envisioning-intelligences/images/emotional-echo-of-cultural-intelligence.png + - component: PageSection + tagline: Artists and affiliations + level: 3 + content: + - component: Markdown + data: | + - Christiane Hütter and Martin Chiettini, Ludwig Boltzmann Institute for Network Medicine and the Max Perutz Labs at the University of Vienna + - Felix Müller, Cristian Nogales, Iker Nuñez-Carpintero, and Jörg Menche, Ludwig Boltzmann Institute for Network Medicine, University of Vienna + - Sebastian Pirch, Ludwig Boltzmann Institute for Network Medicine at the University of Vienna and the CeMM Research Center for Molecular Medicine + - component: PageSection + tagline: Description + level: 3 + content: + - component: Markdown + data: | + The internet serves as a vast repository of humanity’s collective intelligence. But it also contains our collective hopes, fears, loves, and prejudices. + Every day, those aspects of our culture that provoke strong emotions are spread quickly and widely across social media as memes. *The Emotional Echo of + Cultural Intelligence* can help us understand how such digital content forms, spreads, and influences public opinion and behavior. + + Here, memes appear as dots in a complex web of connections based on shared topics and themes. Individual years can be extracted from the larger network + to examine how real-world events were depicted in the language of memes. In 2020, for instance, memes of Baby Yoda sipping tea represented feelings of + detachment from a world gripped by a global pandemic. In contrast, “Stop the Steal” memes represented the angry American political landscape and spread + of misinformation related to voter fraud that characterized that election year. + - component: PageSection + tagline: References + level: 3 + content: + - component: Markdown + data: | + - Hütter, Christiane V.R., Felix Müller, Cristian Nogales, Iker Nuñez-Carpintero, Sebastian Pirch, Martin Chiettini, and Jörg Menche. 2021. *The Emotional + Echo of Cultural Intelligence*. In *Envisioning Intelligences 3.1* (2025), edited by Katy Börner, Elizabeth G. Record, and Todd Theriault. + - Hütter, Christiane V.R., Celine Sin, Felix Müller, and Jörg Menche. 2022. “Network Cartographs for Interpretable Visualizations.” *Nature Computational Science* 2. + 84–89. Accessed February 23, 2026. https://www.nature.com/articles/s43588-022-00199-z. + - Literally Media Ltd. 2026. *Know Your Meme: Internet Meme Database*. Accessed February 23, 2026. https://knowyourmeme.com. + - Pirch, Sebastian, Felix Müller, Eugenia Iofinova, Julia Pazmandi, Christiane V.R. Hütter, Martin Chiettini, Celine Sin, Kaan Boztug, Iana Podkosova, + Hannes Kaufmann, and Jörg Menche. 2021. “The VRNetzer Platform Enables Interactive Network Analysis in Virtual Reality.” *Nature Communications* 12 (2432). + Accessed February 23, 2026. https://www.nature.com/articles/s41467-021-22570-w. + + - component: PageSection + tagline: Intelligence Is Information Having Fun, 2024 + anchor: intelligence-is-information-having-fun + level: 2 + content: + - component: Image + src: /assets/content/envisioning-intelligences/images/intelligence-is-information-having-fun.png + - component: PageSection + tagline: Artists and affiliations + level: 3 + content: + - component: Markdown + data: Santiago Ortiz, CDO & Founder at DrumWave and Head of Moebio Labs + - component: PageSection + tagline: Description + level: 3 + content: + - component: Markdown + data: | + In simple terms, large language models (LLM) like ChatGPT are tools that predict what word is most likely to come next in a string of text. + By asking it many times to complete the same sentence opening, you can gather a range of possibilities and calculate which words are more + or less likely to follow. Visualizing this data would be akin to performing an MRI of ChatGPT’s neural pathways. + + *Intelligence Is Information Having Fun* visualizes the results of asking ChatGPT to complete the sentence “Intelligence is….” The visualization + plots all the ways Chat GPT completes the sentence, starting with a few potential directions that branch off into longer, less likely continuations. + On the left, a network of possible word choices is completely highlighted at the sentence’s beginning but begins to fade as the sentence progresses + and potential directions are cut off. + - component: PageSection + tagline: References + level: 3 + content: + - component: Markdown + data: | + - Ortiz, Santiago. 2024. “Mind.” Accessed February 19, 2026. https://moebio.com/mind. + - Ortiz, Santiago. 2024. *Intelligence Is Information Having Fun*. In *Envisioning Intelligences 3.1* (2025), edited by Katy Börner, Elizabeth G. Record, and Todd Theriault. + + - component: PageSection + tagline: Artificial Worldviews, 2023 + anchor: artificial-worldviews + level: 2 + content: + - component: Image + src: /assets/content/envisioning-intelligences/images/artificial-worldviews.png + - component: PageSection + tagline: Artists and affiliations + level: 3 + content: + - component: Markdown + data: Kim Albrecht, Center for Complex Network Research + - component: PageSection + tagline: Description + level: 3 + content: + - component: Markdown + data: | + Large language models, created by humans and trained on human-produced work, reflect human beliefs and values. Given the widespread popularity and extraordinary influence of LLMs, programs like ChatGPT are likely to influence the way we write, think, and perceive the world for years to come. This makes it important to uncover the hidden biases and implicit values that shape AI’s operations. + + Over the course of 1,764 requests, data artist Kim Albrecht coaxed ChatGPT into defining what constitutes “knowledge” and “power.” The answers it gave reveal a value system in which certain people, objects, and ideas are emphasized or marginalized, included or excluded. Explore the two networks to discover what kinds of knowledge and what types of power are valued by ChatGPT. + - component: PageSection + tagline: References + level: 3 + content: + - component: Markdown + data: | + - Albrecht, Kim. 2023. “Artificial Worldviews.” Accessed February 19, 2026. https://artificial-worldviews.kimalbrecht.com. + - Albrecht, Kim. 2023\. *Artificial Worldviews*. In *Envisioning Intelligences 3.1* (2025), edited by Katy Börner, Elizabeth G. Record, and Todd Theriault. + + - component: PageSection + tagline: 'ReCollection: You Only Have Seven Seconds, 2023' + anchor: recollection-you-only-have-seven-seconds + level: 2 + content: + - component: Image + src: /assets/content/envisioning-intelligences/images/recollection.png + - component: PageSection + tagline: Artists and affiliations + level: 3 + content: + - component: Markdown + data: | + - Weidi Zhang, Media and Immersive eXperience (MIX) Center, Arizona State University + - Jieliang (Rodger) Luo, Minus AI + - component: PageSection + tagline: Description + level: 3 + content: + - component: Markdown + data: | + *ReCollection* is a poetic, AI-generated documentary that visualizes fading memory at the intersection of remembrance and imagination. + Against the backdrop of rising Alzheimer’s cases and the emergence of machine-generated false memories, the video reimagines remembrance + through the lens of artificial intelligence. + + Inspired by her grandmother’s cognitive decline, the artist created a custom AI system that transforms fragmented spoken recollections into + synthetic visual sequences. + + Originally presented as a public interactive AI art installation, *ReCollection* has welcomed thousands of visitors from around the world to whisper + their fading memories—each within seven seconds—into the artwork and generate visual memories in real time. These visual memories—constructed + through speech recognition, text auto-completion, and text-to-image generation—form the foundation of an evolving visual archive. + - component: PageSection + tagline: References + level: 3 + content: + - component: Markdown + data: | + - Zhang, Weidi, and Rodger Luo. 2023. “ReCollection.” Accessed February 23, 2026. https://www.zhangweidi.com/recollection. + - Zhang, Weidi, and Rodger Luo. 2023. *ReCollection: You Only Have Seven Seconds*. In *Envisioning Intelligences 3.1* (2025), edited by Katy Börner, Elizabeth G. Record, and Todd Theriault. + + - component: PageSection + tagline: 'Responsibility: Visualizing AI for Google DeepMind, 2024' + anchor: responsibility-visualizing-ai-for-google-deepmind + level: 2 + content: + - component: Image + src: /assets/content/envisioning-intelligences/images/responsibility-deepmind.png + - component: PageSection + tagline: Artists and affiliations + level: 3 + content: + - component: Markdown + data: Aurora Mititelu, University of California, Los Angeles + - component: PageSection + tagline: Description + level: 3 + content: + - component: Markdown + data: | + Whether they are collecting data, labeling content, or identifying images, humans play a substantial role in training artificial intelligence systems. + In an era of crowdsourced labor, some have voiced concerns about the fair treatment, payment, and recognition of freelance employees who are often + described as “ghost workers.” + + *Responsibility* was created for Google DeepMind’s Visualizing AI, a collection of digital art imagining AI’s rewards, roles, and responsibilities. + It visualizes the push and pull, the mutual effort of humans and machines needed to create intelligent technologies. The clusters of particles + show how data is grouped and processed inside an unsupervised machine learning model as the computer begins to find patterns on its own. + At the same time, human hands enter the operation at various points, refining the data and adjusting the algorithm, guiding it in the desired direction. + - component: PageSection + tagline: References + level: 3 + content: + - component: Markdown + data: | + - Google. 2026. Google DeepMind. Accessed February 23, 2026. https://deepmind.google. + - Hawkins, Will, and Brent Mittelstadt. 2023. “The Ethical Ambiguity of AI Data Enrichment: Measuring Gaps in Research Ethics Norms and Practices.” + In *Proceedings of the 2023 ACM Conference on Fairness, Accountability, and Transparency (FAccT ‘23)*. New York, NY: ACM Publications. 261–270. Accessed February 23, 2026. https://doi.org/10.1145/3593013.3593995. + - Mititelu, Aurora. 2024. “Visualising AI: Responsibility for Google DeepMind.” Accessed February 26, 2026. https://auroramititelu.com/visualising-ai-google-deepmind. + - Mititelu, Aurora. 2024. *Responsibility: Visualizing AI for Google DeepMind*. In *Envisioning Intelligences 3.1* (2025), edited by Katy Börner, Elizabeth G. Record, and Todd Theriault. diff --git a/apps/cns-website/public/assets/content/envisioning-intelligences/images/artificial-worldviews.png b/apps/cns-website/public/assets/content/envisioning-intelligences/images/artificial-worldviews.png new file mode 100644 index 0000000000..4e281f9588 Binary files /dev/null and b/apps/cns-website/public/assets/content/envisioning-intelligences/images/artificial-worldviews.png differ diff --git a/apps/cns-website/public/assets/content/envisioning-intelligences/images/emotional-echo-of-cultural-intelligence.png b/apps/cns-website/public/assets/content/envisioning-intelligences/images/emotional-echo-of-cultural-intelligence.png new file mode 100644 index 0000000000..c1f10c6ce0 Binary files /dev/null and b/apps/cns-website/public/assets/content/envisioning-intelligences/images/emotional-echo-of-cultural-intelligence.png differ diff --git a/apps/cns-website/public/assets/content/envisioning-intelligences/images/header-images.png b/apps/cns-website/public/assets/content/envisioning-intelligences/images/header-images.png new file mode 100644 index 0000000000..c2a389cd24 Binary files /dev/null and b/apps/cns-website/public/assets/content/envisioning-intelligences/images/header-images.png differ diff --git a/apps/cns-website/public/assets/content/envisioning-intelligences/images/intelligence-is-information-having-fun.png b/apps/cns-website/public/assets/content/envisioning-intelligences/images/intelligence-is-information-having-fun.png new file mode 100644 index 0000000000..5ad3b995e9 Binary files /dev/null and b/apps/cns-website/public/assets/content/envisioning-intelligences/images/intelligence-is-information-having-fun.png differ diff --git a/apps/cns-website/public/assets/content/envisioning-intelligences/images/knowledge-cosmos.png b/apps/cns-website/public/assets/content/envisioning-intelligences/images/knowledge-cosmos.png new file mode 100644 index 0000000000..db26776291 Binary files /dev/null and b/apps/cns-website/public/assets/content/envisioning-intelligences/images/knowledge-cosmos.png differ diff --git a/apps/cns-website/public/assets/content/envisioning-intelligences/images/recollection.png b/apps/cns-website/public/assets/content/envisioning-intelligences/images/recollection.png new file mode 100644 index 0000000000..42b716943e Binary files /dev/null and b/apps/cns-website/public/assets/content/envisioning-intelligences/images/recollection.png differ diff --git a/apps/cns-website/public/assets/content/envisioning-intelligences/images/responsibility-deepmind.png b/apps/cns-website/public/assets/content/envisioning-intelligences/images/responsibility-deepmind.png new file mode 100644 index 0000000000..a41d9611cb Binary files /dev/null and b/apps/cns-website/public/assets/content/envisioning-intelligences/images/responsibility-deepmind.png differ diff --git a/apps/cns-website/public/assets/content/envisioning-intelligences/images/zoonotic-web.png b/apps/cns-website/public/assets/content/envisioning-intelligences/images/zoonotic-web.png new file mode 100644 index 0000000000..73412abebf Binary files /dev/null and b/apps/cns-website/public/assets/content/envisioning-intelligences/images/zoonotic-web.png differ diff --git a/apps/cns-website/public/assets/content/exhibit/data.yaml b/apps/cns-website/public/assets/content/exhibit/data.yaml new file mode 100644 index 0000000000..deda0421d3 --- /dev/null +++ b/apps/cns-website/public/assets/content/exhibit/data.yaml @@ -0,0 +1,153 @@ +$schema: ../../../app/schemas/content-page/content-page.schema.json +title: 30-Year Exhibit +subtitle: | + Explore the *Places & Spaces* (2005-2024) and *Envisioning Intelligences* exhibits (2025-2034). +breadcrumbs: + - name: Home + route: / + - name: 30-Year Exhibit +headerContent: + - component: GridContainer + content: + - component: ActionCard + variant: outlined + image: assets/content/exhibit/images/envisioning-intelligences.png + tagline: Envisioning Intelligences (2025-2034) + content: + - component: Markdown + data: The third decade welcomes visualizations of linguistic, kinesthetic, emotional, and other intelligences with a focus on collaboration and coordination across life forms. + actionsLeft: + component: TextHyperlink + text: View visualizations + url: /exhibit/envisioning-intelligences + ariaLabel: View visualizations from the Envisioning Intelligences exhibit + actionsRight: + component: TextHyperlink + text: Talks and demos + url: https://cns-iu.github.io/workshops/2025-24h + ariaLabel: View talks and demos from the Envisioning Intelligences exhibit + - component: ActionCard + variant: outlined + image: assets/content/exhibit/images/macroscopes.png + tagline: Macroscopes (2015-2024) + content: + - component: Markdown + data: Macroscopes refer to interactive data visualizations, allowing us to engage directly with large datasets in ways that empower discovery and direct our own lines of questioning. + actionsLeft: + component: TextHyperlink + text: View macroscopes + url: https://scimaps.org/macroscopes + ariaLabel: View macroscopes from the Macroscopes exhibit + actionsRight: + component: TextHyperlink + text: Talks and demos + url: https://cns-iu.github.io/workshops/2023-12-9_24hour_science_map/ + ariaLabel: View talks and demos from the Macroscopes exhibit + - component: ActionCard + variant: outlined + image: assets/content/exhibit/images/maps.png + tagline: Maps (2005-2014) + content: + - component: Markdown + data: For centuries, cartographic maps of earth and water have guided human exploration. Maps of science showcase the contexts, relationships, and dynamism of science. + actionsLeft: + component: TextHyperlink + text: View maps + url: https://scimaps.org/maps + ariaLabel: View maps from the Maps exhibit + actionsRight: + component: TextHyperlink + text: Talks and demos + url: https://cns-iu.github.io/workshops/2021-12-10_24hour_science_map + ariaLabel: View talks and demos from the Maps exhibit +content: + - component: PageSection + tagline: About the exhibits + anchor: about-the-exhibits + level: 2 + content: + - component: Markdown + data: | + Since 2005, the *Places & Spaces* exhibit team has shared world-class examples of science maps and interactive + data visualizations via physical exhibits, 219 press items, and more than 8 million website visits. Together, + the different exhibit iterations aim to create ever more excitement for the power and value of science, share + the pleasures of scientific discovery, and give good energy to all on this planet. + - component: Button + label: Explore exhibit + href: https://scimaps.org + type: cta + + - component: PageSection + tagline: Venues and events + anchor: venues-and-events + level: 2 + content: + - component: Markdown + data: The exhibit has been on display at more that 480 venues in more than 160 cities. + - component: VenuesTable + venuesUrl: https://dev.scimaps.org/assets/indexes/venues.json + linkBaseHref: https://scimaps.org/ + + - component: PageSection + tagline: Talks and demos + anchor: talks-and-demos + level: 2 + content: + - component: Markdown + data: | + Over the course of 24 hours, scholars and practitioners from around the globe discussed their work, invited + viewers behind the scenes, and demonstrated valuable tools and methodologies. + + - component: GridContainer + content: + - component: ActionCard + variant: outlined + image: assets/content/exhibit/images/EI2.png + tagline: Envisioning Intelligences + content: + - component: Markdown + data: December 13-14, 2025 + actionsLeft: + component: TextHyperlink + text: Event website + url: https://cns-iu.github.io/workshops/2025-24h/ + ariaLabel: View event website for Envisioning Intelligences talks and demos + actionsRight: + component: TextHyperlink + text: YouTube playlist + url: https://www.youtube.com/playlist?list=PL-CUnYVIy7DPbiDhpt0M233PeVV9WVyDv + ariaLabel: View YouTube playlist for Envisioning Intelligences talks and demos + - component: ActionCard + variant: outlined + image: assets/content/exhibit/images/atlas-of-inequality.png + tagline: Macroscopes + content: + - component: Markdown + data: December 9-10, 2023 + actionsLeft: + component: TextHyperlink + text: Event website + url: https://cns-iu.github.io/workshops/2023-12-9_24hour_science_map/ + ariaLabel: View event website for Macroscopes talks and demos + actionsRight: + component: TextHyperlink + text: YouTube playlist + url: https://www.youtube.com/playlist?list=PL-CUnYVIy7DNM0EMoeWvIn-pHD2-QmPhx + ariaLabel: View YouTube playlist for Macroscopes talks and demos + - component: ActionCard + variant: outlined + image: assets/content/exhibit/images/2pm.png + tagline: Maps + content: + - component: Markdown + data: December 11-12, 2021 + actionsLeft: + component: TextHyperlink + text: Event website + url: https://cns-iu.github.io/workshops/2021-12-10_24hour_science_map/ + ariaLabel: View event website for Maps talks and demos + actionsRight: + component: TextHyperlink + text: YouTube playlist + url: https://www.youtube.com/playlist?list=PL-CUnYVIy7DO6TkNXWpHrxISnMf2Yny-f + ariaLabel: View YouTube playlist for Maps talks and demos diff --git a/apps/cns-website/public/assets/content/exhibit/images/2pm.png b/apps/cns-website/public/assets/content/exhibit/images/2pm.png new file mode 100644 index 0000000000..abedbbf1d2 Binary files /dev/null and b/apps/cns-website/public/assets/content/exhibit/images/2pm.png differ diff --git a/apps/cns-website/public/assets/content/exhibit/images/EI2.png b/apps/cns-website/public/assets/content/exhibit/images/EI2.png new file mode 100644 index 0000000000..b40d3e25fc Binary files /dev/null and b/apps/cns-website/public/assets/content/exhibit/images/EI2.png differ diff --git a/apps/cns-website/public/assets/content/exhibit/images/atlas-of-inequality.png b/apps/cns-website/public/assets/content/exhibit/images/atlas-of-inequality.png new file mode 100644 index 0000000000..24e3f965f2 Binary files /dev/null and b/apps/cns-website/public/assets/content/exhibit/images/atlas-of-inequality.png differ diff --git a/apps/cns-website/public/assets/content/exhibit/images/envisioning-intelligences.png b/apps/cns-website/public/assets/content/exhibit/images/envisioning-intelligences.png new file mode 100644 index 0000000000..f9680f8149 Binary files /dev/null and b/apps/cns-website/public/assets/content/exhibit/images/envisioning-intelligences.png differ diff --git a/apps/cns-website/public/assets/content/exhibit/images/macroscopes.png b/apps/cns-website/public/assets/content/exhibit/images/macroscopes.png new file mode 100644 index 0000000000..b6866ad374 Binary files /dev/null and b/apps/cns-website/public/assets/content/exhibit/images/macroscopes.png differ diff --git a/apps/cns-website/public/assets/content/exhibit/images/maps.png b/apps/cns-website/public/assets/content/exhibit/images/maps.png new file mode 100644 index 0000000000..20c70cbe8d Binary files /dev/null and b/apps/cns-website/public/assets/content/exhibit/images/maps.png differ diff --git a/apps/cns-website/public/assets/content/jobs-page/data.yaml b/apps/cns-website/public/assets/content/jobs-page/data.yaml new file mode 100644 index 0000000000..d8b9f59123 --- /dev/null +++ b/apps/cns-website/public/assets/content/jobs-page/data.yaml @@ -0,0 +1,46 @@ +$schema: ../../../../src/app/schemas/content-page/content-page.schema.json +title: Jobs +subtitle: Work at Indiana University's Cyberinfrastructure for Network Science Center. +breadcrumbs: + - name: Home + route: / + - name: Jobs +content: + - component: PageSection + tagline: Biomedical Consultant + anchor: biomedical-consultant + level: 2 + content: + - component: Markdown + data: | + The biomedical consultant will work on the NIH-funded HuBMAP project, which aims to map the human body at single-cell level. + They will join an interdisciplinary team that includes software developers, data scientists, medical illustrators, biologists, + and project managers at CNS (https://cns.iu.edu). + They will collaborate with external organ experts to catalogue and link anatomical structures, cell types, + and biomarkers to the Human Reference Atlas (HRA), document work in standard operating procedures, and contribute to scientific publications. + They will work with users of the HRA on defining and implementing existing and new use cases. +

+ The ideal candidate will have strong oral and written communication skills and a unique combination of training + and/or prior experience in more than one of the following areas: biomedical imaging, histology, human anatomy and physiology, + data analysis, and single cell analyses. Familiarity with multiplexed microscopy image analysis software and molecular biology assays + and bioinformatics is a plus. The biomedical expert will have an opportunity to co-author scholarly publications in high-profile scientific outlets. +

+ This is a flexible, part-time position of up to 20 hours per week and could accommodate remote work for the right candidate, + but would ideally be an in-person position on the Indiana University Bloomington campus. + To apply, email cover letter and resume to CNS at [cnscntr@iu.edu](mailto:cnscntr@iu.edu) using “Biomedical Consultant position” as the subject line. +

+ Responsibilities: + - Supports efforts of leading scientists in harmonizing terminology across multiple areas of cellular biology. + - Assists researchers in interpreting and using experimental data in HRA construction and usage. + - Documents procedures in standard operating procedures (SOPs). + - Attends relevant meetings, representing the project in a professional manner. + - Collaborated on documenting results in scholarly publications. +
+ + Qualifications: + - Education: Master's degree in biology or a related field is strongly encouraged. + Research experience in a higher education environment is preferred. + - Experience: At least 2 years of experience working in a laboratory setting addressing biomolecular research questions. + With a focus on single cell experimental designs and data analysis. + - Required knowledge, skills, and abilities: Excellent written and oral communication skills. + Problem solving skills. Must be willing to work in a team-oriented environment and must be a motivated self-starter. diff --git a/apps/cns-website/public/assets/content/landing-page/bg-homepage.png b/apps/cns-website/public/assets/content/landing-page/bg-homepage.png new file mode 100644 index 0000000000..9e329476e0 Binary files /dev/null and b/apps/cns-website/public/assets/content/landing-page/bg-homepage.png differ diff --git a/apps/cns-website/public/assets/content/privacy-policy-page/data.yaml b/apps/cns-website/public/assets/content/privacy-policy-page/data.yaml new file mode 100644 index 0000000000..709cd672d1 --- /dev/null +++ b/apps/cns-website/public/assets/content/privacy-policy-page/data.yaml @@ -0,0 +1,246 @@ +$schema: ../../../../src/app/schemas/content-page/content-page.schema.json +title: Privacy Policy +subtitle: 'Effective: 2025-10-15' +breadcrumbs: + - name: Home + route: / + - name: Privacy Policy +content: + - component: PageSection + tagline: Overview + anchor: overview + level: 2 + content: + - component: Markdown + data: | + At Indiana University (IU), we are committed to protecting the privacy + and confidentiality of personal information entrusted to us. + By accessing and using IU's services, you acknowledge and consent to the practices described + in our global privacy statement here: https://privacy.iu.edu/privacy/global.html. +

+ + For additional information outlining how Cyberinfrastructure for Network Science Center collects, + uses, and safeguards personal information obtained specifically through our website (https://cns.iu.edu/), + please also review the information below. +

+ + Continued use of our website indicates consent to the collection, use, and disclosure of this information + as described in this notice. +

+ + Visitors to other IU websites should review the privacy notices for the sites they visit, + as other units at the university may collect and use visitor information in different ways. + Cyberinfrastructure for Network Science Center is not responsible for the content of other websites + or for the privacy practices of websites outside the scope of this notice. + + - component: PageSection + tagline: Changes + anchor: changes + level: 2 + content: + - component: Markdown + data: | + Because Internet technologies continue to evolve rapidly, + Cyberinfrastructure for Network Science Center may make appropriate changes to this notice in the future. + Any such changes will be consistent with our commitment to respecting visitor privacy, + and will be clearly posted in a revised privacy notice. + + - component: PageSection + tagline: Collection and Use + anchor: collection-and-use + level: 2 + content: + - component: PageSection + tagline: Passive/Automatic Collection + anchor: passive-collection + level: 3 + content: + component: Markdown + data: | + In addition to any information outline in the global statement, our server and/or site collects the following: + + - Your IP address + - The domain name from which you visit our site + - Aggregate information on pages visited + - The referring website + - The date and time of visit + - The duration of visit + - Your browser type + - Your screen resolution + + Some technical information is retained in aggregate for up to 365 days. + + - component: PageSection + tagline: Active/Manual/Voluntary Collection + anchor: active-collection + level: 3 + content: + component: Markdown + data: | + In addition to the technical information about your visit described above (or cookies, described below), + we may ask you to provide information voluntarily, such as through forms or other manual input. + We might ask for you to provide this information in order to make products and services available to you, + to maintain and manage our relationship with you, including providing associated services or to better understand and + serve your needs. This information is generally retained as long as you continue to maintain a relationship with us. + Your providing this information is wholly voluntary. + However, not providing the requested information (or subsequently asking that the data be removed) may affect our ability + to deliver the products or service for which the information is needed. + Providing the requested information indicates your consent to the collection, use, and disclosure of this information as + described in this notice. Information we may actively collect could include: + - The email addresses of those who communicate with us via email + - Name + + - component: PageSection + tagline: Information Usage + anchor: information-usage + level: 3 + content: + component: Markdown + data: | + This information is: + + - Used for internal review and is then discarded + + - component: PageSection + tagline: Information Used For Contact + anchor: information-used-for-contact + level: 3 + content: + component: Markdown + data: | + If you supply us with your postal/mailing address: + + - You may receive periodic mailings from us with information on new products and services or upcoming events. + + If you do not wish to receive such mailings, or would like to be added to our "do not share" list, please let us know by: + + - Sending us email at the listed address + - Calling us at the listed telephone number + - Writing to us at the listed address + + - component: PageSection + tagline: Information Sharing + anchor: information-sharing + level: 3 + content: + component: Markdown + data: | + We may share aggregate, non-personally identifiable information with other entities or organizations. + + Except as described in the [IU Privacy statement](https://privacy.iu.edu/privacy/global.html), + we will not share any information with any other entities or organizations for any reason. + + Except as provided in the sections below, we do not attempt to use the technical information + discussed in this section to identify individual visitors. + + - component: PageSection + tagline: Cookies + anchor: cookies + level: 3 + content: + component: Markdown + data: | + For more information on how we use cookies, please review the [IU Privacy statement](https://privacy.iu.edu/privacy/global.html). + + - component: PageSection + tagline: Children + anchor: children + level: 3 + content: + component: Markdown + data: | + - We ask children under age 13 to obtain permission from a parent or guardian before interacting with our site. + + - component: PageSection + tagline: Use of Third Party Services + anchor: use-of-third-party-services + level: 3 + content: + component: Markdown + data: | + This website utilizes Amazon Web Services a web analytics service. + More information about this service can be found here: https://aws.amazon.com/privacy/ + + - component: PageSection + tagline: Social Media + anchor: social-media + level: 3 + content: + component: Markdown + data: | + Third-party social media sites may collect information from this site. + Indiana University is not responsible for their privacy practices. + This site embeds features from third party social media sites. + + - YouTube (visit YouTube's privacy policy at: https://policies.google.com/privacy) + + - component: PageSection + tagline: Security + anchor: security + level: 2 + content: + component: Markdown + data: | + Due to the rapidly evolving nature of information technologies, + no transmission of information over the Internet can be guaranteed to be completely secure. + While Indiana University is committed to protecting user privacy, + IU cannot guarantee the security of any information users transmit to university sites, + and users do so at their own risk. + + - Once we receive user information, we will use reasonable safeguards consistent with prevailing industry standards and + commensurate with the sensitivity of the data being stored to maintain the security of that information on our systems. + - We will comply with all applicable federal, state and local laws regarding the privacy and security of user information. + + - component: PageSection + tagline: Links to non-university sites + anchor: links-to-non-university-sites + level: 2 + content: + component: Markdown + data: | + Indiana University is not responsible for the availability, content, or privacy practices of non-university sites. + Non-university sites are not bound by this site privacy notice policy and may or may not have their own privacy policies. + + - component: PageSection + tagline: Privacy Notice Changes + anchor: privacy-notice-changes + level: 2 + content: + component: Markdown + data: | + From time to time, we may use visitor information for new, unanticipated uses not previously disclosed in our privacy notice. + + We will post the policy changes to our Website to notify you of these changes, + but only data collected from the time of the policy change forward will be used for these new purposes. + If you are concerned about how your information is used, you should check back at our Website periodically. + + Visitors may prevent their information from being used for purposes other than those for which it was originally collected by: + + - Sending us email at the listed address + - Calling us at the listed telephone number + - Writing to us at the listed address + + - component: PageSection + tagline: Contact Information + anchor: contact-information + level: 2 + content: + component: Markdown + data: | + If you have questions or concerns about this policy, please contact us. + + Cyberinfrastructure for Network Science Center
+ ATTN: Lisel Record
+ 700 N Woodlawn Ave Luddy Hall Suite 4018
+ Bloomington, IN
+ 47408
+ cnscntr@iu.edu
+ 812-856-7034 + + If you feel as though this site's privacy practices differ from the information stated, + you may contact us at the listed address or phone number. + + If you feel that this site is not following its stated policy and communicating + with the owner of this site does not resolve the matter, or if you have general questions or concerns about privacy + or information technology policy at Indiana University, please contact the chief privacy officer + through the University Information Policy Office, 812-855-UIPO, [privacy@iu.edu](mailto:privacy@iu.edu). diff --git a/apps/cns-website/public/assets/content/visitor-info-page/data.yaml b/apps/cns-website/public/assets/content/visitor-info-page/data.yaml new file mode 100644 index 0000000000..f5b29e4dc8 --- /dev/null +++ b/apps/cns-website/public/assets/content/visitor-info-page/data.yaml @@ -0,0 +1,74 @@ +$schema: ../../../app/schemas/content-page/content-page.schema.json +title: Visitor Info +subtitle: | + Information for your visit to Indiana University’s Cyberinfrastructure for Network Science Center. +breadcrumbs: + - name: Home + route: / + - name: Visitor Info +content: + - component: PageSection + tagline: Address + anchor: address + level: 2 + content: + - component: Markdown + data: | + Luddy School of Informatics, Computing, and Engineering +
+ Indiana University at Bloomington +
+ 700 N Woodlawn Ave. Luddy Hall, Suite 4018 +
+ Bloomington, IN 47408 + - component: PageSection + tagline: Parking + anchor: parking + level: 2 + content: + - component: Markdown + data: | + The nearest visitor parking is available in the Fee Lane garage, located at 709 N. Fee Lane. + - component: TextHyperlink + text: IU Parking Map + url: https://map.concept3d.com/?id=951#!ce/16640?ct/16650,16651,16652,16653?s/fee + icon: arrow_right_alt + + - component: PageSection + tagline: Airport transportation + anchor: airport-transportation + level: 2 + content: + - component: Markdown + data: | + - [Bloomington Airport Shuttle](https://goexpresstravel.com/bloomington-airport-shuttle/) + - [SuperShuttle](https://www.supershuttle.com/) + - Directions: From the Indianapolis Airport (IND), head south on Highway 37, exit onto Walnut Street, then turn left on 10th St. and left on Woodlawn Ave. + + - component: PageSection + tagline: CNS office location + anchor: cns-office-location + level: 2 + content: + - component: Markdown + data: | + CNS Center is on the 4th floor at Indiana University's Luddy Hall: + - Arrive at the Luddy School of Informatics, Computing, and Engineering at 700 N Woodlawn Ave Bloomington, IN 47408, located at the corner of 10th Street and Woodlawn Avenue + - Locate the elevators and press the up button + - Walk into the elevator and press the 4th floor button + - On the 4th floor, exit the elevator to the right + - Take the first right turn, passing the water fountains and Vis Lab (4012) + - Open the door and enter room 4018 to arrive at the Cyberinfrastructure for Network Science Center + + - component: GoogleMaps + url: https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3093.095776349573!2d-86.52570008467531!3d39.17254333828355!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x886c66c6f37e689d%3A0xbf0b1d7c24bd0299!2sLuddy+Hall!5e0!3m2!1sen!2sus!4v1514920610609 + externalUrl: https://maps.app.goo.gl/PqeHqwZbFdU7KBWp8 + fallbackImageUrl: assets/ui-images/google-maps-luddy-hall.png + + - component: PageSection + tagline: Luddy Hall 4th floor + anchor: cns-office-location + level: 3 + content: + - component: Image + src: assets/content/visitor-info-page/images/floor-plan.png diff --git a/apps/cns-website/public/assets/content/visitor-info-page/images/floor-plan.png b/apps/cns-website/public/assets/content/visitor-info-page/images/floor-plan.png new file mode 100644 index 0000000000..cd8b3e9765 Binary files /dev/null and b/apps/cns-website/public/assets/content/visitor-info-page/images/floor-plan.png differ diff --git a/apps/cns-website/public/assets/group.png b/apps/cns-website/public/assets/group.png new file mode 100644 index 0000000000..aca0bf1a67 Binary files /dev/null and b/apps/cns-website/public/assets/group.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-data.png b/apps/cns-website/public/assets/placeholder-images/placeholder-data.png new file mode 100644 index 0000000000..ba5a0879a8 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-data.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-event-conference.png b/apps/cns-website/public/assets/placeholder-images/placeholder-event-conference.png new file mode 100644 index 0000000000..69ad8bb202 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-event-conference.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-event-meeting.png b/apps/cns-website/public/assets/placeholder-images/placeholder-event-meeting.png new file mode 100644 index 0000000000..9322733d97 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-event-meeting.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-event-other.png b/apps/cns-website/public/assets/placeholder-images/placeholder-event-other.png new file mode 100644 index 0000000000..f37046b469 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-event-other.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-event-presentation.png b/apps/cns-website/public/assets/placeholder-images/placeholder-event-presentation.png new file mode 100644 index 0000000000..ae1a34f68d Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-event-presentation.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-event-tutorial.png b/apps/cns-website/public/assets/placeholder-images/placeholder-event-tutorial.png new file mode 100644 index 0000000000..caf2bbb0d5 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-event-tutorial.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-event-visit.png b/apps/cns-website/public/assets/placeholder-images/placeholder-event-visit.png new file mode 100644 index 0000000000..7ec4f684dd Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-event-visit.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-event-workshop.png b/apps/cns-website/public/assets/placeholder-images/placeholder-event-workshop.png new file mode 100644 index 0000000000..56038cd310 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-event-workshop.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-event.png b/apps/cns-website/public/assets/placeholder-images/placeholder-event.png new file mode 100644 index 0000000000..b3f6551aee Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-event.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-funding.png b/apps/cns-website/public/assets/placeholder-images/placeholder-funding.png new file mode 100644 index 0000000000..175f342622 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-funding.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-interactive-display.png b/apps/cns-website/public/assets/placeholder-images/placeholder-interactive-display.png new file mode 100644 index 0000000000..8099d33949 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-interactive-display.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-miscellaneous.png b/apps/cns-website/public/assets/placeholder-images/placeholder-miscellaneous.png new file mode 100644 index 0000000000..f4ea330d30 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-miscellaneous.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-news.png b/apps/cns-website/public/assets/placeholder-images/placeholder-news.png new file mode 100644 index 0000000000..5971fba430 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-news.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-presentation.png b/apps/cns-website/public/assets/placeholder-images/placeholder-presentation.png new file mode 100644 index 0000000000..0c909b52ba Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-presentation.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-publication-article-journal.png b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-article-journal.png new file mode 100644 index 0000000000..08b667a59c Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-article-journal.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-publication-book.png b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-book.png new file mode 100644 index 0000000000..6ee51aaedd Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-book.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-publication-broadcast.png b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-broadcast.png new file mode 100644 index 0000000000..e2808ea63e Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-broadcast.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-publication-chapter.png b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-chapter.png new file mode 100644 index 0000000000..240520db6c Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-chapter.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-publication-manuscript.png b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-manuscript.png new file mode 100644 index 0000000000..d7d37a9341 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-manuscript.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-publication-paper-conference.png b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-paper-conference.png new file mode 100644 index 0000000000..d809f96226 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-paper-conference.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-publication-patent.png b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-patent.png new file mode 100644 index 0000000000..a542262baf Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-patent.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-publication-periodical.png b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-periodical.png new file mode 100644 index 0000000000..2817359bbb Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-periodical.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-publication-report.png b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-report.png new file mode 100644 index 0000000000..696aa5c9a9 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-report.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-publication-thesis.png b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-thesis.png new file mode 100644 index 0000000000..ed8c9a8911 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-thesis.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-publication-unknown.png b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-unknown.png new file mode 100644 index 0000000000..a65b80d453 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-publication-unknown.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-software.png b/apps/cns-website/public/assets/placeholder-images/placeholder-software.png new file mode 100644 index 0000000000..ba060d5924 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-software.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-teaching.png b/apps/cns-website/public/assets/placeholder-images/placeholder-teaching.png new file mode 100644 index 0000000000..a818b76894 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-teaching.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder-visualization.png b/apps/cns-website/public/assets/placeholder-images/placeholder-visualization.png new file mode 100644 index 0000000000..aa6c616919 Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder-visualization.png differ diff --git a/apps/cns-website/public/assets/placeholder-images/placeholder.png b/apps/cns-website/public/assets/placeholder-images/placeholder.png new file mode 100644 index 0000000000..42fa9477dc Binary files /dev/null and b/apps/cns-website/public/assets/placeholder-images/placeholder.png differ diff --git a/apps/cns-website/public/assets/research.png b/apps/cns-website/public/assets/research.png new file mode 100644 index 0000000000..93e74ea724 Binary files /dev/null and b/apps/cns-website/public/assets/research.png differ diff --git a/apps/cns-website/public/favicon.ico b/apps/cns-website/public/favicon.ico new file mode 100644 index 0000000000..0d25b632bf Binary files /dev/null and b/apps/cns-website/public/favicon.ico differ diff --git a/apps/cns-website/src/app/app.component.html b/apps/cns-website/src/app/app.component.html new file mode 100644 index 0000000000..3a75824fa2 --- /dev/null +++ b/apps/cns-website/src/app/app.component.html @@ -0,0 +1,4 @@ + + + + diff --git a/apps/cns-website/src/app/app.component.scss b/apps/cns-website/src/app/app.component.scss new file mode 100644 index 0000000000..512f7a92b1 --- /dev/null +++ b/apps/cns-website/src/app/app.component.scss @@ -0,0 +1,6 @@ +@use 'vars'; + +:host { + display: block; + background: vars.$background; +} diff --git a/apps/cns-website/src/app/app.component.ts b/apps/cns-website/src/app/app.component.ts new file mode 100644 index 0000000000..40ac252da9 --- /dev/null +++ b/apps/cns-website/src/app/app.component.ts @@ -0,0 +1,31 @@ +import { ViewportScroller } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { Router, RouterModule } from '@angular/router'; +import { BaseApplicationComponent } from '@hra-ui/application'; +import { HraCommonModule } from '@hra-ui/common'; +import { CustomScrollService } from '@hra-ui/common/custom-scroll'; +import { HeaderComponent } from './components/header/header.component'; + +/** + * Main application component + */ +@Component({ + selector: 'cns-website', + imports: [HraCommonModule, RouterModule, HeaderComponent], + templateUrl: './app.component.html', + styleUrl: './app.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'hra-app', + }, +}) +export class AppComponent extends BaseApplicationComponent { + /** Initialize application */ + constructor() { + super(); + + inject(CustomScrollService); + inject(ViewportScroller).setOffset([0, 56 + 24]); + inject(Router).initialNavigation(); + } +} diff --git a/apps/cns-website/src/app/app.config.ts b/apps/cns-website/src/app/app.config.ts new file mode 100644 index 0000000000..4731e2f7d7 --- /dev/null +++ b/apps/cns-website/src/app/app.config.ts @@ -0,0 +1,98 @@ +import { HttpClient } from '@angular/common/http'; +import { ApplicationConfig, isDevMode, provideZonelessChangeDetection, signal } from '@angular/core'; +import { + provideRouter, + withComponentInputBinding, + withInMemoryScrolling, + withNavigationErrorHandler, +} from '@angular/router'; +import { provideContentTemplateDefs } from '@hra-ui/cdk/content-template'; +import { provideAnalytics, withErrorHandler, withRouterEvents } from '@hra-ui/common/analytics'; +import { provideTelemetryEndpoint } from '@hra-ui/common/analytics/plugins/hra-analytics'; +import { provideAppConfiguration } from '@hra-ui/common/injectors'; +import { provideRouterExt } from '@hra-ui/common/router-ext'; +import { provideDesignSystem } from '@hra-ui/design-system'; +import { provideBrandLogos } from '@hra-ui/design-system/brand/logo'; +import { ButtonDef } from '@hra-ui/design-system/buttons/button'; +import { provideSocials } from '@hra-ui/design-system/buttons/social-media-button'; +import { TextHyperlinkDef } from '@hra-ui/design-system/buttons/text-hyperlink'; +import { ActionCardDef } from '@hra-ui/design-system/cards/action-card'; +import { ProfileCardDef } from '@hra-ui/design-system/cards/profile-card'; +import { ApiCommandDef } from '@hra-ui/design-system/content-templates/api-command'; +import { FlexContainerDef } from '@hra-ui/design-system/content-templates/flex-container'; +import { GoogleMapsDef } from '@hra-ui/design-system/content-templates/google-maps'; +import { GridContainerDef } from '@hra-ui/design-system/content-templates/grid-container'; +import { ImageDef } from '@hra-ui/design-system/content-templates/image'; +import { MarkdownDef } from '@hra-ui/design-system/content-templates/markdown'; +import { PageSectionDef } from '@hra-ui/design-system/content-templates/page-section'; +import { VenuesTableDef } from '@hra-ui/design-system/content-templates/venues-table'; +import { YouTubePlayerDef } from '@hra-ui/design-system/content-templates/youtube-player'; +import { IconDef } from '@hra-ui/design-system/icons'; +import { provideConsentBannerConfig } from '@hra-ui/design-system/privacy/consent-banner'; +import { PageTableDef } from '@hra-ui/design-system/table'; +import { provideMarkdown } from 'ngx-markdown'; +import { appRoutes } from './app.routes'; +import { CNS_SOCIALS } from './components/static-data/parsed'; +import { handleNavigationError } from './utils/navigation-error-handler'; + +/** Application configuration */ +export const appConfig: ApplicationConfig = { + providers: [ + provideAnalytics(withRouterEvents(), withErrorHandler()), + provideAppConfiguration({ + name: 'cns-website', + version: '1.0.0', + url: 'https://cns.iu.edu/', + }), + provideBrandLogos({ + label: 'CNS Website', + url: 'https://cns.iu.edu/', + logos: [ + { + size: 'regular', + src: 'assets/brand/logo/cns-regular.svg', + width: 140, + height: 47, + }, + { + size: 'small', + src: 'assets/brand/logo/cns-full-small.svg', + width: 228, + height: 39, + }, + ], + }), + provideConsentBannerConfig({ + privacyPolicyUrl: 'https://cns.iu.edu/privacy-policy', + }), + provideContentTemplateDefs([ + ActionCardDef, + ApiCommandDef, + ButtonDef, + FlexContainerDef, + GoogleMapsDef, + GridContainerDef, + IconDef, + ImageDef, + MarkdownDef, + PageSectionDef, + PageTableDef, + ProfileCardDef, + TextHyperlinkDef, + VenuesTableDef, + YouTubePlayerDef, + ]), + provideDesignSystem(), + provideMarkdown({ loader: HttpClient }), + provideRouter( + appRoutes, + withComponentInputBinding(), + withInMemoryScrolling({ anchorScrolling: 'enabled', scrollPositionRestoration: 'enabled' }), + withNavigationErrorHandler(handleNavigationError), + ), + provideRouterExt(), + provideSocials(CNS_SOCIALS), + provideTelemetryEndpoint(signal(`https://cns.iu.edu/tr${isDevMode() ? '-dev' : ''}`), true), + provideZonelessChangeDetection(), + ], +}; diff --git a/apps/cns-website/src/app/app.routes.ts b/apps/cns-website/src/app/app.routes.ts new file mode 100644 index 0000000000..7a538229b2 --- /dev/null +++ b/apps/cns-website/src/app/app.routes.ts @@ -0,0 +1,267 @@ +import { Route } from '@angular/router'; +import { ContentPageDataSchema } from '@hra-ui/design-system/content-templates/content-page'; +import { createJsonSpecResolver, createYamlSpecResolver } from '@hra-ui/design-system/content-templates/resolvers'; +import { NotFoundPageComponent } from '@hra-ui/design-system/error-pages/not-found-page'; +import { ServerErrorPageComponent } from '@hra-ui/design-system/error-pages/server-error-page'; +import { createPersonResolver } from './resolvers/person.resolver'; +import { FeaturedDataSchema } from './schemas/featured.schema'; +import { PeopleDataSchema } from './schemas/people.schema'; +import { ResearchTypesDataSchema } from './schemas/research-type.schema'; +import { ResearchDataSchema } from './schemas/research.schema'; +import { TagsDataSchema } from './schemas/tags.schema'; +import { createMountRedirectRoute } from './utils/mount-redirect'; + +/** Base URL for content and indexes */ +const BASE_URL = 'https://cns.iu.edu/'; + +/** People index URL */ +const PEOPLE_INDEX_URL = BASE_URL + 'assets/indexes/app-people.json'; +/** Featured content index URL */ +const FEATURED_INDEX_URL = BASE_URL + 'assets/indexes/app-featured.json'; +/** News content index URL */ +const NEWS_INDEX_URL = BASE_URL + 'assets/indexes/app-news.json'; +/** Publications content index URL */ +const PUBLICATIONS_INDEX_URL = BASE_URL + 'assets/indexes/app-publications.json'; +/** Publication types content index URL */ +const PUBLICATION_TYPES_INDEX_URL = BASE_URL + 'assets/indexes/app-publication-types.json'; +/** Events content index URL */ +const EVENT_INDEX_URL = BASE_URL + 'assets/indexes/app-events.json'; +/** Event types content index URL */ +const EVENT_TYPES_INDEX_URL = BASE_URL + 'assets/indexes/app-event-types.json'; +/** Funding content index URL */ +const FUNDING_INDEX_URL = BASE_URL + 'assets/indexes/app-funding.json'; +/** Funding types content index URL */ +const FUNDING_TYPES_INDEX_URL = BASE_URL + 'assets/indexes/app-funding-types.json'; +/** Visualizations content index URL */ +const VISUALIZATIONS_INDEX_URL = BASE_URL + 'assets/indexes/app-visualizations.json'; +/** Display tags content index URL */ +const DISPLAY_TAGS_INDEX_URL = BASE_URL + 'assets/indexes/app-display-tags.json'; +/** Base URL for person content */ +const PERSON_BASE_URL = BASE_URL + 'content/people'; + +/** Helper function to load the content component */ +const loadContentComponent = () => + import('./components/content-page/content-page.component').then((m) => m.ContentPageComponent); + +/** Application routes */ +export const appRoutes: Route[] = [ + { + path: '', + pathMatch: 'full', + loadComponent: () => import('./pages/landing-page/landing-page.component').then((m) => m.LandingPageComponent), + resolve: { + featuredContent: createJsonSpecResolver(FEATURED_INDEX_URL, FeaturedDataSchema), + tags: createJsonSpecResolver(DISPLAY_TAGS_INDEX_URL, TagsDataSchema), + }, + }, + + // Content pages + // Please try to keep sorted in alphabetical order + { + path: '2012-ucsdmap', + loadComponent: loadContentComponent, + resolve: { + data: createYamlSpecResolver('assets/content/2012-ucsdmap/data.yaml', ContentPageDataSchema), + }, + }, + { + path: 'about', + loadComponent: loadContentComponent, + resolve: { + data: createYamlSpecResolver('assets/content/about-page/data.yaml', ContentPageDataSchema), + }, + }, + { + path: 'amatria', + loadComponent: loadContentComponent, + resolve: { + data: createYamlSpecResolver('assets/content/amatria/data.yaml', ContentPageDataSchema), + }, + }, + { + path: 'exhibit', + children: [ + { + path: '', + pathMatch: 'full', + loadComponent: loadContentComponent, + resolve: { + data: createYamlSpecResolver('assets/content/exhibit/data.yaml', ContentPageDataSchema), + }, + }, + { + path: 'envisioning-intelligences', + loadComponent: loadContentComponent, + resolve: { + data: createYamlSpecResolver('assets/content/envisioning-intelligences/data.yaml', ContentPageDataSchema), + }, + }, + ], + }, + { + path: 'jobs', + loadComponent: loadContentComponent, + resolve: { + data: createYamlSpecResolver('assets/content/jobs-page/data.yaml', ContentPageDataSchema), + }, + }, + { + path: 'people', + children: [ + { + path: '', + pathMatch: 'full', + loadComponent: () => import('./pages/current-team/current-team.component').then((m) => m.CurrentTeamComponent), + resolve: { + data: createJsonSpecResolver(PEOPLE_INDEX_URL, PeopleDataSchema), + }, + }, + { + path: ':slug', + loadComponent: () => + import('./pages/people-profile/people-profile.component').then((m) => m.PeopleProfileComponent), + resolve: { + data: createPersonResolver(PERSON_BASE_URL), + }, + }, + ], + }, + { + path: 'privacy-policy', + loadComponent: loadContentComponent, + resolve: { + data: createYamlSpecResolver('assets/content/privacy-policy-page/data.yaml', ContentPageDataSchema), + }, + }, + { + path: 'publications', + redirectTo: '/research?category=publication&view=list&group-by=year', + }, + { + path: 'research', + loadComponent: () => import('./pages/research-page/research-page.component').then((m) => m.ResearchPageComponent), + resolve: { + news: createJsonSpecResolver(NEWS_INDEX_URL, ResearchDataSchema), + publications: createJsonSpecResolver(PUBLICATIONS_INDEX_URL, ResearchDataSchema), + events: createJsonSpecResolver(EVENT_INDEX_URL, ResearchDataSchema), + funding: createJsonSpecResolver(FUNDING_INDEX_URL, ResearchDataSchema), + visualizations: createJsonSpecResolver(VISUALIZATIONS_INDEX_URL, ResearchDataSchema), + people: createJsonSpecResolver(PEOPLE_INDEX_URL, PeopleDataSchema), + publicationTypes: createJsonSpecResolver(PUBLICATION_TYPES_INDEX_URL, ResearchTypesDataSchema), + eventTypes: createJsonSpecResolver(EVENT_TYPES_INDEX_URL, ResearchTypesDataSchema), + fundingTypes: createJsonSpecResolver(FUNDING_TYPES_INDEX_URL, ResearchTypesDataSchema), + tags: createJsonSpecResolver(DISPLAY_TAGS_INDEX_URL, TagsDataSchema), + }, + }, + { + path: 'visitor-info', + loadComponent: loadContentComponent, + resolve: { + data: createYamlSpecResolver('assets/content/visitor-info-page/data.yaml', ContentPageDataSchema), + }, + }, + + // Redirects + { + path: '2012-UCSDMap.html', + redirectTo: '/2012-ucsdmap', + }, + { + path: 'all_news.html', + redirectTo: '/research?category=news', + }, + { + path: 'amatria.html', + redirectTo: '/amatria', + }, + { + path: 'collaborators.html', + redirectTo: '/research?team=past&roles=collaborator', + }, + { + path: 'contact.html', + redirectTo: '/contact', + }, + { + path: 'current_students.html', + redirectTo: '/people?roles=phdStudent', + }, + { + path: 'current_team.html', + redirectTo: '/people', + }, + { + path: 'funding.html', + redirectTo: '/research?category=funding', + }, + { + path: 'history.html', + redirectTo: '/about#our-history', + }, + { + path: 'home.html', + redirectTo: '/', + }, + { + path: 'interactive_displays.html', + redirectTo: '/research?category=display', + }, + { + path: 'jobs.html', + redirectTo: '/jobs', + }, + { + path: 'latest_news.html', + redirectTo: '/research?category=news', + }, + { + path: 'mission.html', + redirectTo: '/about#our-mission', + }, + { + path: 'presentations.html', + redirectTo: '/research?event=presentation', + }, + { + path: 'previous_collaborators.html', + redirectTo: '/research?team=past&roles=collaborator', + }, + { + path: 'publications.html', + redirectTo: '/research?category=publication', + }, + { + path: 'visitor_info.html', + redirectTo: '/visitor_info', + }, + { + path: 'visualizations.html', + redirectTo: '/research?category=visualization', + }, + { + path: 'workshops.html', + redirectTo: '/research?event=workshop', + }, + + // Mount redirects + createMountRedirectRoute('docs'), + createMountRedirectRoute('images'), + + // Error pages + { + path: '500', + component: ServerErrorPageComponent, + data: { + reportIssueLink: 'https://github.com/cns-iu/cns-website/issues/new', + }, + }, + { + path: '404', + component: NotFoundPageComponent, + }, + { + path: '**', + loadComponent: () => + import('./pages/archive-redirect/archive-redirect.component').then((m) => m.ArchiveRedirectComponent), + }, +]; diff --git a/apps/cns-website/src/app/components/content-page/content-page.component.html b/apps/cns-website/src/app/components/content-page/content-page.component.html new file mode 100644 index 0000000000..7e97dee9ab --- /dev/null +++ b/apps/cns-website/src/app/components/content-page/content-page.component.html @@ -0,0 +1,22 @@ + + + + + @if (data().action; as action) { + + {{ action.label }} + arrow_right_alt + + } + + @for (item of headerContent(); track $index) { + + } + + + + @for (item of content(); track $index) { + + } + + diff --git a/apps/cns-website/src/app/components/content-page/content-page.component.scss b/apps/cns-website/src/app/components/content-page/content-page.component.scss new file mode 100644 index 0000000000..c3f534076a --- /dev/null +++ b/apps/cns-website/src/app/components/content-page/content-page.component.scss @@ -0,0 +1,17 @@ +@use '@angular/material' as mat; +@use 'vars'; +@use 'utils'; + +:host { + display: block; + + .action { + width: fit-content; + } + + @include mat.divider-overrides( + ( + color: vars.$outline, + ) + ); +} diff --git a/apps/cns-website/src/app/components/content-page/content-page.component.spec.ts b/apps/cns-website/src/app/components/content-page/content-page.component.spec.ts new file mode 100644 index 0000000000..0e92948ff6 --- /dev/null +++ b/apps/cns-website/src/app/components/content-page/content-page.component.spec.ts @@ -0,0 +1,22 @@ +import { provideHttpClient } from '@angular/common/http'; +import { render } from '@testing-library/angular'; +import { provideMarkdown } from 'ngx-markdown'; + +import { ContentPageComponent } from './content-page.component'; + +describe('ContentPageComponent', () => { + it('should create', async () => { + const result = render(ContentPageComponent, { + providers: [provideMarkdown(), provideHttpClient()], + inputs: { + data: { + $schema: '../../../app/schemas/content-page/content-page.schema.json', + title: 'Test Title', + subtitle: 'Test Subtitle', + content: [], + }, + }, + }); + await expect(result).resolves.toBeTruthy(); + }); +}); diff --git a/apps/cns-website/src/app/components/content-page/content-page.component.ts b/apps/cns-website/src/app/components/content-page/content-page.component.ts new file mode 100644 index 0000000000..92e47bb80a --- /dev/null +++ b/apps/cns-website/src/app/components/content-page/content-page.component.ts @@ -0,0 +1,45 @@ +import { coerceArray } from '@angular/cdk/coercion'; +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { ContentTemplateOutletDirective } from '@hra-ui/cdk/content-template'; +import { HraCommonModule } from '@hra-ui/common'; +import { RouterExtModule } from '@hra-ui/common/router-ext'; +import { ButtonsModule } from '@hra-ui/design-system/buttons'; +import { ContentPageData } from '@hra-ui/design-system/content-templates/content-page'; +import { MarkdownComponent } from '@hra-ui/design-system/content-templates/markdown'; +import { PageSectionComponent } from '@hra-ui/design-system/content-templates/page-section'; +import { TableOfContentsLayoutModule } from '@hra-ui/design-system/layouts/table-of-contents'; +import { NavigationModule } from '@hra-ui/design-system/navigation'; +import { FooterComponent } from '../footer/footer.component'; + +/** + * Content Page Component + */ +@Component({ + selector: 'cns-content-page', + imports: [ + HraCommonModule, + RouterExtModule, + ButtonsModule, + ContentTemplateOutletDirective, + MarkdownComponent, + MatIconModule, + PageSectionComponent, + TableOfContentsLayoutModule, + NavigationModule, + FooterComponent, + ], + templateUrl: './content-page.component.html', + styleUrl: './content-page.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContentPageComponent { + /** Input data for content page */ + readonly data = input.required(); + + /** Header content data */ + protected readonly headerContent = computed(() => coerceArray(this.data().headerContent ?? [])); + + /** Content data */ + protected readonly content = computed(() => coerceArray(this.data().content)); +} diff --git a/apps/cns-website/src/app/components/footer/footer.component.html b/apps/cns-website/src/app/components/footer/footer.component.html new file mode 100644 index 0000000000..fef2b484a2 --- /dev/null +++ b/apps/cns-website/src/app/components/footer/footer.component.html @@ -0,0 +1,37 @@ + diff --git a/apps/cns-website/src/app/components/footer/footer.component.scss b/apps/cns-website/src/app/components/footer/footer.component.scss new file mode 100644 index 0000000000..d54f903c0c --- /dev/null +++ b/apps/cns-website/src/app/components/footer/footer.component.scss @@ -0,0 +1,97 @@ +@use '@angular/material' as mat; +@use 'utils'; +@use 'vars'; + +:host { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + background-color: vars.$on-primary-fixed; + + > * { + width: 100%; + } + + .content-container { + display: flex; + flex-direction: column; + justify-content: center; + gap: 2rem; + padding: 2.5rem; + color: #ffffff; + + @media (min-width: 1100px) { + flex-direction: row; + justify-content: space-between; + } + } + + .logo-and-socials { + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; + + @media (min-width: 1100px) { + align-items: start; + } + + .logo { + height: 3rem; + padding: 4px; + border-radius: vars.$corner-extra-small; + + ::ng-deep svg { + height: 2.5rem; + } + + &:focus-visible { + @include utils.inset-outline(vars.$tertiary); + } + } + + .socials { + display: flex; + flex-wrap: wrap; + } + } + + .privacy-container { + @include utils.use-font(body, medium); + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + padding: 2rem 2.5rem; + gap: 1.5rem; + background-color: vars.$tertiary-container; + color: vars.$on-tertiary-container; + + @media (min-width: 1100px) { + flex-direction: row; + justify-content: space-between; + } + + button, + a { + color: vars.$on-tertiary-container; + } + + .privacy { + display: flex; + align-items: center; + width: fit-content; + } + + .separator { + width: 21px; + text-align: center; + } + + .copyright { + text-align: center; + } + } +} diff --git a/apps/cns-website/src/app/components/footer/footer.component.spec.ts b/apps/cns-website/src/app/components/footer/footer.component.spec.ts new file mode 100644 index 0000000000..ec5aa72ef3 --- /dev/null +++ b/apps/cns-website/src/app/components/footer/footer.component.spec.ts @@ -0,0 +1,27 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { render, screen } from '@testing-library/angular'; +import { FooterComponent } from './footer.component'; +import { MatIconTestingModule } from '@angular/material/icon/testing'; +import { userEvent } from '@testing-library/user-event'; + +describe('FooterComponent', () => { + const globalProviders = [provideHttpClient(), provideHttpClientTesting()]; + const imports = [MatIconTestingModule]; + it('should display copyright information correctly', async () => { + await render(FooterComponent, { providers: globalProviders, imports }); + const currentYear = new Date().getFullYear(); + const regex = new RegExp(`©\\s${currentYear}`, 'i'); + expect(screen.getByText(regex)).toBeInTheDocument(); + }); + + it('should render Privacy Preferences link and handle click', async () => { + const { fixture } = await render(FooterComponent, { providers: globalProviders, imports }); + const componentInstance = fixture.componentInstance; + const link = screen.getByText(/Privacy Preferences/i); + expect(link).toBeInTheDocument(); + const spy = jest.spyOn(componentInstance, 'openPrivacyPreferences'); + await userEvent.click(link); + expect(spy).toHaveBeenCalled(); + }); +}); diff --git a/apps/cns-website/src/app/components/footer/footer.component.ts b/apps/cns-website/src/app/components/footer/footer.component.ts new file mode 100644 index 0000000000..ab52dca745 --- /dev/null +++ b/apps/cns-website/src/app/components/footer/footer.component.ts @@ -0,0 +1,47 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { HraCommonModule } from '@hra-ui/common'; +import { RouterExtModule } from '@hra-ui/common/router-ext'; +import { BrandModule } from '@hra-ui/design-system/brand'; +import { ButtonsModule } from '@hra-ui/design-system/buttons'; +import { PrivacyPreferencesService } from '@hra-ui/design-system/privacy'; +import { InlineSVGModule } from 'ng-inline-svg-2'; +import { CNS_SOCIAL_IDS } from '../static-data/parsed'; +import { FundingComponent } from './funding/funding.component'; +import { FUNDER_IDS } from './static-data/parsed'; + +/** + * CNS footer component + */ +@Component({ + selector: 'cns-footer', + imports: [ + HraCommonModule, + RouterExtModule, + MatIconModule, + BrandModule, + ButtonsModule, + FundingComponent, + InlineSVGModule, + ], + templateUrl: './footer.component.html', + styleUrl: './footer.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FooterComponent { + /** List of funders to show */ + readonly funders = input(FUNDER_IDS); + /** List of social media link to show */ + readonly socials = input(CNS_SOCIAL_IDS); + /** inject Privacy Preference Service */ + private readonly privacyPreferences = inject(PrivacyPreferencesService); + + /** Copyright year (always uses current year) */ + readonly copyrightYear = computed(() => `© ${new Date().getFullYear()}`); + + /** Open Privacy Preferences Modal */ + openPrivacyPreferences(event: Event): void { + event.preventDefault(); + this.privacyPreferences.openPrivacyPreferences('manage'); + } +} diff --git a/apps/cns-website/src/app/components/footer/funding/funding.component.html b/apps/cns-website/src/app/components/footer/funding/funding.component.html new file mode 100644 index 0000000000..edff116896 --- /dev/null +++ b/apps/cns-website/src/app/components/footer/funding/funding.component.html @@ -0,0 +1,10 @@ + + Thank you to our generous sponsors +
+ @for (funder of fundersData(); track funder.name) { + + + + } +
+
diff --git a/apps/cns-website/src/app/components/footer/funding/funding.component.scss b/apps/cns-website/src/app/components/footer/funding/funding.component.scss new file mode 100644 index 0000000000..5489329f36 --- /dev/null +++ b/apps/cns-website/src/app/components/footer/funding/funding.component.scss @@ -0,0 +1,37 @@ +@use 'vars'; +@use 'utils'; + +:host { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + justify-content: flex-end; + + .title { + @include utils.use-font(body, medium); + } + + .funders { + display: flex; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; + justify-content: center; + } + + .funder { + display: flex; + flex-direction: row; + + img { + min-width: 2.5rem; + height: 2.5rem; + object-fit: none; + } + + span { + margin: 0 1rem; + } + } +} diff --git a/apps/cns-website/src/app/components/footer/funding/funding.component.spec.ts b/apps/cns-website/src/app/components/footer/funding/funding.component.spec.ts new file mode 100644 index 0000000000..7cef47d5f3 --- /dev/null +++ b/apps/cns-website/src/app/components/footer/funding/funding.component.spec.ts @@ -0,0 +1,16 @@ +import { render } from '@testing-library/angular'; +import { screen } from '@testing-library/dom'; +import { FUNDER_IDS } from '../static-data/parsed'; +import { FundingComponent } from './funding.component'; + +describe('FundingComponent', () => { + it('should display a link for each funder', async () => { + await render(FundingComponent, { inputs: { funders: FUNDER_IDS } }); + + const links = screen.getAllByRole('link'); + expect(links.length).toEqual(FUNDER_IDS.length); + links.forEach((link) => { + expect(link.querySelector('img')).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/cns-website/src/app/components/footer/funding/funding.component.ts b/apps/cns-website/src/app/components/footer/funding/funding.component.ts new file mode 100644 index 0000000000..6f1b3a57a4 --- /dev/null +++ b/apps/cns-website/src/app/components/footer/funding/funding.component.ts @@ -0,0 +1,23 @@ +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { HraCommonModule } from '@hra-ui/common'; +import { FUNDERS } from '../static-data/parsed'; +import { FunderId } from '../types/funders.schema'; + +/** Displays a list of funders */ +@Component({ + selector: 'cns-funding', + imports: [HraCommonModule], + templateUrl: './funding.component.html', + styleUrl: './funding.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FundingComponent { + /** Funders to display */ + readonly funders = input.required(); + + /** Associated data for each funder displayed */ + protected readonly fundersData = computed(() => { + const ids = new Set(this.funders()); + return FUNDERS.filter((item) => ids.has(item.id)); + }); +} diff --git a/apps/cns-website/src/app/components/footer/static-data/funders.json b/apps/cns-website/src/app/components/footer/static-data/funders.json new file mode 100644 index 0000000000..d3a5480e54 --- /dev/null +++ b/apps/cns-website/src/app/components/footer/static-data/funders.json @@ -0,0 +1,47 @@ +{ + "$schema": "../types/funders.schema.json", + "funders": [ + { + "id": "nsf", + "name": "National Science Foundation", + "link": "https://www.nsf.gov/", + "image": "assets/logo/nsf_gray.svg" + }, + { + "id": "nih", + "name": "National Institutes of Health", + "link": "https://www.nih.gov/", + "image": "assets/logo/nih_white.svg" + }, + { + "id": "cifar", + "name": "The Canadian Institute for Advanced Research", + "link": "https://cifar.ca/", + "image": "assets/logo/cifar_white.svg" + }, + { + "id": "iu", + "name": "Indiana University", + "link": "https://iu.edu/", + "image": "assets/logo/iu_white.svg" + }, + { + "id": "ada", + "name": "American Dental Association", + "link": "https://www.ada.org/", + "image": "assets/logo/ada_white.svg" + }, + { + "id": "jsmf", + "name": "James S. McDonnell Foundation", + "link": "https://www.jsmf.org/", + "image": "assets/logo/jsmf_white.svg" + }, + { + "id": "gates", + "name": "Gates Foundation", + "link": "https://www.gatesfoundation.org/", + "image": "assets/logo/gates_white.svg" + } + ] +} diff --git a/apps/cns-website/src/app/components/footer/static-data/parsed.ts b/apps/cns-website/src/app/components/footer/static-data/parsed.ts new file mode 100644 index 0000000000..3d923f832b --- /dev/null +++ b/apps/cns-website/src/app/components/footer/static-data/parsed.ts @@ -0,0 +1,7 @@ +import FundersSchema from '../types/funders.schema'; +import RAW_FUNDERS from './funders.json'; + +/** Parsed funders static data */ +export const FUNDERS = FundersSchema.parse(RAW_FUNDERS).funders; +/** All available funder ids */ +export const FUNDER_IDS = FUNDERS.map(({ id }) => id); diff --git a/apps/cns-website/src/app/components/footer/types/funders.schema.json b/apps/cns-website/src/app/components/footer/types/funders.schema.json new file mode 100644 index 0000000000..ab568ed01e --- /dev/null +++ b/apps/cns-website/src/app/components/footer/types/funders.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "id": "CnsFunders", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "funders": { + "type": "array", + "items": { + "$ref": "#/$defs/CnsFunder" + } + } + }, + "required": [ + "$schema", + "funders" + ], + "additionalProperties": false, + "$defs": { + "CnsFunder": { + "id": "CnsFunder", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "link": { + "type": "string", + "format": "uri" + }, + "image": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "link", + "image" + ], + "additionalProperties": false + } + } +} \ No newline at end of file diff --git a/apps/cns-website/src/app/components/footer/types/funders.schema.ts b/apps/cns-website/src/app/components/footer/types/funders.schema.ts new file mode 100644 index 0000000000..9071d76121 --- /dev/null +++ b/apps/cns-website/src/app/components/footer/types/funders.schema.ts @@ -0,0 +1,28 @@ +import * as z from 'zod'; + +/** Data id of a funder */ +export type FunderId = Funder['id']; + +/** A funder item */ +export type Funder = z.infer; +/** Schema for a funder item */ +export const FunderSchema = z + .object({ + id: z.string().brand<'FunderId'>(), + name: z.string(), + link: z.string().url(), + image: z.string(), + }) + .meta({ id: 'CnsFunder' }); + +/** Multiple funders object */ +export type Funders = z.infer; +/** Schema for multiple funders */ +export const FundersSchema = z + .object({ + $schema: z.string(), + funders: FunderSchema.array(), + }) + .meta({ id: 'CnsFunders' }); + +export default FundersSchema; diff --git a/apps/cns-website/src/app/components/header/header.component.html b/apps/cns-website/src/app/components/header/header.component.html new file mode 100644 index 0000000000..af199a7f96 --- /dev/null +++ b/apps/cns-website/src/app/components/header/header.component.html @@ -0,0 +1,100 @@ +
+ + +
+ @if (sidebarStore.hasSidebar()) { + + } + + +
+ + @if (isMobile()) { + + } @else { + @for (category of menuOptions().options; track $index) { + @if (category.type === 'menu') { + + {{ category.label }} + + + + + + } @else { + + {{ category.label }} + + } + } + } + + @if (isMobile()) { + + + + } +
+
diff --git a/apps/cns-website/src/app/components/header/header.component.scss b/apps/cns-website/src/app/components/header/header.component.scss new file mode 100644 index 0000000000..1cab5454d9 --- /dev/null +++ b/apps/cns-website/src/app/components/header/header.component.scss @@ -0,0 +1,107 @@ +@use '@angular/material' as mat; +@use 'utils'; +@use 'vars'; + +$z-index: 900; + +:host { + display: block; + position: sticky; + top: 0; + z-index: $z-index; + background: vars.$surface-variant; + + &::before { + content: ''; + position: absolute; + inset: 0; + z-index: $z-index + 1; + pointer-events: none; + backdrop-filter: blur(6.25rem); + mask-image: linear-gradient(to bottom, rgba(255, 255, 255, 1) 35.71%, rgba(255, 255, 255, 0) 100%); + } + + .header { + position: relative; + z-index: $z-index + 2; + + @include mat.icon-button-overrides( + ( + state-layer-size: 3rem, + icon-color: vars.$on-surface, + ) + ); + } + + .header-content { + height: 3.5rem; + display: flex; + align-items: center; + padding: 0 0.75rem; + + .filler { + flex-grow: 1; + } + } + + .logo { + display: flex; + justify-content: center; + height: 2.5rem; + padding: 0.25rem; + border-radius: vars.$corner-extra-small; + color: vars.$on-surface; + + ::ng-deep svg { + height: 2rem; + } + + &:focus-visible { + @include utils.inset-outline(vars.$tertiary); + } + } + + .menu-button { + @include mat.button-overrides( + ( + text-label-text-font: vars.$title-medium-font, + text-label-text-size: vars.$title-medium-size, + text-label-text-weight: vars.$title-medium-weight, + text-label-text-tracking: vars.$title-medium-tracking, + text-horizontal-padding: 0.625rem, + ) + ); + + @include mat.button-toggle-overrides( + ( + label-text-font: vars.$title-medium-font, + label-text-size: vars.$title-medium-size, + label-text-weight: vars.$title-medium-weight, + label-text-tracking: vars.$title-medium-tracking, + ) + ); + + ::ng-deep .mat-button-toggle-label-content { + display: flex; + height: 2.5rem; + align-items: center; + padding: 0 0.625rem !important; + } + } +} + +// Reduce z-index of overlay container when menu is open to allow backdrop effect +// For other overlays we want to keep the higher z-index as they should appear above the header +::ng-deep .cdk-overlay-container:has(.menu-open-backdrop) { + z-index: $z-index - 1; + + .menu-open-backdrop { + top: 3.5rem; + background-color: utils.with-alpha(#405d40, 0.16); + backdrop-filter: blur(0.5rem); + mask: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0) 100%); + transition: 0.3s; + z-index: $z-index - 1; + top: 3.5rem; + } +} diff --git a/apps/cns-website/src/app/components/header/header.component.ts b/apps/cns-website/src/app/components/header/header.component.ts new file mode 100644 index 0000000000..6e692e7e70 --- /dev/null +++ b/apps/cns-website/src/app/components/header/header.component.ts @@ -0,0 +1,218 @@ +import { CdkTrapFocus } from '@angular/cdk/a11y'; +import { CdkConnectedOverlay, ConnectedPosition, Overlay, OverlayModule } from '@angular/cdk/overlay'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + ElementRef, + inject, + input, + signal, + viewChild, + viewChildren, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatIconModule } from '@angular/material/icon'; +import { EventType } from '@angular/router'; +import { Breakpoints, watchBreakpoint } from '@hra-ui/cdk/breakpoints'; +import { HraCommonModule } from '@hra-ui/common'; +import { injectRouter, RouterExtModule } from '@hra-ui/common/router-ext'; +import { ButtonsModule } from '@hra-ui/design-system/buttons'; +import { SkipToContentButtonComponent } from '@hra-ui/design-system/buttons/skip-to-content-button'; +import { InlineSVGModule } from 'ng-inline-svg-2'; +import { explicitEffect } from 'ngxtension/explicit-effect'; +import { filter } from 'rxjs'; +import { ScrollbarStore } from '../../state/scrollbar/scrollbar.store'; +import { SidebarStore } from '../../state/sidebar/sidebar.store'; +import { MegaMenuComponent } from './mega-menu/mega-menu.component'; +import { MobileMenuComponent } from './mobile-menu/mobile-menu.component'; +import { MENUS } from './static-data/parsed'; +import { Menu } from './types/menus.schema'; + +/** Position of the mobile menu overlay */ +const MOBILE_MENU_POSITIONS: ConnectedPosition[] = [ + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'top' }, +]; +/** Position of the desktop menu overlay */ +const DESKTOP_MENU_POSITIONS: ConnectedPosition[] = [ + { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top' }, +]; + +/** + * CNS Website header component + */ +@Component({ + selector: 'cns-header', + imports: [ + HraCommonModule, + RouterExtModule, + CdkTrapFocus, + OverlayModule, + MatIconModule, + ButtonsModule, + InlineSVGModule, + MobileMenuComponent, + MegaMenuComponent, + SkipToContentButtonComponent, + ], + templateUrl: './header.component.html', + styleUrl: './header.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class HeaderComponent { + /** Navigation options to display on the header */ + readonly menuOptions = input(MENUS); + + /** Whether the screen is currently mobile sized */ + protected readonly isMobile = watchBreakpoint(Breakpoints.Mobile); + /** Reference to this component's html element */ + private readonly elementRef = inject>(ElementRef); + + /** Sidebar store for managing sidebar state */ + protected readonly sidebarStore = inject(SidebarStore); + /** Scrollbar store for managing viewport scrolling */ + protected readonly scrollbarStore = inject(ScrollbarStore); + + /** Overlay positions for the mobile menu */ + protected readonly mobileMenuPositions = MOBILE_MENU_POSITIONS; + /** Overlay positions for the desktop menu */ + protected readonly desktopMenuPositions = DESKTOP_MENU_POSITIONS; + /** Blocking overlay scroll strategy */ + protected readonly mobileMenuBlockScroll = inject(Overlay).scrollStrategies.block(); + /** Offset from top to the menu. Used to calculate menu heights and max heights */ + protected readonly menuOffsetPx = signal(0); + /** Mobile menu height. Fills the entire screen */ + protected readonly mobileMenuHeight = computed(() => `calc(100vh - ${this.menuOffsetPx()}px)`); + /** Desktop menu max height */ + protected readonly desktopMenuMaxHeight = computed(() => `calc(100vh - ${this.menuOffsetPx()}px - 16px)`); + /** Mobile menu overlay origin */ + private readonly mobileMenuOrigin = viewChild.required('mobileMenuOrigin', { read: ElementRef }); + /** Desktop menu overlay origin */ + private readonly desktopMenuOrigin = viewChild.required('desktopMenuOrigin', { read: ElementRef }); + /** Reference to the mobile overlay */ + private readonly mobileMenuOverlay = viewChild('mobileMenuOverlay', { read: CdkConnectedOverlay }); + /** Currently open menu or undefined */ + private readonly activeMenu = signal(undefined); + + /** Focus traps for the open menus. Used to manage focus when menus are opened and closed */ + private readonly focusTraps = viewChildren(CdkTrapFocus); + + /** Stores the last focused element before a menu was opened, so that focus can be returned to it when the menu is closed */ + private lastFocusedElement?: HTMLElement; + + /** Initialize the header and set cleanup behaviors */ + constructor() { + effect((cleanup) => { + if (this.activeMenu() !== undefined) { + const observer = this.attachResizeObserver(); + cleanup(() => observer.disconnect()); + cleanup(() => { + this.lastFocusedElement?.focus(); + this.lastFocusedElement = undefined; + }); + } + }); + + explicitEffect([this.menuOffsetPx], () => this.updateMenuPositions(), { defer: true }); + + injectRouter({ optional: true }) + ?.events.pipe( + takeUntilDestroyed(), + filter((navigationEvent) => + [EventType.NavigationEnd, EventType.NavigationSkipped].includes(navigationEvent.type), + ), + ) + .subscribe(() => this.closeMenu()); + } + + /** + * Determine whether the specified menu is open + * + * @param menu The menu to check + * @returns true if the menu is open, false otherwise + */ + isMenuActive(menu: Menu | 'mobile'): boolean { + return this.activeMenu() === menu; + } + + /** + * Toggles a menu open or close + * + * @param menu Menu to toggle + */ + toggleMenu(menu: Menu | 'mobile'): void { + this.activeMenu.update((current) => (menu !== current ? menu : undefined)); + } + + /** + * Closes any active menu + */ + closeMenu(menu?: Menu | 'mobile'): void { + this.activeMenu.update((current) => (menu !== undefined && current !== menu ? current : undefined)); + } + + /** + * Moves focus to the specified menu if it is active + * @param menu Menu to move focus to + */ + moveFocusToMenu(menu: Menu): void { + if (this.isMenuActive(menu)) { + const traps = this.focusTraps(); + if (traps.length > 0) { + this.lastFocusedElement = document.activeElement as HTMLElement; + traps[0].focusTrap.focusInitialElementWhenReady(); + } + } + } + + /** + * Scrolls to the top of the page if the menu item is not an external link + * + * @param item Link item + */ + maybeScrollToTop(item: { external?: boolean }): void { + if (!item.external) { + this.scrollbarStore.scrollToTop(); + } + } + + /** + * Creates and attaches a resize observer that updates the menu offset + * whenever the header size changes + * + * @returns The resize observer + */ + private attachResizeObserver(): ResizeObserver { + const observer = new ResizeObserver(() => this.updateMenuOffset()); + observer.observe(this.elementRef.nativeElement, { box: 'border-box' }); + this.updateMenuOffset(); + return observer; + } + + /** + * Computes the bounding box for the menu's overlay origin element + * + * @returns The computed bounding box + */ + private getMenuOriginBbox(): DOMRect { + const origin = this.isMobile() ? this.mobileMenuOrigin() : this.desktopMenuOrigin(); + return (origin.nativeElement as Element).getBoundingClientRect(); + } + + /** + * Updates the menu offset based on the overlay origin's bounding box + */ + private updateMenuOffset(): void { + const { bottom } = this.getMenuOriginBbox(); + this.menuOffsetPx.set(bottom); + } + + /** + * Notify menu overlays of position changes + */ + private updateMenuPositions(): void { + /* istanbul ignore next */ + this.mobileMenuOverlay()?.overlayRef?.updatePosition(); + } +} diff --git a/apps/cns-website/src/app/components/header/mega-menu/mega-menu.component.html b/apps/cns-website/src/app/components/header/mega-menu/mega-menu.component.html new file mode 100644 index 0000000000..27c26d318f --- /dev/null +++ b/apps/cns-website/src/app/components/header/mega-menu/mega-menu.component.html @@ -0,0 +1,74 @@ +@if (menu().featured; as featured) { + +} + +
+ + +
+ @for (id of socials(); track id) { + + } +
+
diff --git a/apps/cns-website/src/app/components/header/mega-menu/mega-menu.component.scss b/apps/cns-website/src/app/components/header/mega-menu/mega-menu.component.scss new file mode 100644 index 0000000000..b67816e0c3 --- /dev/null +++ b/apps/cns-website/src/app/components/header/mega-menu/mega-menu.component.scss @@ -0,0 +1,140 @@ +@use '@angular/material' as mat; +@use 'utils'; +@use 'vars'; + +:host { + display: flex; + min-width: 40rem; + max-width: 81.25rem; + border-radius: vars.$corner-small; + overflow: hidden; + width: calc(100vw); + + .featured-section { + width: 19.75rem; + padding: 1.5rem; + background: vars.$surface-variant; + } + + .featured-link { + width: 100%; + border-radius: vars.$corner-small; + overflow: hidden; + height: fit-content; + } + + .featured-content-container { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .featured-image { + border-radius: vars.$corner-small; + } + + .featured-bottom { + display: flex; + text-align: start; + padding: 1rem; + min-height: 3.5rem; + background: vars.$surface-bright; + border: 0.0625rem solid vars.$outline-variant; + border-radius: vars.$corner-small; + height: auto; + + ::ng-deep .mdc-button__label { + width: 100%; + } + } + + .featured-label { + display: flex; + justify-content: space-between; + gap: 0.25rem; + } + + .featured-label-content { + display: flex; + flex-direction: column; + gap: 0.25rem; + justify-content: center; + width: 12.5rem; + + .tagline { + @include utils.use-font(label, medium); + color: vars.$on-surface-variant; + } + } + + .right-side { + background: vars.$surface-container; + display: flex; + flex-direction: column; + min-width: 25.5rem; + flex-grow: 1; + align-items: center; + gap: 1rem; + } + + .nav-items { + display: flex; + justify-content: center; + max-width: 44.5rem; + width: 100%; + } + + .groups { + display: flex; + width: 100%; + max-width: 44.5rem; + justify-content: space-between; + } + + .group { + @include utils.use-font(label, large); + display: flex; + flex-direction: column; + min-width: 12rem; + max-width: 21rem; + flex-grow: 1; + padding: 1.5rem; + width: 50%; + } + + .items { + display: flex; + flex-direction: column; + justify-content: start; + gap: 0.125rem; + } + + .group-label { + color: vars.$on-surface-variant; + height: 2rem; + padding: 0 0.75rem; + display: flex; + align-items: center; + } + + .item-label { + min-height: 2.75rem; + justify-content: start; + padding: 0.5rem 0.75rem; + border-radius: vars.$corner-small; + height: auto; + } + + .divider { + margin: 0.375rem 0 0.5rem; + color: vars.$outline-variant; + } + + .socials { + display: flex; + width: 100%; + padding: 0.5rem 1.5rem; + justify-content: space-between; + max-width: 44.5rem; + } +} diff --git a/apps/cns-website/src/app/components/header/mega-menu/mega-menu.component.ts b/apps/cns-website/src/app/components/header/mega-menu/mega-menu.component.ts new file mode 100644 index 0000000000..84ad461a6f --- /dev/null +++ b/apps/cns-website/src/app/components/header/mega-menu/mega-menu.component.ts @@ -0,0 +1,40 @@ +import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatIconModule } from '@angular/material/icon'; +import { HraCommonModule } from '@hra-ui/common'; +import { RouterExtModule } from '@hra-ui/common/router-ext'; +import { ButtonsModule } from '@hra-ui/design-system/buttons'; +import { ScrollbarStore } from '../../../state/scrollbar/scrollbar.store'; +import { CNS_SOCIAL_IDS } from '../../static-data/parsed'; +import { Menu } from '../types/menus.schema'; + +/** + * A menu to be shown when certain header options are clicked + */ +@Component({ + selector: 'cns-mega-menu', + imports: [HraCommonModule, RouterExtModule, MatIconModule, ButtonsModule, MatDividerModule], + templateUrl: './mega-menu.component.html', + styleUrl: './mega-menu.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MegaMenuComponent { + /** Menu data to display */ + readonly menu = input.required(); + /** Social media button data */ + readonly socials = input(CNS_SOCIAL_IDS); + + /** Scrollbar store for managing viewport scrolling */ + private readonly scrollbarStore = inject(ScrollbarStore); + + /** + * Scrolls to the top of the page if the menu item is not an external link + * + * @param item Link item + */ + maybeScrollToTop(item: { external?: boolean }): void { + if (!item.external) { + this.scrollbarStore.scrollToTop(); + } + } +} diff --git a/apps/cns-website/src/app/components/header/menu-content/menu-content.component.html b/apps/cns-website/src/app/components/header/menu-content/menu-content.component.html new file mode 100644 index 0000000000..d8ede44145 --- /dev/null +++ b/apps/cns-website/src/app/components/header/menu-content/menu-content.component.html @@ -0,0 +1,16 @@ +@for (item of flattenedMenuItems(); track item) { + + {{ item.label }} + +} diff --git a/apps/cns-website/src/app/components/header/menu-content/menu-content.component.scss b/apps/cns-website/src/app/components/header/menu-content/menu-content.component.scss new file mode 100644 index 0000000000..c119c43cc7 --- /dev/null +++ b/apps/cns-website/src/app/components/header/menu-content/menu-content.component.scss @@ -0,0 +1,27 @@ +@use '@angular/material' as mat; +@use 'utils'; +@use 'vars'; + +$item-height: 3rem; +$item-horizontal-padding: 0.75em; + +:host { + display: flex; + flex-direction: column; + gap: 1rem; + + --hra-header-menu-content-item-horizontal-padding: #{$item-horizontal-padding}; + + ::ng-deep .mdc-button__label { + width: 100%; + } + + .item-label { + height: auto; + min-height: $item-height; + padding: 0 var(--hra-header-menu-content-item-horizontal-padding); + color: vars.$on-surface-variant; + justify-content: start; + @include utils.use-font(title, medium); + } +} diff --git a/apps/cns-website/src/app/components/header/menu-content/menu-content.component.ts b/apps/cns-website/src/app/components/header/menu-content/menu-content.component.ts new file mode 100644 index 0000000000..bacac26b39 --- /dev/null +++ b/apps/cns-website/src/app/components/header/menu-content/menu-content.component.ts @@ -0,0 +1,40 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatIconModule } from '@angular/material/icon'; +import { HraCommonModule } from '@hra-ui/common'; +import { RouterExtModule } from '@hra-ui/common/router-ext'; +import { ButtonsModule } from '@hra-ui/design-system/buttons'; +import { ScrollbarStore } from '../../../state/scrollbar/scrollbar.store'; +import { Menu } from '../types/menus.schema'; + +/** + * Displays the menu for mobile screens + */ +@Component({ + selector: 'cns-menu-content', + imports: [HraCommonModule, RouterExtModule, MatIconModule, ButtonsModule, MatDividerModule], + templateUrl: './menu-content.component.html', + styleUrl: './menu-content.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MenuContentComponent { + /** Menu data to display */ + readonly menu = input.required(); + + /** Menu items with groups flattened */ + readonly flattenedMenuItems = computed(() => this.menu().items?.flatMap((group) => group.items)); + + /** Scrollbar store for managing viewport scrolling */ + private readonly scrollbarStore = inject(ScrollbarStore); + + /** + * Scrolls to the top of the page if the menu item is not an external link + * + * @param item Link item + */ + maybeScrollToTop(item: { external?: boolean }): void { + if (!item.external) { + this.scrollbarStore.scrollToTop(); + } + } +} diff --git a/apps/cns-website/src/app/components/header/mobile-menu/mobile-menu.component.html b/apps/cns-website/src/app/components/header/mobile-menu/mobile-menu.component.html new file mode 100644 index 0000000000..fbf96fd5cf --- /dev/null +++ b/apps/cns-website/src/app/components/header/mobile-menu/mobile-menu.component.html @@ -0,0 +1,59 @@ + + + + + + + @for (option of menuOptions().options; track option) { + @if (option.type === 'item') { + + {{ option.label }} + + } @else { + + + + {{ option.label }} + + + + + + + } + + + } + + + +
+ @for (id of socials(); track id) { + + } +
diff --git a/apps/cns-website/src/app/components/header/mobile-menu/mobile-menu.component.scss b/apps/cns-website/src/app/components/header/mobile-menu/mobile-menu.component.scss new file mode 100644 index 0000000000..46e51bfa95 --- /dev/null +++ b/apps/cns-website/src/app/components/header/mobile-menu/mobile-menu.component.scss @@ -0,0 +1,123 @@ +@use '@angular/material' as mat; +@use 'utils'; +@use 'vars'; + +$header-height: 6rem; +$category-height: 4rem; +$socials-height: 4.5rem; +$horizontal-padding: 1.5rem; +$category-inner-horizontal-padding: 0.75rem; + +:host { + display: flex; + flex-direction: column; + width: 100%; + background-color: vars.$surface-bright; + justify-content: space-between; + + .menu-header { + display: flex; + justify-content: space-between; + align-items: center; + height: $header-height; + padding: 0 $horizontal-padding; + + .logo { + display: flex; + justify-content: center; + color: vars.$on-surface; + } + + button { + @include mat.icon-button-overrides( + ( + icon-color: vars.$on-surface, + ) + ); + } + } + + .divider { + margin: 0 $horizontal-padding; + + @include mat.divider-overrides( + ( + color: vars.$outline, + ) + ); + } + + .categories-container { + height: calc(100% - $header-height - $socials-height); + --scrollbar-offset: 2; + + .categories { + @include mat.expansion-overrides( + ( + container-shape: vars.$corner-none, + container-background-color: vars.$surface-bright, + container-elevation-shadow: none, + container-text-color: vars.$on-surface, + header-collapsed-state-height: $category-height, + header-expanded-state-height: $category-height, + ) + ); + + .category { + margin: 0 $horizontal-padding; + } + + .category.link { + width: calc(100% - 2 * $horizontal-padding); + height: $category-height; + justify-content: start; + padding: 0 $category-inner-horizontal-padding; + border-radius: vars.$corner-none; + @include utils.use-font(title, medium); + } + + .category.panel { + .header { + padding: 0 0 0 $category-inner-horizontal-padding; + + .title { + display: flex; + justify-content: space-between; + margin-right: 0; + + .toggle::after { + display: block; + content: 'add'; + } + } + } + + &.mat-expanded .header .title .toggle::after { + content: 'remove'; + } + + ::ng-deep .mat-expansion-panel-body { + padding: 0; + } + + .content { + padding: 0.5rem 0 1.25rem; + border-top: 1px solid vars.$outline-variant; + } + } + + > *:last-child { + margin-bottom: 1.25rem; + } + } + } + + .socials { + display: flex; + justify-content: space-between; + align-items: center; + height: $socials-height; + padding: 1rem $horizontal-padding; + background: vars.$surface-container-high; + } +} diff --git a/apps/cns-website/src/app/components/header/mobile-menu/mobile-menu.component.ts b/apps/cns-website/src/app/components/header/mobile-menu/mobile-menu.component.ts new file mode 100644 index 0000000000..d3a9c347d0 --- /dev/null +++ b/apps/cns-website/src/app/components/header/mobile-menu/mobile-menu.component.ts @@ -0,0 +1,58 @@ +import { ChangeDetectionStrategy, Component, inject, input, output } from '@angular/core'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatIconModule } from '@angular/material/icon'; +import { HraCommonModule } from '@hra-ui/common'; +import { RouterExtModule } from '@hra-ui/common/router-ext'; +import { ButtonsModule } from '@hra-ui/design-system/buttons'; +import { SOCIAL_IDS, SocialMediaButtonComponent } from '@hra-ui/design-system/buttons/social-media-button'; +import { ScrollingModule } from '@hra-ui/design-system/scrolling'; +import { InlineSVGModule } from 'ng-inline-svg-2'; +import { ScrollbarStore } from '../../../state/scrollbar/scrollbar.store'; +import { MenuContentComponent } from '../menu-content/menu-content.component'; +import { Menus } from '../types/menus.schema'; + +/** + * Display a menu for mobile sized screens + */ +@Component({ + selector: 'cns-mobile-menu', + imports: [ + HraCommonModule, + RouterExtModule, + MatDividerModule, + MatExpansionModule, + MatIconModule, + ScrollingModule, + MenuContentComponent, + ButtonsModule, + InlineSVGModule, + SocialMediaButtonComponent, + ], + templateUrl: './mobile-menu.component.html', + styleUrl: './mobile-menu.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MobileMenuComponent { + /** Options to display on the mobile menu */ + readonly menuOptions = input.required(); + /** Social media button data */ + readonly socials = input(SOCIAL_IDS); + + /** Emits when menu is closed */ + readonly closeMenu = output(); + + /** Scrollbar store for managing viewport scrolling */ + private readonly scrollbarStore = inject(ScrollbarStore); + + /** + * Scrolls to the top of the page if the menu item is not an external link + * + * @param item Link item + */ + maybeScrollToTop(item: { external?: boolean }): void { + if (!item.external) { + this.scrollbarStore.scrollToTop(); + } + } +} diff --git a/apps/cns-website/src/app/components/header/static-data/menus.json b/apps/cns-website/src/app/components/header/static-data/menus.json new file mode 100644 index 0000000000..417f66880d --- /dev/null +++ b/apps/cns-website/src/app/components/header/static-data/menus.json @@ -0,0 +1,160 @@ +{ + "$schema": "../types/menus.schema.json", + "options": [ + { + "type": "menu", + "id": "about", + "label": "About", + "featured": { + "type": "item", + "label": "Meet our team", + "url": "https://cns.iu.edu/people", + "imgSrc": "assets/group.png" + }, + "items": [ + { + "type": "group", + "label": "About CNS", + "items": [ + { + "type": "item", + "label": "History & mission", + "url": "https://cns.iu.edu/about/" + }, + { + "type": "item", + "label": "People", + "url": "https://cns.iu.edu/people/" + }, + { + "type": "item", + "label": "Jobs", + "url": "https://cns.iu.edu/jobs/" + } + ] + }, + { + "type": "group", + "label": "Connect", + "items": [ + { + "type": "item", + "label": "Contact us", + "url": "https://cns.iu.edu/about#contact-us" + }, + { + "type": "item", + "label": "Donate", + "url": "https://give.myiu.org/iu-bloomington/I320004200.html/", + "external": true, + "target": "_blank" + }, + { + "type": "item", + "label": "Visitor info", + "url": "https://cns.iu.edu/visitor-info/" + } + ] + } + ] + }, + { + "type": "item", + "label": "Publications", + "url": "https://cns.iu.edu/research/?category=publication&view=list&group-by=year" + }, + { + "type": "menu", + "id": "research", + "label": "Research", + "featured": { + "type": "item", + "label": "Explore research", + "tagline": "20+ years of research at Indiana University", + "url": "https://cns.iu.edu/research", + "imgSrc": "assets/research.png" + }, + "items": [ + { + "type": "group", + "label": "Research at CNS", + "items": [ + { + "type": "item", + "label": "All research", + "url": "https://cns.iu.edu/research", + "mobileOnly": true + }, + { + "type": "item", + "label": "Events", + "url": "https://cns.iu.edu/research/?category=event&view=list&group-by=year" + }, + { + "type": "item", + "label": "Funding", + "url": "https://cns.iu.edu/research/?category=funding&view=list&group-by=year" + }, + { + "type": "item", + "label": "News", + "url": "https://cns.iu.edu/research/?category=news" + }, + { + "type": "item", + "label": "Publications", + "url": "https://cns.iu.edu/research/?category=publication&view=list&group-by=year" + }, + { + "type": "item", + "label": "Presentations", + "url": "https://cns.iu.edu/research/?category=event&event=presentation&view=list&group-by=year" + }, + { + "type": "item", + "label": "Visualizations", + "url": "https://cns.iu.edu/research/?category=visualization&group-by=year" + } + ] + }, + { + "type": "group", + "label": "Featured projects", + "items": [ + { + "type": "item", + "label": "Amatria: Sentient Architecture", + "url": "https://cns.iu.edu/amatria" + }, + { + "type": "item", + "label": "Human Reference Atlas", + "url": "https://humanatlas.io", + "external": true, + "target": "_blank" + }, + { + "type": "item", + "label": "Places & Spaces Exhibit", + "url": "https://scimaps.org", + "external": true, + "target": "_blank" + } + ] + } + ] + }, + { + "type": "item", + "label": "Teaching", + "url": "https://ivmooc.cns.iu.edu/", + "external": true, + "target": "_blank" + }, + { + "type": "item", + "label": "Exhibits", + "url": "https://cns.iu.edu/exhibit" + } + ] +} diff --git a/apps/cns-website/src/app/components/header/static-data/parsed.ts b/apps/cns-website/src/app/components/header/static-data/parsed.ts new file mode 100644 index 0000000000..eef7331c6b --- /dev/null +++ b/apps/cns-website/src/app/components/header/static-data/parsed.ts @@ -0,0 +1,5 @@ +import { Menus } from '../types/menus.schema'; +import * as menus from './menus.json'; + +/** Menus objects */ +export const MENUS = menus as Menus; diff --git a/apps/cns-website/src/app/components/header/types/menus.schema.json b/apps/cns-website/src/app/components/header/types/menus.schema.json new file mode 100644 index 0000000000..6e20755007 --- /dev/null +++ b/apps/cns-website/src/app/components/header/types/menus.schema.json @@ -0,0 +1,133 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/CnsHeaderMenu" + }, + { + "$ref": "#/$defs/CnsHeaderMenuItem" + } + ] + } + } + }, + "required": [ + "$schema", + "options" + ], + "additionalProperties": false, + "id": "CnsHeaderMenus", + "$defs": { + "CnsHeaderMenu": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "menu" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/CnsHeaderMenuGroup" + } + }, + "featured": { + "$ref": "#/$defs/CnsHeaderMenuItem" + } + }, + "required": [ + "type", + "id", + "label" + ], + "additionalProperties": false, + "id": "CnsHeaderMenu" + }, + "CnsHeaderMenuGroup": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "group" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "external": { + "type": "boolean" + }, + "target": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/CnsHeaderMenuItem" + } + } + }, + "required": [ + "type", + "label", + "items" + ], + "additionalProperties": false, + "id": "CnsHeaderMenuGroup" + }, + "CnsHeaderMenuItem": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "item" + }, + "label": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "tagline": { + "type": "string" + }, + "imgSrc": { + "type": "string" + }, + "external": { + "type": "boolean" + }, + "target": { + "type": "string" + }, + "mobileOnly": { + "type": "boolean" + } + }, + "required": [ + "type", + "label", + "url" + ], + "additionalProperties": false, + "id": "CnsHeaderMenuItem" + } + } +} \ No newline at end of file diff --git a/apps/cns-website/src/app/components/header/types/menus.schema.ts b/apps/cns-website/src/app/components/header/types/menus.schema.ts new file mode 100644 index 0000000000..32b1b6a9b7 --- /dev/null +++ b/apps/cns-website/src/app/components/header/types/menus.schema.ts @@ -0,0 +1,56 @@ +import * as z from 'zod'; + +/** A menu item */ +export type MenuItem = z.infer; +/** Schema for a menu item */ +export const MenuItemSchema = z + .object({ + type: z.literal('item'), + label: z.string(), + url: z.string().url(), + tagline: z.string().optional(), + imgSrc: z.string().optional(), + external: z.boolean().optional(), + target: z.string().optional(), + mobileOnly: z.boolean().optional(), + }) + .meta({ id: 'CnsHeaderMenuItem' }); + +/** A menu group */ +export type MenuGroup = z.infer; +/** Schema for a menu group */ +export const MenuGroupSchema = z + .object({ + type: z.literal('group'), + label: z.string(), + description: z.string().optional(), + external: z.boolean().optional(), + target: z.string().optional(), + items: MenuItemSchema.array(), + }) + .meta({ id: 'CnsHeaderMenuGroup' }); + +/** A menu */ +export type Menu = z.infer; +/** Schema for a menu */ +export const MenuSchema = z + .object({ + type: z.literal('menu'), + id: z.string(), + label: z.string(), + items: MenuGroupSchema.array().optional(), + featured: MenuItemSchema.optional(), + }) + .meta({ id: 'CnsHeaderMenu' }); + +/** Multiple menus */ +export type Menus = z.infer; +/** Schema for multiple menus */ +export const MenusSchema = z + .object({ + $schema: z.string(), + options: z.union([MenuSchema, MenuItemSchema]).array(), + }) + .meta({ id: 'CnsHeaderMenus' }); + +export default MenusSchema; diff --git a/apps/cns-website/src/app/components/static-data/cns-social-media.json b/apps/cns-website/src/app/components/static-data/cns-social-media.json new file mode 100644 index 0000000000..dc85c85b81 --- /dev/null +++ b/apps/cns-website/src/app/components/static-data/cns-social-media.json @@ -0,0 +1,54 @@ +{ + "$schema": "../types/social-media.schema.json", + "socials": [ + { + "id": "linkedin", + "label": "Connect with CNS on LinkedIn", + "icon": "social:linkedin", + "link": "https://www.linkedin.com/company/cns-indiana-university-bloomington" + }, + { + "id": "youtube", + "label": "Learn about CNS on YouTube", + "icon": "social:youtube", + "link": "https://www.youtube.com/@CNSCenter/" + }, + { + "id": "instagram", + "label": "Connect with CNS on Instagram", + "icon": "social:instagram", + "link": "https://www.instagram.com/cns_at_iu/" + }, + { + "id": "facebook", + "label": "Connect with CNS on Facebook", + "icon": "social:facebook", + "link": "https://www.facebook.com/cnscenter/" + }, + { + "id": "github", + "label": "CNS GitHub", + "icon": "social:github", + "link": "https://github.com/cns-iu" + }, + { + "id": "bluesky", + "label": "Connect with CNS on Bluesky", + "icon": "social:bluesky", + "link": "https://bsky.app/profile/cnscenter.bsky.social" + }, + { + "id": "x", + "label": "Connect with CNS on X (formerly Twitter)", + "icon": "social:x", + "link": "https://twitter.com/cnscenter" + }, + { + "id": "email", + "label": "Email us", + "icon": "email", + "link": "mailto:cnscntr@iu.edu", + "isFontIcon": true + } + ] +} diff --git a/apps/cns-website/src/app/components/static-data/parsed.ts b/apps/cns-website/src/app/components/static-data/parsed.ts new file mode 100644 index 0000000000..7667b26c8b --- /dev/null +++ b/apps/cns-website/src/app/components/static-data/parsed.ts @@ -0,0 +1,7 @@ +import { SocialsSchema } from '@hra-ui/design-system/buttons/social-media-button'; +import RAW_CNS_SOCIALS from './cns-social-media.json'; + +/** Parsed CNS social media items */ +export const CNS_SOCIALS = SocialsSchema.parse(RAW_CNS_SOCIALS).socials; +/** All available CNS social ids */ +export const CNS_SOCIAL_IDS = CNS_SOCIALS.map(({ id }) => id); diff --git a/apps/cns-website/src/app/pages/archive-redirect/archive-redirect.component.html b/apps/cns-website/src/app/pages/archive-redirect/archive-redirect.component.html new file mode 100644 index 0000000000..94114ffb3a --- /dev/null +++ b/apps/cns-website/src/app/pages/archive-redirect/archive-redirect.component.html @@ -0,0 +1,72 @@ +
+

+ @if (redirectUrl()) { + This page has been archived + } @else { + This page isn’t available + } +

+ + @if (!redirectUrl()) { +

+ @if (isLoading()) { + Check the page address or try searching Google for this URL. + } @else { + No archived pages found. Check the page address or try searching Google for this URL. + } +

+ + + } + + @if (redirectUrl() || isLoading()) { +
+ + +
+ @if (isLoading()) { + Looking for archived pages... + } @else { +
Redirecting in {{ countdown$ | async }}
+
{{ redirectUrl() }}
+ } +
+
+ } + + +
diff --git a/apps/cns-website/src/app/pages/archive-redirect/archive-redirect.component.scss b/apps/cns-website/src/app/pages/archive-redirect/archive-redirect.component.scss new file mode 100644 index 0000000000..68547be30b --- /dev/null +++ b/apps/cns-website/src/app/pages/archive-redirect/archive-redirect.component.scss @@ -0,0 +1,89 @@ +@use '@angular/material' as mat; +@use 'utils'; +@use 'vars'; + +:host { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: calc(100vh - 3.5rem); + + .content { + display: flex; + flex-direction: column; + align-items: center; + margin: 7.5rem 1rem; + + @media (min-width: 640px) { + margin: 7.5rem 2.5rem; + } + } + + .title { + color: vars.$on-background; + text-align: center; + margin-bottom: 1rem; + @include utils.use-font(display, medium); + + &:has(+ .card) { + margin-bottom: 2.5rem; + } + } + + .description { + color: vars.$on-surface-variant; + margin-bottom: 2rem; + text-align: center; + @include utils.use-font(body, large); + } + + .copy-url:has(+ .card) { + margin-bottom: 2.5rem; + } + + .card { + display: flex; + flex-direction: column; + align-items: center; + max-width: 360px; + padding: 2rem; + border: 1px solid vars.$outline-variant; + border-radius: vars.$corner-medium; + + &.large { + max-width: 28rem; + } + + .spinner { + margin-bottom: 1rem; + + @include mat.progress-spinner-overrides( + ( + active-indicator-color: vars.$secondary, + ) + ); + } + + .spinner-text { + color: vars.$on-surface-variant; + text-align: center; + @include utils.use-font(body, large); + + .line-1 { + margin-bottom: 1rem; + } + + .line-2 { + color: vars.$on-surface; + } + } + } + + .actions { + display: flex; + justify-content: center; + gap: 4rem; + margin-top: 4rem; + } +} diff --git a/apps/cns-website/src/app/pages/archive-redirect/archive-redirect.component.spec.ts b/apps/cns-website/src/app/pages/archive-redirect/archive-redirect.component.spec.ts new file mode 100644 index 0000000000..d9fa49c819 --- /dev/null +++ b/apps/cns-website/src/app/pages/archive-redirect/archive-redirect.component.spec.ts @@ -0,0 +1,115 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ActivatedRoute } from '@angular/router'; +import { provideWindow } from '@hra-ui/common/injectors'; +import { render, screen } from '@testing-library/angular'; +import { firstValueFrom, Observable, of } from 'rxjs'; +import { ArchiveRedirectComponent } from './archive-redirect.component'; +import { ArchiveStore } from './state/archive.store'; + +describe('ArchiveRedirectComponent', () => { + function createStoreMock(options?: { isLoading?: boolean; redirectUrl?: string }) { + const isLoading = options?.isLoading ?? false; + const redirectUrl = options?.redirectUrl; + + return { + loadEntries: jest.fn(), + isLoading: jest.fn(() => isLoading), + getEntryBefore: jest.fn(() => + redirectUrl + ? { + redirectUrl, + timestamp: Date.UTC(2026, 2, 9), + } + : undefined, + ), + }; + } + + async function setup(options?: { + routeSegments?: string[]; + isLoading?: boolean; + redirectUrl?: string; + href?: string; + }) { + const store = createStoreMock({ + isLoading: options?.isLoading, + redirectUrl: options?.redirectUrl, + }); + + const windowMock = { + location: { + href: options?.href ?? 'https://cns.iu.edu/old/page', + }, + }; + + const routeSegments = options?.routeSegments ?? ['old', 'page']; + + const renderResult = await render(ArchiveRedirectComponent, { + componentProviders: [{ provide: ArchiveStore, useValue: store }], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { + provide: ActivatedRoute, + useValue: { + url: of(routeSegments.map((segment) => ({ toString: () => segment }))), + }, + }, + provideWindow(windowMock as unknown as Window & typeof globalThis), + ], + }); + + return { ...renderResult, store, windowMock }; + } + + it('loads archive entries using the current route path', async () => { + const { store } = await setup({ routeSegments: ['legacy', 'news', 'article'] }); + + expect(store.loadEntries).toHaveBeenCalledTimes(1); + + const [route$] = store.loadEntries.mock.calls[0] as [Observable]; + await expect(firstValueFrom(route$)).resolves.toBe('legacy/news/article'); + }); + + it('shows loading state while archive entries are being fetched', async () => { + await setup({ isLoading: true }); + + expect(screen.getByText('This page isn’t available')).toBeInTheDocument(); + expect(screen.getByText('Looking for archived pages...')).toBeInTheDocument(); + expect( + screen.queryByText('No archived pages found. Check the page address or try searching Google for this URL.'), + ).not.toBeInTheDocument(); + }); + + it('shows unavailable state when no archived page is found', async () => { + await setup({ isLoading: false, redirectUrl: undefined }); + + expect(screen.getByText('This page isn’t available')).toBeInTheDocument(); + expect( + screen.getByText('No archived pages found. Check the page address or try searching Google for this URL.'), + ).toBeInTheDocument(); + expect(screen.queryByText('Looking for archived pages...')).not.toBeInTheDocument(); + }); + + it('shows archived page messaging and action link when redirect exists', async () => { + const redirectUrl = 'https://apps.humanatlas.io/archive/page'; + await setup({ isLoading: false, redirectUrl }); + + expect(screen.getByText('This page has been archived')).toBeInTheDocument(); + expect(screen.getByText(redirectUrl)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /view archived page/i })).toHaveAttribute('href', redirectUrl); + }); + + it('redirects to the archived page after countdown reaches zero', async () => { + jest.useFakeTimers(); + const redirectUrl = 'https://apps.humanatlas.io/archive/page'; + + const { windowMock } = await setup({ isLoading: false, redirectUrl }); + + jest.advanceTimersByTime(5000); + expect(windowMock.location.href).toBe(redirectUrl); + + jest.useRealTimers(); + }); +}); diff --git a/apps/cns-website/src/app/pages/archive-redirect/archive-redirect.component.ts b/apps/cns-website/src/app/pages/archive-redirect/archive-redirect.component.ts new file mode 100644 index 0000000000..0db18cdf52 --- /dev/null +++ b/apps/cns-website/src/app/pages/archive-redirect/archive-redirect.component.ts @@ -0,0 +1,133 @@ +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, numberAttribute } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { ActivatedRoute } from '@angular/router'; +import { HraCommonModule } from '@hra-ui/common'; +import { injectWindow } from '@hra-ui/common/injectors'; +import { LinkDirective } from '@hra-ui/common/router-ext'; +import { ButtonsModule } from '@hra-ui/design-system/buttons'; +import { CopyableUrlContainerComponent } from '@hra-ui/design-system/copyable-url-container'; +import { createInjectionToken } from 'ngxtension/create-injection-token'; +import { interval, map, Observable, shareReplay, startWith, Subject, switchMap, take } from 'rxjs'; +import { ArchiveStore } from './state/archive.store'; + +/** Options for configuring the archive redirect component */ +export interface ArchiveRedirectOptions { + /** Timestamp to look for archived pages before (in milliseconds since epoch) */ + timestamp?: number; + /** Delay before redirecting, in seconds */ + redirectDelaySeconds?: number; +} + +/** Default options for the archive redirect component */ +const DEFAULT_OPTIONS: Required = { + timestamp: Date.UTC(2026, 2, 9), // March 9, 2026 + redirectDelaySeconds: 5, +}; + +/** Injection token for archive redirect options */ +const OPTIONS_TOKEN = createInjectionToken((): ArchiveRedirectOptions => DEFAULT_OPTIONS); + +/** Inject archive redirect options */ +export const injectArchiveRedirectOptions = OPTIONS_TOKEN[0]; +/** Provide a different set of archive redirect options */ +export const provideArchiveRedirectOptions = OPTIONS_TOKEN[1]; + +/** + * RxJS operator that creates a countdown from a specified number of seconds, + * emitting the remaining seconds at each tick. + * + * @param seconds Number of seconds to count down from + * @returns Observable emitting the remaining seconds at each tick + */ +function countdown(seconds: number): Observable { + return interval(1000).pipe( + take(seconds), + map((elapsedSeconds) => seconds - elapsedSeconds - 1), + startWith(seconds), + ); +} + +/** + * Component for handling redirects from archived pages. + * It checks if the current URL matches any known archived page URLs and, if so, displays a message and + * automatically redirects to the archived page after a short delay. + */ +@Component({ + selector: 'cns-archive-redirect', + imports: [ + HraCommonModule, + ButtonsModule, + CopyableUrlContainerComponent, + LinkDirective, + MatIconModule, + MatProgressSpinnerModule, + ], + templateUrl: './archive-redirect.component.html', + styleUrl: './archive-redirect.component.scss', + providers: [ArchiveStore], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ArchiveRedirectComponent { + /** Look for pages archived no later than this timestamp */ + readonly timestamp = input(); + + /** Archive redirect options */ + readonly options = { ...DEFAULT_OPTIONS, ...injectArchiveRedirectOptions() }; + + /** Global window reference */ + private readonly window = injectWindow(); + /** Archive store reference */ + private readonly store = inject(ArchiveStore); + /** Activated route reference */ + private readonly activatedRoute = inject(ActivatedRoute); + + /** Parsed timestamp */ + private readonly parsedTimestamp = computed(() => { + const defaultTimestamp = this.options.timestamp; + const value = numberAttribute(this.timestamp(), defaultTimestamp); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : defaultTimestamp; + }); + + /** Current URL */ + protected readonly currentUrl = computed(() => this.window.location.href); + /** Redirect URL if found */ + protected readonly redirectUrl = computed(() => this.store.getEntryBefore(this.parsedTimestamp())?.redirectUrl); + /** Whether the archive entries are loading */ + protected readonly isLoading = computed(() => this.store.isLoading()); + + /** Subject used to start the redirect countdown */ + private startCountdown$ = new Subject(); + /** Redirect countdown observable */ + protected readonly countdown$ = this.startCountdown$.pipe( + switchMap(() => countdown(this.options.redirectDelaySeconds)), + takeUntilDestroyed(), + shareReplay(1), + ); + + /** + * Initializes the component by loading the archive entries for the current route and + * setting up an effect to start the redirect countdown if a redirect URL is found. + */ + constructor() { + const route = this.activatedRoute.url.pipe( + map((segments) => segments.map((segment) => segment.toString()).join('/')), + ); + this.store.loadEntries(route); + + effect(() => { + const url = this.redirectUrl(); + if (!this.isLoading() && url) { + this.startCountdown$.next(); + } + }); + + this.countdown$.subscribe((secondsLeft) => { + const url = this.redirectUrl(); + if (secondsLeft === 0 && url) { + this.window.location.href = url; + } + }); + } +} diff --git a/apps/cns-website/src/app/pages/archive-redirect/state/archive.service.ts b/apps/cns-website/src/app/pages/archive-redirect/state/archive.service.ts new file mode 100644 index 0000000000..e9416b5a64 --- /dev/null +++ b/apps/cns-website/src/app/pages/archive-redirect/state/archive.service.ts @@ -0,0 +1,128 @@ +import { HttpClient } from '@angular/common/http'; +import { computed, inject, Injectable } from '@angular/core'; +import { injectAppHref, joinWithSlash } from '@hra-ui/common/url'; +import { createInjectionToken } from 'ngxtension/create-injection-token'; +import { map, Observable } from 'rxjs'; + +/** Archive page entry */ +export interface ArchiveEntry { + /** Timestamp of the archived page */ + timestamp: number; + /** Archive page URL */ + url: string; + /** Redirect URL */ + redirectUrl: string; +} + +/** Options for configuring the archive service */ +export interface ArchiveServiceOptions { + /** API endpoint URL */ + apiEndpointUrl?: string; + /** Base URL for constructing redirect URLs */ + redirectBaseUrl?: string; + /** Default application href if not available from `injectAppHref` */ + defaultAppHref?: string; +} + +/** Default options for the archive service */ +const DEFAULT_OPTIONS: Required = { + apiEndpointUrl: 'https://demo.cns.iu.edu/cns-api/archive.php', + redirectBaseUrl: 'https://wayback.archive-it.org/219/', + defaultAppHref: 'https://cns.iu.edu/', +}; + +/** Archive options injection methods */ +const OPTIONS_TOKEN = createInjectionToken((): ArchiveServiceOptions => DEFAULT_OPTIONS); + +/** Inject archive options */ +export const injectArchiveOptions = OPTIONS_TOKEN[0]; +/** Provide a different set of archive options */ +export const provideArchiveOptions = OPTIONS_TOKEN[1]; + +/** Service for interacting with the archive API */ +@Injectable({ + providedIn: 'root', +}) +export class ArchiveService { + /** Archive options */ + readonly options = { ...DEFAULT_OPTIONS, ...injectArchiveOptions() }; + + /** Http client */ + private readonly http = inject(HttpClient); + /** Application href */ + private readonly appHref = injectAppHref(); + /** Page base URL */ + private readonly pageBaseUrl = computed(() => this.appHref() || this.options.defaultAppHref); + + /** + * Load archive entries for a given route. + * + * @param route Route to load archive entries for + * @returns Observable of archive entries + */ + loadByRoute(route: string): Observable { + return this.http + .get(this.options.apiEndpointUrl, { + responseType: 'text', + params: { + url: joinWithSlash(this.pageBaseUrl(), route), + }, + }) + .pipe(map((response) => this.parseCdxResponse(response))); + } + + /** + * Parse CDX API response into archive entries. + * + * @param response CDX API response + * @returns Array of archive entries + */ + private parseCdxResponse(response: string): ArchiveEntry[] { + const entries: ArchiveEntry[] = []; + for (const line of response.split('\n')) { + const trimmedLine = line.trim(); + const [timestampString, url] = trimmedLine.split(' '); + const timestamp = this.parseCdxTimestamp(timestampString); + if (timestamp && url) { + entries.push({ + timestamp, + url, + redirectUrl: this.constructRedirectUrl(timestampString, url), + }); + } + } + + return entries; + } + + /** + * Parse CDX timestamp string into a Unix timestamp. + * CDX timestamps are in the format YYYYMMDDhhmmss. + * Also note that the month is 1-based in the CDX timestamp. + * + * @param cdxTimestamp Timestamp string from CDX API response + * @returns Unix timestamp in milliseconds + */ + private parseCdxTimestamp(cdxTimestamp: string): number { + const year = Number(cdxTimestamp.slice(0, 4)); + const month = Number(cdxTimestamp.slice(4, 6)) - 1; + const day = Number(cdxTimestamp.slice(6, 8)); + const hour = Number(cdxTimestamp.slice(8, 10)); + const minute = Number(cdxTimestamp.slice(10, 12)); + const second = Number(cdxTimestamp.slice(12, 14)); + + return Date.UTC(year, month, day, hour, minute, second); + } + + /** + * Construct a redirect URL for an archived page. + * + * @param timestamp Page timestamp in CDX format (YYYYMMDDhhmmss) + * @param url Original URL of the archived page + * @returns Constructed redirect URL + */ + private constructRedirectUrl(timestamp: string, url: string): string { + const base = joinWithSlash(this.options.redirectBaseUrl, timestamp); + return joinWithSlash(base, url); + } +} diff --git a/apps/cns-website/src/app/pages/archive-redirect/state/archive.store.ts b/apps/cns-website/src/app/pages/archive-redirect/state/archive.store.ts new file mode 100644 index 0000000000..0bd60f9a9c --- /dev/null +++ b/apps/cns-website/src/app/pages/archive-redirect/state/archive.store.ts @@ -0,0 +1,46 @@ +import { inject } from '@angular/core'; +import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { deriveLoading } from 'ngxtension/derive-loading'; +import { catchError, of, pipe, switchMap, tap } from 'rxjs'; +import { ArchiveEntry, ArchiveService } from './archive.service'; + +/** Archive state interface */ +interface ArchiveState { + /** Whether the archive entries are loading */ + isLoading: boolean; + /** The loaded archive entries, or null if not loaded */ + entries: ArchiveEntry[] | null; +} + +/** Initial archive state */ +const initialState: ArchiveState = { + isLoading: false, + entries: null, +}; + +/** Archive store */ +export const ArchiveStore = signalStore( + withState(initialState), + withMethods((store, archiveService = inject(ArchiveService)) => ({ + getEntryBefore: (timestamp: number) => { + return store.entries()?.reduce((result, entry) => { + if (entry.timestamp <= timestamp && (!result || result.timestamp < entry.timestamp)) { + return entry; + } + + return result; + }, undefined); + }, + loadEntries: rxMethod( + pipe( + tap(() => patchState(store, { isLoading: false, entries: null })), + switchMap((route) => archiveService.loadByRoute(route)), + catchError(() => of([])), + tap((entries) => patchState(store, { entries })), + deriveLoading({ threshold: 200, loadingTime: 3000 }), + tap((isLoading) => patchState(store, { isLoading })), + ), + ), + })), +); diff --git a/apps/cns-website/src/app/pages/current-team/current-team.component.html b/apps/cns-website/src/app/pages/current-team/current-team.component.html new file mode 100644 index 0000000000..ca1a55e21b --- /dev/null +++ b/apps/cns-website/src/app/pages/current-team/current-team.component.html @@ -0,0 +1,114 @@ + + + + + Current team + Former team + + + + sort + Sort by + + @if (store.team() === 'current') { + Hierarchical + } + Last name (ascending A-Z) + Last name (descending Z-A) + End year (new to old) + Start year (old to new) + + + + + category + Group by + + None + Role + Start year + End year + + + + + + + +
+ + +
+ @if (store.numFilteredPeople() === 0) { + + } @else { + @for (group of store.sortedGroupedPeople(); track group.label) { +
+ @if (group.label) { +

{{ group.label }}

+ } + + + @if (store.team() === 'current') { + + Learn more + arrow_right_alt + + } + + +
+ } + } +
+ + +
+
+
+
diff --git a/apps/cns-website/src/app/pages/current-team/current-team.component.scss b/apps/cns-website/src/app/pages/current-team/current-team.component.scss new file mode 100644 index 0000000000..3e022ed672 --- /dev/null +++ b/apps/cns-website/src/app/pages/current-team/current-team.component.scss @@ -0,0 +1,107 @@ +@use '@angular/material' as mat; +@use 'vars'; +@use 'utils'; + +:host { + display: block; + height: calc(100vh - 3.5rem); + width: 100%; + + .container { + height: 100%; + + @include mat.sidenav-overrides( + ( + container-background-color: vars.$surface-bright, + container-divider-color: vars.$outline-variant, + container-shape: vars.$corner-none, + container-width: 20rem, + content-background-color: vars.$background, + ) + ); + + .sidebar { + .filters { + .team-toggle { + width: min-content; + margin: -0.5rem 0 0.5rem; + } + + .sort-by, + .group-by { + width: 100%; + + @include mat.select-overrides( + ( + enabled-trigger-text-color: vars.$on-surface, + ) + ); + } + } + } + + ng-scrollbar { + --_scrollbar-content-width: 100%; + } + + .content { + display: flex; + flex-direction: column; + align-items: center; + min-height: calc(100vh - 3.5rem); + margin: 0 auto; + --profile-card-border-color: #8dc63f; + --profile-card-description-color: #{vars.$on-surface-variant}; + + .search-bar { + position: sticky; + top: 0; + width: 100%; + max-width: 81.25rem; + z-index: 10; + padding: 1.5rem 1.25rem 2rem; + background: vars.$background; + + @media (min-width: 640px) { + padding: 1.5rem 2.5rem 2rem; + } + } + + .profile-list { + max-width: 81.25rem; + width: 100%; + margin: 2rem 1.25rem; + + @media (min-width: 640px) { + margin: 2.5rem; + } + + .section { + margin: 0 2.5rem; + + .title { + margin-bottom: 2.5rem; + + @include mat.divider-overrides( + ( + color: vars.$outline, + ) + ); + } + + .gallery { + gap: 5rem 2.5rem; + } + + &:not(:last-of-type) { + margin-bottom: 5rem; + } + } + } + + .footer { + margin-top: auto; + } + } + } +} diff --git a/apps/cns-website/src/app/pages/current-team/current-team.component.spec.ts b/apps/cns-website/src/app/pages/current-team/current-team.component.spec.ts new file mode 100644 index 0000000000..76bc197b94 --- /dev/null +++ b/apps/cns-website/src/app/pages/current-team/current-team.component.spec.ts @@ -0,0 +1,1079 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { signal } from '@angular/core'; +import { MatIconTestingModule } from '@angular/material/icon/testing'; +import { render, screen } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; +import { provideMarkdown } from 'ngx-markdown'; +import { PeopleData, PeopleId } from '../../schemas/people.schema'; +import { SidebarStore } from '../../state/sidebar/sidebar.store'; +import { CurrentTeamComponent } from './current-team.component'; + +/** Mock SidebarStore that keeps sidebar open for testing */ +class MockSidebarStore { + readonly sidebar = signal(null); + readonly hasSidebar = signal(true); + readonly mode = signal('side' as const); + readonly isOpen = signal(true); + readonly _isWideScreen = signal(true); + + readonly setSidebar = jest.fn(); + readonly clearSidebar = jest.fn(); + readonly open = jest.fn(); + readonly close = jest.fn(); + readonly toggle = jest.fn(); +} + +describe('CurrentTeamComponent', () => { + const providers = [ + provideMarkdown(), + provideHttpClient(), + provideHttpClientTesting(), + { provide: SidebarStore, useClass: MockSidebarStore }, + ]; + const imports = [MatIconTestingModule]; + + const mockData: PeopleData = [ + { + name: 'Katy Börner', + lastName: 'Börner', + image: '/assets/people/katy-borner.png', + slug: 'katy-borner' as PeopleId, + roles: [ + { + type: 'member', + title: 'Faculty, Center Director', + dateStart: new Date('2005-01-01'), + dateEnd: null, + displayOrder: 1, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + ], + }, + { + name: 'John Smith', + lastName: 'Smith', + image: '/assets/people/john-smith.png', + slug: 'john-smith' as PeopleId, + roles: [ + { + type: 'member', + title: 'Postdoctoral Fellow', + dateStart: new Date('2020-01-01'), + dateEnd: null, + displayOrder: 2, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + ], + }, + { + name: 'Jane Doe', + lastName: 'Doe', + image: '', + slug: 'jane-doe' as PeopleId, + roles: [ + { + type: 'student', + topic: 'Data Visualization', + degree: 'Ph.D.', + department: 'Informatics', + dateStart: new Date('2021-08-01'), + dateEnd: null, + }, + ], + }, + { + name: 'Bob Johnson', + lastName: 'Johnson', + image: '/assets/people/bob-johnson.png', + slug: 'bob-johnson' as PeopleId, + roles: [{ type: 'collaborator', project: 'HuBMAP', dateStart: new Date('2019-01-01'), dateEnd: null }], + }, + { + name: 'Former Member', + lastName: 'Member', + image: '/assets/people/former-member.png', + slug: 'former-member' as PeopleId, + roles: [ + { + type: 'member', + title: 'Research Assistant', + dateStart: new Date('2010-01-01'), + dateEnd: new Date('2015-12-31'), + displayOrder: 3, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + ], + }, + ]; + + async function renderComponent(data: PeopleData = mockData) { + return render(CurrentTeamComponent, { + providers, + imports, + componentInputs: { data }, + }); + } + + it('should create', async () => { + const { fixture } = await renderComponent(); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('should show current members by default and display all role types correctly', async () => { + await renderComponent(); + + const learnMoreLinks = screen.getAllByText(/learn more/i); + expect(learnMoreLinks).toHaveLength(4); + expect(screen.getByText('Katy Börner')).toBeInTheDocument(); + expect(screen.getByText('John Smith')).toBeInTheDocument(); + expect(screen.getByText('Jane Doe')).toBeInTheDocument(); + expect(screen.getByText('Bob Johnson')).toBeInTheDocument(); + + // Verify role types display + expect(screen.getByText('Faculty, Center Director')).toBeInTheDocument(); + expect(screen.getByText('Postdoctoral Fellow')).toBeInTheDocument(); + expect(screen.getByText('Ph.D. Student - Data Visualization')).toBeInTheDocument(); + expect(screen.getByText('Collaborator - HuBMAP')).toBeInTheDocument(); + }); + + it('should toggle between current and former team', async () => { + const user = userEvent.setup(); + await renderComponent(); + + expect(screen.getAllByText(/learn more/i)).toHaveLength(4); + + const formerToggle = await screen.findByRole('radio', { name: /former team/i }); + await user.click(formerToggle); + + expect(await screen.findByText('Former Member')).toBeInTheDocument(); + expect(screen.queryAllByText(/learn more/i)).toHaveLength(0); + + const currentToggle = await screen.findByRole('radio', { name: /current team/i }); + await user.click(currentToggle); + + expect(await screen.findAllByText(/learn more/i)).toHaveLength(4); + }); + + it('should filter by search text and show no results when no matches', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const searchInput = await screen.findByRole('searchbox', { name: /search/i }); + await user.type(searchInput, 'Katy'); + + expect(await screen.findByText('Katy Börner')).toBeInTheDocument(); + expect(screen.getAllByText(/learn more/i)).toHaveLength(1); + + await user.clear(searchInput); + await user.type(searchInput, 'XYZ123'); + expect(await screen.findByText(/no results/i)).toBeInTheDocument(); + }); + + it('should sort by last name ascending and descending', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const sortSelect = await screen.findByRole('combobox', { name: /sort by/i }); + await user.click(sortSelect); + + const ascOption = await screen.findByRole('option', { name: /last name \(ascending a-z\)/i }); + await user.click(ascOption); + + let names = screen.getAllByText(/^(Katy Börner|John Smith|Jane Doe|Bob Johnson)$/); + expect(names[0]).toHaveTextContent('Katy Börner'); + expect(names[3]).toHaveTextContent('John Smith'); + + await user.click(sortSelect); + const descOption = await screen.findByRole('option', { name: /last name \(descending z-a\)/i }); + await user.click(descOption); + + names = screen.getAllByText(/^(Katy Börner|John Smith|Jane Doe|Bob Johnson)$/); + expect(names[0]).toHaveTextContent('John Smith'); + expect(names[names.length - 1]).toHaveTextContent('Katy Börner'); + }); + + it('should sort by start year', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const sortSelect = await screen.findByRole('combobox', { name: /sort by/i }); + await user.click(sortSelect); + + const option = await screen.findByRole('option', { name: /start year \(old to new\)/i }); + await user.click(option); + + const names = screen.getAllByText(/^(Katy Börner|John Smith|Jane Doe|Bob Johnson)$/); + expect(names[0]).toHaveTextContent('Katy Börner'); + }); + + it('should display profile pictures and use placeholder for missing images', async () => { + await renderComponent(); + + const katyImage = screen.getByRole('img', { name: /profile picture of katy börner/i }); + expect(katyImage).toBeInTheDocument(); + expect(katyImage).toHaveAttribute('src'); + + const janeImage = screen.getByRole('img', { name: /profile picture of jane doe/i }); + expect(janeImage).toBeInTheDocument(); + expect(janeImage.getAttribute('src')).toContain('placeholder'); + }); + + it('should have correct profile links', async () => { + await renderComponent(); + + const katyLink = screen.getByRole('link', { name: /learn more about katy/i }); + expect(katyLink).toHaveAttribute('href', '/people/katy-borner'); + }); + + it('should clear filters and search', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const searchInput = await screen.findByRole('searchbox', { name: /search/i }); + await user.type(searchInput, 'NonExistentPerson'); + + const clearFiltersButton = await screen.findByRole('button', { name: /clear filters/i }); + await user.click(clearFiltersButton); + + expect(await screen.findAllByText(/learn more/i)).toHaveLength(4); + + await user.type(searchInput, 'test'); + const clearSearchButton = await screen.findByRole('button', { name: /clear search/i }); + await user.click(clearSearchButton); + expect(searchInput).toHaveValue(''); + }); + + it('should display and update results counter', async () => { + const user = userEvent.setup(); + await renderComponent(); + + expect(screen.getByText((content) => content.includes('4') && content.includes('/'))).toBeInTheDocument(); + + const searchInput = await screen.findByRole('searchbox', { name: /search/i }); + await user.type(searchInput, 'Katy'); + + await screen.findByText('Katy Börner'); + expect(screen.getAllByText(/learn more/i)).toHaveLength(1); + }); + + it('should show hierarchical sort option only for current team', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const sortSelect = await screen.findByRole('combobox', { name: /sort by/i }); + await user.click(sortSelect); + expect(await screen.findByRole('option', { name: /hierarchical/i })).toBeInTheDocument(); + + // Close dropdown and switch to former team + await user.keyboard('{Escape}'); + const formerToggle = await screen.findByRole('radio', { name: /former team/i }); + await user.click(formerToggle); + await screen.findByText('Former Member'); + + await user.click(sortSelect); + expect(screen.queryByRole('option', { name: /hierarchical/i })).not.toBeInTheDocument(); + }); + + it('should handle empty data gracefully', async () => { + await renderComponent([]); + expect(screen.getByText((content) => content.includes('0') && content.includes('/'))).toBeInTheDocument(); + }); + + it('should filter out members with no roles', async () => { + const dataWithNoRoles: PeopleData = [ + ...mockData, + { + name: 'No Role Member', + lastName: 'NoRole', + image: '', + slug: 'no-role' as PeopleId, + roles: [], + }, + ]; + + await renderComponent(dataWithNoRoles); + expect(screen.getAllByText(/learn more/i)).toHaveLength(4); + expect(screen.queryByText('No Role Member')).not.toBeInTheDocument(); + }); + + it('should persist search when switching teams', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const searchInput = await screen.findByRole('searchbox', { name: /search/i }); + await user.type(searchInput, 'Former'); + + expect(await screen.findByText(/no results/i)).toBeInTheDocument(); + + const formerToggle = await screen.findByRole('radio', { name: /former team/i }); + await user.click(formerToggle); + + expect(searchInput).toHaveValue('Former'); + expect(await screen.findByText('Former Member')).toBeInTheDocument(); + }); + + it('should handle masters student degree correctly', async () => { + const dataWithMasters: PeopleData = [ + { + name: 'Masters Student', + lastName: 'Student', + image: '', + slug: 'masters-student' as PeopleId, + roles: [ + { + type: 'student', + topic: 'Machine Learning', + degree: 'Masters', + department: 'Computer Science', + dateStart: new Date('2022-01-01'), + dateEnd: null, + }, + ], + }, + ]; + + await renderComponent(dataWithMasters); + expect(screen.getByText('Masters Student - Machine Learning')).toBeInTheDocument(); + }); + + it('should group by role showing all group headers', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const groupBySelect = await screen.findByRole('combobox', { name: /group by/i }); + await user.click(groupBySelect); + + const roleOption = await screen.findByRole('option', { name: /^role$/i }); + await user.click(roleOption); + + expect(await screen.findByText('Staff')).toBeInTheDocument(); + expect(screen.getByText('PhD Students')).toBeInTheDocument(); + expect(screen.getByText('Collaborators')).toBeInTheDocument(); + }); + + it('should group by start year', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const groupBySelect = await screen.findByRole('combobox', { name: /group by/i }); + await user.click(groupBySelect); + + const startYearOption = await screen.findByRole('option', { name: /start year/i }); + await user.click(startYearOption); + + expect(await screen.findByText('2021')).toBeInTheDocument(); + }); + + it('should group by end year and show Current for active members', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const groupBySelect = await screen.findByRole('combobox', { name: /group by/i }); + await user.click(groupBySelect); + + const endYearOption = await screen.findByRole('option', { name: /end year/i }); + await user.click(endYearOption); + + expect(await screen.findByText('Current')).toBeInTheDocument(); + }); + + it('should group by end year for former members', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const formerToggle = await screen.findByRole('radio', { name: /former team/i }); + await user.click(formerToggle); + await screen.findByText('Former Member'); + + const groupBySelect = await screen.findByRole('combobox', { name: /group by/i }); + await user.click(groupBySelect); + + const endYearOption = await screen.findByRole('option', { name: /end year/i }); + await user.click(endYearOption); + + expect(await screen.findByText('2015')).toBeInTheDocument(); + }); + + it('should sort by end year', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const sortSelect = await screen.findByRole('combobox', { name: /sort by/i }); + await user.click(sortSelect); + + const option = await screen.findByRole('option', { name: /end year \(new to old\)/i }); + await user.click(option); + + expect(screen.getAllByText(/learn more/i)).toHaveLength(4); + }); + + it('should have filter buttons available', async () => { + await renderComponent(); + + expect(await screen.findByRole('button', { name: /^role$/i })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /^active year$/i })).toBeInTheDocument(); + }); + + it('should search with diacritics normalization', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const searchInput = await screen.findByRole('searchbox', { name: /search/i }); + await user.type(searchInput, 'Borner'); + + expect(await screen.findByText('Katy Börner')).toBeInTheDocument(); + expect(screen.getAllByText(/learn more/i)).toHaveLength(1); + }); + + it('should handle member with no title gracefully', async () => { + const dataWithNoTitle: PeopleData = [ + { + name: 'No Title Member', + lastName: 'NoTitle', + image: '', + slug: 'no-title' as PeopleId, + roles: [ + { + type: 'member', + title: '', + dateStart: new Date('2020-01-01'), + dateEnd: null, + displayOrder: 1, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + ], + }, + ]; + + await renderComponent(dataWithNoTitle); + expect(screen.getByText('No Title Member')).toBeInTheDocument(); + }); + + it('should handle people with multiple roles showing most recent role', async () => { + const dataWithMultipleRoles: PeopleData = [ + { + name: 'Multi Role Person', + lastName: 'Multi', + image: '', + slug: 'multi-role' as PeopleId, + roles: [ + { + type: 'student', + topic: 'Old Topic', + degree: 'Ph.D.', + department: 'Physics', + dateStart: new Date('2015-01-01'), + dateEnd: new Date('2018-12-31'), + }, + { + type: 'member', + title: 'Current Position', + dateStart: new Date('2019-01-01'), + dateEnd: null, + displayOrder: 1, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + ], + }, + ]; + + await renderComponent(dataWithMultipleRoles); + expect(screen.getByText('Multi Role Person')).toBeInTheDocument(); + expect(screen.getByText('Current Position')).toBeInTheDocument(); + }); + + it('should handle multiple roles with both ended dates sorted correctly', async () => { + const dataWithMultipleEndedRoles: PeopleData = [ + { + name: 'Multi Ended Roles', + lastName: 'Ended', + image: '', + slug: 'multi-ended' as PeopleId, + roles: [ + { + type: 'member', + title: 'First Role', + dateStart: new Date('2015-01-01'), + dateEnd: new Date('2017-12-31'), + displayOrder: 2, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + { + type: 'member', + title: 'Second Role', + dateStart: new Date('2018-01-01'), + dateEnd: new Date('2020-12-31'), + displayOrder: 1, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + ], + }, + ]; + + const user = userEvent.setup(); + await renderComponent(dataWithMultipleEndedRoles); + + const formerToggle = await screen.findByRole('radio', { name: /former team/i }); + await user.click(formerToggle); + + expect(await screen.findByText('Multi Ended Roles')).toBeInTheDocument(); + expect(screen.getByText('Second Role')).toBeInTheDocument(); + }); + + it('should handle roles where only one has an end date', async () => { + const dataWithMixedEndDates: PeopleData = [ + { + name: 'Mixed End Dates', + lastName: 'Mixed', + image: '', + slug: 'mixed-end' as PeopleId, + roles: [ + { + type: 'member', + title: 'Ended Role', + dateStart: new Date('2015-01-01'), + dateEnd: new Date('2018-12-31'), + displayOrder: 2, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + { + type: 'member', + title: 'Active Role', + dateStart: new Date('2019-01-01'), + dateEnd: null, + displayOrder: 1, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + ], + }, + ]; + + await renderComponent(dataWithMixedEndDates); + expect(screen.getByText('Mixed End Dates')).toBeInTheDocument(); + expect(screen.getByText('Active Role')).toBeInTheDocument(); + }); + + it('should filter members by current/former status based on date range', async () => { + const dataWithDateRange: PeopleData = [ + { + name: 'Active 2010-2015', + lastName: 'Active', + image: '', + slug: 'active-2010' as PeopleId, + roles: [ + { + type: 'member', + title: 'Past Member', + dateStart: new Date('2010-01-01'), + dateEnd: new Date('2015-12-31'), + displayOrder: 1, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + ], + }, + { + name: 'Active 2018-Now', + lastName: 'Current', + image: '', + slug: 'active-2018' as PeopleId, + roles: [ + { + type: 'member', + title: 'Current Member', + dateStart: new Date('2018-01-01'), + dateEnd: null, + displayOrder: 2, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + ], + }, + ]; + + const user = userEvent.setup(); + await renderComponent(dataWithDateRange); + + expect(screen.getByText('Active 2018-Now')).toBeInTheDocument(); + expect(screen.queryByText('Active 2010-2015')).not.toBeInTheDocument(); + + const formerToggle = await screen.findByRole('radio', { name: /former team/i }); + await user.click(formerToggle); + + expect(await screen.findByText('Active 2010-2015')).toBeInTheDocument(); + expect(screen.queryByText('Active 2018-Now')).not.toBeInTheDocument(); + }); + + it('should handle sorting by end year for former members', async () => { + const formerMembersData: PeopleData = [ + { + name: 'Left 2015', + lastName: 'First', + image: '', + slug: 'left-2015' as PeopleId, + roles: [ + { + type: 'member', + title: 'Former', + dateStart: new Date('2010-01-01'), + dateEnd: new Date('2015-12-31'), + displayOrder: 1, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + ], + }, + { + name: 'Left 2020', + lastName: 'Second', + image: '', + slug: 'left-2020' as PeopleId, + roles: [ + { + type: 'member', + title: 'Recent Former', + dateStart: new Date('2015-01-01'), + dateEnd: new Date('2020-12-31'), + displayOrder: 2, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + ], + }, + ]; + + const user = userEvent.setup(); + await renderComponent(formerMembersData); + + const formerToggle = await screen.findByRole('radio', { name: /former team/i }); + await user.click(formerToggle); + + const names = await screen.findAllByText(/^(Left 2015|Left 2020)$/); + expect(names[0]).toHaveTextContent('Left 2020'); + expect(names[1]).toHaveTextContent('Left 2015'); + }); + + it('should handle masters student filter grouping correctly', async () => { + const dataWithMasters: PeopleData = [ + { + name: 'Masters Person', + lastName: 'Masters', + image: '', + slug: 'masters-person' as PeopleId, + roles: [ + { + type: 'student', + topic: 'AI Research', + degree: 'Masters', + department: 'Computer Science', + dateStart: new Date('2022-01-01'), + dateEnd: null, + }, + ], + }, + ]; + + const user = userEvent.setup(); + await renderComponent(dataWithMasters); + + const groupBySelect = await screen.findByRole('combobox', { name: /group by/i }); + await user.click(groupBySelect); + + const roleOption = await screen.findByRole('option', { name: /^role$/i }); + await user.click(roleOption); + + expect(await screen.findByText('Master Students')).toBeInTheDocument(); + expect(screen.getByText('Masters Person')).toBeInTheDocument(); + }); + + it('should handle comparison of group keys when mixing years', async () => { + const mixedData: PeopleData = [ + { + name: 'Person 2020', + lastName: 'A', + image: '', + slug: 'person-2020' as PeopleId, + roles: [ + { + type: 'member', + title: 'Role', + dateStart: new Date('2020-01-01'), + dateEnd: new Date('2020-12-31'), + displayOrder: 1, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + ], + }, + { + name: 'Person 2021', + lastName: 'B', + image: '', + slug: 'person-2021' as PeopleId, + roles: [ + { + type: 'member', + title: 'Role', + dateStart: new Date('2021-01-01'), + dateEnd: new Date('2021-12-31'), + displayOrder: 1, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + ], + }, + ]; + + const user = userEvent.setup(); + await renderComponent(mixedData); + + const formerToggle = await screen.findByRole('radio', { name: /former team/i }); + await user.click(formerToggle); + await screen.findByText('Person 2020'); + + const groupBySelect = await screen.findByRole('combobox', { name: /group by/i }); + await user.click(groupBySelect); + + const endYearOption = await screen.findByRole('option', { name: /end year/i }); + await user.click(endYearOption); + + expect(await screen.findByText('2020')).toBeInTheDocument(); + expect(screen.getByText('2021')).toBeInTheDocument(); + }); + + it('should handle display order for hierarchical sorting', async () => { + const dataWithDisplayOrder: PeopleData = [ + { + name: 'High Priority', + lastName: 'A', + image: '', + slug: 'high-priority' as PeopleId, + roles: [ + { + type: 'member', + title: 'Director', + dateStart: new Date('2010-01-01'), + dateEnd: null, + displayOrder: 1, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + ], + }, + { + name: 'Low Priority', + lastName: 'B', + image: '', + slug: 'low-priority' as PeopleId, + roles: [ + { + type: 'member', + title: 'Assistant', + dateStart: new Date('2020-01-01'), + dateEnd: null, + displayOrder: 10, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + ], + }, + ]; + + await renderComponent(dataWithDisplayOrder); + + const names = screen.getAllByText(/^(High Priority|Low Priority)$/); + expect(names[0]).toHaveTextContent('High Priority'); + expect(names[1]).toHaveTextContent('Low Priority'); + }); + + it('should handle member without displayOrder using default ordering', async () => { + const dataWithoutDisplayOrder: PeopleData = [ + { + name: 'No Order Member', + lastName: 'NoOrder', + image: '', + slug: 'no-order' as PeopleId, + roles: [ + { + type: 'student', + topic: 'Research', + degree: 'Ph.D.', + department: 'CS', + dateStart: new Date('2020-01-01'), + dateEnd: null, + }, + ], + }, + ]; + + await renderComponent(dataWithoutDisplayOrder); + expect(screen.getByText('No Order Member')).toBeInTheDocument(); + }); + + it('should group by none and show all ungrouped', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const groupBySelect = await screen.findByRole('combobox', { name: /group by/i }); + await user.click(groupBySelect); + + const noneOption = await screen.findByRole('option', { name: /none/i }); + await user.click(noneOption); + + expect(screen.getAllByText(/learn more/i)).toHaveLength(4); + }); + + it('should handle sorting when both roles have same undefined end dates', async () => { + const dataWithBothUndefined: PeopleData = [ + { + name: 'Both Current', + lastName: 'BothCurrent', + image: '', + slug: 'both-current' as PeopleId, + roles: [ + { + type: 'member', + title: 'Role A', + dateStart: new Date('2020-01-01'), + dateEnd: null, + displayOrder: 1, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + { + type: 'collaborator', + project: 'Project B', + dateStart: new Date('2021-01-01'), + dateEnd: null, + }, + ], + }, + ]; + + await renderComponent(dataWithBothUndefined); + expect(screen.getByText('Both Current')).toBeInTheDocument(); + }); + + it('should handle null end year in sorting comparisons', async () => { + const nullEndData: PeopleData = [ + { + name: 'Ended 2018', + lastName: 'Ended', + image: '', + slug: 'ended-2018' as PeopleId, + roles: [ + { + type: 'member', + title: 'Ended Role', + dateStart: new Date('2015-01-01'), + dateEnd: new Date('2018-12-31'), + displayOrder: 1, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + ], + }, + { + name: 'Still Active', + lastName: 'Active', + image: '', + slug: 'still-active' as PeopleId, + roles: [ + { + type: 'member', + title: 'Active Role', + dateStart: new Date('2019-01-01'), + dateEnd: null, + displayOrder: 2, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + ], + }, + ]; + + const user = userEvent.setup(); + await renderComponent(nullEndData); + + const sortSelect = await screen.findByRole('combobox', { name: /sort by/i }); + await user.click(sortSelect); + + const option = await screen.findByRole('option', { name: /end year \(new to old\)/i }); + await user.click(option); + + const names = screen.getAllByText(/^(Ended 2018|Still Active)$/); + expect(names[0]).toHaveTextContent('Still Active'); + }); + + it('should sort by start year comparing different years', async () => { + const sortData: PeopleData = [ + { + name: 'Early Starter', + lastName: 'AA', + image: '', + slug: 'early-starter' as PeopleId, + roles: [ + { + type: 'member', + title: 'Role', + dateStart: new Date('2005-01-01'), + dateEnd: null, + displayOrder: 1, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + ], + }, + { + name: 'Late Starter', + lastName: 'ZZ', + image: '', + slug: 'late-starter' as PeopleId, + roles: [ + { + type: 'member', + title: 'Role', + dateStart: new Date('2020-01-01'), + dateEnd: null, + displayOrder: 2, + office: '', + phone: '', + fax: '', + email: '', + education: '', + background: '', + interests: '', + }, + ], + }, + ]; + + const user = userEvent.setup(); + await renderComponent(sortData); + + const sortSelect = await screen.findByRole('combobox', { name: /sort by/i }); + await user.click(sortSelect); + + const option = await screen.findByRole('option', { name: /start year \(old to new\)/i }); + await user.click(option); + + const names = screen.getAllByText(/^(Early Starter|Late Starter)$/); + expect(names[0]).toHaveTextContent('Early Starter'); + expect(names[1]).toHaveTextContent('Late Starter'); + }); +}); diff --git a/apps/cns-website/src/app/pages/current-team/current-team.component.ts b/apps/cns-website/src/app/pages/current-team/current-team.component.ts new file mode 100644 index 0000000000..31e9c0ee49 --- /dev/null +++ b/apps/cns-website/src/app/pages/current-team/current-team.component.ts @@ -0,0 +1,94 @@ +import { ChangeDetectionStrategy, Component, effect, inject, input, viewChild } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSidenav, MatSidenavModule } from '@angular/material/sidenav'; +import { HraCommonModule } from '@hra-ui/common'; +import { LinkDirective } from '@hra-ui/common/router-ext'; +import { ButtonsModule } from '@hra-ui/design-system/buttons'; +import { ProfileCardComponent } from '@hra-ui/design-system/cards/profile-card'; +import { SectionLinkComponent } from '@hra-ui/design-system/content-templates/section-link'; +import { FilterMenuComponent } from '@hra-ui/design-system/filter-menu'; +import { GalleryGridComponent, GalleryGridItemDirective } from '@hra-ui/design-system/gallery-grid'; +import { IconsModule } from '@hra-ui/design-system/icons'; +import { NoResultsIndicatorComponent } from '@hra-ui/design-system/indicators/no-results-indicator'; +import { ScrollingModule } from '@hra-ui/design-system/scrolling'; +import { SearchFilterComponent } from '@hra-ui/design-system/search-filter'; +import { NgScrollbar } from 'ngx-scrollbar'; +import { FooterComponent } from '../../components/footer/footer.component'; +import { PeopleData } from '../../schemas/people.schema'; +import { ScrollbarStore } from '../../state/scrollbar/scrollbar.store'; +import { SidebarStore } from '../../state/sidebar/sidebar.store'; +import { CurrentTeamStore } from './state/current-team.store'; + +/** + * Page component for displaying current team members + */ +@Component({ + selector: 'cns-current-team', + imports: [ + HraCommonModule, + ButtonsModule, + FilterMenuComponent, + FooterComponent, + FormsModule, + GalleryGridComponent, + GalleryGridItemDirective, + IconsModule, + LinkDirective, + MatButtonToggleModule, + MatFormFieldModule, + MatSelectModule, + MatSidenavModule, + NoResultsIndicatorComponent, + ProfileCardComponent, + ScrollingModule, + SearchFilterComponent, + SectionLinkComponent, + ], + templateUrl: './current-team.component.html', + styleUrl: './current-team.component.scss', + providers: [CurrentTeamStore], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CurrentTeamComponent { + /** + * Team members data from route resolver + */ + readonly data = input.required(); + + /** Store for managing team member state and filters */ + protected readonly store = inject(CurrentTeamStore); + + /** Sidebar store for managing sidebar state */ + protected readonly sidebarStore = inject(SidebarStore); + /** Scrollbar store for managing viewport scrolling */ + protected readonly scrollbarStore = inject(ScrollbarStore); + + /** Gender neutral placeholder image for members without pictures */ + protected readonly placeholderImage = '/assets/placeholder-images/placeholder.png'; + + /** Reference to the sidebar component */ + private readonly sidebar = viewChild.required(MatSidenav); + /** Scrollbar component reference */ + private readonly scrollbar = viewChild.required(NgScrollbar); + + /** + * Initializes the component and store with route data + * - Sets people data from route resolver + */ + constructor() { + this.store.setPeople(this.data); + + effect((onCleanup) => { + this.sidebarStore.setSidebar(this.sidebar()); + onCleanup(() => this.sidebarStore.clearSidebar()); + }); + + effect((onCleanup) => { + this.scrollbarStore.setScrollbar(this.scrollbar()); + onCleanup(() => this.scrollbarStore.clearScrollbar()); + }); + } +} diff --git a/apps/cns-website/src/app/pages/current-team/state/current-team.store.ts b/apps/cns-website/src/app/pages/current-team/state/current-team.store.ts new file mode 100644 index 0000000000..1346947816 --- /dev/null +++ b/apps/cns-website/src/app/pages/current-team/state/current-team.store.ts @@ -0,0 +1,59 @@ +import { computed, Signal, WritableSignal } from '@angular/core'; +import { signalStore, withHooks } from '@ngrx/signals'; +import { linkedQueryParam } from 'ngxtension/linked-query-param'; +import { + parseGroupBy, + parseRoles, + parseSearch, + parseSortBy, + parseTeamType, + parseYears, + serializeRoles, + serializeYears, +} from './serialization'; +import { withFilters } from './with-filters.feature'; +import { withOrdering } from './with-ordering.feature'; +import { withPeople } from './with-people.feature'; + +/** + * Creates a WritableSignal wrapper around a read-only state signal and setter function. + * Used to bridge ngrx signal store state with APIs that expect WritableSignal. + * @param stateSignal - The read-only signal from the store + * @param set - The setter function to update the state + * @returns A WritableSignal that wraps the state signal + */ +function createWritableStateSlice(stateSignal: Signal, set: (value: T) => void): WritableSignal { + const signal = computed(() => stateSignal()) as WritableSignal; + signal.set = set; + signal.update = (updateFn) => set(updateFn(stateSignal())); + signal.asReadonly = () => stateSignal; + return signal; +} + +/** + * Signal store for managing current team state. + * Combines people management, filtering, and ordering features. + */ +export const CurrentTeamStore = signalStore( + withPeople(), + withFilters(), + withOrdering(), + withHooks({ + onInit(store) { + const teamType = createWritableStateSlice(store.team, store.setTeam); + const roles = createWritableStateSlice(store.roles, store.setRoles); + const years = createWritableStateSlice(store.years, store.setYears); + const search = createWritableStateSlice(store.search, store.setSearch); + const sortBy = createWritableStateSlice(store._sortBy, store.setSortBy); + const groupBy = createWritableStateSlice(store.groupBy, store.setGroupBy); + const commonOptions = { replaceUrl: true, preserveFragment: true }; + + linkedQueryParam('team', { source: teamType, parse: parseTeamType, ...commonOptions }); + linkedQueryParam('roles', { source: roles, parse: parseRoles, stringify: serializeRoles, ...commonOptions }); + linkedQueryParam('years', { source: years, parse: parseYears, stringify: serializeYears, ...commonOptions }); + linkedQueryParam('search', { source: search, parse: parseSearch, ...commonOptions }); + linkedQueryParam('sortBy', { source: sortBy, parse: parseSortBy, ...commonOptions }); + linkedQueryParam('groupBy', { source: groupBy, parse: parseGroupBy, ...commonOptions }); + }, + }), +); diff --git a/apps/cns-website/src/app/pages/current-team/state/serialization.ts b/apps/cns-website/src/app/pages/current-team/state/serialization.ts new file mode 100644 index 0000000000..d312b1d051 --- /dev/null +++ b/apps/cns-website/src/app/pages/current-team/state/serialization.ts @@ -0,0 +1,124 @@ +import { REFINED_ROLE_TYPE_OPTIONS } from '../../../utils/refined-roles'; +import { RoleTypeOption, TeamType, YEAR_OPTIONS, YearOption } from './with-filters.feature'; +import { GroupBy, SortBy } from './with-ordering.feature'; + +/** + * Parses an unknown value into an enum value + * @param enumObj - The enum object to match against + * @param value - The value to parse + * @returns The matched enum value or null if not found + */ +function parseEnum(enumObj: T, value: unknown): T[keyof T] | null { + if (!value) { + return null; + } + + const strValue = String(value).toLowerCase().trim(); + const enumValues = Object.values(enumObj); + const match = enumValues.find((ev) => ev.toLowerCase() === strValue); + return (match as T[keyof T]) ?? null; +} + +/** + * Parses a comma-separated string into an array of matching options + * @param options - Available options to match against + * @param value - Comma-separated string of option IDs + * @returns Array of matched options or null if none found + */ +function parseOptions(options: T[], value: unknown): T[] | null { + if (!value) { + return []; + } + + const strValue = String(value).toLowerCase().trim(); + const parts = strValue.split(',').map((part) => part.trim()); + const selectedOptions: T[] = []; + for (const part of parts) { + const option = options.find((opt) => opt.id.toLowerCase() === part); + if (option) { + selectedOptions.push(option); + } + } + + return selectedOptions.length > 0 ? selectedOptions : null; +} + +/** + * Parses a URL query param value into a TeamType enum + * @param value - The query param value + * @returns The parsed TeamType, defaults to Current + */ +export function parseTeamType(value: unknown): TeamType { + return parseEnum(TeamType, value) ?? TeamType.Current; +} + +/** + * Parses a URL query param value into role type options + * @param value - Comma-separated role IDs + * @returns Array of matched role options or null + */ +export function parseRoles(value: unknown): RoleTypeOption[] | null { + return parseOptions(REFINED_ROLE_TYPE_OPTIONS, value); +} + +/** + * Parses a URL query param value into year options + * @param value - Comma-separated year values + * @returns Array of matched year options or null + */ +export function parseYears(value: unknown): YearOption[] | null { + return parseOptions(YEAR_OPTIONS, value); +} + +/** + * Parses a URL query param value into a search string + * @param value - The search query param + * @returns The search string or null + */ +export function parseSearch(value: unknown): string | null { + return value ? String(value) : null; +} + +/** + * Parses a URL query param value into a SortBy enum + * @param value - The sort query param + * @returns The parsed SortBy value or null + */ +export function parseSortBy(value: unknown): SortBy | null { + return parseEnum(SortBy, value); +} + +/** + * Parses a URL query param value into a GroupBy enum + * @param value - The group query param + * @returns The parsed GroupBy value or null + */ +export function parseGroupBy(value: unknown): GroupBy | null { + return parseEnum(GroupBy, value); +} + +/** + * Serializes role options into a comma-separated string for URL + * @param options - Array of role options to serialize + * @returns Comma-separated string of role IDs or null + */ +export function serializeRoles(options: RoleTypeOption[] | null): string | null { + if (!options?.length) { + return null; + } + + return options.map((option) => option.id).join(','); +} + +/** + * Serializes year options into a comma-separated string for URL + * @param options - Array of year options to serialize + * @returns Comma-separated string of years or null + */ +export function serializeYears(options: YearOption[] | null): string | null { + if (!options?.length) { + return null; + } + + return options.map((option) => option.year.toString()).join(','); +} diff --git a/apps/cns-website/src/app/pages/current-team/state/with-filters.feature.ts b/apps/cns-website/src/app/pages/current-team/state/with-filters.feature.ts new file mode 100644 index 0000000000..65c6041371 --- /dev/null +++ b/apps/cns-website/src/app/pages/current-team/state/with-filters.feature.ts @@ -0,0 +1,225 @@ +import { computed, Signal } from '@angular/core'; +import { FilterOptionCategory } from '@hra-ui/design-system/filter-menu'; +import { SearchListOption } from '@hra-ui/design-system/search-list'; +import { + patchState, + signalMethod, + signalStoreFeature, + type, + withComputed, + withMethods, + withState, +} from '@ngrx/signals'; +import { PeopleItem } from '../../../schemas/people.schema'; +import { REFINED_ROLE_TYPE_OPTIONS, RefinedRoleType, refineRoleType } from '../../../utils/refined-roles'; +import { PeopleMethods, PeopleProps } from './with-people.feature'; + +/** + * Team type filter - current or past members + */ +export enum TeamType { + Current = 'current', + Past = 'past', +} + +/** + * Role type option for filtering + */ +export interface RoleTypeOption extends SearchListOption { + /** The role type identifier */ + id: RefinedRoleType; +} + +/** + * Year option for filtering + */ +export interface YearOption extends SearchListOption { + /** The year value */ + year: number; +} + +/** + * Props provided by the filters feature + */ +export type FilterProps = { + /** List of people after applying all filters */ + filteredPeople: Signal; + /** Number of people after applying all filters */ + numFilteredPeople: Signal; + /** Number of people in the selected team (current/past) before other filters */ + numFilteredByTeam: Signal; + /** List of filters with selected options */ + filters: Signal[]>; +}; + +/** Any internal props */ +type InternalProps = { [key: `_${string}`]: unknown }; + +/** + * Internal state for filters + */ +interface FilterState { + /** Selected team filter */ + team: TeamType; + /** Selected roles filter */ + roles: RoleTypeOption[] | null; + /** Selected years filter */ + years: YearOption[] | null; + /** Search text for filtering by name */ + search: string | null; +} + +/** + * Create a list of years from startYear to current year + * @param startYear - The starting year + * @returns Array of years in descending order + */ +function createYearList(startYear: number): number[] { + const currentYear = new Date().getFullYear(); + const years: number[] = []; + for (let year = currentYear; year >= startYear; year--) { + years.push(year); + } + + return years; +} + +/** Available year options for filtering team members by active year (from 2000 to current year) */ +export const YEAR_OPTIONS: YearOption[] = createYearList(2000).map((year) => ({ + id: year.toString(), + label: year.toString(), + year, +})); + +/** Filter configuration for roles with all available role type options */ +const ROLES_FILTER: FilterOptionCategory = { + id: 'roles', + label: 'Role', + disableSearch: true, + options: REFINED_ROLE_TYPE_OPTIONS, + selected: [], +}; + +/** Filter configuration for years with all available year options */ +const YEARS_FILTER: FilterOptionCategory = { + id: 'years', + label: 'Active year', + options: YEAR_OPTIONS, + selected: [], +}; + +/** + * Initial state for filters + */ +const initialFilterState: FilterState = { + team: TeamType.Current, + roles: null, + years: null, + search: null, +}; + +/** + * Adds filtering capabilities for team members + * Filters by team type (current/past), role, active year, and search text + * @returns Signal store feature + */ +export function withFilters() { + return signalStoreFeature( + { props: type(), methods: type() }, + withState(initialFilterState), + withComputed((store) => { + const _rolesFilter = computed(() => ({ ...ROLES_FILTER, selected: store.roles() ?? [] })); + const _yearsFilter = computed(() => ({ ...YEARS_FILTER, selected: store.years() ?? [] })); + const filters = computed((): FilterOptionCategory[] => [_rolesFilter(), _yearsFilter()]); + + const _peopleByTeam = computed(() => { + const people = store.people(); + const endYearByPerson = store.endYearByPerson(); + const currentTeam: PeopleItem[] = []; + const pastTeam: PeopleItem[] = []; + for (const person of people) { + const isActive = endYearByPerson.get(person) === null; + const team = isActive ? currentTeam : pastTeam; + team.push(person); + } + + return { current: currentTeam, past: pastTeam }; + }); + + const _filteredByTeam = computed(() => { + const team = store.team(); + const peopleByTeam = _peopleByTeam(); + return peopleByTeam[team]; + }); + + const _filteredByRole = computed(() => { + const selectedRoles = store.roles() ?? []; + const selectedTypes = new Set(selectedRoles.map((option) => option.id)); + const people = _filteredByTeam(); + if (selectedRoles.length === 0) { + return people; + } + + const rolesByPerson = store.rolesByPerson(); + return people.filter((person) => { + const roles = rolesByPerson.get(person); + return roles && roles.some((role) => selectedTypes.has(refineRoleType(role))); + }); + }); + + const _filteredByYear = computed(() => { + const years = store.years() ?? []; + const people = _filteredByRole(); + if (years.length === 0) { + return people; + } + + return people.filter((person) => { + return years.some((yearOption) => store.isActiveInYear(person, yearOption.year)); + }); + }); + + const _filteredBySearch = computed(() => { + const search = store.search()?.trim(); + const people = _filteredByYear(); + if (!search) { + return people; + } + + const normalizedSearch = search + .toLocaleLowerCase() + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .replace(/\s{2,}/, ' '); + + return people.filter((person) => store.getSearchableText(person).includes(normalizedSearch)); + }); + + return { + filters, + filteredPeople: _filteredBySearch, + numFilteredPeople: computed(() => _filteredBySearch().length), + numFilteredByTeam: computed(() => _filteredByTeam().length), + _rolesFilter, + _yearsFilter, + _peopleByTeam, + _filteredByTeam, + _filteredByRole, + _filteredByYear, + _filteredBySearch, + } satisfies FilterProps & InternalProps; + }), + withMethods((store) => ({ + setTeam: signalMethod((team: TeamType) => patchState(store, { team })), + setRoles: signalMethod((roles: RoleTypeOption[] | null) => patchState(store, { roles })), + setYears: signalMethod((years: YearOption[] | null) => patchState(store, { years })), + setFilters: signalMethod((filters: FilterOptionCategory[]) => { + const roles = filters[0].selected as RoleTypeOption[]; + const years = filters[1].selected as YearOption[]; + patchState(store, { roles, years }); + }), + setSearch: signalMethod((search: string | null) => patchState(store, { search })), + resetFilters: () => patchState(store, initialFilterState), + })), + ); +} diff --git a/apps/cns-website/src/app/pages/current-team/state/with-ordering.feature.ts b/apps/cns-website/src/app/pages/current-team/state/with-ordering.feature.ts new file mode 100644 index 0000000000..317b65c5e6 --- /dev/null +++ b/apps/cns-website/src/app/pages/current-team/state/with-ordering.feature.ts @@ -0,0 +1,336 @@ +import { computed, Signal } from '@angular/core'; +import { + patchState, + signalMethod, + signalStoreFeature, + type, + withComputed, + withMethods, + withState, +} from '@ngrx/signals'; +import { PeopleItem } from '../../../schemas/people.schema'; +import { AnyRole } from '../../../schemas/roles.schema'; +import { RefinedRoleType, refineRoleType } from '../../../utils/refined-roles'; +import { FilterProps, TeamType } from './with-filters.feature'; +import { PeopleMethods, PeopleProps } from './with-people.feature'; + +/** + * Sort options for team members + */ +export enum SortBy { + Hierarchical = 'hierarchical', + LastNameAsc = 'lastNameAsc', + LastNameDesc = 'lastNameDesc', + EndYearNewest = 'endYearNewest', + StartYearOldest = 'startYearOldest', +} + +/** + * Group by options for team members + */ +export enum GroupBy { + Role = 'role', + StartYear = 'startYear', + EndYear = 'endYear', +} + +/** + * Group of people with a label + */ +export interface SortedGroup { + /** The group label */ + label: string; + /** Team members in this group */ + people: PeopleItem[]; +} + +/** + * Props provided by the ordering feature + */ +export type OrderingProps = { + /** Selected sort order */ + sortBy: Signal; + /** People grouped and sorted according to current settings */ + sortedGroupedPeople: Signal; +}; + +/** Any internal props */ +type InternalProps = { [key: `_${string}`]: unknown }; + +/** + * Internal state for ordering + */ +interface OrderingState { + /** Selected sort order */ + _sortBy: SortBy | null; + /** Selected grouping option */ + groupBy: GroupBy | null; +} + +/** Grouping keys */ +type GroupByKey = '' | 'current' | 'unknown' | 'skip' | RefinedRoleType | number; + +/** + * Initial state for ordering + */ +const initialOrderingState: OrderingState = { + _sortBy: null, + groupBy: null, +}; + +/** + * Compares two team members by last name + * + * @param a First team member + * @param b Second team member + * @param order Comparison order (-1 for descending, 1 for ascending) + * @returns Comparison result + */ +function compareByName(a: PeopleItem, b: PeopleItem, order: -1 | 1): number { + const comparison = a.lastName.localeCompare(b.lastName); + return order * comparison; +} + +/** + * Compares two team members by a numeric property + * + * @param a First team member + * @param b Second team member + * @param propertyMap Property map to compare by + * @param order Comparison order (-1 for descending, 1 for ascending) + * @returns Comparison result + */ +function compareByNumericProperty( + a: PeopleItem, + b: PeopleItem, + propertyMap: Map, + order: -1 | 1, +): number { + const propA = propertyMap.get(a) ?? null; + const propB = propertyMap.get(b) ?? null; + if (propA === null) { + return propB === null ? 0 : -order; + } else if (propB === null) { + return order; + } + + return order * (propA - propB); +} + +/** + * Order of groupBy keys for sorting. + * Some keys should never appear at this point, but are included to ensure consistency. They are instead given high values (9999). + * Also 'current' should only appear in combination with numeric years and is always placed before any year. + */ +const GROUP_BY_KEY_ORDER: Record = { + '': 9999, + current: -1, + skip: 9999, + unknown: 6, + [RefinedRoleType.Collaborator]: 5, + [RefinedRoleType.MasterStudent]: 3, + [RefinedRoleType.PhDStudent]: 2, + [RefinedRoleType.Staff]: 0, + [RefinedRoleType.Student]: 4, +}; + +/** + * Compares two groupBy keys for sorting + * + * @param a First group key + * @param b Second group key + * @returns Comparison result + */ +function compareByGroupKey(a: GroupByKey, b: GroupByKey): number { + if (typeof a === 'number' || typeof b === 'number') { + if (typeof a === 'string') { + return -1; + } else if (typeof b === 'string') { + return 1; + } + + return b - a; + } + + return GROUP_BY_KEY_ORDER[a] - GROUP_BY_KEY_ORDER[b]; +} + +/** + * Creates a sorting function based on the selected sort option + * + * @param sortBy Selected sort option + * @param store Store containing people properties + * @returns A comparison function for sorting team members + */ +function createSortFn(sortBy: SortBy, store: PeopleProps): (a: PeopleItem, b: PeopleItem) => number { + switch (sortBy) { + case SortBy.LastNameAsc: + return (a, b) => compareByName(a, b, 1); + case SortBy.LastNameDesc: + return (a, b) => compareByName(a, b, -1); + + case SortBy.EndYearNewest: { + const endYearByPerson = store.endYearByPerson(); + return (a, b) => compareByNumericProperty(a, b, endYearByPerson, -1); + } + case SortBy.StartYearOldest: { + const startYearByPerson = store.startYearByPerson(); + return (a, b) => compareByNumericProperty(a, b, startYearByPerson, 1); + } + + default: { + const displayOrderByPerson = store.displayOrderByPerson(); + return (a, b) => { + const byOrder = compareByNumericProperty(a, b, displayOrderByPerson, 1); + return byOrder !== 0 ? byOrder : compareByName(a, b, 1); + }; + } + } +} + +/** + * Creates a function to derive groupBy keys for team members + * + * @param groupBy Selected grouping option + * @param store Store containing people properties + * @returns A function that derives group keys for team members + */ +function createGroupByKeyFn( + groupBy: GroupBy | null, + store: PeopleProps & PeopleMethods, +): (person: PeopleItem) => GroupByKey { + const rolesByPerson = store.rolesByPerson(); + const impl = createGroupByKeyImpl(groupBy); + return (person) => { + const roles = rolesByPerson.get(person); + return roles && roles.length > 0 ? impl(roles[0]) : 'skip'; + }; +} + +/** + * Creates implementation for deriving groupBy keys for a team member's role + * + * @param groupBy Selected grouping option + * @returns A function that derives group keys from a role + */ +function createGroupByKeyImpl(groupBy: GroupBy | null): (role: AnyRole) => GroupByKey { + switch (groupBy) { + case GroupBy.Role: + return (role) => refineRoleType(role); + case GroupBy.StartYear: + return (role) => role.dateStart.getFullYear(); + case GroupBy.EndYear: + return (role) => role.dateEnd?.getFullYear() ?? 'current'; + default: + return () => 'unknown'; + } +} + +/** + * Labels for groupBy keys + */ +const GROUP_BY_KEY_LABELS: Record = { + '': '', + current: 'Current', + skip: '', + unknown: 'Unknown', + [RefinedRoleType.Collaborator]: 'Collaborators', + [RefinedRoleType.MasterStudent]: 'Master Students', + [RefinedRoleType.PhDStudent]: 'PhD Students', + [RefinedRoleType.Staff]: 'Staff', + [RefinedRoleType.Student]: 'Students', +}; + +/** + * Converts a groupBy key to a human-readable label + * + * @param key Key to convert + * @returns Human-readable label + */ +function groupKeyToLabel(key: GroupByKey): string { + if (typeof key === 'number') { + return key.toString(); + } + return GROUP_BY_KEY_LABELS[key]; +} + +/** + * Adds sorting and grouping capabilities for team members + * Supports multiple sort orders and grouping by role, start year, or end year + * + * @returns Signal store feature + */ +export function withOrdering() { + return signalStoreFeature( + { state: type<{ team: TeamType }>(), props: type(), methods: type() }, + withState(initialOrderingState), + withComputed((store) => { + const sortBy = computed(() => { + const _sortBy = store._sortBy(); + const team = store.team(); + if (!_sortBy) { + return team === 'current' ? SortBy.Hierarchical : SortBy.EndYearNewest; + } else if (team === 'past' && _sortBy === SortBy.Hierarchical) { + return SortBy.EndYearNewest; + } + + return _sortBy; + }); + + const _sortFn = computed(() => createSortFn(sortBy(), store)); + const _sortedPeople = computed(() => { + const people = store.filteredPeople(); + return [...people].sort(_sortFn()); + }); + + const _groupByKeyFn = computed(() => createGroupByKeyFn(store.groupBy(), store)); + const _groupedPeople = computed(() => { + const people = _sortedPeople(); + const groupBy = store.groupBy(); + if (!groupBy) { + return new Map([['', people]]); + } + + const groupByKeyFn = _groupByKeyFn(); + const groups = new Map(); + for (const person of people) { + const groupKey = groupByKeyFn(person); + if (groupKey === 'skip') { + continue; + } + + let group = groups.get(groupKey); + if (!group) { + group = []; + groups.set(groupKey, group); + } + group.push(person); + } + + return groups; + }); + + const sortedGroupedPeople = computed(() => { + const groups = Array.from(_groupedPeople().entries()); + groups.sort((a, b) => compareByGroupKey(a[0], b[0])); + if (store.groupBy() === GroupBy.StartYear) { + groups.reverse(); + } + return groups.map(([key, people]) => ({ label: groupKeyToLabel(key), people })); + }); + + return { + sortBy, + sortedGroupedPeople, + _sortFn, + _sortedPeople, + _groupByKeyFn, + _groupedPeople, + } satisfies OrderingProps & InternalProps; + }), + withMethods((store) => ({ + setSortBy: signalMethod((sortBy: SortBy | null) => patchState(store, { _sortBy: sortBy })), + setGroupBy: signalMethod((groupBy: GroupBy | null) => patchState(store, { groupBy })), + })), + ); +} diff --git a/apps/cns-website/src/app/pages/current-team/state/with-people.feature.ts b/apps/cns-website/src/app/pages/current-team/state/with-people.feature.ts new file mode 100644 index 0000000000..57c1b98953 --- /dev/null +++ b/apps/cns-website/src/app/pages/current-team/state/with-people.feature.ts @@ -0,0 +1,198 @@ +import { computed, Signal } from '@angular/core'; +import { + patchState, + SignalMethod, + signalMethod, + signalStoreFeature, + type, + withComputed, + withMethods, +} from '@ngrx/signals'; +import { entityConfig, setEntities, withEntities } from '@ngrx/signals/entities'; +import { PeopleItem } from '../../../schemas/people.schema'; +import { AnyRole } from '../../../schemas/roles.schema'; + +/** + * Props provided by the people feature + */ +export type PeopleProps = { + /** List of all people */ + people: Signal; + /** Total number of people */ + numPeople: Signal; + /** Map of people to their roles (sorted by most recent role) */ + rolesByPerson: Signal>; + /** Map of people to their start year */ + startYearByPerson: Signal>; + /** Map of people to their end year (null if currently active) */ + endYearByPerson: Signal>; + /** Map of people to their display order */ + displayOrderByPerson: Signal>; +}; + +/** + * Methods provided by the people feature + */ +export type PeopleMethods = { + /** Get the display title for a team member */ + getMemberTitle(person: PeopleItem): string; + /** Get the searchable text for a team member */ + getSearchableText(person: PeopleItem): string; + /** Check if a person was active in a given year */ + isActiveInYear(person: PeopleItem, year: number): boolean; + /** Set the list of people */ + setPeople: SignalMethod; +}; + +/** + * Entity configuration for people + */ +const peopleConfig = entityConfig({ + collection: 'people', + entity: type(), + selectId: (person) => person.slug, +}); + +/** + * Creates a map of people to a derived property from their roles + * + * @param people List of people + * @param getProperty Function to extract the desired property from a role + * @param reducer Function to reduce the list of properties to a single value + * @returns Map of people to the derived property + */ +function createRolesPropertyMap( + people: PeopleItem[], + getProperty: (role: AnyRole) => T, + reducer: (values: T[]) => R, +): Map { + const result = new Map(); + for (const person of people) { + const values = person.roles.map(getProperty); + result.set(person, reducer(values)); + } + return result; +} + +/** + * Sorts roles by end date in descending order (most recent first) + * + * @param a First role + * @param b Second role + * @returns Comparison result + */ +function sortRoleByDateDesc(a: AnyRole, b: AnyRole): number { + const aEnd = a.dateEnd?.getTime(); + const bEnd = b.dateEnd?.getTime(); + if (aEnd === undefined) { + return aEnd === bEnd ? 0 : -1; + } else if (bEnd === undefined) { + return 1; + } + + return bEnd - aEnd; +} + +/** + * Adds people data, role mappings, and query methods + * + * @returns Signal store feature + */ +export function withPeople() { + return signalStoreFeature( + withEntities(peopleConfig), + withComputed(({ peopleEntities }) => { + const people = computed(() => peopleEntities().filter((person) => person.roles.length > 0)); + const numPeople = computed(() => people().length); + + const rolesByPerson = computed(() => + createRolesPropertyMap( + people(), + (role) => role, + (roles) => roles.sort(sortRoleByDateDesc), + ), + ); + + const displayOrderByPerson = computed(() => + createRolesPropertyMap( + people(), + (role) => (role.type === 'member' && typeof role.displayOrder === 'number' ? role.displayOrder : 99999), + (orders) => Math.min(...orders), + ), + ); + + const startYearByPerson = computed(() => + createRolesPropertyMap( + people(), + (role) => role.dateStart.getFullYear(), + (years) => Math.max(...years), + ), + ); + + const endYearByPerson = computed(() => + createRolesPropertyMap( + people(), + (role) => role.dateEnd?.getFullYear() ?? null, + (years) => { + if (years.some((year) => year === null)) { + return null; + } + return Math.max(...(years as number[])); + }, + ), + ); + + return { + people, + numPeople, + rolesByPerson, + displayOrderByPerson, + startYearByPerson, + endYearByPerson, + } satisfies PeopleProps; + }), + withMethods((store) => { + const getMemberTitle = (person: PeopleItem) => { + const rolesByPerson = store.rolesByPerson(); + const role = rolesByPerson.get(person)?.[0]; + switch (role?.type) { + case 'collaborator': + return `Collaborator - ${role.project}`; + case 'member': + return role.title || ''; + case 'student': + return `${role.degree} Student - ${role.topic}`; + default: + return ''; + } + }; + + const getSearchableText = (person: PeopleItem): string => { + const parts = [person.name, getMemberTitle(person)]; + return parts + .join('\t') + .toLocaleLowerCase() + .trim() + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .replace(/\s{2,}/, ' '); + }; + + const isActiveInYear = (person: PeopleItem, year: number): boolean => { + for (const role of person.roles) { + const startYear = role.dateStart.getFullYear(); + const endYear = role.dateEnd ? role.dateEnd.getFullYear() : null; + if (year >= startYear && (endYear === null || year <= endYear)) { + return true; + } + } + + return false; + }; + + const setPeople = signalMethod((people: PeopleItem[]) => patchState(store, setEntities(people, peopleConfig))); + + return { getMemberTitle, getSearchableText, isActiveInYear, setPeople } satisfies PeopleMethods; + }), + ); +} diff --git a/apps/cns-website/src/app/pages/landing-page/landing-page.component.html b/apps/cns-website/src/app/pages/landing-page/landing-page.component.html new file mode 100644 index 0000000000..d41a1d4f5a --- /dev/null +++ b/apps/cns-website/src/app/pages/landing-page/landing-page.component.html @@ -0,0 +1,44 @@ +
+

Science you can see

+
+ Celebrating 20+ years of research at Indiana University's Cyberinfrastructure for Network Science Center. +
+ + + @for (item of contentTypeItems; track item.slug) { + {{ item.label }} + } + + +
+ + + +
+
+ + diff --git a/apps/cns-website/src/app/pages/landing-page/landing-page.component.scss b/apps/cns-website/src/app/pages/landing-page/landing-page.component.scss new file mode 100644 index 0000000000..4996009e23 --- /dev/null +++ b/apps/cns-website/src/app/pages/landing-page/landing-page.component.scss @@ -0,0 +1,66 @@ +@use '@angular/material' as mat; +@use 'utils'; +@use 'vars'; + +:host { + display: flex; + flex-direction: column; + height: 100%; + min-height: calc(100vh - 3.5rem); + + @media (prefers-color-scheme: dark) { + background: utils.with-alpha(vars.$primary, 0.08); + + .content { + background-color: rgba(0, 0, 0, 0.7); + background-blend-mode: darken; + } + } + + .content { + opacity: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex-grow: 1; + padding: 6.25rem 1.25rem; + background-size: cover; + background-position: center; + background-attachment: fixed; + min-height: calc(100vh - 3.5rem); + + @media (min-width: 640px) { + padding-left: 2.5rem; + padding-right: 2.5rem; + } + } + + .title { + text-align: center; + margin-bottom: 0.25rem; + color: vars.$on-background; + @include utils.use-font(display, large); + } + + .tagline { + margin-bottom: 2rem; + max-width: 31.5rem; + text-align: center; + color: vars.$on-surface; + @include utils.use-font(body, large); + } + + .content-filter { + margin-bottom: 3rem; + } + + .content-grid { + width: 100%; + max-width: 86.5rem; + } + + hra-content-button { + animation-delay: var(--animate-delay); + } +} diff --git a/apps/cns-website/src/app/pages/landing-page/landing-page.component.spec.ts b/apps/cns-website/src/app/pages/landing-page/landing-page.component.spec.ts new file mode 100644 index 0000000000..5abed7a212 --- /dev/null +++ b/apps/cns-website/src/app/pages/landing-page/landing-page.component.spec.ts @@ -0,0 +1,280 @@ +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ComponentFixture } from '@angular/core/testing'; +import { MatButtonToggleGroupHarness } from '@angular/material/button-toggle/testing'; +import { provideIcons } from '@hra-ui/design-system/icons'; +import { render, screen, within } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; +import { FeaturedData } from '../../schemas/featured.schema'; +import { ResearchTypeId } from '../../schemas/research-type.schema'; +import { ResearchCategoryId, ResearchId } from '../../schemas/research.schema'; +import { TagId, TagsData } from '../../schemas/tags.schema'; +import { LandingPageComponent } from './landing-page.component'; + +describe('LandingPageComponent', () => { + const mockResearchItem = { + slug: 'item-1' as ResearchId, + category: 'research' as ResearchCategoryId, + type: 'event' as ResearchTypeId, + title: 'Featured Item Title', + description: 'Featured item description', + dateStart: new Date(2024, 0, 1), + dateEnd: new Date(2024, 0, 15), + link: '/featured', + people: [], + tags: ['tag1'] as TagId[], + image: 'featured-image.jpg', + }; + + const mockPublicationItem = { + slug: 'pub-1' as ResearchId, + category: 'publication' as ResearchCategoryId, + type: 'publication' as ResearchTypeId, + title: 'Publication Title', + description: 'Publication description', + dateStart: new Date(2024, 0, 2), + dateEnd: new Date(2024, 0, 2), + link: 'https://external-domain.com/publication', + people: [], + tags: ['tag2'] as TagId[], + }; + + const mockNewsItem = { + slug: 'news-1' as ResearchId, + category: 'news' as ResearchCategoryId, + type: 'news' as ResearchTypeId, + title: 'News Title', + description: 'News description', + dateStart: new Date(2024, 0, 3), + dateEnd: new Date(2024, 0, 3), + link: 'http://news.example.com/story', + people: [], + tags: ['tag3'] as TagId[], + }; + + const mockFeaturedContent: FeaturedData = { + featured: [mockResearchItem], + publications: [mockPublicationItem], + news: [mockNewsItem], + }; + + const mockTags: TagsData = [ + { slug: 'tag1' as TagId, name: 'Research', description: 'Research related items' }, + { slug: 'tag2' as TagId, name: 'Publications', description: 'Publication items' }, + { slug: 'tag3' as TagId, name: 'Other', description: 'Other items' }, + ]; + + // Helper function to set up component with common providers and inputs + async function setupComponent(featuredContent = mockFeaturedContent, tags = mockTags) { + const renderResult = await render(LandingPageComponent, { + providers: [provideHttpClient(), provideHttpClientTesting(), provideIcons()], + inputs: { + featuredContent, + tags, + }, + }); + + return { + ...renderResult, + user: userEvent.setup(), + }; + } + + describe('Title', () => { + it('should render the landing page with title', async () => { + await setupComponent(); + expect(screen.getByText('Science you can see')).toBeInTheDocument(); + }); + + it('should render the tagline', async () => { + await setupComponent(); + expect( + screen.getByText( + "Celebrating 20+ years of research at Indiana University's Cyberinfrastructure for Network Science Center.", + ), + ).toBeInTheDocument(); + }); + }); + + describe('Content Type Toggles', () => { + async function getToggles(fixture: ComponentFixture) { + const loader = TestbedHarnessEnvironment.loader(fixture); + const buttonToggleGroup = await loader.getHarness(MatButtonToggleGroupHarness); + return await buttonToggleGroup.getToggles(); + } + + it('should render all content type toggle buttons', async () => { + await setupComponent(); + expect(screen.getByRole('radio', { name: 'Featured' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Publications' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'News' })).toBeInTheDocument(); + }); + + it('should have Featured selected by default', async () => { + const { fixture } = await setupComponent(); + const toggles = await getToggles(fixture); + + expect(await toggles[0].isChecked()).toBe(true); + expect(await toggles[1].isChecked()).toBe(false); + expect(await toggles[2].isChecked()).toBe(false); + }); + + it('should switch to Publications when Publications button is clicked', async () => { + const { fixture, user } = await setupComponent(); + const publicationsButton = screen.getByRole('radio', { name: 'Publications' }); + const toggles = await getToggles(fixture); + + await user.click(publicationsButton); + expect(await toggles[0].isChecked()).toBe(false); + expect(await toggles[1].isChecked()).toBe(true); + expect(await toggles[2].isChecked()).toBe(false); + }); + + it('should switch to News when News button is clicked', async () => { + const { fixture, user } = await setupComponent(); + const newsButton = screen.getByRole('radio', { name: 'News' }); + const toggles = await getToggles(fixture); + + await user.click(newsButton); + expect(await toggles[0].isChecked()).toBe(false); + expect(await toggles[1].isChecked()).toBe(false); + expect(await toggles[2].isChecked()).toBe(true); + }); + }); + + describe('Content Display', () => { + it('should display featured items by default', async () => { + await setupComponent(); + expect(screen.getByText('Featured Item Title')).toBeInTheDocument(); + }); + + it('should display publication items when Publications is selected', async () => { + const { user } = await setupComponent(); + const publicationsButton = screen.getByRole('radio', { name: 'Publications' }); + await user.click(publicationsButton); + + expect(screen.getByText('Publication Title')).toBeInTheDocument(); + }); + + it('should display news items when News is selected', async () => { + const { user } = await setupComponent(); + const newsButton = screen.getByRole('radio', { name: 'News' }); + await user.click(newsButton); + + expect(screen.getByText('News Title')).toBeInTheDocument(); + }); + + it('should only display items for the selected content type', async () => { + const { user } = await setupComponent(); + + // Featured is shown initially + expect(screen.getByText('Featured Item Title')).toBeInTheDocument(); + expect(screen.queryByText('Publication Title')).not.toBeInTheDocument(); + expect(screen.queryByText('News Title')).not.toBeInTheDocument(); + + // Switch to Publications + await user.click(screen.getByRole('radio', { name: 'Publications' })); + expect(screen.queryByText('Featured Item Title')).not.toBeInTheDocument(); + expect(screen.getByText('Publication Title')).toBeInTheDocument(); + expect(screen.queryByText('News Title')).not.toBeInTheDocument(); + }); + }); + + describe('Card Data Mapping and Links', () => { + function getCardByTagline(tagline: string): HTMLElement { + const card = screen.getByText(tagline).closest('a'); + expect(card).toBeTruthy(); + return card as HTMLElement; + } + + it('should map research item title and tags to the card', async () => { + await setupComponent(); + const card = getCardByTagline('Featured Item Title'); + const cardScope = within(card); + + expect(cardScope.getByText('Research')).toBeInTheDocument(); + }); + + it('should map dateStart to the card date', async () => { + await setupComponent(); + const card = getCardByTagline('Featured Item Title'); + expect(within(card).getByText('Jan 1, 2024')).toBeInTheDocument(); + }); + + it('should skip tags that do not have a matching label', async () => { + const customContent: FeaturedData = { + featured: [ + { + ...mockResearchItem, + tags: ['tag1', 'unknown-tag'] as TagId[], + }, + ], + publications: [], + news: [], + }; + + await setupComponent(customContent); + const card = getCardByTagline('Featured Item Title'); + const cardScope = within(card); + + expect(cardScope.getByText('Research')).toBeInTheDocument(); + expect(cardScope.queryByText('unknown-tag')).not.toBeInTheDocument(); + }); + + it('should include all available tag labels for an item with multiple tags', async () => { + const customContent: FeaturedData = { + featured: [ + { + ...mockResearchItem, + tags: ['tag1', 'tag2'] as TagId[], + }, + ], + publications: [], + news: [], + }; + + await setupComponent(customContent); + const card = getCardByTagline('Featured Item Title'); + const cardScope = within(card); + + expect(cardScope.getByText('Research')).toBeInTheDocument(); + expect(cardScope.getByText('Publications')).toBeInTheDocument(); + }); + + it('should detect internal links correctly', async () => { + await setupComponent(); + const card = getCardByTagline('Featured Item Title'); + + expect(card).toHaveAttribute('href', '/featured'); + expect(card).not.toHaveAttribute('target'); + expect(card).not.toHaveAttribute('rel'); + }); + + it('should detect external HTTPS links correctly', async () => { + const { user } = await setupComponent(); + await user.click(screen.getByRole('radio', { name: 'Publications' })); + + const card = getCardByTagline('Publication Title'); + expect(card).toHaveAttribute('href', 'https://external-domain.com/publication'); + expect(card).toHaveAttribute('target', '_blank'); + expect(card).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('should not display cards without a link', async () => { + const customContent: FeaturedData = { + featured: [ + { + ...mockResearchItem, + link: undefined, + }, + ], + publications: [], + news: [], + }; + + await setupComponent(customContent); + expect(screen.queryByText('Featured Item Title')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/cns-website/src/app/pages/landing-page/landing-page.component.ts b/apps/cns-website/src/app/pages/landing-page/landing-page.component.ts new file mode 100644 index 0000000000..8ccc2a1445 --- /dev/null +++ b/apps/cns-website/src/app/pages/landing-page/landing-page.component.ts @@ -0,0 +1,141 @@ +import { ChangeDetectionStrategy, Component, computed, input, signal } from '@angular/core'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { HraCommonModule } from '@hra-ui/common'; +import { isAbsolute } from '@hra-ui/common/url'; +import { ContentButtonComponent } from '@hra-ui/design-system/cards/content-button'; +import { GalleryGridComponent, GalleryGridItemDirective } from '@hra-ui/design-system/gallery-grid'; +import { FooterComponent } from '../../components/footer/footer.component'; +import { FeaturedData, FeaturedDataKey } from '../../schemas/featured.schema'; +import { ResearchItem } from '../../schemas/research.schema'; +import { TagsData } from '../../schemas/tags.schema'; +import { getImageUrl } from '../../utils/research-item-images'; +import { ButtonsModule } from '@hra-ui/design-system/buttons'; + +/** Content type item */ +interface ContentTypeItem { + /** Display label for the content type */ + label: string; + /** Slug for the content type, used for matching with data keys */ + slug: FeaturedDataKey; +} + +/** Content card data structure for displaying research items on the landing page */ +interface ContentCard { + /** Unique slug for the content card */ + slug: string; + /** Tagline of the card */ + tagline: string; + /** Tags of the card */ + tags: string[]; + /** Date of the content */ + date: Date; + /** Image URL for the card */ + image: string; + /** Link to the content */ + link: string; + /** Whether the link is external */ + external: boolean; +} + +/** Predefined content type items for the landing page */ +const CONTENT_TYPE_ITEMS: ContentTypeItem[] = [ + { label: 'Featured', slug: 'featured' }, + { label: 'Publications', slug: 'publications' }, + { label: 'News', slug: 'news' }, +]; + +/** + * Landing page of CNS website + */ +@Component({ + selector: 'cns-landing-page', + imports: [ + HraCommonModule, + FooterComponent, + MatButtonToggleModule, + GalleryGridComponent, + ContentButtonComponent, + GalleryGridItemDirective, + ButtonsModule, + ], + templateUrl: './landing-page.component.html', + styleUrl: './landing-page.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LandingPageComponent { + /** Featured content data */ + readonly featuredContent = input.required(); + + /** Tags data */ + readonly tags = input.required(); + + /** Currently selected content type */ + protected readonly contentType = signal('featured'); + + /** Content type items for the toggle buttons */ + protected readonly contentTypeItems = CONTENT_TYPE_ITEMS; + + /** Content mapped to cards */ + protected readonly cards = computed(() => { + return Object.entries(this.featuredContent()).reduce( + (acc, [key, items]) => { + acc[key as FeaturedDataKey] = this.toContentCards(items); + return acc; + }, + {} as Record, + ); + }); + + /** + * Converts a list of ResearchItems to ContentCards, sorted by date descending and filtered to exclude items without valid links. + * @param items List of ResearchItems to convert + * @returns List of ContentCards mapped from the input items, sorted and filtered + */ + private toContentCards(items: ResearchItem[]): ContentCard[] { + return [...items] + .sort((a, b) => new Date(b.dateStart).getTime() - new Date(a.dateStart).getTime()) + .map((item) => this.toContentCard(item)) + .filter((card) => card.link !== '#'); + } + + /** + * Converts a ResearchItem to a ContentCard + * + * @param item Content data + * @returns Mapped content card data + */ + private toContentCard(item: ResearchItem): ContentCard { + const { slug, title: tagline, category, tags, dateStart: date, link } = item; + const tagLabels = this.getTagLabels([category, ...tags]); + + return { + slug, + tagline, + tags: tagLabels.slice(0, 2), + date, + image: getImageUrl(item), + link: link ?? '#', + external: link !== undefined && isAbsolute(link), + }; + } + + /** + * Gets the labels for a list of tag slugs. + * Slugs without a matching tag are skipped. + * + * @param slugs Slugs to find labels for + * @returns The labels for the given tag slugs + */ + private getTagLabels(slugs: string[]): string[] { + const tags = this.tags(); + const labels: string[] = []; + for (const slug of slugs) { + const tag = tags.find((t) => t.slug === slug); + if (tag) { + labels.push(tag.name); + } + } + + return labels; + } +} diff --git a/apps/cns-website/src/app/pages/people-profile/contact-info/contact-info.component.html b/apps/cns-website/src/app/pages/people-profile/contact-info/contact-info.component.html new file mode 100644 index 0000000000..39ef90a718 --- /dev/null +++ b/apps/cns-website/src/app/pages/people-profile/contact-info/contact-info.component.html @@ -0,0 +1,38 @@ + + @if (image(); as src) { +
+ Profile picture +
+ } + + @let data = role(); + @if (data && data.type === 'member') { + + @if (data.office; as office) { + +
{{ office }}
+
+ } + + @if (data.phone; as phone) { + +
{{ phone }}
+
+ } + + @if (data.fax; as fax) { + +
{{ fax }}
+
+ } + + @if (data.email; as email) { + + + + } +
+ } +
diff --git a/apps/cns-website/src/app/pages/people-profile/contact-info/contact-info.component.scss b/apps/cns-website/src/app/pages/people-profile/contact-info/contact-info.component.scss new file mode 100644 index 0000000000..3ed5caeff5 --- /dev/null +++ b/apps/cns-website/src/app/pages/people-profile/contact-info/contact-info.component.scss @@ -0,0 +1,67 @@ +@use 'vars' as vars; +@use 'utils' as utils; + +:host { + display: flex; + flex-direction: column; + min-width: 12.5rem; + max-width: 100%; + flex: 0; + + @media (min-width: 640px) { + background: vars.$surface-container-low; + padding: 2rem; + padding-top: 2.5rem; + max-width: 18.75rem; + flex: 1; + min-height: 100%; + } +} + +.profile-picture { + width: 100%; + max-width: 15rem; + max-height: 18.75rem; + margin-bottom: 2rem; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.value { + @include utils.use-font(body, large); + + a { + color: inherit; + text-decoration: none; + } +} + +.contact-info-section { + color: vars.$on-background; + + ::ng-deep { + > section .content { + gap: 1rem; + } + + hra-page-label { + h5 { + @include utils.use-font(headline, small); + border-bottom: 1px solid vars.$outline; + padding-bottom: 0.25rem; + } + h6 { + @include utils.use-font(title, large); + padding-bottom: 0.25rem; + } + } + + hra-page-section .content { + margin-top: 0; + } + } +} diff --git a/apps/cns-website/src/app/pages/people-profile/contact-info/contact-info.component.ts b/apps/cns-website/src/app/pages/people-profile/contact-info/contact-info.component.ts new file mode 100644 index 0000000000..55b183b14e --- /dev/null +++ b/apps/cns-website/src/app/pages/people-profile/contact-info/contact-info.component.ts @@ -0,0 +1,22 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { HraCommonModule } from '@hra-ui/common'; +import { PageSectionComponent } from '@hra-ui/design-system/content-templates/page-section'; +import { AnyRole } from '../../../schemas/roles.schema'; + +/** + * Component displaying contact information sidebar + */ +@Component({ + selector: 'cns-contact-info', + imports: [HraCommonModule, PageSectionComponent], + templateUrl: './contact-info.component.html', + styleUrl: './contact-info.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContactInfoComponent { + /** Profile picture URL */ + readonly image = input.required(); + + /** Primary role information */ + readonly role = input.required(); +} diff --git a/apps/cns-website/src/app/pages/people-profile/people-profile.component.html b/apps/cns-website/src/app/pages/people-profile/people-profile.component.html new file mode 100644 index 0000000000..62357d0728 --- /dev/null +++ b/apps/cns-website/src/app/pages/people-profile/people-profile.component.html @@ -0,0 +1,39 @@ + + + + + + + + + + @if (hasContactInfo() && isMobile()) { + + } + + + + @for (section of sections(); track section.anchor) { + + + + } + + +
+ + +
+
diff --git a/apps/cns-website/src/app/pages/people-profile/people-profile.component.scss b/apps/cns-website/src/app/pages/people-profile/people-profile.component.scss new file mode 100644 index 0000000000..4ce340de8c --- /dev/null +++ b/apps/cns-website/src/app/pages/people-profile/people-profile.component.scss @@ -0,0 +1,52 @@ +@use '@angular/material' as mat; +@use 'vars' as vars; + +:host { + display: block; + + .container { + height: calc(100vh - 3.5rem); + --hra-table-of-contents-layout-top-offset: 2.5rem; + + @include mat.sidenav-overrides( + ( + container-background-color: vars.$surface-container-low, + container-divider-color: vars.$outline, + container-shape: vars.$corner-none, + container-width: 16rem, + content-background-color: vars.$on-secondary, + ) + ); + + @media (min-width: 924px) { + @include mat.sidenav-overrides( + ( + container-width: 18.75rem, + ) + ); + } + + .content { + display: flex; + flex-direction: column; + + .layout-container { + margin: 2.5rem 1.5rem; + + .section { + width: 100%; + max-width: 87.5rem; + margin: auto; + } + + @media (min-width: 640px) { + margin: 2.5rem; + } + } + + .spacer { + flex-grow: 1; + } + } + } +} diff --git a/apps/cns-website/src/app/pages/people-profile/people-profile.component.ts b/apps/cns-website/src/app/pages/people-profile/people-profile.component.ts new file mode 100644 index 0000000000..0661a75ffa --- /dev/null +++ b/apps/cns-website/src/app/pages/people-profile/people-profile.component.ts @@ -0,0 +1,140 @@ +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { MatSidenav, MatSidenavContainer, MatSidenavContent } from '@angular/material/sidenav'; +import { Breakpoints, watchBreakpoint } from '@hra-ui/cdk/breakpoints'; +import { HraCommonModule } from '@hra-ui/common'; +import { BreadcrumbItem } from '@hra-ui/design-system/buttons/breadcrumbs'; +import { ChipsModule } from '@hra-ui/design-system/chips'; +import { MarkdownComponent } from '@hra-ui/design-system/content-templates/markdown'; +import { PageSectionComponent } from '@hra-ui/design-system/content-templates/page-section'; +import { + TableOfContentsLayoutComponent, + TableOfContentsLayoutHeaderComponent, +} from '@hra-ui/design-system/layouts/table-of-contents'; +import { FooterComponent } from '../../components/footer/footer.component'; +import { PeopleItem } from '../../schemas/people.schema'; +import { getRefinedRoleTypeLabel, refineRoleType } from '../../utils/refined-roles'; +import { ContactInfoComponent } from './contact-info/contact-info.component'; + +/** Profile section data */ +interface ProfileSection { + /** Section title/tagline */ + tagline: string; + /** Section anchor id */ + anchor: string; + /** Section content in markdown */ + content: string; +} + +/** + * Page component for displaying people profiles + */ +@Component({ + selector: 'cns-people-profile', + imports: [ + HraCommonModule, + ChipsModule, + ContactInfoComponent, + FooterComponent, + MarkdownComponent, + MatSidenav, + MatSidenavContainer, + MatSidenavContent, + PageSectionComponent, + TableOfContentsLayoutComponent, + TableOfContentsLayoutHeaderComponent, + ], + templateUrl: './people-profile.component.html', + styleUrl: './people-profile.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PeopleProfileComponent { + /** + * Profile data from route resolver + */ + readonly data = input.required(); + + /** Whether the current screen size matches mobile breakpoint */ + protected readonly isMobile = watchBreakpoint(Breakpoints.Mobile); + + /** Breadcrumbs computed for navigation */ + protected readonly breadcrumbs = computed((): BreadcrumbItem[] => [ + { name: 'Home', route: '/' }, + { name: 'People', route: '/people' }, + { name: this.data().name }, + ]); + + /** Primary role computed from profile data */ + protected readonly primaryRole = computed(() => this.data().roles?.[0]); + + /** Whether contact info is available for display */ + protected readonly hasContactInfo = computed(() => { + if (this.data().image) { + return true; + } + + const role = this.primaryRole(); + if (!role || role.type !== 'member') { + return false; + } + + const { email, fax, office, phone } = role; + return !!(email || fax || office || phone); + }); + + /** Role tags computed for display */ + protected readonly tags = computed(() => { + const role = this.primaryRole(); + if (!role) { + return []; + } + + const tags: string[] = []; + if (role.type === 'member') { + if (role.title) { + tags.push(role.title); + } + } + + tags.push(getRefinedRoleTypeLabel(refineRoleType(role))); + + return tags; + }); + + /** Profile sections computed from role data */ + protected readonly sections = computed(() => { + const role = this.primaryRole(); + if (!role) { + return []; + } + + const sections: ProfileSection[] = []; + + if (role.type === 'member') { + if (role.education) { + sections.push({ + tagline: 'Education', + anchor: 'education', + content: role.education, + }); + } + + if (role.background) { + sections.push({ + tagline: 'Background', + anchor: 'background', + content: role.background, + }); + } + + if (role.interests) { + sections.push({ + tagline: 'Interests', + anchor: 'interests', + content: role.interests, + }); + } + } + + return sections; + }); +} diff --git a/apps/cns-website/src/app/pages/research-page/research-page.component.html b/apps/cns-website/src/app/pages/research-page/research-page.component.html new file mode 100644 index 0000000000..8507b9b38e --- /dev/null +++ b/apps/cns-website/src/app/pages/research-page/research-page.component.html @@ -0,0 +1,105 @@ + + + + + {{ store.view() === 'gallery' ? 'cards' : 'list' }} + View as + + + + Gallery + + + + List + + + + + + sort + Sort by + + Ascending (A-Z) by title + Descending (Z-A) by title + Newest + Oldest + + + + + category + Group by + + None + Publication type + Year + + + + + + + + + +
+ @if (store.numFilteredItems() === 0) { + + } @else if (store.view() === 'gallery') { + @for (group of store.sortedGroupedItems(); track group.label) { + + } + } @else { + + } + +
+ + +
+ + +
+
+
+
diff --git a/apps/cns-website/src/app/pages/research-page/research-page.component.scss b/apps/cns-website/src/app/pages/research-page/research-page.component.scss new file mode 100644 index 0000000000..9c06ddaba1 --- /dev/null +++ b/apps/cns-website/src/app/pages/research-page/research-page.component.scss @@ -0,0 +1,91 @@ +@use '@angular/material' as mat; +@use 'utils'; +@use 'vars'; + +:host { + display: block; + height: calc(100vh - 3.5rem); + + .container { + height: 100%; + + @include mat.sidenav-overrides( + ( + container-background-color: vars.$surface-bright, + container-divider-color: vars.$outline, + container-shape: vars.$corner-none, + container-width: 20rem, + content-background-color: vars.$background, + ) + ); + } + + mat-sidenav-content { + display: flex; + flex-direction: column; + height: calc(100vh - 3.5rem); + align-items: center; + } + + hra-search-filter, + .content > *:not(cns-footer) { + max-width: 95rem; + width: calc(100% - 2.5rem); + + @media (min-width: 640px) { + width: calc(100% - 5rem); + } + } + + hra-search-filter { + position: sticky; + top: 0; + z-index: 10; + margin-top: 1.5rem; + margin-bottom: 2rem; + } + + ng-scrollbar { + width: 100%; + + ::ng-deep ng-scroll-content { + width: 100%; + } + } + + .content { + display: flex; + flex-direction: column; + align-items: center; + min-height: calc(100vh - 10.5rem); + + &:has(.gallery-group) { + gap: 3rem; + } + + .gallery-group .title { + margin-bottom: 2rem; + + @include mat.divider-overrides( + ( + color: vars.$outline, + ) + ); + } + + .page-end { + padding-bottom: 2.5rem; + margin-bottom: auto; + + mat-divider { + margin-top: 2rem; + margin-bottom: 2.5rem; + } + } + + hra-end-of-results-indicator { + padding: 0; + flex-direction: row; + } + } +} diff --git a/apps/cns-website/src/app/pages/research-page/research-page.component.spec.ts b/apps/cns-website/src/app/pages/research-page/research-page.component.spec.ts new file mode 100644 index 0000000000..ce97106b12 --- /dev/null +++ b/apps/cns-website/src/app/pages/research-page/research-page.component.spec.ts @@ -0,0 +1,248 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { render, screen } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; +import { PeopleData, PeopleId } from '../../schemas/people.schema'; +import { ResearchTypeId, ResearchTypesData } from '../../schemas/research-type.schema'; +import { ResearchCategoryId, ResearchData, ResearchId, ResearchItem } from '../../schemas/research.schema'; +import { TagId, TagsData } from '../../schemas/tags.schema'; +import { SidebarStore } from '../../state/sidebar/sidebar.store'; +import { ResearchPageComponent } from './research-page.component'; +import { MatIconTestingModule } from '@angular/material/icon/testing'; +import { provideMarkdown } from 'ngx-markdown'; + +const mockResearchItem = (overrides?: Partial): ResearchItem => ({ + slug: 'test-research-1' as ResearchId, + category: 'publication' as ResearchCategoryId, + type: 'journal-article' as ResearchTypeId, + title: 'Test Research Item', + description: 'A test research description', + dateStart: new Date('2024-01-15'), + dateEnd: new Date('2024-01-20'), + link: 'https://example.com/research', + people: ['person-1' as PeopleId], + tags: ['method-computational' as TagId, 'organ-brain' as TagId], + ...overrides, +}); + +const mockPeople: PeopleData = [ + { + slug: 'person-1' as PeopleId, + name: 'Dr. Jane', + lastName: 'Smith', + roles: [], + image: 'https://example.com/jane.jpg', + }, +]; + +const mockPublicationTypes: ResearchTypesData = [ + { label: 'Journal Article', value: 'journal-article' as ResearchTypeId }, + { label: 'Conference Paper', value: 'conference-paper' as ResearchTypeId }, +]; + +const mockEventTypes: ResearchTypesData = [ + { label: 'Workshop', value: 'workshop' as ResearchTypeId }, + { label: 'Seminar', value: 'seminar' as ResearchTypeId }, + { label: 'Conference', value: 'conference' as ResearchTypeId }, +]; + +const mockFundingTypes: ResearchTypesData = [ + { label: 'Research', value: 'research' as ResearchTypeId }, + { label: 'Travel', value: 'travel' as ResearchTypeId }, + { label: 'Interactive Visualization', value: 'interactive-visualization' as ResearchTypeId }, +]; + +const mockTags: TagsData = [ + { slug: 'method-computational' as TagId, name: 'Computational Method', description: 'Methods involving computation' }, + { slug: 'organ-brain' as TagId, name: 'Brain', description: 'Research related to the brain' }, +]; + +describe('ResearchPageComponent', () => { + const renderComponent = async (overrides?: { + news?: ResearchData; + publications?: ResearchData; + events?: ResearchData; + funding?: ResearchData; + visualizations?: ResearchData; + people?: PeopleData; + publicationTypes?: ResearchTypesData; + eventTypes?: ResearchTypesData; + fundingTypes?: ResearchTypesData; + tags?: TagsData; + }) => { + const news = overrides?.news ?? [ + mockResearchItem({ + slug: 'news-1' as ResearchId, + category: 'news' as ResearchCategoryId, + dateStart: new Date('2024-02-01'), + dateEnd: new Date('2024-02-05'), + title: 'Breaking News Update', + }), + ]; + + const publications = overrides?.publications ?? [ + mockResearchItem({ + slug: 'pub-1' as ResearchId, + category: 'publication' as ResearchCategoryId, + type: 'journal-article' as ResearchTypeId, + title: 'Research on Network Science', + dateStart: new Date('2024-01-10'), + }), + mockResearchItem({ + slug: 'pub-2' as ResearchId, + category: 'publication' as ResearchCategoryId, + type: 'conference-paper' as ResearchTypeId, + title: 'Another Publication', + dateStart: new Date('2023-12-15'), + description: 'Details about the publication.', + }), + ]; + + const events = overrides?.events ?? [ + mockResearchItem({ + slug: 'event-1' as ResearchId, + category: 'event' as ResearchCategoryId, + type: 'workshop' as ResearchTypeId, + title: 'Network Science Workshop', + dateStart: new Date('2024-03-15'), + dateEnd: new Date('2024-03-16'), + description: 'A hands-on workshop on network science techniques.', + }), + mockResearchItem({ + slug: 'event-2' as ResearchId, + category: 'event' as ResearchCategoryId, + type: 'seminar' as ResearchTypeId, + title: 'Advanced Computational Methods Seminar', + dateStart: new Date('2024-02-20'), + dateEnd: new Date('2024-02-20'), + }), + ]; + + const funding = overrides?.funding ?? [ + mockResearchItem({ + slug: 'funding-1' as ResearchId, + category: 'funding' as ResearchCategoryId, + type: 'research' as ResearchTypeId, + title: 'CNS Research Grant Award', + dateStart: new Date('2024-01-05'), + dateEnd: new Date('2024-12-31'), + description: 'Research funding for network science initiatives.', + }), + ]; + + const visualizations = overrides?.visualizations ?? [ + mockResearchItem({ + slug: 'viz-1' as ResearchId, + category: 'visualization' as ResearchCategoryId, + type: 'interactive-visualization' as ResearchTypeId, + title: 'Interactive Network Visualization', + dateStart: new Date('2023-11-10'), + dateEnd: new Date('2023-11-10'), + description: 'An interactive visualization of neural networks.', + }), + ]; + + return render(ResearchPageComponent, { + providers: [provideMarkdown(), provideHttpClient(), provideHttpClientTesting(), SidebarStore], + imports: [MatIconTestingModule], + componentInputs: { + news, + publications, + events, + funding, + visualizations, + people: overrides?.people ?? mockPeople, + publicationTypes: overrides?.publicationTypes ?? mockPublicationTypes, + eventTypes: overrides?.eventTypes ?? mockEventTypes, + fundingTypes: overrides?.fundingTypes ?? mockFundingTypes, + tags: overrides?.tags ?? mockTags, + }, + }); + }; + + describe('Component Rendering', () => { + it('should render the component with main content area', async () => { + await renderComponent(); + expect(screen.getByRole('main')).toBeInTheDocument(); + }); + + it('should render sidenav container with filter sidebar', async () => { + await renderComponent(); + expect(await screen.findByText('Filter')).toBeInTheDocument(); + }); + }); + + describe('View Mode Management', () => { + it('should set view as list', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const viewAsSelect = await screen.findByText('View as'); + await user.click(viewAsSelect); + + const listOption = await screen.findByRole('option', { name: 'List' }); + await user.click(listOption); + + expect(await screen.findByText('Details about the publication.')).toBeInTheDocument(); + }); + + it('should set view as gallery', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const viewAsSelect = await screen.findByText('View as'); + await user.click(viewAsSelect); + + const galleryOption = await screen.findByRole('option', { name: 'Gallery' }); + await user.click(galleryOption); + + expect(screen.queryByText('Details about the publication.')).not.toBeInTheDocument(); + }); + }); + + describe('Sorting and Grouping', () => { + it('should group by publication type', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const groupBySelect = await screen.findByText('Group by'); + await user.click(groupBySelect); + + const typeOption = await screen.findByRole('option', { name: /^publication type$/i }); + await user.click(typeOption); + + expect(await screen.findByText('Conference Paper')).toBeInTheDocument(); + }); + + it('should group by year', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const groupBySelect = await screen.findByText('Group by'); + await user.click(groupBySelect); + + const yearOption = await screen.findByRole('option', { name: /year/i }); + await user.click(yearOption); + + expect(await screen.findByText('2024')).toBeInTheDocument(); + }); + }); + + describe('Filtering and Search', () => { + it('should search for an item', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const searchInput = await screen.findByRole('searchbox', { name: /search/i }); + await user.type(searchInput, 'Network Science'); + + const results = await screen.findAllByText(/Research on Network Science/i); + expect(results.length).toBeGreaterThan(0); + }); + }); + + it('should handle empty data gracefully', async () => { + await renderComponent({ news: [], publications: [], events: [], funding: [], visualizations: [] }); + expect(screen.getByText((content) => content.includes('0') && content.includes('/'))).toBeInTheDocument(); + }); +}); diff --git a/apps/cns-website/src/app/pages/research-page/research-page.component.ts b/apps/cns-website/src/app/pages/research-page/research-page.component.ts new file mode 100644 index 0000000000..2052407101 --- /dev/null +++ b/apps/cns-website/src/app/pages/research-page/research-page.component.ts @@ -0,0 +1,142 @@ +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, viewChild } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatDivider } from '@angular/material/divider'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSidenav, MatSidenavModule } from '@angular/material/sidenav'; +import { HraCommonModule } from '@hra-ui/common'; +import { ButtonsModule } from '@hra-ui/design-system/buttons'; +import { CardsModule } from '@hra-ui/design-system/cards'; +import { TagItem } from '@hra-ui/design-system/cards/gallery-card'; +import { ListViewComponent } from '@hra-ui/design-system/content-templates/list-view'; +import { SectionLinkComponent } from '@hra-ui/design-system/content-templates/section-link'; +import { FilterMenuComponent } from '@hra-ui/design-system/filter-menu'; +import { GalleryGridComponent, GalleryGridItemDirective } from '@hra-ui/design-system/gallery-grid'; +import { IconsModule } from '@hra-ui/design-system/icons'; +import { EndOfResultsIndicatorComponent } from '@hra-ui/design-system/indicators/end-of-results'; +import { NoResultsIndicatorComponent } from '@hra-ui/design-system/indicators/no-results-indicator'; +import { ScrollingModule } from '@hra-ui/design-system/scrolling'; +import { SearchFilterComponent } from '@hra-ui/design-system/search-filter'; +import { NgScrollbar } from 'ngx-scrollbar'; +import { FooterComponent } from '../../components/footer/footer.component'; +import { PeopleData } from '../../schemas/people.schema'; +import { ResearchTypesData } from '../../schemas/research-type.schema'; +import { ResearchCategoryId, ResearchData } from '../../schemas/research.schema'; +import { TagId, TagsData } from '../../schemas/tags.schema'; +import { ScrollbarStore } from '../../state/scrollbar/scrollbar.store'; +import { SidebarStore } from '../../state/sidebar/sidebar.store'; +import { getImageUrl } from '../../utils/research-item-images'; +import { ResearchStore } from './state/research.store'; + +/** + * Research page with filtering, sorting, and dual view modes. + * Displays research outputs with multi-faceted filtering and query parameter sync. + */ +@Component({ + selector: 'cns-research-page', + imports: [ + HraCommonModule, + ButtonsModule, + CardsModule, + EndOfResultsIndicatorComponent, + FilterMenuComponent, + FooterComponent, + FormsModule, + GalleryGridComponent, + GalleryGridItemDirective, + IconsModule, + ListViewComponent, + MatDivider, + MatFormFieldModule, + MatSelectModule, + MatSidenavModule, + NoResultsIndicatorComponent, + SearchFilterComponent, + SectionLinkComponent, + ScrollingModule, + ], + templateUrl: './research-page.component.html', + styleUrl: './research-page.component.scss', + providers: [ResearchStore], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ResearchPageComponent { + /** News research data */ + readonly news = input.required(); + /** Publications research data */ + readonly publications = input.required(); + /** Events research data */ + readonly events = input.required(); + /** Funding research data */ + readonly funding = input.required(); + /** Visualizations research data */ + readonly visualizations = input.required(); + /** People data for filtering and display */ + readonly people = input.required(); + /** Publication type definitions */ + readonly publicationTypes = input.required(); + /** Event type definitions */ + readonly eventTypes = input.required(); + /** Funding type definitions */ + readonly fundingTypes = input.required(); + /** Tags data from resolver */ + readonly tags = input.required(); + + /** Research store for state management */ + protected readonly store = inject(ResearchStore); + /** Sidebar store for managing sidebar visibility */ + protected readonly sidebarStore = inject(SidebarStore); + /** Scrollbar store for managing viewport scrolling */ + protected readonly scrollbarStore = inject(ScrollbarStore); + + /** Sidebar component reference */ + private readonly sidebar = viewChild.required(MatSidenav); + /** Scrollbar component reference */ + private readonly scrollbar = viewChild.required(NgScrollbar); + + /** Combined research items from news, publications, events, funding, and visualizations */ + private readonly researchItems = computed(() => [ + ...this.news(), + ...this.publications(), + ...this.events(), + ...this.funding(), + ...this.visualizations(), + ]); + + /** Utility to get image URL for a research item */ + protected readonly getImageUrl = getImageUrl; + + /** Initializes store with data and registers sidebar */ + constructor() { + this.store.setResearchItems(this.researchItems); + this.store.setPeopleItems(this.people); + this.store.setPublicationTypes(this.publicationTypes); + this.store.setEventTypes(this.eventTypes); + this.store.setFundingTypes(this.fundingTypes); + this.store.setTags(this.tags); + + effect((onCleanup) => { + this.sidebarStore.setSidebar(this.sidebar()); + onCleanup(() => this.sidebarStore.clearSidebar()); + }); + + effect((onCleanup) => { + this.scrollbarStore.setScrollbar(this.scrollbar()); + onCleanup(() => this.scrollbarStore.clearScrollbar()); + }); + } + + /** + * Gets tag items from array of tag ids using the store's tags map + * @param category research category for the tags (e.g. 'publication', 'event') + * @param ids tag ids + * @returns tag items + */ + getTagItems(category: ResearchCategoryId, ids: TagId[]): TagItem[] { + const tagsMap = this.store.tagsMap(); + return [category, ...ids] + .map((id) => tagsMap.get(id)) + .filter((tag) => tag !== undefined) + .slice(0, 2); + } +} diff --git a/apps/cns-website/src/app/pages/research-page/state/research.store.ts b/apps/cns-website/src/app/pages/research-page/state/research.store.ts new file mode 100644 index 0000000000..2d35467f10 --- /dev/null +++ b/apps/cns-website/src/app/pages/research-page/state/research.store.ts @@ -0,0 +1,114 @@ +import { computed, Signal, WritableSignal } from '@angular/core'; +import { signalStore, withHooks } from '@ngrx/signals'; +import { linkedQueryParam } from 'ngxtension/linked-query-param'; +import { + parseCategories, + parseEventIds, + parseFundingIds, + parseGroupBy, + parsePeopleIds, + parsePublicationIds, + parseSearch, + parseSortBy, + parseView, + parseYears, + serializeCategories, + serializeEventIds, + serializeFundingIds, + serializePeopleIds, + serializePublicationIds, + serializeYears, +} from './serialization'; +import { withFilters } from './with-filters.feature'; +import { withOrdering } from './with-ordering.feature'; +import { withResearch } from './with-research.feature'; +import { withView } from './with-view.feature'; + +/** + * Creates a writable signal slice from a readonly signal and setter function. + * @param stateSignal Source readonly signal + * @param set Setter used to update the state + */ +function createWritableStateSlice(stateSignal: Signal, set: (value: T) => void): WritableSignal { + const signal = computed(() => stateSignal()) as WritableSignal; + signal.set = set; + signal.update = (updateFn) => set(updateFn(stateSignal())); + signal.asReadonly = () => stateSignal; + return signal; +} + +/** + * Research page state store combining data, filters, view, and ordering. + * Keeps state in sync with URL query parameters for sharing and navigation. + */ +export const ResearchStore = signalStore( + withResearch(), + withView(), + withFilters(), + withOrdering(), + withHooks({ + onInit(store) { + /** Writable signal slices for linkedQueryParam compatibility */ + const view = createWritableStateSlice(store._view, store.setView); + const categories = createWritableStateSlice(store.categories, store.setCategories); + const events = createWritableStateSlice(store.eventIds, store.setEventIds); + const funding = createWritableStateSlice(store.fundingIds, store.setFundingIds); + const publications = createWritableStateSlice(store.publicationIds, store.setPublicationIds); + const people = createWritableStateSlice(store.peopleIds, store.setPeopleIds); + const years = createWritableStateSlice(store.years, store.setYears); + const search = createWritableStateSlice(store.search, store.setSearch); + const sortBy = createWritableStateSlice(store._sortBy, store.setSortBy); + const groupBy = createWritableStateSlice(store.groupBy, store.setGroupBy); + const commonOptions = { replaceUrl: true, preserveFragment: true }; + + linkedQueryParam('category', { + source: categories, + parse: parseCategories, + stringify: serializeCategories, + ...commonOptions, + }); + linkedQueryParam('event', { + source: events, + parse: parseEventIds, + stringify: serializeEventIds, + ...commonOptions, + }); + linkedQueryParam('funding', { + source: funding, + parse: parseFundingIds, + stringify: serializeFundingIds, + ...commonOptions, + }); + linkedQueryParam('publication', { + source: publications, + parse: parsePublicationIds, + stringify: serializePublicationIds, + ...commonOptions, + }); + linkedQueryParam('people', { + source: people, + parse: parsePeopleIds, + stringify: serializePeopleIds, + ...commonOptions, + }); + linkedQueryParam('year', { + source: years, + parse: parseYears, + stringify: serializeYears, + ...commonOptions, + }); + linkedQueryParam('search', { source: search, parse: parseSearch, ...commonOptions }); + linkedQueryParam('view', { + source: view, + parse: parseView, + ...commonOptions, + }); + linkedQueryParam('sort-by', { + source: sortBy, + parse: parseSortBy, + ...commonOptions, + }); + linkedQueryParam('group-by', { source: groupBy, parse: parseGroupBy, ...commonOptions }); + }, + }), +); diff --git a/apps/cns-website/src/app/pages/research-page/state/serialization.ts b/apps/cns-website/src/app/pages/research-page/state/serialization.ts new file mode 100644 index 0000000000..19881ab23a --- /dev/null +++ b/apps/cns-website/src/app/pages/research-page/state/serialization.ts @@ -0,0 +1,200 @@ +import { CATEGORY_OPTIONS, CategoryOption, YEAR_OPTIONS, YearOption } from './with-filters.feature'; +import { GroupBy, SortBy } from './with-ordering.feature'; +import { View } from './with-view.feature'; + +/** + * Parses a string value into a matching enum member, case-insensitive. + * @param value Raw input + */ +function parseEnum(enumObj: T, value: unknown): T[keyof T] | null { + if (!value) { + return null; + } + + const strValue = String(value).toLowerCase().trim(); + const enumValues = Object.values(enumObj); + const match = enumValues.find((ev) => ev.toLowerCase() === strValue); + return (match as T[keyof T]) ?? null; +} + +/** + * Parses comma-separated option IDs into option objects, preserving order. + * @param value Raw query value + */ +function parseOptions(options: T[], value: unknown): T[] | null { + if (!value) { + return []; + } + + const strValue = String(value).toLowerCase().trim(); + const parts = strValue.split(',').map((part) => part.trim()); + const selectedOptions: T[] = []; + for (const part of parts) { + const option = options.find((opt) => opt.id.toLowerCase() === part); + if (option) { + selectedOptions.push(option); + } + } + + return selectedOptions.length > 0 ? selectedOptions : null; +} + +/** + * Serializes selected options to a comma-delimited string for query params. + * @param options Selected options + */ +function serializeOptions(options: T[] | null): string | null { + if (!options?.length) { + return null; + } + + return options.map((opt) => opt.id).join(','); +} + +/** + * Parses view query parameter into a view enum. + * @param value Raw query value + */ +export function parseView(value: unknown): View | null { + return parseEnum(View, value); +} + +/** + * Parses category query parameter into category options. + * @param value Raw query value + */ +export function parseCategories(value: unknown): CategoryOption[] | null { + return parseOptions(CATEGORY_OPTIONS, value); +} + +/** + * Parses event query parameter into event options. + * @param value Raw query value + */ +export function parseEventIds(value: unknown): string[] | null { + if (!value) { + return []; + } + + const strValue = String(value).toLowerCase().trim(); + return strValue.split(',').map((part) => part.trim()); +} + +/** + * Parses funding query parameter into funding options. + * @param value Raw query value + */ +export function parseFundingIds(value: unknown): string[] | null { + if (!value) { + return []; + } + + const strValue = String(value).toLowerCase().trim(); + return strValue.split(',').map((part) => part.trim()); +} + +/** + * Parses publication IDs from comma-separated query parameter. + * @param value Raw query value + */ +export function parsePublicationIds(value: unknown): string[] | null { + if (!value) { + return []; + } + + const strValue = String(value).toLowerCase().trim(); + return strValue.split(',').map((part) => part.trim()); +} + +/** + * Parses people IDs from comma-separated query parameter. + * @param value Raw query value + */ +export function parsePeopleIds(value: unknown): string[] | null { + if (!value) { + return []; + } + + const strValue = String(value).toLowerCase().trim(); + return strValue.split(',').map((part) => part.trim()); +} + +/** + * Parses year query parameter into year options. + * @param value Raw query value + */ +export function parseYears(value: unknown): YearOption[] | null { + return parseOptions(YEAR_OPTIONS, value); +} + +/** + * Parses search text from query parameter. + * @param value Raw query value + */ +export function parseSearch(value: unknown): string | null { + return value ? String(value) : null; +} + +/** + * Parses sort-by query parameter into sort enum. + * @param value Raw query value + */ +export function parseSortBy(value: unknown): SortBy | null { + return parseEnum(SortBy, value); +} + +/** + * Parses group-by query parameter into group enum. + * @param value Raw query value + */ +export function parseGroupBy(value: unknown): string | null { + return parseEnum(GroupBy, value); +} + +/** + * Serializes selected categories to query parameter format. + * @param options Selected categories + */ +export function serializeCategories(options: CategoryOption[] | null): string | null { + return serializeOptions(options); +} + +/** + * Serializes selected events to query parameter format. + * @param ids Selected event IDs + */ +export function serializeEventIds(ids: string[] | null): string | null { + return ids?.join(',') || null; +} + +/** + * Serializes selected funding options to query parameter format. + * @param ids Selected funding IDs + */ +export function serializeFundingIds(ids: string[] | null): string | null { + return ids?.join(',') || null; +} + +/** + * Serializes publication IDs to comma-delimited string. + * @param ids Publication IDs + */ +export function serializePublicationIds(ids: string[] | null): string | null { + return ids?.join(',') || null; +} + +/** + * Serializes people IDs to comma-delimited string. + * @param ids People IDs + */ +export function serializePeopleIds(ids: string[] | null): string | null { + return ids?.join(',') || null; +} + +/** + * Serializes selected years to query parameter format. + * @param options Selected years + */ +export function serializeYears(options: YearOption[] | null): string | null { + return serializeOptions(options); +} diff --git a/apps/cns-website/src/app/pages/research-page/state/with-filters.feature.ts b/apps/cns-website/src/app/pages/research-page/state/with-filters.feature.ts new file mode 100644 index 0000000000..ce5a2e7e48 --- /dev/null +++ b/apps/cns-website/src/app/pages/research-page/state/with-filters.feature.ts @@ -0,0 +1,487 @@ +import { computed, Signal } from '@angular/core'; +import { FilterOptionCategory } from '@hra-ui/design-system/filter-menu'; +import { SearchListOption } from '@hra-ui/design-system/search-list'; +import { + patchState, + signalMethod, + signalStoreFeature, + type, + withComputed, + withMethods, + withState, +} from '@ngrx/signals'; +import { PeopleId } from '../../../schemas/people.schema'; +import { ResearchTypeId, ResearchTypeItem } from '../../../schemas/research-type.schema'; +import { ResearchCategoryId, ResearchItem } from '../../../schemas/research.schema'; +import { ResearchState } from './with-research.feature'; + +/** Generic search list option with a typed id */ +type TypedSearchListOption = SearchListOption & { id: T }; + +/** Filter option for research categories */ +export type CategoryOption = TypedSearchListOption; + +/** Filter option for research events */ +export type EventOption = TypedSearchListOption; + +/** Filter option for research funding */ +export type FundingOption = TypedSearchListOption; + +/** Filter option for research publications */ +export type PublicationOption = TypedSearchListOption; + +/** Filter option for people */ +export type PeopleOption = TypedSearchListOption; + +/** Year option with numeric year value */ +export interface YearOption extends SearchListOption { + /** Year value */ + year: number; +} + +/** Signals exposed by the filters feature */ +export interface FilterProps { + /** Selected people options */ + people: Signal; + /** Available filters with selected options */ + filters: Signal[]>; + /** Items after all filters applied */ + filteredItems: Signal; + /** Count of filtered items */ + numFilteredItems: Signal; + /** Counts by category */ + countsByCategory: Signal>; + /** Counts by event type */ + countsByEventType: Signal>; + /** Counts by funding type */ + countsByFundingType: Signal>; + /** Counts by publication type */ + countsByPublicationType: Signal>; + /** Counts by people */ + countsByPeople: Signal>; + /** Counts by year */ + countsByYear: Signal>; + /** Aggregate counts array */ + counts: Signal[]>; +} + +/** Internal filter state backing the signals */ +type InternalProps = { [key: `_${string}`]: unknown }; + +/** Selected filter values for the research page */ +interface FilterState { + /** Selected categories */ + categories: CategoryOption[] | null; + /** Selected publication IDs */ + publicationIds: string[] | null; + /** Selected event IDs */ + eventIds: string[] | null; + /** Selected funding IDs */ + fundingIds: string[] | null; + /** Selected people IDs */ + peopleIds: string[] | null; + /** Selected years */ + years: YearOption[] | null; + /** Search text */ + search: string | null; +} + +/** + * Builds a descending list of years starting at startYear. + * @param startYear Inclusive starting year + */ +function createYearList(startYear: number): number[] { + const currentYear = new Date().getFullYear(); + const years: number[] = []; + for (let year = currentYear; year >= startYear; year--) { + years.push(year); + } + + return years; +} + +/** Category filter options */ +export const CATEGORY_OPTIONS: CategoryOption[] = [ + { id: 'data-tool' as ResearchCategoryId, label: 'Data & tools' }, + { id: 'event' as ResearchCategoryId, label: 'Events' }, + { id: 'funding' as ResearchCategoryId, label: 'Funding' }, + { id: 'display' as ResearchCategoryId, label: 'Interactive displays' }, + { id: 'miscellaneous' as ResearchCategoryId, label: 'Miscellaneous' }, + { id: 'news' as ResearchCategoryId, label: 'News' }, + { id: 'publication' as ResearchCategoryId, label: 'Publications' }, + { id: 'software' as ResearchCategoryId, label: 'Software Products' }, + { id: 'teaching' as ResearchCategoryId, label: 'Teaching' }, + { id: 'visualization' as ResearchCategoryId, label: 'Visualizations' }, +]; + +/** Year filter options from 1991 to current year */ +export const YEAR_OPTIONS: YearOption[] = createYearList(1991).map((year) => ({ + id: year.toString(), + label: year.toString(), + year, +})); + +/** Category filter configuration */ +const CATEGORIES_FILTER: FilterOptionCategory = { + id: 'category', + label: 'Category', + options: CATEGORY_OPTIONS, + selected: [], +}; + +/** Event filter configuration */ +const EVENTS_FILTER: FilterOptionCategory = { + id: 'event-type', + label: 'Event type', + options: [], + selected: [], +}; + +/** Funding filter configuration */ +const FUNDING_FILTER: FilterOptionCategory = { + id: 'funding-type', + label: 'Funding type', + options: [], + selected: [], +}; + +/** Publication filter configuration */ +const PUBLICATIONS_FILTER: FilterOptionCategory = { + id: 'publication-type', + label: 'Publication type', + options: [], + selected: [], +}; + +/** People filter configuration */ +const PEOPLE_FILTER: FilterOptionCategory = { + id: 'people', + label: 'People', + options: [], + selected: [], +}; + +/** Year filter configuration */ +const YEARS_FILTER: FilterOptionCategory = { + id: 'year', + label: 'Year', + options: YEAR_OPTIONS, + selected: [], +}; + +/** Initial filter state with no selections */ +const initialState: FilterState = { + categories: null, + publicationIds: null, + eventIds: null, + fundingIds: null, + peopleIds: null, + years: null, + search: null, +}; + +/** + * Converts research type definitions to typed search list options. + * @param researchTypes Accessor for research type definitions + * @return Accessor for typed search list options + */ +function researchTypesToOptions( + researchTypes: () => ResearchTypeItem[], +): Signal[]> { + return computed(() => + researchTypes() + .map((item) => ({ + id: item.value, + label: toSentenceCase(item.label), + })) + .sort((a, b) => a.label.localeCompare(b.label)), + ); +} + +/** + * Filters options by selected IDs. + * @param options Accessor for all options + * @param ids Accessor for selected option IDs + * @return Accessor for typed search list options + */ +function filterOptionsByIds( + options: () => TypedSearchListOption[], + ids: () => string[] | null, +): Signal[]> { + return computed(() => { + const idSet = new Set(ids()); + return options().filter((option) => idSet.has(option.id)); + }); +} + +/** + * Merges base filter config with current selection and options. + * @param base Base filter definition + * @param selected Current selected options accessor + * @param options Optional dynamic options accessor + */ +function optionsToFilter( + base: FilterOptionCategory, + selected: () => Opt[] | null, + options?: () => Opt[], +): Signal> { + return computed(() => ({ ...base, options: options?.() ?? base.options, selected: selected() ?? [] })); +} + +/** + * Builds a set of selected option IDs across multiple selections. + * @param options Accessors returning selected option arrays + */ +function optionsToSet(...options: (() => TypedSearchListOption[] | null)[]): Signal> { + return computed(() => new Set(options.flatMap((opts) => opts()?.map((option) => option.id) ?? []))); +} + +/** + * Filters items by selected options using a provided predicate. + * @param items Accessor for all items + * @param options Accessor for selected option IDs + * @param filterFn Predicate that checks item against selected options + */ +function createFilteredBy( + items: () => T[], + options: () => Set, + filterFn: (item: T, options: Set) => boolean, +): Signal { + return computed(() => { + const selectedOptions = options(); + const allItems = items(); + if (selectedOptions.size === 0) { + return allItems; + } + + return allItems.filter((item) => filterFn(item, selectedOptions)); + }); +} + +/** + * Normalizes search text for case/diacritic-insensitive matching. + * @param str Raw input string + */ +function normalizeSearchString(str: string): string { + return str + .trim() + .toLocaleLowerCase() + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .replace(/\s{2,}/, ' '); +} + +/** + * Converts a string to sentence case. + * @param str Raw input string + */ +function toSentenceCase(str: string): string { + return str.slice(0, 1).toUpperCase() + str.slice(1).toLowerCase(); +} + +/** + * Counts occurrences of keys derived from items. + * @param items Accessor for items to count + * @param keyFn Key selector returning one or many keys per item + */ +function countsByKey( + items: () => ResearchItem[], + keyFn: (item: ResearchItem) => string | string[], + filterFn?: (item: ResearchItem) => boolean, +): Signal> { + return computed(() => { + const counts: Record = {}; + for (const item of items()) { + if (filterFn?.(item) === false) { + continue; + } + + const keys = keyFn(item); + for (const key of Array.isArray(keys) ? keys : [keys]) { + counts[key] ??= 0; + counts[key] += 1; + } + } + return counts; + }); +} + +/** + * Adds filtering capabilities for research items. + * Provides filtered item lists, counts, and filter option signals. + */ +export function withFilters() { + return signalStoreFeature( + { state: type() }, + withState(initialState), + withComputed((store) => { + const _peopleOptions = computed(() => + store + .peopleItems() + .map((person) => ({ id: person.slug, label: person.name })) + .sort((a, b) => a.label.localeCompare(b.label)), + ); + const people = filterOptionsByIds(_peopleOptions, store.peopleIds); + + const _publicationOptions = researchTypesToOptions(store.pubTypes); + const publications = filterOptionsByIds(_publicationOptions, store.publicationIds); + + const _eventOptions = researchTypesToOptions(store.eventTypes); + const events = filterOptionsByIds(_eventOptions, store.eventIds); + + const _fundingOptions = researchTypesToOptions(store.fundingTypes); + const funding = filterOptionsByIds(_fundingOptions, store.fundingIds); + + const _categoriesFilter = optionsToFilter(CATEGORIES_FILTER, store.categories); + const _eventsFilter = optionsToFilter(EVENTS_FILTER, events, _eventOptions); + const _fundingFilter = optionsToFilter(FUNDING_FILTER, funding, _fundingOptions); + const _publicationsFilter = optionsToFilter(PUBLICATIONS_FILTER, publications, _publicationOptions); + const _peopleFilter = optionsToFilter(PEOPLE_FILTER, people, _peopleOptions); + const _yearsFilter = optionsToFilter(YEARS_FILTER, store.years); + + const filters = computed((): FilterOptionCategory[] => [ + _categoriesFilter(), + _eventsFilter(), + _fundingFilter(), + _publicationsFilter(), + _peopleFilter(), + _yearsFilter(), + ]); + + const _selectedCategories = optionsToSet(store.categories); + const _filteredByCategory = createFilteredBy( + store.researchItems, + _selectedCategories, + (item, selectedCategories) => selectedCategories.has(item.category), + ); + + const _selectedTypes = optionsToSet(funding, publications, events); + const _filteredByType = createFilteredBy(_filteredByCategory, _selectedTypes, (item, selectedTypes) => + selectedTypes.has(item.type), + ); + + const _selectedPeople = optionsToSet(people); + const _filteredByPeople = createFilteredBy(_filteredByType, _selectedPeople, (item, selectedPeople) => + item.people.some((person) => selectedPeople.has(person)), + ); + + const _selectedYears = computed(() => new Set(store.years()?.map((option) => option.year) ?? [])); + const _filteredByYear = createFilteredBy(_filteredByPeople, _selectedYears, (item, selectedYears) => + selectedYears.has(item.dateStart.getFullYear()), + ); + + const _filteredBySearch = computed(() => { + const search = store.search()?.trim(); + const items = _filteredByYear(); + if (!search) { + return items; + } + + const normalizedSearch = normalizeSearchString(search); + return items.filter( + (item) => + normalizeSearchString(item.title).includes(normalizedSearch) || + normalizeSearchString(item.description).includes(normalizedSearch), + ); + }); + + const countsByCategory = countsByKey(store.researchItems, (item) => item.category); + const countsByEventType = countsByKey( + store.researchItems, + (item) => item.type, + (item) => item.category === 'event', + ); + const countsByFundingType = countsByKey( + store.researchItems, + (item) => item.type, + (item) => item.category === 'funding', + ); + const countsByPublicationType = countsByKey( + store.researchItems, + (item) => item.type, + (item) => item.category === 'publication', + ); + const countsByPeople = countsByKey(store.researchItems, (item) => item.people); + const countsByYear = countsByKey(store.researchItems, (item) => item.dateStart.getFullYear().toString()); + + const counts = computed(() => [ + countsByCategory(), + countsByEventType(), + countsByFundingType(), + countsByPublicationType(), + countsByPeople(), + countsByYear(), + ]); + + return { + people, + filters, + filteredItems: _filteredBySearch, + numFilteredItems: computed(() => _filteredBySearch().length), + countsByCategory, + countsByEventType, + countsByFundingType, + countsByPublicationType, + countsByPeople, + countsByYear, + counts, + _filteredByCategory, + _filteredByType, + _filteredByPeople, + _filteredByYear, + } satisfies FilterProps & InternalProps; + }), + withMethods((store) => ({ + /** Sets selected categories */ + setCategories: signalMethod((categories: CategoryOption[] | null) => patchState(store, { categories })), + /** Sets selected events */ + setEventIds: signalMethod((eventIds: string[] | null) => patchState(store, { eventIds })), + /** Sets selected funding IDs */ + setFundingIds: signalMethod((fundingIds: string[] | null) => patchState(store, { fundingIds })), + /** Sets selected publication IDs */ + setPublicationIds: signalMethod((publicationIds: string[] | null) => patchState(store, { publicationIds })), + /** Sets selected people IDs */ + setPeopleIds: signalMethod((peopleIds: string[] | null) => patchState(store, { peopleIds })), + /** + * Sets selected people options. + * @param people Selected people options + */ + setPeople: signalMethod((people: PeopleOption[] | null) => + patchState(store, { peopleIds: people?.map((p) => p.id) ?? null }), + ), + /** + * Sets selected years. + * @param years Selected year options + */ + setYears: signalMethod((years: YearOption[] | null) => patchState(store, { years })), + /** + * Sets search text. + * @param search Search string + */ + setSearch: signalMethod((search: string | null) => patchState(store, { search })), + /** + * Updates all filters from filter menu selections. + * @param filters Filter menu categories with selections + */ + updateFilters: signalMethod((filters: FilterOptionCategory[]) => { + const categories = filters[0]?.selected as CategoryOption[]; + const events = filters[1]?.selected as EventOption[]; + const funding = filters[2]?.selected as FundingOption[]; + const publications = filters[3]?.selected as PublicationOption[]; + const people = filters[4]?.selected as PeopleOption[]; + const years = filters[5]?.selected as YearOption[]; + + patchState(store, { + categories: categories.length > 0 ? categories : null, + publicationIds: publications.length > 0 ? publications.map((p) => p.id) : null, + eventIds: events.length > 0 ? events.map((e) => e.id) : null, + fundingIds: funding.length > 0 ? funding.map((f) => f.id) : null, + peopleIds: people.length > 0 ? people.map((p) => p.id) : null, + years: years.length > 0 ? years : null, + }); + }), + /** Resets all filters to defaults */ + resetFilters: () => patchState(store, initialState), + })), + ); +} diff --git a/apps/cns-website/src/app/pages/research-page/state/with-ordering.feature.ts b/apps/cns-website/src/app/pages/research-page/state/with-ordering.feature.ts new file mode 100644 index 0000000000..c8cae67250 --- /dev/null +++ b/apps/cns-website/src/app/pages/research-page/state/with-ordering.feature.ts @@ -0,0 +1,216 @@ +import { computed, Signal } from '@angular/core'; +import { ListViewGroup, ListViewItem } from '@hra-ui/design-system/content-templates/list-view'; +import { + patchState, + signalMethod, + signalStoreFeature, + type, + withComputed, + withMethods, + withState, +} from '@ngrx/signals'; +import { ResearchTypeId, ResearchTypeItem } from '../../../schemas/research-type.schema'; +import { ResearchItem } from '../../../schemas/research.schema'; +import { FilterProps } from './with-filters.feature'; +import { ResearchState } from './with-research.feature'; + +/** Sort order options for research items */ +export enum SortBy { + NameAsc = 'nameAsc', + NameDesc = 'nameDesc', + Newest = 'newest', + Oldest = 'oldest', +} + +/** Grouping criteria for research items */ +export enum GroupBy { + PublicationType = 'publicationType', + Year = 'year', +} + +/** Group of research items with a label */ +export interface GroupedResearchItems { + /** Group display label */ + label: string; + /** Items belonging to the group */ + items: ResearchItem[]; +} + +/** Key type for grouping research items */ +type GroupByKey = ResearchTypeId | number | '' | 'unknown' | 'skip'; + +/** Ordering state for sorting and grouping */ +interface OrderingState { + /** Active sort option */ + _sortBy: SortBy | null; + /** Active grouping option */ + groupBy: GroupBy | null; +} + +/** Default ordering state (newest, no grouping) */ +const initialState: OrderingState = { + _sortBy: null, + groupBy: null, +}; + +/** + * Creates a sorting function based on the selected sort option. + * @param sortBy Selected sort option + */ +function createSortByFn(sortBy: SortBy | null): ((a: ResearchItem, b: ResearchItem) => number) | undefined { + switch (sortBy) { + case SortBy.NameAsc: + return (a, b) => a.title.localeCompare(b.title); + case SortBy.NameDesc: + return (a, b) => b.title.localeCompare(a.title); + case SortBy.Newest: + return (a, b) => b.dateStart.getTime() - a.dateStart.getTime(); + case SortBy.Oldest: + return (a, b) => a.dateStart.getTime() - b.dateStart.getTime(); + default: + return undefined; + } +} + +/** + * Creates a function to extract the grouping key from a research item. + * @param groupBy Selected grouping option + */ +function createGroupByKeyFn(groupBy: GroupBy | null): (item: ResearchItem) => GroupByKey { + switch (groupBy) { + case GroupBy.PublicationType: + return (item) => (item.category === 'publication' ? item.type : 'skip'); + case GroupBy.Year: + return (item) => item.dateStart.getFullYear(); + default: + return () => ''; + } +} + +/** + * Creates a mapping of group keys to display labels. + * @param pubTypes Publication type definitions + */ +function createKeyLabelsMap(types: (() => ResearchTypeItem[])[]): Signal> { + return computed(() => { + const map: Record = { + '': '', + skip: '', + unknown: 'Unknown', + }; + + for (const item of types.flatMap((fn) => fn())) { + map[item.value as GroupByKey] = item.label; + } + + return map; + }); +} + +/** + * Converts a group key to its display label. + * @param key Group key value + * @param keyLabels Map of key labels + */ +function groupByKeyToLabel(key: GroupByKey, keyLabels: Record): string { + if (typeof key === 'number') { + return key.toString(); + } + + return keyLabels[key]; +} + +/** + * Converts a research item to a list view item format. + * @param item Research item to convert + */ +function convertToListViewItem(item: ResearchItem): ListViewItem { + return { content: item.description }; +} + +/** + * Adds sorting and grouping capabilities for research items. + * Provides sorted and grouped views of filtered research items. + */ +export function withOrdering() { + return signalStoreFeature( + { state: type(), props: type() }, + withState(initialState), + withComputed((store) => { + const sortBy = computed(() => store._sortBy() ?? SortBy.Newest); + const _sortByFn = computed(() => createSortByFn(sortBy())); + const _sortedItems = computed(() => { + const items = store.filteredItems(); + const sortByFn = _sortByFn(); + return sortByFn ? [...items].sort(sortByFn) : items; + }); + + const _groupByKeyFn = computed(() => createGroupByKeyFn(store.groupBy())); + const _groupedItems = computed(() => { + const items = _sortedItems(); + const groupBy = store.groupBy(); + if (!groupBy) { + return new Map([['', items]]); + } + + const groupByKeyFn = _groupByKeyFn(); + const groups = new Map(); + for (const item of items) { + const key = groupByKeyFn(item); + if (key === 'skip') { + continue; + } + + let group = groups.get(key); + if (!group) { + group = []; + groups.set(key, group); + } + group.push(item); + } + + return groups; + }); + + const _keyLabels = createKeyLabelsMap([store.pubTypes, store.eventTypes, store.fundingTypes]); + const sortedGroupedItems = computed(() => { + const groups = Array.from(_groupedItems()); + const groupBy = store.groupBy(); + const keyLabels = _keyLabels(); + + const groupedItems = groups.map(([key, items]) => ({ + label: groupByKeyToLabel(key, keyLabels), + items, + })); + + groupedItems.sort((a, b) => a.label.localeCompare(b.label)); + if (groupBy === GroupBy.Year) { + groupedItems.reverse(); + } + + return groupedItems; + }); + + const sortedGroupedListItems = computed(() => { + const groups = sortedGroupedItems(); + return groups.map( + ({ label, items }): ListViewGroup => ({ group: label, items: items.map(convertToListViewItem) }), + ); + }); + + return { + sortBy, + sortedGroupedItems, + sortedGroupedListItems, + _sortedItems, + _groupedItems, + }; + }), + withMethods((store) => ({ + /** Sets the current sort option */ + setSortBy: signalMethod((sortBy: SortBy | null) => patchState(store, { _sortBy: sortBy })), + /** Sets the current grouping option */ + setGroupBy: signalMethod((groupBy: GroupBy | null) => patchState(store, { groupBy })), + })), + ); +} diff --git a/apps/cns-website/src/app/pages/research-page/state/with-research.feature.ts b/apps/cns-website/src/app/pages/research-page/state/with-research.feature.ts new file mode 100644 index 0000000000..a1988572fd --- /dev/null +++ b/apps/cns-website/src/app/pages/research-page/state/with-research.feature.ts @@ -0,0 +1,70 @@ +import { computed } from '@angular/core'; +import { patchState, signalMethod, signalStoreFeature, withComputed, withMethods, withState } from '@ngrx/signals'; +import { PeopleItem } from '../../../schemas/people.schema'; +import { ResearchTypeId, ResearchTypeItem } from '../../../schemas/research-type.schema'; +import { ResearchItem } from '../../../schemas/research.schema'; +import { TagItem } from '../../../schemas/tags.schema'; + +/** Core research page state containing all research data */ +export interface ResearchState { + /** Research items to display */ + researchItems: ResearchItem[]; + /** People items associated with research */ + peopleItems: PeopleItem[]; + /** Publication type definitions */ + pubTypes: ResearchTypeItem[]; + /** Event type definitions */ + eventTypes: ResearchTypeItem[]; + /** Funding type definitions */ + fundingTypes: ResearchTypeItem[]; + /** Tag items */ + tags: TagItem[]; +} + +/** Initial empty research state */ +const initialState: ResearchState = { + researchItems: [], + peopleItems: [], + pubTypes: [], + eventTypes: [], + fundingTypes: [], + tags: [], +}; + +/** + * Provides core research data management. + * Stores research items, people, publication type definitions, and tags. + */ +export function withResearch() { + return signalStoreFeature( + withState(initialState), + withComputed((store) => { + return { + /** Count of research items */ + numResearchItems: computed(() => store.researchItems().length), + /** Map of tags for quick lookup */ + tagsMap: computed(() => new Map(store.tags().map((tag) => [tag.slug as string, tag]))), + }; + }), + withMethods((store) => ({ + /** Sets research items */ + setResearchItems: signalMethod((researchItems: ResearchItem[]) => + patchState(store, { + researchItems: researchItems.map((item) => ({ + ...item, + type: (item.type !== '' ? item.type : 'unknown') as ResearchTypeId, + })), + }), + ), + /** Sets people items */ + setPeopleItems: signalMethod((peopleItems: PeopleItem[]) => patchState(store, { peopleItems })), + /** Sets publication types */ + setPublicationTypes: signalMethod((pubTypes: ResearchTypeItem[]) => + patchState(store, { pubTypes: [...pubTypes, { label: 'Unknown', value: 'unknown' as ResearchTypeId }] }), + ), + setEventTypes: signalMethod((eventTypes: ResearchTypeItem[]) => patchState(store, { eventTypes })), + setFundingTypes: signalMethod((fundingTypes: ResearchTypeItem[]) => patchState(store, { fundingTypes })), + setTags: signalMethod((tags: TagItem[]) => patchState(store, { tags })), + })), + ); +} diff --git a/apps/cns-website/src/app/pages/research-page/state/with-view.feature.ts b/apps/cns-website/src/app/pages/research-page/state/with-view.feature.ts new file mode 100644 index 0000000000..c3da024c69 --- /dev/null +++ b/apps/cns-website/src/app/pages/research-page/state/with-view.feature.ts @@ -0,0 +1,35 @@ +import { patchState, signalMethod, signalStoreFeature, withComputed, withMethods, withState } from '@ngrx/signals'; + +/** Display mode for research items */ +export enum View { + Gallery = 'gallery', + List = 'list', +} + +/** View state for the research page */ +interface ViewState { + /** Active display mode */ + _view: View | null; +} + +/** Default view state (gallery) */ +const initialState: ViewState = { + _view: null, +}; + +/** + * Manages view mode for research items (gallery or list). + * Automatically sets list view for certain categories on initial load. + */ +export function withView() { + return signalStoreFeature( + withState(initialState), + withComputed((store) => ({ + view: () => store._view() ?? View.Gallery, + })), + withMethods((store) => ({ + /** Sets the active view mode */ + setView: signalMethod((view: View | null) => patchState(store, { _view: view })), + })), + ); +} diff --git a/apps/cns-website/src/app/resolvers/person.resolver.ts b/apps/cns-website/src/app/resolvers/person.resolver.ts new file mode 100644 index 0000000000..3d111e1a07 --- /dev/null +++ b/apps/cns-website/src/app/resolvers/person.resolver.ts @@ -0,0 +1,36 @@ +import { HttpClient } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { ResolveFn } from '@angular/router'; +import { joinWithSlash } from '@hra-ui/common/url'; +import { load } from 'js-yaml'; +import { map } from 'rxjs'; +import { PeopleItem, PeopleItemSchema } from '../schemas/people.schema'; + +/** + * Resolver to load person data by slug + * + * @param baseUrl Base URL for person content + * @returns A resolve function that fetches and parses person data + */ +export function createPersonResolver(baseUrl: string): ResolveFn { + return (route) => { + const slug = route.paramMap.get('slug'); + if (!slug) { + throw new Error('Internal error: route parameter "slug" is missing'); + } + + const http = inject(HttpClient); + const url = joinWithSlash(baseUrl, `${slug}/data.yaml`); + return http.get(url, { responseType: 'text' }).pipe( + map((data) => load(data, { filename: url })), + map((data) => PeopleItemSchema.parse(data)), + map((item) => { + if (item.image) { + item.image = joinWithSlash(baseUrl, `${slug}/${item.image}`); + } + + return item; + }), + ); + }; +} diff --git a/apps/cns-website/src/app/schemas/content-page/content-page.schema.json b/apps/cns-website/src/app/schemas/content-page/content-page.schema.json new file mode 100644 index 0000000000..135c5bc0c8 --- /dev/null +++ b/apps/cns-website/src/app/schemas/content-page/content-page.schema.json @@ -0,0 +1,1192 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "#/$defs/ContentPageData", + "$defs": { + "ContentPageData": { + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "title": { + "type": "string" + }, + "subtitle": { + "type": "string" + }, + "icons": { + "$ref": "#/$defs/IconList" + }, + "breadcrumbs": { + "type": "array", + "items": { + "$ref": "#/$defs/BreadcrumbItem" + } + }, + "action": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "label", + "url" + ], + "additionalProperties": false + }, + "headerContent": { + "$ref": "#/$defs/ProjectedContentTemplate" + }, + "content": { + "$ref": "#/$defs/ProjectedContentTemplate" + } + }, + "required": [ + "$schema", + "title", + "subtitle", + "content" + ], + "additionalProperties": false, + "id": "ContentPageData" + }, + "IconList": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/IconData" + } + ] + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/IconData" + } + ] + } + } + ], + "id": "IconList" + }, + "IconData": { + "type": "object", + "properties": { + "svgIcon": { + "type": "string" + }, + "fontIcon": { + "type": "string" + }, + "fontSet": { + "type": "string" + }, + "inline": { + "type": "boolean" + } + }, + "additionalProperties": false, + "id": "IconData" + }, + "BreadcrumbItem": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "route": { + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false, + "id": "BreadcrumbItem" + }, + "ProjectedContentTemplate": { + "anyOf": [ + { + "$ref": "#/$defs/AnyContentTemplate" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/AnyContentTemplate" + } + } + ], + "id": "ProjectedContentTemplate" + }, + "AnyContentTemplate": { + "id": "AnyContentTemplate", + "oneOf": [ + { + "$ref": "#/$defs/ActionCard" + }, + { + "$ref": "#/$defs/ApiCommand" + }, + { + "$ref": "#/$defs/Button" + }, + { + "$ref": "#/$defs/FlexContainer" + }, + { + "$ref": "#/$defs/GoogleMaps" + }, + { + "$ref": "#/$defs/GridContainer" + }, + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Image" + }, + { + "$ref": "#/$defs/Markdown" + }, + { + "$ref": "#/$defs/PageSection" + }, + { + "$ref": "#/$defs/Table" + }, + { + "$ref": "#/$defs/ProfileCard" + }, + { + "$ref": "#/$defs/TextHyperlink" + }, + { + "$ref": "#/$defs/VenuesTable" + }, + { + "$ref": "#/$defs/YouTubePlayer" + } + ] + }, + "ActionCard": { + "type": "object", + "properties": { + "component": { + "type": "string", + "const": "ActionCard" + }, + "classes": { + "$ref": "#/$defs/Classes" + }, + "styles": { + "$ref": "#/$defs/Styles" + }, + "controllers": { + "type": "array", + "items": { + "$ref": "#/$defs/Controller" + } + }, + "variant": { + "$ref": "#/$defs/ActionCardVariant" + }, + "tagline": { + "type": "string" + }, + "subtagline": { + "type": "string" + }, + "image": { + "type": "string" + }, + "icons": { + "$ref": "#/$defs/IconList" + }, + "content": { + "$ref": "#/$defs/ProjectedContentTemplate" + }, + "actionsLeft": { + "$ref": "#/$defs/ProjectedContentTemplate" + }, + "actionsRight": { + "$ref": "#/$defs/ProjectedContentTemplate" + } + }, + "required": [ + "component", + "variant", + "tagline" + ], + "additionalProperties": false, + "id": "ActionCard" + }, + "Classes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ], + "id": "Classes" + }, + "Styles": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + ], + "id": "Styles" + }, + "Controller": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": {}, + "id": "Controller" + }, + "ActionCardVariant": { + "type": "string", + "enum": [ + "elevated", + "flat", + "outlined", + "outlined-with-icons" + ], + "id": "ActionCardVariant" + }, + "ApiCommand": { + "type": "object", + "properties": { + "component": { + "type": "string", + "const": "ApiCommand" + }, + "classes": { + "$ref": "#/$defs/Classes" + }, + "styles": { + "$ref": "#/$defs/Styles" + }, + "controllers": { + "type": "array", + "items": { + "$ref": "#/$defs/Controller" + } + }, + "request": { + "type": "string" + }, + "method": { + "type": "string", + "enum": [ + "GET", + "POST" + ] + }, + "rightButton": { + "$ref": "#/$defs/ApiCommandButton" + } + }, + "required": [ + "component", + "request", + "method", + "rightButton" + ], + "additionalProperties": false, + "id": "ApiCommand" + }, + "ApiCommandButton": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "label" + ], + "additionalProperties": false, + "id": "ApiCommandButton" + }, + "Button": { + "type": "object", + "properties": { + "component": { + "type": "string", + "const": "Button" + }, + "classes": { + "$ref": "#/$defs/Classes" + }, + "styles": { + "$ref": "#/$defs/Styles" + }, + "controllers": { + "type": "array", + "items": { + "$ref": "#/$defs/Controller" + } + }, + "label": { + "type": "string" + }, + "href": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "default", + "flat", + "cta", + "fab" + ] + }, + "variant": { + "type": "string", + "enum": [ + "primary", + "secondary" + ] + }, + "size": { + "type": "string", + "enum": [ + "small", + "medium" + ] + }, + "disabled": { + "type": "boolean" + }, + "icon": { + "type": "string" + } + }, + "required": [ + "component", + "label", + "href" + ], + "additionalProperties": false, + "id": "Button" + }, + "FlexContainer": { + "type": "object", + "properties": { + "component": { + "type": "string", + "const": "FlexContainer" + }, + "classes": { + "$ref": "#/$defs/Classes" + }, + "styles": { + "$ref": "#/$defs/Styles" + }, + "controllers": { + "type": "array", + "items": { + "$ref": "#/$defs/Controller" + } + }, + "rowGap": { + "type": "string" + }, + "columnGap": { + "type": "string" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/$defs/AnyContentTemplate" + } + } + }, + "required": [ + "component", + "content" + ], + "additionalProperties": false, + "id": "FlexContainer" + }, + "GoogleMaps": { + "type": "object", + "properties": { + "component": { + "type": "string", + "const": "GoogleMaps" + }, + "classes": { + "$ref": "#/$defs/Classes" + }, + "styles": { + "$ref": "#/$defs/Styles" + }, + "controllers": { + "type": "array", + "items": { + "$ref": "#/$defs/Controller" + } + }, + "url": { + "type": "string" + }, + "externalUrl": { + "type": "string" + }, + "fallbackImageUrl": { + "type": "string" + } + }, + "required": [ + "component", + "url", + "externalUrl", + "fallbackImageUrl" + ], + "additionalProperties": false, + "id": "GoogleMaps" + }, + "GridContainer": { + "type": "object", + "properties": { + "component": { + "type": "string", + "const": "GridContainer" + }, + "classes": { + "$ref": "#/$defs/Classes" + }, + "styles": { + "$ref": "#/$defs/Styles" + }, + "controllers": { + "type": "array", + "items": { + "$ref": "#/$defs/Controller" + } + }, + "itemMinWidth": { + "type": "string" + }, + "content": { + "type": "array", + "items": { + "$ref": "#/$defs/AnyContentTemplate" + } + } + }, + "required": [ + "component", + "content" + ], + "additionalProperties": false, + "id": "GridContainer" + }, + "Icon": { + "type": "object", + "properties": { + "component": { + "type": "string", + "const": "Icon" + }, + "classes": { + "$ref": "#/$defs/Classes" + }, + "styles": { + "$ref": "#/$defs/Styles" + }, + "controllers": { + "type": "array", + "items": { + "$ref": "#/$defs/Controller" + } + }, + "svgIcon": { + "type": "string" + }, + "fontIcon": { + "type": "string" + }, + "fontSet": { + "type": "string" + }, + "inline": { + "type": "boolean" + } + }, + "required": [ + "component" + ], + "additionalProperties": false, + "id": "Icon" + }, + "Image": { + "type": "object", + "properties": { + "component": { + "type": "string", + "const": "Image" + }, + "classes": { + "$ref": "#/$defs/Classes" + }, + "styles": { + "$ref": "#/$defs/Styles" + }, + "controllers": { + "type": "array", + "items": { + "$ref": "#/$defs/Controller" + } + }, + "src": { + "type": "string" + }, + "alt": { + "type": "string" + } + }, + "required": [ + "component", + "src" + ], + "additionalProperties": false, + "id": "Image" + }, + "Markdown": { + "type": "object", + "properties": { + "component": { + "type": "string", + "const": "Markdown" + }, + "classes": { + "$ref": "#/$defs/Classes" + }, + "styles": { + "$ref": "#/$defs/Styles" + }, + "controllers": { + "type": "array", + "items": { + "$ref": "#/$defs/Controller" + } + }, + "data": { + "type": "string" + }, + "src": { + "type": "string" + } + }, + "required": [ + "component" + ], + "additionalProperties": false, + "id": "Markdown" + }, + "PageSection": { + "type": "object", + "properties": { + "component": { + "type": "string", + "const": "PageSection" + }, + "classes": { + "$ref": "#/$defs/Classes" + }, + "styles": { + "$ref": "#/$defs/Styles" + }, + "controllers": { + "type": "array", + "items": { + "$ref": "#/$defs/Controller" + } + }, + "tagline": { + "type": "string" + }, + "level": { + "type": "integer", + "minimum": 1, + "maximum": 6 + }, + "icons": { + "$ref": "#/$defs/IconList" + }, + "anchor": { + "type": "string" + }, + "breadcrumbs": { + "type": "array", + "items": { + "$ref": "#/$defs/BreadcrumbItem" + } + }, + "content": { + "$ref": "#/$defs/ProjectedContentTemplate" + } + }, + "required": [ + "component", + "tagline", + "content" + ], + "additionalProperties": false, + "id": "PageSection" + }, + "Table": { + "type": "object", + "properties": { + "component": { + "type": "string", + "const": "PageTable" + }, + "classes": { + "$ref": "#/$defs/Classes" + }, + "styles": { + "$ref": "#/$defs/Styles" + }, + "controllers": { + "type": "array", + "items": { + "$ref": "#/$defs/Controller" + } + }, + "csvUrl": { + "type": "string" + }, + "columns": { + "type": "array", + "items": { + "$ref": "#/$defs/TableColumn" + } + }, + "rows": { + "type": "array", + "items": { + "$ref": "#/$defs/TableRow" + } + }, + "variant": { + "$ref": "#/$defs/TableVariant" + }, + "enableSort": { + "type": "boolean" + }, + "verticalDividers": { + "type": "boolean" + }, + "enableSelection": { + "type": "boolean" + } + }, + "required": [ + "component" + ], + "additionalProperties": false, + "id": "Table" + }, + "TableColumn": { + "type": "object", + "properties": { + "column": { + "type": "string" + }, + "label": { + "type": "string" + }, + "type": { + "default": "text", + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "const": "text" + }, + { + "type": "string", + "const": "date" + }, + { + "type": "string", + "const": "numeric" + }, + { + "type": "string", + "const": "markdown" + }, + { + "type": "string", + "const": "icon" + }, + { + "type": "string", + "const": "menu" + }, + { + "type": "string", + "const": "dataExploration" + } + ] + }, + { + "anyOf": [ + { + "$ref": "#/$defs/TextColumnType" + }, + { + "$ref": "#/$defs/DateColumnType" + }, + { + "$ref": "#/$defs/NumericColumnType" + }, + { + "$ref": "#/$defs/MarkdownColumnType" + }, + { + "$ref": "#/$defs/LinkColumnType" + }, + { + "$ref": "#/$defs/IconColumnType" + }, + { + "$ref": "#/$defs/MenuButtonColumnType" + }, + { + "$ref": "#/$defs/DataExplorationColumnType" + } + ] + } + ] + } + }, + "required": [ + "column", + "label", + "type" + ], + "additionalProperties": false, + "id": "TableColumn" + }, + "TextColumnType": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "required": [ + "type" + ], + "additionalProperties": false, + "id": "TextColumnType" + }, + "DateColumnType": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "date" + } + }, + "required": [ + "type" + ], + "additionalProperties": false, + "id": "DateColumnType" + }, + "NumericColumnType": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "numeric" + }, + "computeTotal": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false, + "id": "NumericColumnType" + }, + "MarkdownColumnType": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "markdown" + } + }, + "required": [ + "type" + ], + "additionalProperties": false, + "id": "MarkdownColumnType" + }, + "LinkColumnType": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "link" + }, + "urlColumn": { + "type": "string" + }, + "internal": { + "type": "boolean" + } + }, + "required": [ + "type", + "urlColumn" + ], + "additionalProperties": false, + "id": "LinkColumnType" + }, + "IconColumnType": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "icon" + }, + "icon": { + "type": "string" + }, + "tooltip": { + "type": "string" + } + }, + "required": [ + "type", + "icon" + ], + "additionalProperties": false, + "id": "IconColumnType" + }, + "MenuButtonColumnType": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "menu" + }, + "icon": { + "type": "string" + }, + "options": { + "type": "string" + }, + "tooltip": { + "type": "string" + } + }, + "required": [ + "type", + "icon", + "options" + ], + "additionalProperties": false, + "id": "MenuButtonColumnType" + }, + "DataExplorationColumnType": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "dataExploration" + }, + "label": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "external": { + "type": "boolean" + }, + "titleColumn": { + "type": "string" + }, + "imageUrlColumn": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false, + "id": "DataExplorationColumnType" + }, + "TableRow": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + {}, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": {} + } + ] + }, + "id": "TableRow" + }, + "TableVariant": { + "type": "string", + "enum": [ + "alternating", + "divider", + "basic" + ], + "id": "TableVariant" + }, + "ProfileCard": { + "type": "object", + "properties": { + "component": { + "type": "string", + "const": "ProfileCard" + }, + "classes": { + "$ref": "#/$defs/Classes" + }, + "styles": { + "$ref": "#/$defs/Styles" + }, + "controllers": { + "type": "array", + "items": { + "$ref": "#/$defs/Controller" + } + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "pictureUrl": { + "type": "string" + }, + "centerContent": { + "type": "boolean" + }, + "actions": { + "$ref": "#/$defs/ProjectedContentTemplate" + } + }, + "required": [ + "component", + "name", + "description", + "pictureUrl" + ], + "additionalProperties": false, + "id": "ProfileCard" + }, + "TextHyperlink": { + "type": "object", + "properties": { + "component": { + "type": "string", + "const": "TextHyperlink" + }, + "classes": { + "$ref": "#/$defs/Classes" + }, + "styles": { + "$ref": "#/$defs/Styles" + }, + "controllers": { + "type": "array", + "items": { + "$ref": "#/$defs/Controller" + } + }, + "text": { + "type": "string" + }, + "url": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "external": { + "type": "boolean" + } + }, + "required": [ + "component", + "text", + "url" + ], + "additionalProperties": false, + "id": "TextHyperlink" + }, + "VenuesTable": { + "type": "object", + "properties": { + "component": { + "type": "string", + "const": "VenuesTable" + }, + "classes": { + "$ref": "#/$defs/Classes" + }, + "styles": { + "$ref": "#/$defs/Styles" + }, + "controllers": { + "type": "array", + "items": { + "$ref": "#/$defs/Controller" + } + }, + "csvUrl": { + "type": "string" + }, + "columns": { + "type": "array", + "items": { + "$ref": "#/$defs/TableColumn" + } + }, + "rows": { + "type": "array", + "items": { + "$ref": "#/$defs/TableRow" + } + }, + "variant": { + "$ref": "#/$defs/TableVariant" + }, + "enableSort": { + "type": "boolean" + }, + "verticalDividers": { + "type": "boolean" + }, + "enableSelection": { + "type": "boolean" + }, + "venuesUrl": { + "type": "string" + }, + "linkBaseHref": { + "type": "string" + } + }, + "required": [ + "component", + "venuesUrl" + ], + "additionalProperties": false, + "id": "VenuesTable" + }, + "YouTubePlayer": { + "type": "object", + "properties": { + "component": { + "type": "string", + "const": "YouTubePlayer" + }, + "classes": { + "$ref": "#/$defs/Classes" + }, + "styles": { + "$ref": "#/$defs/Styles" + }, + "controllers": { + "type": "array", + "items": { + "$ref": "#/$defs/Controller" + } + }, + "videoId": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "component", + "videoId", + "label" + ], + "additionalProperties": false, + "id": "YouTubePlayer" + } + } +} \ No newline at end of file diff --git a/apps/cns-website/src/app/schemas/content-page/content-page.schema.ts b/apps/cns-website/src/app/schemas/content-page/content-page.schema.ts new file mode 100644 index 0000000000..76dc1cf3a2 --- /dev/null +++ b/apps/cns-website/src/app/schemas/content-page/content-page.schema.ts @@ -0,0 +1,40 @@ +import { setContentTemplateSpecs } from '@hra-ui/cdk/content-template'; +import { ButtonSchema } from '@hra-ui/design-system/buttons/button'; +import { TextHyperlinkSchema } from '@hra-ui/design-system/buttons/text-hyperlink'; +import { ActionCardSchema } from '@hra-ui/design-system/cards/action-card'; +import { ProfileCardSchema } from '@hra-ui/design-system/cards/profile-card'; +import { ApiCommandSchema } from '@hra-ui/design-system/content-templates/api-command'; +import { ContentPageDataSchema } from '@hra-ui/design-system/content-templates/content-page'; +import { FlexContainerSchema } from '@hra-ui/design-system/content-templates/flex-container'; +import { GoogleMapsSchema } from '@hra-ui/design-system/content-templates/google-maps'; +import { GridContainerSchema } from '@hra-ui/design-system/content-templates/grid-container'; +import { ImageSchema } from '@hra-ui/design-system/content-templates/image'; +import { MarkdownSchema } from '@hra-ui/design-system/content-templates/markdown'; +import { PageSectionSchema } from '@hra-ui/design-system/content-templates/page-section'; +import { VenuesTableSchema } from '@hra-ui/design-system/content-templates/venues-table'; +import { YouTubePlayerSchema } from '@hra-ui/design-system/content-templates/youtube-player'; +import { IconSchema } from '@hra-ui/design-system/icons'; +import { PageTableSchema } from '@hra-ui/design-system/table'; +import * as z from 'zod'; + +export default z.lazy(() => { + setContentTemplateSpecs([ + ActionCardSchema, + ApiCommandSchema, + ButtonSchema, + FlexContainerSchema, + GoogleMapsSchema, + GridContainerSchema, + IconSchema, + ImageSchema, + MarkdownSchema, + PageSectionSchema, + PageTableSchema, + ProfileCardSchema, + TextHyperlinkSchema, + VenuesTableSchema, + YouTubePlayerSchema, + ]); + + return ContentPageDataSchema; +}); diff --git a/apps/cns-website/src/app/schemas/date.schema.ts b/apps/cns-website/src/app/schemas/date.schema.ts new file mode 100644 index 0000000000..137ab0d9e3 --- /dev/null +++ b/apps/cns-website/src/app/schemas/date.schema.ts @@ -0,0 +1,23 @@ +import * as z from 'zod'; + +/** + * Parse input into a Date object. + * Unlike `new Date()`, this will parse date 'YYYY-MM-DD' formats in the local timezone rather than UTC. + */ +export const LocalDateSchema = z + .unknown() + .transform((value) => { + if (typeof value === 'string') { + const parts = value.split('-'); + if (parts.length === 3) { + const [year, month, day] = parts.map(Number); + if (!isNaN(year) && !isNaN(month) && !isNaN(day)) { + return new Date(year, month - 1, day); + } + } + } + + return value; + }) + .pipe(z.coerce.date()) + .meta({ id: 'Date' }); diff --git a/apps/cns-website/src/app/schemas/featured.schema.ts b/apps/cns-website/src/app/schemas/featured.schema.ts new file mode 100644 index 0000000000..ea48971693 --- /dev/null +++ b/apps/cns-website/src/app/schemas/featured.schema.ts @@ -0,0 +1,17 @@ +import * as z from 'zod'; +import { ResearchItemSchema } from './research.schema'; + +/** Keys in the featured data object */ +export type FeaturedDataKey = keyof FeaturedData; + +/** Type for featured data */ +export type FeaturedData = z.infer; + +/** Featured data schema */ +export const FeaturedDataSchema = z + .object({ + featured: z.array(ResearchItemSchema), + news: z.array(ResearchItemSchema), + publications: z.array(ResearchItemSchema), + }) + .meta({ id: 'FeaturedData' }); diff --git a/apps/cns-website/src/app/schemas/people.schema.ts b/apps/cns-website/src/app/schemas/people.schema.ts new file mode 100644 index 0000000000..47681ca5dd --- /dev/null +++ b/apps/cns-website/src/app/schemas/people.schema.ts @@ -0,0 +1,40 @@ +import * as z from 'zod'; +import { AnyRole, RoleSchema } from './roles.schema'; + +/** Type for people identifiers */ +export type PeopleId = z.infer; +/** Branded type for people identifiers */ +export const PeopleIdSchema = z.string().brand('PeopleId'); + +/** Type for a single people item */ +export type PeopleItem = z.infer; + +/** People item schema */ +export const PeopleItemSchema = z + .object({ + slug: PeopleIdSchema, + name: z.string(), + lastName: z.string(), + image: z.string().optional(), + roles: z.array(RoleSchema).transform((roles) => roles.sort(compareRolesByEndDateDesc)), + }) + .meta({ id: 'PeopleItem' }); + +/** Type for the people data array */ +export type PeopleData = z.infer; + +/** People data schema (array of items) */ +export const PeopleDataSchema = z.array(PeopleItemSchema); + +/** + * Compare two roles by their end dates in descending order. + * + * @param a First role to compare + * @param b Second role to compare + * @returns Comparison result for sorting + */ +function compareRolesByEndDateDesc(a: AnyRole, b: AnyRole): number { + const aEnd = a.dateEnd?.getTime() ?? Number.MAX_VALUE; + const bEnd = b.dateEnd?.getTime() ?? Number.MAX_VALUE; + return bEnd - aEnd; +} diff --git a/apps/cns-website/src/app/schemas/research-type.schema.ts b/apps/cns-website/src/app/schemas/research-type.schema.ts new file mode 100644 index 0000000000..07ecbb814c --- /dev/null +++ b/apps/cns-website/src/app/schemas/research-type.schema.ts @@ -0,0 +1,25 @@ +import * as z from 'zod'; + +/** Type for research type identifiers */ +export type ResearchTypeId = z.infer; +/** Branded type for research type identifiers */ +export const ResearchTypeIdSchema = z.string().brand('ResearchTypeId'); + +/** Type for a single research type item */ +export type ResearchTypeItem = z.infer; + +/** Research type item schema */ +export const ResearchTypeItemSchema = z + .object({ + /** Label for the research type */ + label: z.string(), + /** Value for the research type */ + value: ResearchTypeIdSchema, + }) + .meta({ id: 'ResearchTypeItem' }); + +/** Type for the research types data array */ +export type ResearchTypesData = z.infer; + +/** Research types data schema - array of research type items */ +export const ResearchTypesDataSchema = z.array(ResearchTypeItemSchema).meta({ id: 'ResearchTypesData' }); diff --git a/apps/cns-website/src/app/schemas/research.schema.ts b/apps/cns-website/src/app/schemas/research.schema.ts new file mode 100644 index 0000000000..4001cf15a7 --- /dev/null +++ b/apps/cns-website/src/app/schemas/research.schema.ts @@ -0,0 +1,62 @@ +import * as z from 'zod'; +import { LocalDateSchema } from './date.schema'; +import { PeopleIdSchema } from './people.schema'; +import { ResearchTypeIdSchema } from './research-type.schema'; +import { TagIdSchema } from './tags.schema'; + +/** Type for research identifiers */ +export type ResearchId = z.infer; +/** Branded type for research identifiers */ +export const ResearchIdSchema = z.string().brand('ResearchId'); + +/** Type for research category identifier */ +export type ResearchCategoryId = z.infer; +/** Type for research category identifier */ +export const ResearchCategoryIdSchema = z.string().brand('ResearchCategoryId'); + +/** Type for a single research item */ +export type ResearchItem = z.infer; + +/** Research schema */ +export const ResearchItemSchema = z + .object({ + /** Research slug identifier */ + slug: ResearchIdSchema, + /** Category of the item */ + category: ResearchCategoryIdSchema, + /** Research type identifier */ + type: ResearchTypeIdSchema, + /** Title of the research */ + title: z.string(), + /** Description of the research */ + description: z.string(), + /** Start date of the research */ + dateStart: LocalDateSchema, + /** End date of the research */ + dateEnd: LocalDateSchema, + /** Link associated with the research */ + link: z.string().optional(), + /** People associated with the research */ + people: z.array(PeopleIdSchema), + /** Tags for categorizing the research */ + tags: z.array(TagIdSchema).transform((tags) => uniqueValues(tags)), + /** Image source URL */ + image: z.string().optional(), + }) + .meta({ id: 'Research' }); + +/** Type for the research data array */ +export type ResearchData = z.infer; + +/** Research data schema - array of research items */ +export const ResearchDataSchema = z.array(ResearchItemSchema).meta({ id: 'ResearchData' }); + +/** + * Remove duplicate items from an array + * + * @param items Array of items + * @returns Array with unique items + */ +function uniqueValues(items: T[]): T[] { + return Array.from(new Set(items)); +} diff --git a/apps/cns-website/src/app/schemas/roles.schema.ts b/apps/cns-website/src/app/schemas/roles.schema.ts new file mode 100644 index 0000000000..9eda8d1a75 --- /dev/null +++ b/apps/cns-website/src/app/schemas/roles.schema.ts @@ -0,0 +1,65 @@ +import * as z from 'zod'; +import { LocalDateSchema } from './date.schema'; + +/** Base role schema with common fields */ +const BaseRoleSchema = z.object({ + dateStart: LocalDateSchema, + dateEnd: z.union([z.literal('').transform(() => null), LocalDateSchema]).nullable(), +}); + +/** Type representing a team member role */ +export type MemberRole = z.infer; + +/** Member role schema */ +export const MemberRoleSchema = z + .object({ + ...BaseRoleSchema.shape, + type: z.literal('member'), + title: z.string(), + displayOrder: z.number().nullish(), + office: z.string(), + phone: z.string(), + fax: z.string(), + email: z.string(), + education: z.string(), + background: z.string(), + interests: z.string(), + }) + .meta({ id: 'RoleMember' }); + +/** Type representing a student role */ +export type StudentRole = z.infer; + +/** Student role schema */ +export const StudentRoleSchema = z + .object({ + ...BaseRoleSchema.shape, + type: z.literal('student'), + topic: z.string(), + degree: z.enum(['Ph.D.', 'Masters']).nullable(), + department: z.string(), + }) + .meta({ id: 'RoleStudent' }); + +/** Type representing a collaborator role */ +export type CollaboratorRole = z.infer; + +/** Collaborator role schema */ +export const CollaboratorRoleSchema = z + .object({ + ...BaseRoleSchema.shape, + type: z.literal('collaborator'), + project: z.string(), + }) + .meta({ id: 'RoleCollaborator' }); + +/** Union type representing any valid role */ +export type AnyRole = z.infer; + +/** Literal union of all possible role type discriminators */ +export type RoleType = AnyRole['type']; + +/** Discriminated union of all role types */ +export const RoleSchema = z + .discriminatedUnion('type', [MemberRoleSchema, StudentRoleSchema, CollaboratorRoleSchema]) + .meta({ id: 'Role' }); diff --git a/apps/cns-website/src/app/schemas/tags.schema.ts b/apps/cns-website/src/app/schemas/tags.schema.ts new file mode 100644 index 0000000000..f28ec9cae7 --- /dev/null +++ b/apps/cns-website/src/app/schemas/tags.schema.ts @@ -0,0 +1,28 @@ +import * as z from 'zod'; + +/** Type for tag identifiers */ +export type TagId = z.infer; + +/** Branded type for tag identifiers */ +export const TagIdSchema = z.string().brand('TagId'); + +/** Type for a single tag item */ +export type TagItem = z.infer; + +/** Tag item schema */ +export const TagItemSchema = z + .object({ + /** Tag identifier */ + slug: TagIdSchema, + /** Display name of the tag */ + name: z.string(), + /** Description of the tag */ + description: z.string(), + }) + .meta({ id: 'TagItem' }); + +/** Type for the tags data array */ +export type TagsData = z.infer; + +/** Tags data schema - array of tag items */ +export const TagsDataSchema = z.array(TagItemSchema).meta({ id: 'TagsData' }); diff --git a/apps/cns-website/src/app/state/scrollbar/scrollbar.store.ts b/apps/cns-website/src/app/state/scrollbar/scrollbar.store.ts new file mode 100644 index 0000000000..9c312f653c --- /dev/null +++ b/apps/cns-website/src/app/state/scrollbar/scrollbar.store.ts @@ -0,0 +1,37 @@ +import { untracked } from '@angular/core'; +import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; +import { NgScrollbar } from 'ngx-scrollbar'; +import { SmoothScrollOptions } from 'ngx-scrollbar/smooth-scroll'; + +/** Scrollbar state */ +interface ScrollbarState { + /** The scrollbar instance */ + scrollbar: NgScrollbar | null; +} + +/** Initial state for the scrollbar store */ +const initialState: ScrollbarState = { + scrollbar: null, +}; + +/** + * Global store for managing the scrollbar state and providing scroll-related methods + */ +export const ScrollbarStore = signalStore( + { providedIn: 'root' }, + withState(initialState), + withMethods((store) => ({ + setScrollbar: (scrollbar: NgScrollbar) => { + if (untracked(store.scrollbar) !== null) { + throw new Error('Scrollbar has already been set.'); + } + + patchState(store, { scrollbar }); + }, + clearScrollbar: () => patchState(store, { scrollbar: null }), + scrollToTop: (options?: SmoothScrollOptions) => { + const scrollbar = store.scrollbar(); + scrollbar?.scrollTo({ top: 0, duration: 0, ...options }); + }, + })), +); diff --git a/apps/cns-website/src/app/state/sidebar/sidebar.store.spec.ts b/apps/cns-website/src/app/state/sidebar/sidebar.store.spec.ts new file mode 100644 index 0000000000..6d377180ca --- /dev/null +++ b/apps/cns-website/src/app/state/sidebar/sidebar.store.spec.ts @@ -0,0 +1,81 @@ +import { signal, type WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatSidenav } from '@angular/material/sidenav'; +import { watchBreakpoint } from '@hra-ui/cdk/breakpoints'; +import { SidebarStore } from './sidebar.store'; + +jest.mock('@hra-ui/cdk/breakpoints', () => ({ + watchBreakpoint: jest.fn(), +})); + +describe('SidebarStore', () => { + let breakpointSignal: WritableSignal; + + const configureStore = (breakpointValue = false) => { + TestBed.resetTestingModule(); + breakpointSignal = signal(breakpointValue); + (watchBreakpoint as jest.Mock).mockReturnValue(breakpointSignal); + TestBed.configureTestingModule({}); + return TestBed.inject(SidebarStore); + }; + + it('starts without a sidebar', () => { + const store = configureStore(); + + expect(store.sidebar()).toBeNull(); + expect(store.hasSidebar()).toBe(false); + }); + + it('sets a sidebar once and then rejects additional registrations', () => { + const store = configureStore(); + const sidebar = {} as MatSidenav; + + store.setSidebar(sidebar); + expect(store.sidebar()).toBe(sidebar); + expect(store.hasSidebar()).toBe(true); + expect(() => store.setSidebar({} as MatSidenav)).toThrow('Sidebar has already been set.'); + }); + + it('clears the sidebar', () => { + const store = configureStore(); + + store.setSidebar({} as MatSidenav); + store.clearSidebar(); + + expect(store.sidebar()).toBeNull(); + expect(store.hasSidebar()).toBe(false); + }); + + it('derives mode from the breakpoint signal', () => { + const store = configureStore(); + + expect(store.mode()).toBe('over'); + + breakpointSignal.set(true); + expect(store.mode()).toBe('side'); + }); + + it('initializes isOpen from the breakpoint signal', () => { + const store = configureStore(true); + + expect(store.isOpen()).toBe(true); + }); + + it('opens, closes, and toggles the sidebar', () => { + const store = configureStore(); + + expect(store.isOpen()).toBe(false); + + store.open(); + expect(store.isOpen()).toBe(true); + + store.close(); + expect(store.isOpen()).toBe(false); + + store.toggle(); + expect(store.isOpen()).toBe(true); + + store.toggle(); + expect(store.isOpen()).toBe(false); + }); +}); diff --git a/apps/cns-website/src/app/state/sidebar/sidebar.store.ts b/apps/cns-website/src/app/state/sidebar/sidebar.store.ts new file mode 100644 index 0000000000..1a49670cb2 --- /dev/null +++ b/apps/cns-website/src/app/state/sidebar/sidebar.store.ts @@ -0,0 +1,46 @@ +import { computed, untracked } from '@angular/core'; +import { MatSidenav } from '@angular/material/sidenav'; +import { watchBreakpoint } from '@hra-ui/cdk/breakpoints'; +import { patchState, signalStore, withComputed, withLinkedState, withMethods, withState } from '@ngrx/signals'; + +/** Sidebar global state */ +interface SidebarState { + /** The currently active sidebar component */ + sidebar: MatSidenav | null; +} + +/** Initial state for sidebar */ +const initialState: SidebarState = { + sidebar: null, +}; + +/** + * Global store for managing the sidebar state + */ +export const SidebarStore = signalStore( + { providedIn: 'root' }, + withState(initialState), + withComputed((store) => { + const hasSidebar = computed(() => store.sidebar() !== null); + const _isWideScreen = watchBreakpoint('(min-width: 1100px)'); + const mode = computed(() => (_isWideScreen() ? 'side' : 'over')); + + return { hasSidebar, mode, _isWideScreen }; + }), + withLinkedState((store) => ({ + isOpen: () => store._isWideScreen(), + })), + withMethods((store) => ({ + setSidebar: (sidebar: MatSidenav) => { + if (untracked(store.sidebar) !== null) { + throw new Error('Sidebar has already been set.'); + } + + patchState(store, { sidebar }); + }, + clearSidebar: () => patchState(store, { sidebar: null }), + open: () => patchState(store, { isOpen: true }), + close: () => patchState(store, { isOpen: false }), + toggle: () => patchState(store, (state) => ({ isOpen: !state.isOpen })), + })), +); diff --git a/apps/cns-website/src/app/utils/mount-redirect.ts b/apps/cns-website/src/app/utils/mount-redirect.ts new file mode 100644 index 0000000000..b310d04a42 --- /dev/null +++ b/apps/cns-website/src/app/utils/mount-redirect.ts @@ -0,0 +1,31 @@ +import { inject } from '@angular/core'; +import { Route, Router, UrlSegmentGroup, UrlTree } from '@angular/router'; +import { injectAppHref, joinWithSlash } from '@hra-ui/common/url'; +import { NEVER } from 'rxjs'; + +/** + * Create a route that redirects mounted paths to the corresponding URL on the server. + * + * @param prefix Initial path segments of the mount path. + * @returns A route that redirects mounted paths to the corresponding URL on the server. + */ +export function createMountRedirectRoute(prefix: string): Route { + return { + path: prefix, + canMatch: [() => inject(Router).navigated], + children: [ + { + path: '**', + redirectTo: (snapshot) => { + const baseUrl = injectAppHref(); + const group = new UrlSegmentGroup(snapshot.url, {}); + const root = new UrlSegmentGroup([], { primary: group }); + const tree = new UrlTree(root, snapshot.queryParams, snapshot.fragment); + const url = joinWithSlash(baseUrl(), joinWithSlash(prefix, tree.toString())); + window.location.assign(url); + return NEVER; + }, + }, + ], + }; +} diff --git a/apps/cns-website/src/app/utils/navigation-error-handler.ts b/apps/cns-website/src/app/utils/navigation-error-handler.ts new file mode 100644 index 0000000000..c4229c56ee --- /dev/null +++ b/apps/cns-website/src/app/utils/navigation-error-handler.ts @@ -0,0 +1,46 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { assertInInjectionContext, inject } from '@angular/core'; +import { NavigationError, RedirectCommand, Router } from '@angular/router'; +import { AnalyticsService } from '@hra-ui/common/analytics'; +import { CoreEvents } from '@hra-ui/common/analytics/events'; + +/** + * Selects the appropriate redirect path based on the navigation error + * + * @param event Navigation error event + * @returns Either the 404 or 500 error page path + */ +function selectRedirectPath(event: NavigationError): string { + const { error } = event; + if (error instanceof HttpErrorResponse && error.status === 404) { + return '/404'; + } + + return '/500'; +} + +/** + * Handles navigation errors by logging and redirecting to appropriate error pages + * + * @param event Navigation error event + * @returns Redirect command to the appropriate error page + */ +export function handleNavigationError(event: NavigationError): RedirectCommand | undefined { + assertInInjectionContext(handleNavigationError); + + if (event.error instanceof Event && event.error.type === 'abort') { + return; + } + + // Returning a redirect command stops router error propagation so we log the error here instead + const analytics = inject(AnalyticsService); + analytics.logEvent(CoreEvents.Error, { + message: 'NavigationError', + context: { url: event.url }, + reason: event.error, + }); + + const router = inject(Router); + const path = selectRedirectPath(event); + return new RedirectCommand(router.parseUrl(path), { skipLocationChange: true, replaceUrl: false }); +} diff --git a/apps/cns-website/src/app/utils/refined-roles.ts b/apps/cns-website/src/app/utils/refined-roles.ts new file mode 100644 index 0000000000..f5c10a74f7 --- /dev/null +++ b/apps/cns-website/src/app/utils/refined-roles.ts @@ -0,0 +1,52 @@ +import { AnyRole } from '../schemas/roles.schema'; + +/** Refined role types */ +export enum RefinedRoleType { + Collaborator = 'collaborator', + MasterStudent = 'masterStudent', + PhDStudent = 'phdStudent', + Staff = 'staff', + Student = 'student', +} + +/** Refined role types along with their labels */ +export const REFINED_ROLE_TYPE_OPTIONS: { id: RefinedRoleType; label: string }[] = [ + { id: RefinedRoleType.Collaborator, label: 'Collaborator' }, + { id: RefinedRoleType.MasterStudent, label: 'Masters student' }, + { id: RefinedRoleType.PhDStudent, label: 'PhD student' }, + { id: RefinedRoleType.Staff, label: 'Staff' }, + { id: RefinedRoleType.Student, label: 'Student' }, +]; + +/** + * Derives a refined role type from a given role based on its type and degree (if applicable) + * + * @param role The role to refine + * @returns The refined role type + */ +export function refineRoleType(role: AnyRole): RefinedRoleType { + switch (role.type) { + case 'collaborator': + return RefinedRoleType.Collaborator; + case 'student': + if (role.degree === 'Ph.D.') { + return RefinedRoleType.PhDStudent; + } else if (role.degree === 'Masters') { + return RefinedRoleType.MasterStudent; + } + return RefinedRoleType.Student; + case 'member': + return RefinedRoleType.Staff; + } +} + +/** + * Gets the display label for a given refined role type + * + * @param type Role type + * @returns The display label for the role type + */ +export function getRefinedRoleTypeLabel(type: RefinedRoleType): string { + const option = REFINED_ROLE_TYPE_OPTIONS.find((opt) => opt.id === type); + return option?.label ?? ''; +} diff --git a/apps/cns-website/src/app/utils/research-item-images.ts b/apps/cns-website/src/app/utils/research-item-images.ts new file mode 100644 index 0000000000..4131029072 --- /dev/null +++ b/apps/cns-website/src/app/utils/research-item-images.ts @@ -0,0 +1,17 @@ +import { ResearchItem } from '../schemas/research.schema'; + +/** + * Gets image url and uses the appropriate placeholder if none is provided. + * @param item Research item + * @returns image url + */ +export function getImageUrl(item: ResearchItem): string { + if (item.image) { + return item.image; + } + const url = `assets/placeholder-images/placeholder-${item.category}`; + if (item.category === 'publication' || item.category === 'event') { + return `${url}-${item.type}.png`; + } + return `${url}.png`; +} diff --git a/apps/cns-website/src/index.html b/apps/cns-website/src/index.html new file mode 100644 index 0000000000..7db00cd6bb --- /dev/null +++ b/apps/cns-website/src/index.html @@ -0,0 +1,22 @@ + + + + + Cyberinfrastructure for Network Science Center + + + + + + + + + + + diff --git a/apps/cns-website/src/main.ts b/apps/cns-website/src/main.ts new file mode 100644 index 0000000000..17447a5dce --- /dev/null +++ b/apps/cns-website/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err)); diff --git a/apps/cns-website/src/styles.scss b/apps/cns-website/src/styles.scss new file mode 100644 index 0000000000..ef4b8716ff --- /dev/null +++ b/apps/cns-website/src/styles.scss @@ -0,0 +1,696 @@ +@use '@angular/material' as mat; +@use 'cns-theme' as cns; +@use 'cns-typography'; +@use 'sass:map'; + +@use 'theming'; +@use 'utils'; +@use 'animate.css'; +@use 'vars'; + +@include utils.global-styles() { + @include theming.theme(cns.$theme, cns-typography.$overrides); + @include cns-typography.custom-variants(); + @include mat.theme-overrides( + ( + background: map.get(cns.$theme, color, primary, 100), + on-error-container: map.get(cns.$theme, color, primary, error, 10), + on-primary-container: map.get(cns.$theme, color, primary, 100), + on-secondary-container: map.get(cns.$theme, color, primary, secondary, 100), + on-tertiary-container: map.get(cns.$theme, color, tertiary, 100), + on-surface-variant: map.get(cns.$theme, color, primary, neutral-variant, 25), + outline: map.get(cns.$theme, color, primary, neutral, 60), + primary: map.get(cns.$theme, color, primary, 5), + primary-container: map.get(cns.$theme, color, primary, 10), + secondary: map.get(cns.$theme, color, primary, secondary, 15), + secondary-container: map.get(cns.$theme, color, primary, secondary, 25), + surface: map.get(cns.$theme, color, primary, neutral, 96), + surface-bright: map.get(cns.$theme, color, primary, neutral, 99), + surface-container: map.get(cns.$theme, color, primary, neutral, 95), + surface-container-low: map.get(cns.$theme, color, primary, neutral, 98), + surface-variant: map.get(cns.$theme, color, primary, neutral-variant, 92), + tertiary: map.get(cns.$theme, color, tertiary, 30), + tertiary-container: map.get(cns.$theme, color, tertiary, 35), + ) + ); + + @media (prefers-color-scheme: dark) { + @include theming.theme(cns.$theme-dark, cns-typography.$overrides); + @include cns-typography.custom-variants(); + @include mat.theme-overrides( + ( + background: map.get(cns.$theme, color, primary, neutral, 0), + error: map.get(cns.$theme, color, primary, error, 90), + error-container: map.get(cns.$theme, color, primary, error, 80), + inverse-on-surface: map.get(cns.$theme, color, primary, neutral, 10), + inverse-primary: map.get(cns.$theme, color, primary, 30), + on-background: map.get(cns.$theme, color, primary, neutral, 95), + on-error: map.get(cns.$theme, color, primary, error, 5), + on-error-container: map.get(cns.$theme, color, primary, error, 5), + on-primary: map.get(cns.$theme, color, primary, 5), + on-primary-container: map.get(cns.$theme, color, primary, 5), + on-secondary: map.get(cns.$theme, color, primary, secondary, 5), + on-secondary-container: map.get(cns.$theme, color, primary, secondary, 5), + on-surface: map.get(cns.$theme, color, primary, neutral, 95), + on-tertiary: map.get(cns.$theme, color, tertiary, 5), + on-tertiary-container: map.get(cns.$theme, color, tertiary, 5), + outline-variant: map.get(cns.$theme, color, primary, neutral-variant, 40), + primary: map.get(cns.$theme, color, primary, 95), + primary-container: map.get(cns.$theme, color, primary, 90), + secondary: map.get(cns.$theme, color, primary, secondary, 95), + secondary-container: map.get(cns.$theme, color, primary, secondary, 80), + surface: map.get(cns.$theme, color, primary, neutral, 5), + surface-bright: map.get(cns.$theme, color, primary, neutral, 6), + surface-container: map.get(cns.$theme, color, primary, neutral, 10), + surface-container-high: map.get(cns.$theme, color, primary, neutral, 12), + surface-container-highest: map.get(cns.$theme, color, primary, neutral, 17), + surface-container-low: map.get(cns.$theme, color, primary, neutral, 4), + surface-container-lowest: map.get(cns.$theme, color, primary, neutral, 0), + surface-dim: map.get(cns.$theme, color, primary, neutral, 24), + surface-variant: map.get(cns.$theme, color, primary, neutral-variant, 25), + tertiary: map.get(cns.$theme, color, tertiary, 95), + tertiary-container: map.get(cns.$theme, color, tertiary, 80), + ) + ); + } + + body { + height: 100vh; + min-width: 20rem; + } + + ng-scrollbar { + --scrollbar-thumb-color: #{utils.with-alpha(vars.$inverse-surface, 0.72)}; + --hra-scroll-overflow-fade-color: #{vars.$background}; + } + + @include mat.form-field-overrides( + ( + container-height: 3.5rem, + filled-with-label-container-padding-top: 1.5rem, + filled-with-label-container-padding-bottom: 0.5rem, + + filled-active-indicator-color: vars.$on-surface-variant, + filled-container-color: vars.$surface-container-highest, + filled-focus-active-indicator-color: vars.$on-surface, + filled-hover-label-text-color: vars.$on-surface-variant, + filled-input-text-color: vars.$on-surface, + filled-label-text-color: vars.$on-surface, + + leading-icon-color: vars.$on-surface, + ) + ); + + @include mat.select-overrides( + ( + enabled-trigger-text-color: vars.$on-surface, + enabled-arrow-color: vars.$on-surface, + focused-arrow-color: vars.$on-surface, + panel-background-color: vars.$surface-container-low, + ) + ); + + @include mat.option-overrides( + ( + focus-state-layer-color: color-mix(in srgb, vars.$on-surface 12%, transparent), + hover-state-layer-color: color-mix(in srgb, vars.$on-surface 12%, transparent), + label-text-color: vars.$on-surface, + selected-state-label-text-color: vars.$on-surface, + selected-state-layer-color: utils.with-alpha(vars.$tertiary, 16%), + ) + ); + + @include mat.checkbox-overrides( + ( + unselected-focus-icon-color: vars.$primary, + unselected-hover-icon-color: vars.$primary, + unselected-icon-color: vars.$on-surface-variant, + + unselected-focus-state-layer-color: vars.$on-surface-variant, + unselected-hover-state-layer-color: vars.$on-surface-variant, + unselected-pressed-state-layer-color: vars.$on-surface-variant, + + selected-focus-icon-color: vars.$primary, + selected-hover-icon-color: vars.$primary, + selected-icon-color: vars.$primary, + + selected-focus-state-layer-color: vars.$primary, + selected-hover-state-layer-color: vars.$primary, + selected-pressed-state-layer-color: vars.$primary, + ) + ); + + @include mat.icon-button-overrides( + ( + icon-color: vars.$on-surface, + state-layer-color: vars.$on-surface, + ) + ); + + @include mat.button-overrides( + ( + text-container-height: 3rem, + text-icon-spacing: 0.375rem, + filled-label-text-color: vars.$on-primary-container, + filled-state-layer-color: vars.$on-primary-container, + filled-container-color: vars.$primary-container, + text-label-text-color: vars.$on-surface, + text-state-layer-color: vars.$on-surface, + ) + ); + + hra-breadcrumbs { + .label { + color: vars.$on-surface-variant; + } + } + + hra-page-section { + color: vars.$on-background; + + section > .content { + color: vars.$on-surface-variant !important; + } + } + + hra-page-label { + mat-chip { + @include mat.chips-overrides( + ( + elevated-container-color: vars.$surface-bright, + ) + ); + } + } + + hra-list-view { + hra-markdown { + color: vars.$on-surface-variant !important; + } + } + + hra-markdown a { + color: vars.$on-surface-variant !important; + } + + a.hra-text-hyperlink { + color: vars.$on-surface !important; + + mat-icon { + color: vars.$on-surface !important; + } + } + + .privacy-container { + button, + a { + color: vars.$on-tertiary-container !important; + } + } + + hra-table-of-contents { + .tagline { + color: vars.$on-surface !important; + } + .link { + color: vars.$on-surface-variant !important; + } + } + + hra-action-card { + &.hra-action-card-variant-outlined { + background-color: vars.$background; + + .content { + .tagline { + color: vars.$on-surface !important; + } + .description { + color: vars.$on-surface-variant !important; + } + } + } + } + + hra-table { + .mat-sort-header-arrow { + color: unset !important; + } + } + + hra-profile-card { + .name { + color: vars.$on-surface !important; + } + } + + hra-social-media-button { + a[mat-icon-button].hra-icon-button-variant-dark { + --mat-icon-button-icon-color: #{vars.$on-surface}; + --mat-icon-button-state-layer-color: #{vars.$on-surface}; + } + + a[mat-icon-button].hra-icon-button-variant-light { + --mat-icon-button-icon-color: #ffffff; + --mat-icon-button-state-layer-color: #ffffff; + } + } + + [mat-button] { + &.hra-button-variant-secondary.hra-cta-button { + --mat-button-text-label-text-color: #{vars.$primary}; + --mat-button-text-state-layer-color: #{vars.$primary}; + } + + &.hra-button-variant-secondary:not(.hra-cta-button) { + --mat-button-text-label-text-color: #{vars.$on-surface} !important; + --mat-button-text-state-layer-color: #{vars.$on-surface} !important; + } + + &.hra-cta-button:not(.hra-button-variant-secondary) { + background-color: vars.$primary-container; + --mat-button-text-label-text-color: #{vars.$on-primary-container}; + --mat-button-text-state-layer-color: #{vars.$on-primary-container}; + } + } + + hra-results-indicator { + --hra-results-indicator-height: 3.5rem; + color: vars.$on-surface-variant; + background: vars.$surface-bright; + } + + hra-search-list { + mat-list-option { + @include mat.list-overrides( + ( + list-item-container-color: vars.$surface-bright, + ) + ); + } + .option-count { + color: vars.$on-surface-variant !important; + } + } + + hra-search-filter { + @include mat.form-field-overrides( + ( + leading-icon-color: vars.$on-surface-variant, + ) + ); + } + + hra-end-of-results-indicator { + .results-count, + .end-message { + color: vars.$on-surface-variant !important; + } + } + + mat-button-toggle-group { + @include mat.button-toggle-overrides( + ( + background-color: vars.$background, + hover-state-layer-opacity: 0.08, + divider-color: vars.$outline, + shape: vars.$corner-full, + height: 2.5rem, + ) + ); + + .mat-button-toggle-label-content { + padding: 0 0.75rem !important; + } + + mat-button-toggle.mat-button-toggle-checked { + .mat-button-toggle-label-content { + padding: 0 0.75rem 0 0.375rem !important; + } + } + } + + hra-consent-banner { + color: vars.$on-surface; + + .text2 { + color: vars.$on-surface-variant; + } + + .customize-button { + @include mat.button-overrides( + ( + text-label-text-color: vars.$primary, + ) + ); + } + } + + .hra-privacy-preferences-panel { + @include mat.dialog-overrides( + ( + container-shape: vars.$corner-small, + ) + ); + } + + hra-privacy-preferences { + color: vars.$on-surface; + + > .header { + background: vars.$surface-container-highest; + } + + .privacy-details { + color: vars.$on-surface-variant !important; + background: vars.$on-secondary; + + mat-icon { + color: vars.$on-surface; + } + } + + mat-tab-group { + @include mat.tabs-overrides( + ( + active-indicator-color: vars.$primary, + active-focus-indicator-color: vars.$primary, + active-hover-indicator-color: vars.$primary, + active-focus-label-text-color: vars.$on-surface, + inactive-label-text-color: vars.$on-surface-variant, + ) + ); + } + + @include mat.slide-toggle-overrides( + ( + selected-track-color: vars.$primary, + selected-focus-track-color: vars.$primary, + selected-hover-track-color: vars.$primary, + selected-pressed-track-color: vars.$primary, + selected-handle-color: vars.$on-primary, + selected-focus-handle-color: vars.$on-primary, + selected-hover-handle-color: vars.$on-primary, + selected-pressed-handle-color: vars.$on-primary, + selected-focus-state-layer-color: vars.$primary, + selected-hover-state-layer-color: vars.$primary, + selected-pressed-state-layer-color: vars.$primary, + selected-icon-color: vars.$primary, + + unselected-track-color: vars.$surface-variant, + unselected-hover-track-color: vars.$surface-variant, + unselected-focus-track-color: vars.$surface-variant, + unselected-pressed-track-color: vars.$surface-variant, + track-outline-color: vars.$outline, + unselected-handle-color: vars.$outline, + unselected-focus-handle-color: vars.$outline, + unselected-hover-handle-color: vars.$outline, + unselected-pressed-handle-color: vars.$outline, + unselected-focus-state-layer-color: vars.$outline, + unselected-hover-state-layer-color: vars.$outline, + unselected-pressed-state-layer-color: vars.$outline, + + disabled-track-opacity: 0.38, + ) + ); + } + + hra-categories { + .description { + color: vars.$on-surface-variant; + } + } + + hra-copyable-url-container { + .text { + color: vars.$on-surface-variant !important; + } + } + + hra-server-error-page, + hra-not-found-page { + height: calc(100vh - 3.5rem); + background-color: unset !important; + + .title { + color: vars.$on-background; + } + .description { + color: vars.$on-surface-variant !important; + } + } + + hra-no-results-indicator { + .indicator-text { + color: vars.$primary !important; + } + } + + .hra-plain-tooltip { + --mat-tooltip-supporting-text-color: #{vars.$inverse-on-surface} !important; + --mat-tooltip-container-color: #{vars.$inverse-surface} !important; + } + + //Typography overrides + + @include mat.form-field-overrides( + ( + container-text-font: vars.$body-large-font, + container-text-size: vars.$body-large-size, + container-text-line-height: vars.$body-large-line-height, + container-text-weight: vars.$body-large-weight, + container-text-tracking: vars.$body-large-tracking, + + filled-label-text-font: vars.$body-large-font, + filled-label-text-size: vars.$body-large-size, + filled-label-text-weight: vars.$body-large-weight, + filled-label-text-tracking: vars.$body-large-tracking, + ) + ); + + @include mat.select-overrides( + ( + trigger-text-font: vars.$body-large-font, + trigger-text-size: vars.$body-large-size, + trigger-text-tracking: vars.$body-large-tracking, + trigger-text-weight: vars.$body-large-weight, + trigger-text-line-height: vars.$body-large-line-height, + ) + ); + + @include mat.option-overrides( + ( + label-text-font: vars.$label-large-font, + label-text-line-height: vars.$label-large-line-height, + label-text-tracking: vars.$label-large-tracking, + label-text-size: vars.$label-large-size, + label-text-weight: vars.$label-large-weight, + ) + ); + + hra-filter-menu { + .tagline .title { + font: vars.$title-medium-emphasized !important; + letter-spacing: vars.$title-medium-emphasized-tracking !important; + } + } + + hra-page-label, + .gallery-group, + .list-view-group { + h2 { + @include utils.use-font(display, small); + } + + h3 { + @include utils.use-font(headline, large); + } + } + + hra-list-view { + hra-markdown { + font: vars.$body-large !important; + letter-spacing: vars.$body-large-tracking !important; + } + } + + hra-results-indicator { + @include utils.use-font(body, medium); + } + + hra-page-section { + section > .content { + font: vars.$body-large !important; + letter-spacing: vars.$body-large-tracking !important; + } + } + + hra-table-of-contents { + .tagline { + font: vars.$title-medium !important; + letter-spacing: vars.$title-medium-tracking !important; + } + .link { + @include utils.use-font(body, medium); + } + } + + hra-consent-banner { + .text1 { + font: vars.$title-medium !important; + letter-spacing: vars.$title-medium-tracking !important; + } + + .text2 { + font: vars.$body-medium !important; + letter-spacing: vars.$body-medium-tracking !important; + } + + .privacy-policy-button { + font: vars.$body-medium !important; + letter-spacing: vars.$body-medium-tracking !important; + } + } + + hra-privacy-preferences { + .consent-content { + font: vars.$body-medium !important; + letter-spacing: vars.$body-medium-tracking; + } + + .privacy-details { + .provider-title { + @include utils.use-font(title, small); + } + .provider-link-container { + a { + @include utils.use-font(body, medium); + } + } + } + } + + hra-categories { + .title { + font: vars.$title-small !important; + letter-spacing: vars.$title-small-tracking; + } + .description { + font: vars.$body-medium !important; + letter-spacing: vars.$body-medium-tracking; + } + } + + hra-breadcrumbs { + .label { + @include utils.use-font(body, medium); + } + } + + hra-profile-card { + .content .description { + font: vars.$body-medium !important; + letter-spacing: vars.$body-medium-tracking !important; + } + + .actions { + @include mat.button-overrides( + ( + text-label-text-font: vars.$body-medium-font, + text-label-text-size: vars.$body-medium-size, + text-label-text-weight: vars.$body-medium-weight, + text-label-text-tracking: vars.$body-medium-tracking, + ) + ); + } + } + + hra-action-card { + .content { + .actions { + font: var(--mat-sys-body-medium) !important; + letter-spacing: var(--mat-sys-body-medium-tracking) !important; + } + } + + &.hra-action-card-variant-elevated, + &.hra-action-card-variant-flat, + &.hra-action-card-variant-outlined, + &.hra-action-card-variant-outlined-with-icons { + .content { + .description { + font: var(--mat-sys-body-medium) !important; + letter-spacing: var(--mat-sys-body-medium-tracking) !important; + } + } + } + + &.hra-action-card-variant-outlined, + &.hra-action-card-variant-outlined-with-icons { + .content { + .tagline { + font: var(--mat-sys-title-medium) !important; + letter-spacing: var(--mat-sys-title-medium-tracking) !important; + } + } + } + } + + hra-search-list { + @include mat.form-field-overrides( + ( + container-height: 3rem, + filled-with-label-container-padding-top: 1.25rem, + filled-with-label-container-padding-bottom: 0.25rem, + leading-icon-color: vars.$on-surface-variant, + ) + ); + + .search-label { + font: var(--mat-sys-body-large) !important; + letter-spacing: var(--mat-sys-body-large-tracking) !important; + } + + .option-primary-label { + @include utils.use-font(body, large); + } + .option-count { + @include utils.use-font(label, small); + } + } + + hra-table { + thead th { + vertical-align: middle; + } + + @include mat.table-overrides( + ( + header-headline-font: vars.$title-small-font, + header-headline-size: vars.$title-small-size, + header-headline-weight: vars.$title-small-weight, + header-headline-tracking: vars.$title-small-tracking, + + row-item-label-text-font: vars.$body-medium-font, + row-item-label-text-size: vars.$body-medium-size, + row-item-label-text-weight: vars.$body-medium-weight, + row-item-label-text-tracking: vars.$body-medium-tracking, + ) + ); + } + + hra-copyable-url-container { + .link { + font: var(--mat-sys-body-large) !important; + letter-spacing: var(--mat-sys-body-large-tracking) !important; + } + } + + hra-server-error-page, + hra-not-found-page { + .description { + font: var(--mat-sys-body-large) !important; + letter-spacing: var(--mat-sys-body-large-tracking) !important; + } + } +} diff --git a/apps/cns-website/src/test-setup.ts b/apps/cns-website/src/test-setup.ts new file mode 100644 index 0000000000..1928dfb03e --- /dev/null +++ b/apps/cns-website/src/test-setup.ts @@ -0,0 +1,7 @@ +import '@testing-library/jest-dom'; +import { setupZonelessTestEnv } from 'jest-preset-angular/setup-env/zoneless'; + +setupZonelessTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/apps/cns-website/tsconfig.app.json b/apps/cns-website/tsconfig.app.json new file mode 100644 index 0000000000..9272074e3b --- /dev/null +++ b/apps/cns-website/tsconfig.app.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/test-setup.ts", "**/*.spec.ts"] +} diff --git a/apps/cns-website/tsconfig.editor.json b/apps/cns-website/tsconfig.editor.json new file mode 100644 index 0000000000..88f3a6dbb4 --- /dev/null +++ b/apps/cns-website/tsconfig.editor.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "include": ["**/*.ts"], + "compilerOptions": {}, + "exclude": ["jest.config.ts", "src/test-setup.ts", "**/*.spec.ts"] +} diff --git a/apps/cns-website/tsconfig.json b/apps/cns-website/tsconfig.json new file mode 100644 index 0000000000..bd98c1ed1f --- /dev/null +++ b/apps/cns-website/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": {}, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.editor.json" + }, + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/apps/cns-website/tsconfig.spec.json b/apps/cns-website/tsconfig.spec.json new file mode 100644 index 0000000000..b003825eb0 --- /dev/null +++ b/apps/cns-website/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["jest", "node", "jest-dom"], + "isolatedModules": true + }, + "files": ["src/test-setup.ts"], + "include": ["jest.config.ts", "**/*.spec.ts", "**/*.d.ts"] +} diff --git a/libs/common/analytics/plugins/hra-analytics/src/lib/util/serialize.ts b/libs/common/analytics/plugins/hra-analytics/src/lib/util/serialize.ts index 72122dad12..6cdb10f0a4 100644 --- a/libs/common/analytics/plugins/hra-analytics/src/lib/util/serialize.ts +++ b/libs/common/analytics/plugins/hra-analytics/src/lib/util/serialize.ts @@ -1,4 +1,5 @@ import { HttpErrorResponse } from '@angular/common/http'; +import { ZodError } from 'zod'; /** * Serializes complex values into simpler types. @@ -20,6 +21,9 @@ export function serialize(value: unknown): unknown { if (value instanceof Date) { return value.toISOString(); + } else if (value instanceof ZodError) { + const props = pick(value, ['name', 'stack']); + return { ...props, issues: JSON.stringify(value.issues) }; } else if (value instanceof Error) { return pick(value, ['name', 'message', 'stack']); } else if (value instanceof HttpErrorResponse) { diff --git a/libs/common/array-util/src/lib/find-or-throw.spec.ts b/libs/common/array-util/src/lib/find-or-throw.spec.ts new file mode 100644 index 0000000000..e651714ded --- /dev/null +++ b/libs/common/array-util/src/lib/find-or-throw.spec.ts @@ -0,0 +1,83 @@ +import { findOrThrow } from './find-or-throw'; + +describe('findOrThrow', () => { + it('should return the first matching item', () => { + const array = [1, 2, 3, 4, 5]; + const result = findOrThrow(array, (value) => value === 3); + expect(result).toBe(3); + }); + + it('should return the first matching item in an array of objects', () => { + const array = [ + { id: 1, name: 'a' }, + { id: 2, name: 'b' }, + { id: 3, name: 'c' }, + ]; + const result = findOrThrow(array, (item) => item.id === 2); + expect(result).toEqual({ id: 2, name: 'b' }); + }); + + it('should use the predicate with index parameter', () => { + const array = [10, 20, 30, 40]; + const result = findOrThrow(array, (value, index) => index === 2); + expect(result).toBe(30); + }); + + it('should pass the full array to the predicate', () => { + const array = [1, 2, 3]; + let passedArray: number[] | undefined; + findOrThrow(array, (value, index, arr) => { + passedArray = arr; + return value === 2; + }); + expect(passedArray).toEqual([1, 2, 3]); + }); + + it('should throw error with default message when no match is found', () => { + const array = [1, 2, 3]; + expect(() => findOrThrow(array, (value) => value === 99)).toThrow('Could not find a matching item'); + }); + + it('should throw error with custom string message when no match is found', () => { + const array = [1, 2, 3]; + const customError = 'Item not found in array'; + expect(() => findOrThrow(array, (value) => value === 99, customError)).toThrow(customError); + }); + + it('should throw error with custom function message when no match is found', () => { + const array = [1, 2, 3]; + const errorMessage = 'No item matching the criteria'; + const errorFn = () => errorMessage; + expect(() => findOrThrow(array, (value) => value === 99, errorFn)).toThrow(errorMessage); + }); + + it('should not call error function if a match is found', () => { + const array = [1, 2, 3]; + const errorFn = jest.fn(() => 'error'); + const result = findOrThrow(array, (value) => value === 2, errorFn); + expect(result).toBe(2); + expect(errorFn).not.toHaveBeenCalled(); + }); + + it('should throw when searching an empty array', () => { + const array: number[] = []; + expect(() => findOrThrow(array, () => true)).toThrow('Could not find a matching item'); + }); + + it('should work with array of strings', () => { + const array = ['apple', 'banana', 'cherry']; + const result = findOrThrow(array, (str) => str.startsWith('b')); + expect(result).toBe('banana'); + }); + + it('should throw for array of strings when no match', () => { + const array = ['apple', 'banana', 'cherry']; + expect(() => findOrThrow(array, (str) => str.startsWith('z'))).toThrow('Could not find a matching item'); + }); + + it('should find the first match when multiple items match predicate', () => { + const array = [1, 2, 3, 4, 5, 6]; + const result = findOrThrow(array, (value) => value > 2); + expect(result).toBe(3); + }); +}); diff --git a/libs/common/custom-scroll/src/lib/custom-scroll.service.ts b/libs/common/custom-scroll/src/lib/custom-scroll.service.ts index ca54ddfa97..4809d9c495 100644 --- a/libs/common/custom-scroll/src/lib/custom-scroll.service.ts +++ b/libs/common/custom-scroll/src/lib/custom-scroll.service.ts @@ -1,5 +1,5 @@ import { ViewportScroller } from '@angular/common'; -import { inject, Injectable } from '@angular/core'; +import { afterNextRender, inject, Injectable, Injector } from '@angular/core'; import { Event, NavigationEnd, NavigationSkipped, Router, RouterEvent, Scroll } from '@angular/router'; import { filter, pairwise, startWith } from 'rxjs/operators'; @@ -17,6 +17,9 @@ export class CustomScrollService { */ private viewportScroller = inject(ViewportScroller); + /** Injector reference */ + private readonly injector = inject(Injector); + /** * CustomScrollService constructor * @param router The Angular Router instance to listen for navigation events @@ -48,7 +51,9 @@ export class CustomScrollService { if (current.position) { this.viewportScroller.scrollToPosition(current.position); } else if (current.anchor) { - this.viewportScroller.scrollToAnchor(current.anchor); + const { anchor } = current; + // Use afterNextRender to ensure the layout is stable before attempting to scroll to the anchor + afterNextRender({ write: () => this.viewportScroller.scrollToAnchor(anchor) }, { injector: this.injector }); } else { const previousUrl = this.getUrlFromRouterEvent(previous.routerEvent); const currentUrl = this.getUrlFromRouterEvent(current.routerEvent); diff --git a/libs/common/package.json b/libs/common/package.json index 5389369ff8..de1d19a84d 100644 --- a/libs/common/package.json +++ b/libs/common/package.json @@ -11,10 +11,11 @@ "@angular/cdk": "^21.1.4", "qs": "^6.14.0", "ngxtension": "^7.0.2", - "type-fest": "^5.0.1", "@angular/forms": "^21.1.4", "nanoid": "^5.1.5", - "store2": "^2.14.4" + "store2": "^2.14.4", + "type-fest": "^5.3.1", + "zod": "^4.3.6" }, "sideEffects": false } diff --git a/libs/design-system/assets/brand/logo/cns-full-small.svg b/libs/design-system/assets/brand/logo/cns-full-small.svg new file mode 100644 index 0000000000..f409852d90 --- /dev/null +++ b/libs/design-system/assets/brand/logo/cns-full-small.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libs/design-system/assets/brand/logo/cns-regular.svg b/libs/design-system/assets/brand/logo/cns-regular.svg index 596f6b9daf..fb64629a5c 100644 --- a/libs/design-system/assets/brand/logo/cns-regular.svg +++ b/libs/design-system/assets/brand/logo/cns-regular.svg @@ -1,55 +1,18 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + - - + + + + + diff --git a/libs/design-system/assets/brand/logo/cns-small.svg b/libs/design-system/assets/brand/logo/cns-small.svg index 6a01490363..0ed44d4c63 100644 --- a/libs/design-system/assets/brand/logo/cns-small.svg +++ b/libs/design-system/assets/brand/logo/cns-small.svg @@ -1,8 +1,8 @@ - + - - - + + + diff --git a/libs/design-system/assets/logo/ada_white.svg b/libs/design-system/assets/logo/ada_white.svg new file mode 100644 index 0000000000..610f69244b --- /dev/null +++ b/libs/design-system/assets/logo/ada_white.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/libs/design-system/assets/logo/cifar.svg b/libs/design-system/assets/logo/cifar.svg index 1d81e40763..efe499c505 100644 --- a/libs/design-system/assets/logo/cifar.svg +++ b/libs/design-system/assets/logo/cifar.svg @@ -1,13 +1,18 @@ - - - + + + - - - - - - - + + + + + + + + + + + + diff --git a/libs/design-system/assets/logo/cifar_white.svg b/libs/design-system/assets/logo/cifar_white.svg new file mode 100644 index 0000000000..d3475829f6 --- /dev/null +++ b/libs/design-system/assets/logo/cifar_white.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/libs/design-system/assets/logo/gates.svg b/libs/design-system/assets/logo/gates.svg new file mode 100644 index 0000000000..fb530bdd4f --- /dev/null +++ b/libs/design-system/assets/logo/gates.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/libs/design-system/assets/logo/gates_white.svg b/libs/design-system/assets/logo/gates_white.svg new file mode 100644 index 0000000000..ad23382627 --- /dev/null +++ b/libs/design-system/assets/logo/gates_white.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libs/design-system/assets/logo/iu_white.svg b/libs/design-system/assets/logo/iu_white.svg new file mode 100644 index 0000000000..2a280664a3 --- /dev/null +++ b/libs/design-system/assets/logo/iu_white.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/libs/design-system/assets/logo/jsmf.svg b/libs/design-system/assets/logo/jsmf.svg new file mode 100644 index 0000000000..945ffa5298 --- /dev/null +++ b/libs/design-system/assets/logo/jsmf.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/libs/design-system/assets/logo/jsmf_white.svg b/libs/design-system/assets/logo/jsmf_white.svg new file mode 100644 index 0000000000..f243f65696 --- /dev/null +++ b/libs/design-system/assets/logo/jsmf_white.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libs/design-system/assets/logo/nih_white.svg b/libs/design-system/assets/logo/nih_white.svg new file mode 100644 index 0000000000..80295b8c72 --- /dev/null +++ b/libs/design-system/assets/logo/nih_white.svg @@ -0,0 +1,4 @@ + + + + diff --git a/libs/design-system/assets/logo/nsf.svg b/libs/design-system/assets/logo/nsf.svg new file mode 100644 index 0000000000..f1d7718519 --- /dev/null +++ b/libs/design-system/assets/logo/nsf.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/libs/design-system/assets/logo/nsf_gray.svg b/libs/design-system/assets/logo/nsf_gray.svg new file mode 100644 index 0000000000..4873b84610 --- /dev/null +++ b/libs/design-system/assets/logo/nsf_gray.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/libs/design-system/brand/logo/src/lib/brand-logos.ts b/libs/design-system/brand/logo/src/lib/brand-logos.ts index cff179c8ec..f11a595b53 100644 --- a/libs/design-system/brand/logo/src/lib/brand-logos.ts +++ b/libs/design-system/brand/logo/src/lib/brand-logos.ts @@ -1,5 +1,30 @@ import { createInjectionToken } from 'ngxtension/create-injection-token'; +/** Brand logo size */ +export type BrandLogoSize = 'regular' | 'small'; + +/** Brand logo */ +export interface BrandLogo { + /** Logo size */ + size: BrandLogoSize; + /** Logo source url */ + src: string; + /** Logo width */ + width: number; + /** Logo height */ + height: number; +} + +/** Brand logos configuration */ +export interface BrandLogosConfig { + /** Label for the brand */ + label: string; + /** URL for the brand website */ + url: string; + /** Array of brand logos */ + logos: BrandLogo[]; +} + /** Default logos for applications */ export const DEFAULT_LOGOS: BrandLogo[] = [ { @@ -16,26 +41,18 @@ export const DEFAULT_LOGOS: BrandLogo[] = [ }, ]; -/** Brand logo size */ -export type BrandLogoSize = 'regular' | 'small'; - -/** Brand logo */ -export interface BrandLogo { - /** Logo size */ - size: BrandLogoSize; - /** Logo source url */ - src: string; - /** Logo width */ - width: number; - /** Logo height */ - height: number; -} +/** Default brand logos configuration */ +export const DEFAULT_BRAND_LOGOS_CONFIG: BrandLogosConfig = { + label: 'Human Reference Atlas', + url: 'https://humanatlas.io', + logos: DEFAULT_LOGOS, +}; /** Injection token for brand logos configuration */ -const BRAND_LOGOS = createInjectionToken(() => DEFAULT_LOGOS); +const BRAND_LOGOS = createInjectionToken(() => DEFAULT_BRAND_LOGOS_CONFIG); /** Inject the brand logos configuration */ export const injectBrandLogos = BRAND_LOGOS[0]; /** Set the brand logos configuration */ -export const provideBrandLogos = BRAND_LOGOS[1] as (logos: BrandLogo[]) => ReturnType<(typeof BRAND_LOGOS)[1]>; +export const provideBrandLogos = BRAND_LOGOS[1] as (config: BrandLogosConfig) => ReturnType<(typeof BRAND_LOGOS)[1]>; diff --git a/libs/design-system/brand/logo/src/lib/logo.component.html b/libs/design-system/brand/logo/src/lib/logo.component.html index ef626a6d49..0f52dfbc77 100644 --- a/libs/design-system/brand/logo/src/lib/logo.component.html +++ b/libs/design-system/brand/logo/src/lib/logo.component.html @@ -2,9 +2,9 @@ class="logo" hraFeature="logo" hraClickEvent - aria-label="Visit Human Reference Atlas" - href="https://humanatlas.io/" target="_self" + [attr.aria-label]="`Visit ${brandConfig().label}`" + [hraLink]="brandConfig().url" [inlineSVG]="data().src | assetUrl" [evalScripts]="NEVER_EVAL_SCRIPTS" [style.width.px]="data().width" diff --git a/libs/design-system/brand/logo/src/lib/logo.component.spec.ts b/libs/design-system/brand/logo/src/lib/logo.component.spec.ts index ad128a8d0b..ba3da4e6fe 100644 --- a/libs/design-system/brand/logo/src/lib/logo.component.spec.ts +++ b/libs/design-system/brand/logo/src/lib/logo.component.spec.ts @@ -11,6 +11,5 @@ describe('BrandLogoComponent', () => { const link = screen.queryByRole('link'); expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute('href', 'https://humanatlas.io/'); }); }); diff --git a/libs/design-system/brand/logo/src/lib/logo.component.stories.ts b/libs/design-system/brand/logo/src/lib/logo.component.stories.ts index 12cf112f01..a32921997a 100644 --- a/libs/design-system/brand/logo/src/lib/logo.component.stories.ts +++ b/libs/design-system/brand/logo/src/lib/logo.component.stories.ts @@ -43,10 +43,14 @@ export const CNS: Story = { decorators: [ moduleMetadata({ providers: [ - provideBrandLogos([ - { size: 'regular', src: 'assets/brand/logo/cns-regular.svg', width: 228, height: 39 }, - { size: 'small', src: 'assets/brand/logo/cns-small.svg', width: 84, height: 28 }, - ]), + provideBrandLogos({ + label: 'CNS', + url: 'https://cns.iu.edu', + logos: [ + { size: 'regular', src: 'assets/brand/logo/cns-regular.svg', width: 228, height: 39 }, + { size: 'small', src: 'assets/brand/logo/cns-small.svg', width: 84, height: 28 }, + ], + }), ], }), ], diff --git a/libs/design-system/brand/logo/src/lib/logo.component.ts b/libs/design-system/brand/logo/src/lib/logo.component.ts index 99dfc36dc5..8916b4e6f5 100644 --- a/libs/design-system/brand/logo/src/lib/logo.component.ts +++ b/libs/design-system/brand/logo/src/lib/logo.component.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import { HraCommonModule } from '@hra-ui/common'; import { findOrThrow } from '@hra-ui/common/array-util'; +import { RouterExtModule } from '@hra-ui/common/router-ext'; import { InlineSVGModule, type SVGScriptEvalMode } from 'ng-inline-svg-2'; import { BrandLogoSize, injectBrandLogos } from './brand-logos'; @@ -9,7 +10,7 @@ import { BrandLogoSize, injectBrandLogos } from './brand-logos'; /** Brand Logo Component */ @Component({ selector: 'hra-brand-logo', - imports: [HraCommonModule, InlineSVGModule], + imports: [HraCommonModule, InlineSVGModule, RouterExtModule], templateUrl: './logo.component.html', styleUrl: './logo.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -21,12 +22,12 @@ export class BrandLogoComponent { /** Size of the logo */ readonly size = input('regular'); - /** Logos from injection token */ - readonly logos = input(injectBrandLogos()); + /** Brand configuration from injection token */ + readonly brandConfig = input(injectBrandLogos()); /** SVG script eval mode */ protected readonly NEVER_EVAL_SCRIPTS = 'never' as SVGScriptEvalMode; /** Logo data */ - protected readonly data = computed(() => findOrThrow(this.logos(), ({ size }) => size === this.size())); + protected readonly data = computed(() => findOrThrow(this.brandConfig().logos, ({ size }) => size === this.size())); } diff --git a/libs/design-system/buttons/breadcrumbs/src/index.ts b/libs/design-system/buttons/breadcrumbs/src/index.ts index 28dc00baef..ad2c85c06c 100644 --- a/libs/design-system/buttons/breadcrumbs/src/index.ts +++ b/libs/design-system/buttons/breadcrumbs/src/index.ts @@ -1 +1,2 @@ export * from './lib/breadcrumbs.component'; +export * from './lib/breadcrumbs.schema'; diff --git a/libs/design-system/buttons/breadcrumbs/src/lib/breadcrumbs.component.scss b/libs/design-system/buttons/breadcrumbs/src/lib/breadcrumbs.component.scss index 1890ddf99f..75a9503c0e 100644 --- a/libs/design-system/buttons/breadcrumbs/src/lib/breadcrumbs.component.scss +++ b/libs/design-system/buttons/breadcrumbs/src/lib/breadcrumbs.component.scss @@ -32,7 +32,7 @@ .separator { padding: 0.125rem 0.25rem; - color: vars.$outline; + color: vars.$on-surface-variant; user-select: none; } } diff --git a/libs/design-system/buttons/breadcrumbs/src/lib/breadcrumbs.schema.ts b/libs/design-system/buttons/breadcrumbs/src/lib/breadcrumbs.schema.ts new file mode 100644 index 0000000000..e4141d0a57 --- /dev/null +++ b/libs/design-system/buttons/breadcrumbs/src/lib/breadcrumbs.schema.ts @@ -0,0 +1,9 @@ +import * as z from 'zod'; + +/** Breadcrumb Item Schema */ +export const BreadcrumbItemSchema = z + .object({ + name: z.string(), + route: z.string().optional(), + }) + .meta({ id: 'BreadcrumbItem' }); diff --git a/libs/design-system/buttons/navigation-category-toggle/src/lib/navigation-category-toggle.component.scss b/libs/design-system/buttons/navigation-category-toggle/src/lib/navigation-category-toggle.component.scss index 8614f131bd..1572a3fc1f 100644 --- a/libs/design-system/buttons/navigation-category-toggle/src/lib/navigation-category-toggle.component.scss +++ b/libs/design-system/buttons/navigation-category-toggle/src/lib/navigation-category-toggle.component.scss @@ -12,14 +12,14 @@ ); .toggle { - color: vars.$secondary; + color: vars.$on-surface; border: none; border-radius: 0.25rem; .text { display: inline-block; margin-right: 0.125rem; - @include utils.use-font(label, large); + @include utils.use-font(title, medium); } :is(&.mat-button-toggle-checked, &:hover, &:active) .text { diff --git a/libs/design-system/buttons/skip-to-content-button/README.md b/libs/design-system/buttons/skip-to-content-button/README.md new file mode 100644 index 0000000000..73ceb0da66 --- /dev/null +++ b/libs/design-system/buttons/skip-to-content-button/README.md @@ -0,0 +1,3 @@ +# @hra-ui/design-system/buttons/skip-to-content-button + +Secondary entry point of `@hra-ui/design-system`. It can be used by importing from `@hra-ui/design-system/buttons/skip-to-content-button`. diff --git a/libs/design-system/buttons/skip-to-content-button/ng-package.json b/libs/design-system/buttons/skip-to-content-button/ng-package.json new file mode 100644 index 0000000000..c781f0df46 --- /dev/null +++ b/libs/design-system/buttons/skip-to-content-button/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/design-system/buttons/skip-to-content-button/src/index.ts b/libs/design-system/buttons/skip-to-content-button/src/index.ts new file mode 100644 index 0000000000..81e07c23a3 --- /dev/null +++ b/libs/design-system/buttons/skip-to-content-button/src/index.ts @@ -0,0 +1 @@ +export * from './lib/skip-to-content-button.component'; diff --git a/libs/design-system/buttons/skip-to-content-button/src/lib/skip-to-content-button.component.html b/libs/design-system/buttons/skip-to-content-button/src/lib/skip-to-content-button.component.html new file mode 100644 index 0000000000..5bdac4fc13 --- /dev/null +++ b/libs/design-system/buttons/skip-to-content-button/src/lib/skip-to-content-button.component.html @@ -0,0 +1,3 @@ + + {{ label() }} + diff --git a/libs/design-system/buttons/skip-to-content-button/src/lib/skip-to-content-button.component.scss b/libs/design-system/buttons/skip-to-content-button/src/lib/skip-to-content-button.component.scss new file mode 100644 index 0000000000..58709190b1 --- /dev/null +++ b/libs/design-system/buttons/skip-to-content-button/src/lib/skip-to-content-button.component.scss @@ -0,0 +1,47 @@ +@use '../../../../styles/vars'; + +:host { + display: block; + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + border: 0; + + .link { + position: absolute; + top: 0; + left: 50%; + opacity: 0; + border-radius: 0.5rem; + clip-path: inset(50%); + } + + &:active, + &:focus, + &:focus-within { + position: static; + width: auto; + height: auto; + margin: 0; + overflow: visible; + clip-path: none; + + .link:focus, + .link:focus-visible { + top: 3rem; + min-height: 4rem; + transform: translateX(-50%); + z-index: 2000; + opacity: 1; + color: vars.$on-surface; + background-color: vars.$surface-container-low; + border-color: vars.$on-surface; + box-shadow: 0px 0.3125rem 1rem 0px vars.$tertiary-container; + clip-path: none; + } + } +} diff --git a/libs/design-system/buttons/skip-to-content-button/src/lib/skip-to-content-button.component.spec.ts b/libs/design-system/buttons/skip-to-content-button/src/lib/skip-to-content-button.component.spec.ts new file mode 100644 index 0000000000..8bf2d787ef --- /dev/null +++ b/libs/design-system/buttons/skip-to-content-button/src/lib/skip-to-content-button.component.spec.ts @@ -0,0 +1,35 @@ +import { RenderComponentOptions, render, screen } from '@testing-library/angular'; +import { SkipToContentButtonComponent } from './skip-to-content-button.component'; + +describe('SkipToContentButtonComponent', () => { + const anchorId = 'main-content'; + const customLabel = 'Skip to section'; + + function setup(options?: RenderComponentOptions) { + return render(SkipToContentButtonComponent, { + ...options, + inputs: { + anchorId, + ...options?.inputs, + }, + }); + } + + it('renders the default label', async () => { + await setup(); + + expect(screen.getByRole('link', { name: 'Skip to main content' })).toBeInTheDocument(); + }); + + it('renders a custom label when provided', async () => { + await setup({ inputs: { label: customLabel } }); + + expect(screen.getByRole('link', { name: customLabel })).toBeInTheDocument(); + }); + + it('sets the fragment href from anchorId and normalizes a leading hash', async () => { + await setup({ inputs: { anchorId: '#target-section' } }); + + expect(screen.getByRole('link')).toHaveAttribute('href', '#target-section'); + }); +}); diff --git a/libs/design-system/buttons/skip-to-content-button/src/lib/skip-to-content-button.component.ts b/libs/design-system/buttons/skip-to-content-button/src/lib/skip-to-content-button.component.ts new file mode 100644 index 0000000000..1654a4f1c8 --- /dev/null +++ b/libs/design-system/buttons/skip-to-content-button/src/lib/skip-to-content-button.component.ts @@ -0,0 +1,22 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { FragmentLinkDirective } from '@hra-ui/common/router-ext'; +import { ButtonsModule } from '@hra-ui/design-system/buttons'; + +/** + * "Skip to content" button component for accessibility, + * allowing users to quickly navigate to the main content of the page. + */ +@Component({ + selector: 'hra-skip-to-content-button', + imports: [ButtonsModule, FragmentLinkDirective], + templateUrl: './skip-to-content-button.component.html', + styleUrl: './skip-to-content-button.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkipToContentButtonComponent { + /** The ID of the anchor element to skip to. */ + readonly anchorId = input.required(); + + /** The label for the button. */ + readonly label = input('Skip to main content'); +} diff --git a/libs/design-system/buttons/social-media-button/src/index.ts b/libs/design-system/buttons/social-media-button/src/index.ts index 2247ec9771..0e5dd9250d 100644 --- a/libs/design-system/buttons/social-media-button/src/index.ts +++ b/libs/design-system/buttons/social-media-button/src/index.ts @@ -1,3 +1,4 @@ export * from './lib/social-media-button.component'; +export { injectSocials, provideSocials } from './lib/socials'; export { SOCIAL_IDS } from './lib/static-data/parsed'; -export { SocialMediaId } from './lib/types/social-media.schema'; +export { SocialMediaId, SocialsSchema } from './lib/types/social-media.schema'; diff --git a/libs/design-system/buttons/social-media-button/src/lib/social-media-button.component.ts b/libs/design-system/buttons/social-media-button/src/lib/social-media-button.component.ts index 266fdf5a8d..a65e73e4de 100644 --- a/libs/design-system/buttons/social-media-button/src/lib/social-media-button.component.ts +++ b/libs/design-system/buttons/social-media-button/src/lib/social-media-button.component.ts @@ -1,9 +1,9 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import { HraCommonModule } from '@hra-ui/common'; +import { findOrThrow } from '@hra-ui/common/array-util'; import { IconButtonModule, IconButtonSize, IconButtonVariant } from '@hra-ui/design-system/buttons/icon-button'; -import { SOCIALS } from './static-data/parsed'; +import { injectSocials } from './socials'; import { SocialMediaId } from './types/social-media.schema'; -import { findOrThrow } from '@hra-ui/common/array-util'; /** * Social media buttons for HRA apps @@ -25,6 +25,8 @@ export class SocialMediaButtonComponent { /** Button variant */ readonly variant = input('dark'); + private readonly socials = injectSocials(); + /** Social media button data */ - protected readonly data = computed(() => findOrThrow(SOCIALS, ({ id }) => id === this.id())); + protected readonly data = computed(() => findOrThrow(this.socials, ({ id }) => id === this.id())); } diff --git a/libs/design-system/buttons/social-media-button/src/lib/socials.ts b/libs/design-system/buttons/social-media-button/src/lib/socials.ts new file mode 100644 index 0000000000..5cf899d875 --- /dev/null +++ b/libs/design-system/buttons/social-media-button/src/lib/socials.ts @@ -0,0 +1,10 @@ +import { createInjectionToken } from 'ngxtension/create-injection-token'; +import { SOCIALS } from './static-data/parsed'; + +/** Injection token for socials */ +const SOCIALS_TOKEN = createInjectionToken(() => SOCIALS); + +/** Inject the socials */ +export const injectSocials = SOCIALS_TOKEN[0]; +/** Provide new socials */ +export const provideSocials = SOCIALS_TOKEN[1] as (socials: typeof SOCIALS) => ReturnType<(typeof SOCIALS_TOKEN)[1]>; diff --git a/libs/design-system/buttons/text-hyperlink/src/lib/text-hyperlink.component.html b/libs/design-system/buttons/text-hyperlink/src/lib/text-hyperlink.component.html index 8c7673b7b0..10205c4de8 100644 --- a/libs/design-system/buttons/text-hyperlink/src/lib/text-hyperlink.component.html +++ b/libs/design-system/buttons/text-hyperlink/src/lib/text-hyperlink.component.html @@ -1,4 +1,4 @@ - + {{ text() }} @if (icon(); as fontIcon) { {{ fontIcon }} diff --git a/libs/design-system/buttons/text-hyperlink/src/lib/text-hyperlink.component.ts b/libs/design-system/buttons/text-hyperlink/src/lib/text-hyperlink.component.ts index b968cf6408..5db681b1c0 100644 --- a/libs/design-system/buttons/text-hyperlink/src/lib/text-hyperlink.component.ts +++ b/libs/design-system/buttons/text-hyperlink/src/lib/text-hyperlink.component.ts @@ -35,4 +35,9 @@ export class TextHyperlinkComponent { * Whether the link should open in a new tab/window */ readonly external = input(false); + + /** + * Aria label of text hyperlink component + */ + readonly ariaLabel = input(); } diff --git a/libs/design-system/buttons/text-hyperlink/src/lib/types/text-hyperlink.schema.ts b/libs/design-system/buttons/text-hyperlink/src/lib/types/text-hyperlink.schema.ts index e1fde1f3ba..1cf4a5263f 100644 --- a/libs/design-system/buttons/text-hyperlink/src/lib/types/text-hyperlink.schema.ts +++ b/libs/design-system/buttons/text-hyperlink/src/lib/types/text-hyperlink.schema.ts @@ -15,4 +15,5 @@ export const TextHyperlinkSchema = ContentTemplateSchema.extend({ url: z.string(), icon: z.string().optional(), external: z.boolean().optional(), + ariaLabel: z.string().optional(), }).meta({ id: 'TextHyperlink' }); diff --git a/libs/design-system/cards/action-card/src/lib/action-card.component.scss b/libs/design-system/cards/action-card/src/lib/action-card.component.scss index 0d3444fafd..c67463ead5 100644 --- a/libs/design-system/cards/action-card/src/lib/action-card.component.scss +++ b/libs/design-system/cards/action-card/src/lib/action-card.component.scss @@ -9,8 +9,11 @@ .header { .image { + display: block; width: 100%; aspect-ratio: 16 / 9; + object-fit: cover; + object-position: top; } } diff --git a/libs/design-system/cards/action-card/src/lib/action-card.component.stories.ts b/libs/design-system/cards/action-card/src/lib/action-card.component.stories.ts index bbfa92e041..e9537fb5c0 100644 --- a/libs/design-system/cards/action-card/src/lib/action-card.component.stories.ts +++ b/libs/design-system/cards/action-card/src/lib/action-card.component.stories.ts @@ -89,6 +89,10 @@ export const Flat: Story = { }; export const Outlined: Story = { + args: { + subtagline: 'Small f', + }, + render: render( 'outlined', ` diff --git a/libs/design-system/cards/content-button/src/lib/content-button.component.html b/libs/design-system/cards/content-button/src/lib/content-button.component.html index 4df7d9a6a3..c6584e1f91 100644 --- a/libs/design-system/cards/content-button/src/lib/content-button.component.html +++ b/libs/design-system/cards/content-button/src/lib/content-button.component.html @@ -1,14 +1,14 @@ - +
- {{ date() }} + {{ date() | date }} {{ tagline() }} - - @for (tag of tags(); track tag) { - {{ tag }} + + @for (tag of tags(); track $index) { + {{ tag }} }
diff --git a/libs/design-system/cards/content-button/src/lib/content-button.component.scss b/libs/design-system/cards/content-button/src/lib/content-button.component.scss index 2de3eb3e75..3a13e97ca3 100644 --- a/libs/design-system/cards/content-button/src/lib/content-button.component.scss +++ b/libs/design-system/cards/content-button/src/lib/content-button.component.scss @@ -3,6 +3,8 @@ @use '../../../../styles/vars'; :host { + // --mat-button-text-pressed-state-layer-opacity: 0.1; + display: flex; flex-direction: column; overflow: hidden; @@ -15,9 +17,19 @@ ( text-state-layer-color: vars.$secondary, text-hover-state-layer-opacity: 0, + text-pressed-state-layer-opacity: 0.1, ) ); + mat-chip { + @include mat.chips-overrides( + ( + container-height: 1.625rem, + elevated-container-color: transparent, + ) + ); + } + .content-button { text-align: start; justify-content: start; @@ -26,7 +38,7 @@ padding: 1rem; border: 0.0625rem solid vars.$outline; border-radius: 0.75rem; - background-color: vars.$on-primary; + background-color: vars.$background; &:hover, &:focus-visible { @@ -60,9 +72,9 @@ flex-grow: 1; .date { - color: vars.$secondary; + color: vars.$on-surface-variant; margin-bottom: 0.375rem; - @include utils.use-font(label, medium); + @include utils.use-font(title, small); } .tag { @@ -73,9 +85,9 @@ .tagline { min-height: 3rem; - color: vars.$primary; + color: vars.$on-surface; margin-bottom: 1rem; - @include utils.use-font(label, large); + @include utils.use-font(title, medium); @include utils.line-clamp(2); } } diff --git a/libs/design-system/cards/content-button/src/lib/content-button.component.ts b/libs/design-system/cards/content-button/src/lib/content-button.component.ts index 0a94b928c2..cdbb20ee1c 100644 --- a/libs/design-system/cards/content-button/src/lib/content-button.component.ts +++ b/libs/design-system/cards/content-button/src/lib/content-button.component.ts @@ -1,7 +1,7 @@ import { booleanAttribute, ChangeDetectionStrategy, Component, input } from '@angular/core'; import { MatChipsModule } from '@angular/material/chips'; -import { LinkDirective } from '@hra-ui/common/router-ext'; import { HraCommonModule } from '@hra-ui/common'; +import { LinkDirective } from '@hra-ui/common/router-ext'; import { ButtonsModule } from '@hra-ui/design-system/buttons'; /** @@ -18,7 +18,7 @@ export class ContentButtonComponent { /** Image url */ readonly imageSrc = input.required(); /** Date to display on card */ - readonly date = input.required(); + readonly date = input.required(); /** Card tagline (less than 2 lines or truncated) */ readonly tagline = input.required(); /** Tags to display on bottom of card */ diff --git a/libs/design-system/cards/gallery-card/src/index.ts b/libs/design-system/cards/gallery-card/src/index.ts index 82c4f8a8c8..1de9be8ea4 100644 --- a/libs/design-system/cards/gallery-card/src/index.ts +++ b/libs/design-system/cards/gallery-card/src/index.ts @@ -1 +1,2 @@ export * from './lib/gallery-card.component'; +export * from './lib/gallery-card.schema'; diff --git a/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.html b/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.html index 70b6a51ae1..83e5a23351 100644 --- a/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.html +++ b/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.html @@ -1,13 +1,17 @@
{{ date() }}
- - {{ tagline() }} - + @if (link(); as link) { + + {{ tagline() }} + + } @else { +
{{ tagline() }}
+ } @if (tags().length > 0) { - @for (tag of tags(); track tag) { - {{ tag }} + @for (tag of tags(); track tag.name) { + {{ tag.name }} } } diff --git a/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.scss b/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.scss index 17f2655c8e..a02763e0a2 100644 --- a/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.scss +++ b/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.scss @@ -1,9 +1,13 @@ +@use '@angular/material' as mat; @use '../../../../styles/utils'; @use '../../../../styles/vars'; :host { + display: flex; + flex-direction: column; min-width: 17rem; max-width: 28rem; + width: 100%; .image { width: 100%; @@ -15,25 +19,35 @@ .content { display: flex; flex-direction: column; - gap: 0.5rem; + flex-grow: 1; padding-top: 1rem; - min-height: 6.875rem; .date { - @include utils.use-font(label, medium); - color: vars.$on-secondary-fixed; + color: vars.$on-surface-variant; + @include utils.use-font(title, small); + } + + .tagline-link, + .tagline { + color: vars.$on-surface; + margin-top: 0.5rem; + @include utils.use-font(title, medium); + @include utils.line-clamp(2); } .tagline-link { - color: vars.$tertiary; text-decoration: underline; cursor: pointer; - @include utils.use-font(title, medium); - @include utils.line-clamp(2); } .tags { padding-top: 1.25rem; + margin-top: auto; } } + + mat-chip { + margin: 0; + margin-left: 0.5rem; + } } diff --git a/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.spec.ts b/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.spec.ts index 40526cd285..dddb97a26a 100644 --- a/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.spec.ts +++ b/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.spec.ts @@ -23,7 +23,10 @@ describe('GalleryCardComponent', () => { date: 'January 15, 2025', link: 'https://example.com', external: true, - tags: ['Research', 'HRA'], + tags: [ + { name: 'Research', description: 'Items related to research activities' }, + { name: 'HRA', description: 'Content about the Human Reference Atlas' }, + ], }, }); diff --git a/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.stories.ts b/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.stories.ts index 24457970c5..f6e5d3c7ab 100644 --- a/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.stories.ts +++ b/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.stories.ts @@ -12,11 +12,14 @@ const meta: Meta = { }, args: { tagline: 'Exploring the Human Reference Atlas: A Comprehensive Guide.', - imageSrc: 'assets/ui-images/placeholder.png', + imageSrc: 'assets/ui-images/placeholder-publication-article-journal.png', date: 'March 15, 2024', link: 'https://humanatlas.io', external: true, - tags: ['Research', 'HRA'], + tags: [ + { name: 'Research', description: 'Items related to research activities' }, + { name: 'HRA', description: 'Content about the Human Reference Atlas' }, + ], }, }; export default meta; diff --git a/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.ts b/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.ts index 9564bcec4c..5a996048bf 100644 --- a/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.ts +++ b/libs/design-system/cards/gallery-card/src/lib/gallery-card.component.ts @@ -2,15 +2,31 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { MatChipsModule } from '@angular/material/chips'; import { LinkDirective } from '@hra-ui/common/router-ext'; import { AssetUrlPipe } from '@hra-ui/common/url'; -import { PlainTooltipDirective } from '@hra-ui/design-system/tooltips/plain-tooltip'; import { TextHyperlinkDirective } from '@hra-ui/design-system/buttons/text-hyperlink'; +import { ChipSizeDirective } from '@hra-ui/design-system/chips'; +import { PlainTooltipDirective } from '@hra-ui/design-system/tooltips/plain-tooltip'; + +/** Tag item interface for gallery card tags */ +export interface TagItem { + /** Tag name */ + name: string; + /** Tag description */ + description: string; +} /** * Gallery card component for displaying content with images, dates, and tags */ @Component({ selector: 'hra-gallery-card', - imports: [AssetUrlPipe, LinkDirective, MatChipsModule, PlainTooltipDirective, TextHyperlinkDirective], + imports: [ + AssetUrlPipe, + LinkDirective, + MatChipsModule, + PlainTooltipDirective, + TextHyperlinkDirective, + ChipSizeDirective, + ], templateUrl: './gallery-card.component.html', styleUrl: './gallery-card.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -23,9 +39,9 @@ export class GalleryCardComponent { /** Date to display */ readonly date = input.required(); /** URL for the tagline link */ - readonly link = input.required(); + readonly link = input(); /** Whether the link opens in new tab */ readonly external = input(false); /** Tags to display */ - readonly tags = input([]); + readonly tags = input([]); } diff --git a/libs/design-system/cards/gallery-card/src/lib/gallery-card.schema.ts b/libs/design-system/cards/gallery-card/src/lib/gallery-card.schema.ts new file mode 100644 index 0000000000..d391997244 --- /dev/null +++ b/libs/design-system/cards/gallery-card/src/lib/gallery-card.schema.ts @@ -0,0 +1,14 @@ +import * as z from 'zod'; + +/** News page data item type */ +export type GalleryCardItem = z.infer; + +/** News page data item schema */ +export const GalleryCardItemSchema = z.object({ + name: z.string(), + imageSrc: z.string(), + date: z.string(), + link: z.string(), + external: z.boolean(), + tags: z.string().array(), +}); diff --git a/libs/design-system/cards/profile-card/src/lib/profile-card.component.html b/libs/design-system/cards/profile-card/src/lib/profile-card.component.html index 99b26e90e5..e83eeb3233 100644 --- a/libs/design-system/cards/profile-card/src/lib/profile-card.component.html +++ b/libs/design-system/cards/profile-card/src/lib/profile-card.component.html @@ -1,5 +1,5 @@
- +
{{ name() }}
diff --git a/libs/design-system/cards/profile-card/src/lib/profile-card.component.scss b/libs/design-system/cards/profile-card/src/lib/profile-card.component.scss index a58363c78e..1fba961750 100644 --- a/libs/design-system/cards/profile-card/src/lib/profile-card.component.scss +++ b/libs/design-system/cards/profile-card/src/lib/profile-card.component.scss @@ -10,7 +10,7 @@ width: 11.25rem; height: 13.375rem; clip-path: polygon(0% 0%, 100% 0%, 100% 90%, 90% 100%, 0% 100%); - border-left: 0.75rem solid #ff0043; + border-left: 0.75rem solid var(--profile-card-border-color, #ff0043); margin-bottom: 2rem; img { @@ -21,7 +21,7 @@ } .content { - margin-bottom: 1rem; + margin-bottom: 0.5rem; flex-grow: 1; .name { @@ -32,7 +32,7 @@ .description { @include utils.use-font(body, large); - color: vars.$on-secondary-fixed; + color: var(--profile-card-description-color, #{vars.$on-secondary-fixed}); margin-top: 0; ::ng-deep p { diff --git a/libs/design-system/cards/src/lib/cards.module.ts b/libs/design-system/cards/src/lib/cards.module.ts index 63b0a175cb..1213d760fe 100644 --- a/libs/design-system/cards/src/lib/cards.module.ts +++ b/libs/design-system/cards/src/lib/cards.module.ts @@ -3,9 +3,24 @@ import { ActionCardActionComponent, ActionCardComponent } from '@hra-ui/design-s import { FlatCardModule } from '@hra-ui/design-system/cards/flat-card'; import { ProfileCardComponent } from '@hra-ui/design-system/cards/profile-card'; import { GalleryCardComponent } from '@hra-ui/design-system/cards/gallery-card'; +import { ContentButtonComponent } from '@hra-ui/design-system/cards/content-button'; @NgModule({ - imports: [ActionCardComponent, ActionCardActionComponent, FlatCardModule, ProfileCardComponent, GalleryCardComponent], - exports: [ActionCardComponent, ActionCardActionComponent, FlatCardModule, ProfileCardComponent, GalleryCardComponent], + imports: [ + ActionCardComponent, + ActionCardActionComponent, + FlatCardModule, + ProfileCardComponent, + GalleryCardComponent, + ContentButtonComponent, + ], + exports: [ + ActionCardComponent, + ActionCardActionComponent, + FlatCardModule, + ProfileCardComponent, + GalleryCardComponent, + ContentButtonComponent, + ], }) export class CardsModule {} diff --git a/libs/design-system/content-templates/content-page/src/lib/types/content-page.schema.ts b/libs/design-system/content-templates/content-page/src/lib/types/content-page.schema.ts index d0d22e6ad3..79aba583d6 100644 --- a/libs/design-system/content-templates/content-page/src/lib/types/content-page.schema.ts +++ b/libs/design-system/content-templates/content-page/src/lib/types/content-page.schema.ts @@ -1,4 +1,5 @@ import { ProjectedContentTemplateSchema } from '@hra-ui/cdk/content-template'; +import { BreadcrumbItemSchema } from '@hra-ui/design-system/buttons/breadcrumbs'; import { IconListSchema } from '@hra-ui/design-system/icons'; import * as z from 'zod'; @@ -12,6 +13,7 @@ export const ContentPageDataSchema = z title: z.string(), subtitle: z.string(), icons: IconListSchema.optional(), + breadcrumbs: z.array(BreadcrumbItemSchema).optional(), action: z .object({ label: z.string(), diff --git a/libs/design-system/content-templates/list-view/src/lib/list-view.component.scss b/libs/design-system/content-templates/list-view/src/lib/list-view.component.scss index c61d50e08e..28bd9c208c 100644 --- a/libs/design-system/content-templates/list-view/src/lib/list-view.component.scss +++ b/libs/design-system/content-templates/list-view/src/lib/list-view.component.scss @@ -17,8 +17,9 @@ } .content { - @include utils.use-font(body, xl); + overflow-wrap: break-word; color: vars.$primary; + @include utils.use-font(body, xl); ::ng-deep p { margin-bottom: 0; diff --git a/libs/design-system/content-templates/page-label/src/lib/page-label.component.html b/libs/design-system/content-templates/page-label/src/lib/page-label.component.html index b7b1cc9a52..39ea88b65c 100644 --- a/libs/design-system/content-templates/page-label/src/lib/page-label.component.html +++ b/libs/design-system/content-templates/page-label/src/lib/page-label.component.html @@ -46,7 +46,7 @@

{{ tagline() }}

@for (tag of tags(); track $index) { - {{ tag }} + {{ tag }} }
diff --git a/libs/design-system/content-templates/page-label/src/lib/page-label.component.ts b/libs/design-system/content-templates/page-label/src/lib/page-label.component.ts index e26b5d4a08..35b92ad864 100644 --- a/libs/design-system/content-templates/page-label/src/lib/page-label.component.ts +++ b/libs/design-system/content-templates/page-label/src/lib/page-label.component.ts @@ -2,13 +2,21 @@ import { ChangeDetectionStrategy, Component, input, numberAttribute } from '@ang import { MatChipsModule } from '@angular/material/chips'; import { HraCommonModule } from '@hra-ui/common'; import { BreadcrumbItem, BreadcrumbsComponent } from '@hra-ui/design-system/buttons/breadcrumbs'; +import { ChipSizeDirective } from '@hra-ui/design-system/chips'; import { SectionLinkComponent } from '@hra-ui/design-system/content-templates/section-link'; import { coerceIconList, IconsModule } from '@hra-ui/design-system/icons'; /** Label for a page section. Can also be used standalone */ @Component({ selector: 'hra-page-label', - imports: [HraCommonModule, IconsModule, SectionLinkComponent, BreadcrumbsComponent, MatChipsModule], + imports: [ + HraCommonModule, + IconsModule, + SectionLinkComponent, + BreadcrumbsComponent, + MatChipsModule, + ChipSizeDirective, + ], templateUrl: './page-label.component.html', styleUrl: './page-label.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/libs/design-system/content-templates/page-section/src/lib/page-section.component.stories.ts b/libs/design-system/content-templates/page-section/src/lib/page-section.component.stories.ts index 70c12ce0cb..09f9a6a70a 100644 --- a/libs/design-system/content-templates/page-section/src/lib/page-section.component.stories.ts +++ b/libs/design-system/content-templates/page-section/src/lib/page-section.component.stories.ts @@ -17,6 +17,10 @@ const meta: Meta = { level: 1, anchor: 'page-label', icons: 'product:apps', + breadcrumbs: [ + { name: 'Home', route: '/' }, + { name: 'Section', route: '/section' }, + ], }, decorators: [ moduleMetadata({ @@ -31,7 +35,7 @@ type Story = StoryObj; export const Default: Story = { render: (args) => ({ props: args, - template: ` + template: ` This is placeholder text. We should try to keep this short. When writing content, imagine you've never been to the HRA before. What would you want to learn here?
  • Components may be swapped out for this button set
  • diff --git a/libs/design-system/content-templates/page-section/src/lib/types/page-section.schema.ts b/libs/design-system/content-templates/page-section/src/lib/types/page-section.schema.ts index f8cb5bcc28..e43eb29d83 100644 --- a/libs/design-system/content-templates/page-section/src/lib/types/page-section.schema.ts +++ b/libs/design-system/content-templates/page-section/src/lib/types/page-section.schema.ts @@ -1,4 +1,5 @@ import { ContentTemplateSchema, ProjectedContentTemplateSchema } from '@hra-ui/cdk/content-template'; +import { BreadcrumbItemSchema } from '@hra-ui/design-system/buttons/breadcrumbs'; import { IconListSchema } from '@hra-ui/design-system/icons'; import * as z from 'zod'; @@ -9,5 +10,6 @@ export const PageSectionSchema = ContentTemplateSchema.extend({ level: z.number().int().gte(1).lte(6).optional(), icons: IconListSchema.optional(), anchor: z.string().optional(), + breadcrumbs: z.array(BreadcrumbItemSchema).optional(), content: ProjectedContentTemplateSchema, }).meta({ id: 'PageSection' }); diff --git a/libs/design-system/content-templates/section-link/src/lib/section-link.component.html b/libs/design-system/content-templates/section-link/src/lib/section-link.component.html index ff8345ce1d..f42e0dbc8d 100644 --- a/libs/design-system/content-templates/section-link/src/lib/section-link.component.html +++ b/libs/design-system/content-templates/section-link/src/lib/section-link.component.html @@ -1,12 +1,12 @@ @if (anchor(); as fragment) { - + link } -
    +
    diff --git a/libs/design-system/content-templates/section-link/src/lib/section-link.component.scss b/libs/design-system/content-templates/section-link/src/lib/section-link.component.scss index 283f6182f6..00d2670265 100644 --- a/libs/design-system/content-templates/section-link/src/lib/section-link.component.scss +++ b/libs/design-system/content-templates/section-link/src/lib/section-link.component.scss @@ -58,6 +58,7 @@ $level-configs: ( visibility: hidden; } + &:focus .icon, &:focus-visible .icon { visibility: visible; } diff --git a/libs/design-system/content-templates/section-link/src/lib/section-link.component.ts b/libs/design-system/content-templates/section-link/src/lib/section-link.component.ts index b7345391b7..ffa1957d91 100644 --- a/libs/design-system/content-templates/section-link/src/lib/section-link.component.ts +++ b/libs/design-system/content-templates/section-link/src/lib/section-link.component.ts @@ -1,8 +1,9 @@ import { CommonModule } from '@angular/common'; -import { booleanAttribute, ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { booleanAttribute, ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import { MatDividerModule } from '@angular/material/divider'; import { MatIconModule } from '@angular/material/icon'; import { FragmentLinkDirective } from '@hra-ui/common/router-ext'; +import { stripLeadingHash } from '@hra-ui/common/url'; import { ButtonsModule } from '@hra-ui/design-system/buttons'; /** @@ -24,4 +25,10 @@ export class SectionLinkComponent { /** Whether to display the underline */ readonly underlined = input(false, { transform: booleanAttribute }); + + /** Label id */ + protected readonly labelId = computed(() => { + const anchor = this.anchor(); + return anchor ? `section-link-label--${stripLeadingHash(anchor)}` : null; + }); } diff --git a/libs/design-system/content-templates/venues-table/ng-package.json b/libs/design-system/content-templates/venues-table/ng-package.json new file mode 100644 index 0000000000..c781f0df46 --- /dev/null +++ b/libs/design-system/content-templates/venues-table/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/design-system/content-templates/venues-table/src/index.ts b/libs/design-system/content-templates/venues-table/src/index.ts new file mode 100644 index 0000000000..94523c1540 --- /dev/null +++ b/libs/design-system/content-templates/venues-table/src/index.ts @@ -0,0 +1,3 @@ +export * from './lib/venues-table.component'; +export { VenuesTableDef } from './lib/types/venues-table.definition'; +export { VenuesTable, VenueDataSchema, VenuesTableSchema } from './lib/types/venues-table.schema'; diff --git a/libs/design-system/content-templates/venues-table/src/lib/types/venues-table.definition.ts b/libs/design-system/content-templates/venues-table/src/lib/types/venues-table.definition.ts new file mode 100644 index 0000000000..a206d13bf9 --- /dev/null +++ b/libs/design-system/content-templates/venues-table/src/lib/types/venues-table.definition.ts @@ -0,0 +1,9 @@ +import { ContentTemplateDef } from '@hra-ui/cdk/content-template'; +import { VenuesTableComponent } from '../venues-table.component'; +import { VenuesTableSchema } from './venues-table.schema'; + +/** Content template definition for VenuesTableComponent */ +export const VenuesTableDef: ContentTemplateDef = { + component: VenuesTableComponent, + spec: VenuesTableSchema, +}; diff --git a/libs/design-system/content-templates/venues-table/src/lib/types/venues-table.schema.ts b/libs/design-system/content-templates/venues-table/src/lib/types/venues-table.schema.ts new file mode 100644 index 0000000000..454a29b49d --- /dev/null +++ b/libs/design-system/content-templates/venues-table/src/lib/types/venues-table.schema.ts @@ -0,0 +1,42 @@ +import { ContentTemplateSchema } from '@hra-ui/cdk/content-template'; +import { PageTableSchema } from '@hra-ui/design-system/table'; +import * as z from 'zod'; + +/** Type for a single venue item */ +export type VenueItem = z.infer; + +/** Venue item schema */ +export const VenueItemSchema = z + .object({ + dateStart: z.coerce.date(), + dateEnd: z.coerce.date().optional(), + title: z.string(), + venue: z.string().nullish(), + organizer: z.string().nullish(), + credit: z.string().nullish(), + city: z.string().nullish(), + state: z.string().nullish(), + country: z.string().nullish(), + pdfLink: z.string().nullish(), + websiteUrl: z.string().optional(), + venueImages: z.array(z.object({ sm: z.string().optional(), lg: z.string().optional() })).nullish(), + }) + .meta({ id: 'VenueItem' }); + +/** Type for the Venue data array */ +export type VenueData = z.infer; + +/** Venue data schema (array of items) */ +export const VenueDataSchema = z.array(VenueItemSchema); + +/** Type for Venues Table */ +export type VenuesTable = z.infer; + +/** Schema for Venues Table */ +export const VenuesTableSchema = ContentTemplateSchema.merge(PageTableSchema) + .extend({ + component: z.literal('VenuesTable'), + venuesUrl: z.string(), + linkBaseHref: z.string().optional(), + }) + .meta({ id: 'VenuesTable' }); diff --git a/libs/design-system/content-templates/venues-table/src/lib/venues-table.component.html b/libs/design-system/content-templates/venues-table/src/lib/venues-table.component.html new file mode 100644 index 0000000000..094d3eb834 --- /dev/null +++ b/libs/design-system/content-templates/venues-table/src/lib/venues-table.component.html @@ -0,0 +1,8 @@ + diff --git a/libs/design-system/content-templates/venues-table/src/lib/venues-table.component.scss b/libs/design-system/content-templates/venues-table/src/lib/venues-table.component.scss new file mode 100644 index 0000000000..0cb7fa3056 --- /dev/null +++ b/libs/design-system/content-templates/venues-table/src/lib/venues-table.component.scss @@ -0,0 +1,52 @@ +@use '@angular/material' as mat; +@use '../../../../styles/vars'; + +:host { + ::ng-deep hra-table { + --hra-table-max-height: 26.125rem; + border-color: vars.$outline; + border-radius: 0.5rem; + + @include mat.table-overrides( + ( + header-headline-color: vars.$on-surface, + row-item-label-text-color: vars.$on-surface-variant, + ) + ); + + thead { + tr { + background-color: vars.$surface-container-highest; + } + } + + .mat-column-date { + min-width: 8rem; + max-width: 9.75rem; + } + + .mat-column-event { + min-width: 10rem; + max-width: 30.25rem; + } + + .mat-column-location { + min-width: 8.75rem; + max-width: 11.25rem; + } + + .mat-column-contact { + min-width: 10rem; + max-width: 13.5rem; + } + + .mat-column-links { + min-width: 8rem; + max-width: 11.25rem; + + a { + color: inherit; + } + } + } +} diff --git a/libs/design-system/content-templates/venues-table/src/lib/venues-table.component.ts b/libs/design-system/content-templates/venues-table/src/lib/venues-table.component.ts new file mode 100644 index 0000000000..47ff48bb84 --- /dev/null +++ b/libs/design-system/content-templates/venues-table/src/lib/venues-table.component.ts @@ -0,0 +1,138 @@ +import { HttpClient, httpResource } from '@angular/common/http'; +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { assetUrl } from '@hra-ui/common/url'; +import { TableColumn, TableComponent, TableRow } from '@hra-ui/design-system/table'; +import { VenueData, VenueDataSchema, VenueItem } from './types/venues-table.schema'; + +/** + * Component to display a table of venues for Scimaps exhibit + */ +@Component({ + selector: 'hra-venues-table', + imports: [TableComponent], + templateUrl: './venues-table.component.html', + styleUrl: './venues-table.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VenuesTableComponent { + /** HttpClient for making API requests */ + readonly http = inject(HttpClient); + + /** URL to fetch venues data from */ + readonly venuesUrl = input.required(); + + /** Base href for links in the table (e.g. website, photo gallery, PDF) */ + readonly linkBaseHref = input(); + + /** Columns for the venues table */ + protected readonly columns: TableColumn[] = [ + { + column: 'date', + label: 'Date', + type: 'date', + }, + { + column: 'event', + label: 'Event', + type: 'markdown', + }, + { + column: 'location', + label: 'Location', + type: 'text', + }, + { + column: 'contact', + label: 'Contact', + type: 'text', + }, + { + column: 'links', + label: 'Links', + type: 'markdown', + }, + ]; + + /** Resource to fetch venues data */ + protected readonly venuesData = httpResource(assetUrl(this.venuesUrl), { + parse: (data) => VenueDataSchema.parse(data), + defaultValue: [], + }); + + /** Table rows computed from the venues data */ + protected readonly rows = computed(() => this.convertToTableRows(this.venuesData.value())); + + /** + * Converts venues data to table rows + * @param venues Venues data to convert + * @returns Table rows generated from the venues data + */ + private convertToTableRows(venues: VenueData): TableRow[] { + return venues.map((venue) => ({ + date: venue.dateStart, + event: venue.title.replace(/Places\s*&\s*Spaces/g, '*Places & Spaces*'), + location: [venue.city, venue.state, venue.country].filter((s) => !!s).join(', '), + contact: venue.organizer || '', + links: this.createLinks(venue), + })); + } + + /** + * Formats a date as a segmented string (YYYY/MM-DD) for use in URLs + * @param date Date to format + * @returns Formatted date string in the format YYYY/MM-DD + */ + private getSegmentedDate(date: Date): string { + const year = date.getUTCFullYear(); + const day = String(date.getUTCDate()).padStart(2, '0'); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + return `${year}/${month}-${day}`; + } + + /** + * Creates markdown links for the venue based on available data (website, photo gallery, PDF) + * @param venue Venue item to create links for + * @returns Markdown string containing links for the venue (website, photo gallery, PDF) based on available data + */ + private createLinks(venue: VenueItem): string { + const links = []; + if (venue.websiteUrl) { + links.push(`[Website](${venue.websiteUrl})`); + } + if (venue.venueImages) { + links.push(`[Photo gallery](${this.buildLinkUrl('venues/gallery', venue.dateStart, venue.title, '')})`); + } + if (venue.pdfLink) { + links.push(`[PDF](${this.buildLinkUrl('assets/content/venues', venue.dateStart, venue.title, venue.pdfLink)})`); + } + return links.join(' | '); + } + + private slugifyTitle(title: string): string { + return title + .toLowerCase() + .trim() + .replace(/\s+/g, '-') // Replace spaces with - + .replace(/&/g, 'and') // Replace '&' with 'and' + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') // Strip diacritics after transliteration + .replace(/[^\w-]+/g, '') // Remove all non-word chars + .replace(/--+/g, '-') // Replace multiple - with single - + .replace(/^-+/, '') // Trim - from start of text + .replace(/-+$/, ''); // Trim - from end of text + } + + /** + * Builds a link URL for the venue based on the provided path, date, title, and optional extra segment + * @param path Base path for the link (e.g. 'venues/gallery' or 'assets/content/venues') + * @param date Date of the venue, used to create a segmented date string for the URL + * @param title Title of the venue, used to create a slugified segment for the URL + * @param [extra] Optional extra segment to append to the URL (e.g. PDF filename) + * @returns Constructed URL string combining the base href, path, segmented date, slugified title, and optional extra segment + */ + private buildLinkUrl(path: string, date: Date, title: string, extra?: string): string { + const dateSegment = this.getSegmentedDate(date); + const titleSegment = this.slugifyTitle(title); + return [this.linkBaseHref(), path, dateSegment, titleSegment, extra].filter((s) => !!s).join('/'); + } +} diff --git a/libs/design-system/content-templates/versioned-data-table/src/lib/types/versioned-data-table.schema.ts b/libs/design-system/content-templates/versioned-data-table/src/lib/types/versioned-data-table.schema.ts index 34c0863d8a..8de85f7858 100644 --- a/libs/design-system/content-templates/versioned-data-table/src/lib/types/versioned-data-table.schema.ts +++ b/libs/design-system/content-templates/versioned-data-table/src/lib/types/versioned-data-table.schema.ts @@ -38,6 +38,7 @@ export type VersionedDataTable = z.infer; export const VersionedDataTableSchema = ContentTemplateSchema.merge( PageTableSchema.pick({ columns: true, + rows: true, variant: true, enableSort: true, verticalDividers: true, diff --git a/libs/design-system/error-pages/not-found-page/src/lib/not-found-page.component.html b/libs/design-system/error-pages/not-found-page/src/lib/not-found-page.component.html index 1d7347e8d1..d92ed3436e 100644 --- a/libs/design-system/error-pages/not-found-page/src/lib/not-found-page.component.html +++ b/libs/design-system/error-pages/not-found-page/src/lib/not-found-page.component.html @@ -1,4 +1,4 @@ -
    This page isn't available
    +
    This page isn’t available
    Check the URL or start fresh from the homepage:
    Go to homepage diff --git a/libs/design-system/error-pages/server-error-page/src/lib/server-error-page.component.html b/libs/design-system/error-pages/server-error-page/src/lib/server-error-page.component.html index 8c213ad9f8..090957f665 100644 --- a/libs/design-system/error-pages/server-error-page/src/lib/server-error-page.component.html +++ b/libs/design-system/error-pages/server-error-page/src/lib/server-error-page.component.html @@ -1,12 +1,12 @@
    Oops! We’re having a moment.
    Try refreshing the page or report the issue if it continues.
    diff --git a/libs/design-system/error-pages/server-error-page/src/lib/server-error-page.component.ts b/libs/design-system/error-pages/server-error-page/src/lib/server-error-page.component.ts index f1e21d6d01..fd14922008 100644 --- a/libs/design-system/error-pages/server-error-page/src/lib/server-error-page.component.ts +++ b/libs/design-system/error-pages/server-error-page/src/lib/server-error-page.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; -import { RouterModule } from '@angular/router'; import { HraCommonModule } from '@hra-ui/common'; +import { RouterExtModule } from '@hra-ui/common/router-ext'; import { ButtonsModule } from '@hra-ui/design-system/buttons'; /** @@ -10,7 +10,7 @@ import { ButtonsModule } from '@hra-ui/design-system/buttons'; */ @Component({ selector: 'hra-server-error-page', - imports: [HraCommonModule, ButtonsModule, MatIconModule, RouterModule], + imports: [HraCommonModule, ButtonsModule, MatIconModule, RouterExtModule], templateUrl: './server-error-page.component.html', styleUrl: './server-error-page.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/libs/design-system/filter-container/src/lib/filter-container.component.html b/libs/design-system/filter-container/src/lib/filter-container.component.html index 16f7effcfd..cc396e93e8 100644 --- a/libs/design-system/filter-container/src/lib/filter-container.component.html +++ b/libs/design-system/filter-container/src/lib/filter-container.component.html @@ -9,29 +9,32 @@
    } - + {{ action() }} +
    {{ totalCount() | number }}
@if (chips().length > 0) { - + @for (chip of chips(); track chip.label) { - + {{ chip.label }} - } diff --git a/libs/design-system/filter-container/src/lib/filter-container.component.scss b/libs/design-system/filter-container/src/lib/filter-container.component.scss index 52e4252160..c07fd6e322 100644 --- a/libs/design-system/filter-container/src/lib/filter-container.component.scss +++ b/libs/design-system/filter-container/src/lib/filter-container.component.scss @@ -13,7 +13,8 @@ .top-row { display: flex; align-items: center; - color: vars.$secondary; + justify-content: flex-start; + color: vars.$on-surface; .info-icon { padding: 0.5rem; @@ -23,21 +24,22 @@ .category-button { flex: 0 1 auto; min-width: fit-content; - width: 100%; + width: auto; justify-content: flex-start; + border: none; - @include mat.button-overrides( + @include mat.button-toggle-overrides( ( - text-horizontal-padding: 0.75rem, - text-label-text-font: vars.$label-medium-font, - text-label-text-size: vars.$label-medium-size, - text-label-text-tracking: vars.$label-medium-tracking, - text-label-text-weight: vars.$label-medium-weight, + label-text-font: vars.$body-medium-font, + label-text-size: vars.$body-medium-size, + label-text-tracking: vars.$body-medium-tracking, + label-text-weight: vars.$body-medium-weight, ) ); } .count { + margin-left: auto; color: vars.$primary; @include utils.use-font(label, small); } @@ -49,17 +51,6 @@ gap: 0.25rem; mat-chip { - @include mat.chips-overrides( - ( - label-text-font: vars.$label-medium-font, - label-text-line-height: vars.$label-medium-line-height, - label-text-size: vars.$label-medium-size, - label-text-tracking: vars.$label-medium-tracking, - label-text-weight: vars.$label-medium-weight, - container-height: 2rem, - ) - ); - button[matChipRemove] { mat-icon { font-size: 1.25rem; diff --git a/libs/design-system/filter-container/src/lib/filter-container.component.stories.ts b/libs/design-system/filter-container/src/lib/filter-container.component.stories.ts index f9cd1cef90..b2c78e667a 100644 --- a/libs/design-system/filter-container/src/lib/filter-container.component.stories.ts +++ b/libs/design-system/filter-container/src/lib/filter-container.component.stories.ts @@ -44,6 +44,7 @@ export const Default: Story = { render: (args) => ({ props: args, template: ``, + styles: [`.hra-app {width: 20rem;}`], }), }; @@ -60,6 +61,7 @@ export const WithInfoButton: Story = { `, + styles: [`.hra-app {width: 20rem;}`], }), }; @@ -76,6 +78,7 @@ export const WithChipsAndInfo: Story = { `, + styles: [`.hra-app {width: 20rem;}`], }), args: { action: 'Category', @@ -95,6 +98,7 @@ export const WithDivider: Story = { `, + styles: [`.hra-app {width: 20rem;}`], }), args: { action: 'Category', diff --git a/libs/design-system/filter-container/src/lib/filter-container.component.ts b/libs/design-system/filter-container/src/lib/filter-container.component.ts index 5f8cdbe7af..7deb4d30dd 100644 --- a/libs/design-system/filter-container/src/lib/filter-container.component.ts +++ b/libs/design-system/filter-container/src/lib/filter-container.component.ts @@ -1,4 +1,5 @@ import { booleanAttribute, ChangeDetectionStrategy, Component, input, model, output } from '@angular/core'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatChipsModule } from '@angular/material/chips'; import { MatDividerModule } from '@angular/material/divider'; import { MatIconModule } from '@angular/material/icon'; @@ -9,6 +10,7 @@ import { InfoButtonComponent, InfoButtonTaglineDirective, } from '@hra-ui/design-system/buttons/info-button'; +import { ChipSizeDirective } from '@hra-ui/design-system/chips'; import { PlainTooltipDirective } from '@hra-ui/design-system/tooltips/plain-tooltip'; /** A filter chip representing a selected filter option */ @@ -25,13 +27,15 @@ export interface FilterChip { imports: [ HraCommonModule, ButtonsModule, - MatIconModule, + MatButtonToggleModule, MatChipsModule, MatDividerModule, + MatIconModule, InfoButtonComponent, InfoButtonTaglineDirective, InfoButtonActionsDirective, PlainTooltipDirective, + ChipSizeDirective, ], templateUrl: './filter-container.component.html', styleUrl: './filter-container.component.scss', @@ -44,6 +48,9 @@ export class FilterContainerComponent { /** Total count of filter options in the category */ readonly totalCount = input(); + /** Whether the filter container is active/open */ + readonly active = model(false); + /** Whether to show the info button with tooltip */ readonly showTooltip = input(false, { transform: booleanAttribute }); diff --git a/libs/design-system/filter-menu/src/lib/filter-menu.component.html b/libs/design-system/filter-menu/src/lib/filter-menu.component.html index a495e870dc..f9b9d74d5d 100644 --- a/libs/design-system/filter-menu/src/lib/filter-menu.component.html +++ b/libs/design-system/filter-menu/src/lib/filter-menu.component.html @@ -23,7 +23,6 @@
- Customize
@@ -31,20 +30,21 @@
-
+
- - Filters + Filter
@for (filter of filtersWithCounts(); track $index) { @@ -60,8 +60,11 @@ (detach)="closeFilterMenu(filter)" > diff --git a/libs/design-system/filter-menu/src/lib/filter-menu.component.scss b/libs/design-system/filter-menu/src/lib/filter-menu.component.scss index 3833d17c5b..8619d04044 100644 --- a/libs/design-system/filter-menu/src/lib/filter-menu.component.scss +++ b/libs/design-system/filter-menu/src/lib/filter-menu.component.scss @@ -6,29 +6,33 @@ display: flex; flex-direction: column; height: 100%; - width: 20rem; - color: vars.$secondary; + color: vars.$on-surface; gap: 1.5rem; + .tagline { + .title { + @include utils.use-font(title, medium); + } + } + .header { display: flex; align-items: center; margin-bottom: 1rem; + + .title { + padding: 0.5rem 0; + @include utils.use-font(title, medium); + } } .icon { padding: 0.5rem; } - .title { - padding: 0.5rem 0; - @include utils.use-font(title, small); - } - .section { padding-right: 1.5rem; padding-left: 1.5rem; - width: 20rem; } .intro { diff --git a/libs/design-system/filter-menu/src/lib/filter-menu.component.stories.ts b/libs/design-system/filter-menu/src/lib/filter-menu.component.stories.ts index dddffb739b..ca69b436c2 100644 --- a/libs/design-system/filter-menu/src/lib/filter-menu.component.stories.ts +++ b/libs/design-system/filter-menu/src/lib/filter-menu.component.stories.ts @@ -75,6 +75,7 @@ const CUSTOM_CONTROLS = ` const STYLES = ` .hra-app { height: 100vh; + width: 20rem; } .toggle-group { width: fit-content; diff --git a/libs/design-system/filter-menu/src/lib/filter-menu.component.ts b/libs/design-system/filter-menu/src/lib/filter-menu.component.ts index 49f1f93236..5bf1f6b2d8 100644 --- a/libs/design-system/filter-menu/src/lib/filter-menu.component.ts +++ b/libs/design-system/filter-menu/src/lib/filter-menu.component.ts @@ -20,11 +20,15 @@ export interface FilterOptionCategory { selected?: T[]; /** Total count */ totalCount?: number; + /** Whether search should be disabled for this filter */ + disableSearch?: boolean; } /** Position of the filter menu overlay */ const FILTER_MENU_POSITIONS: ConnectedPosition[] = [ { originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top', offsetX: 16 }, + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 16 }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -16 }, ]; /** @@ -62,6 +66,9 @@ export class FilterMenuComponent { /** List of all filters with options */ readonly filters = model.required[]>(); + /** Counts for each filter option */ + readonly counts = input[]>(); + /** Emits when the form opening state is toggled */ readonly closeClick = output(); @@ -95,6 +102,14 @@ export class FilterMenuComponent { this.filters.update((filters) => filters.map((filter) => (filter.id === category.id ? updated : filter))); } + /** + * Toggles filter menu open/close + * @param category Filter category to toggle + */ + toggleFilterMenu(category?: FilterOptionCategory): void { + this.activeFilter.update((current) => (current === category ? undefined : category)); + } + /** * Closes filter menu * @param category Filter category to close diff --git a/libs/design-system/gallery-grid/src/lib/gallery-grid.component.scss b/libs/design-system/gallery-grid/src/lib/gallery-grid.component.scss index cc22b64ca8..db6180d00e 100644 --- a/libs/design-system/gallery-grid/src/lib/gallery-grid.component.scss +++ b/libs/design-system/gallery-grid/src/lib/gallery-grid.component.scss @@ -1,6 +1,6 @@ :host { display: grid; - grid-template-columns: repeat(auto-fill, minmax(16.25rem, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); gap: 1.5rem; justify-items: center; } diff --git a/libs/design-system/indicators/end-of-results/src/lib/end-of-results-indicator.component.scss b/libs/design-system/indicators/end-of-results/src/lib/end-of-results-indicator.component.scss index 281063cbe4..eecb073319 100644 --- a/libs/design-system/indicators/end-of-results/src/lib/end-of-results-indicator.component.scss +++ b/libs/design-system/indicators/end-of-results/src/lib/end-of-results-indicator.component.scss @@ -26,10 +26,5 @@ @media (max-width: 39.9375rem) { flex-direction: column; gap: 0.75rem; - - .results-count, - .end-message { - width: 100%; - } } } diff --git a/libs/design-system/indicators/no-results-indicator/src/lib/no-results-indicator.component.html b/libs/design-system/indicators/no-results-indicator/src/lib/no-results-indicator.component.html index 9ad3a93e0a..9f6dfda7bd 100644 --- a/libs/design-system/indicators/no-results-indicator/src/lib/no-results-indicator.component.html +++ b/libs/design-system/indicators/no-results-indicator/src/lib/no-results-indicator.component.html @@ -1,2 +1,2 @@
No results. Adjust filters or search again.
- + diff --git a/libs/design-system/indicators/results-indicator/src/lib/results-indicator.component.scss b/libs/design-system/indicators/results-indicator/src/lib/results-indicator.component.scss index ed77a57fa1..3a37f7c035 100644 --- a/libs/design-system/indicators/results-indicator/src/lib/results-indicator.component.scss +++ b/libs/design-system/indicators/results-indicator/src/lib/results-indicator.component.scss @@ -6,10 +6,11 @@ display: inline-flex; justify-content: center; align-items: center; + text-align: center; height: var(--hra-results-indicator-height); color: vars.$primary; padding: 0.5rem 0.75rem; border: 0.0625rem solid vars.$outline-variant; - border-radius: 0.25rem; + border-radius: 0.5rem; @include utils.use-font(label, small); } diff --git a/libs/design-system/jest.config.ts b/libs/design-system/jest.config.ts index 2a6b77b5ea..5f4ec1dd82 100644 --- a/libs/design-system/jest.config.ts +++ b/libs/design-system/jest.config.ts @@ -7,7 +7,7 @@ export default { coverageThreshold: { global: { statements: 73, - branches: 63, + branches: 62, lines: 72, functions: 71, }, diff --git a/libs/design-system/package.json b/libs/design-system/package.json index 47a9fcb46c..ea449fdf00 100644 --- a/libs/design-system/package.json +++ b/libs/design-system/package.json @@ -50,7 +50,7 @@ "@angular/forms": "^21.1.4", "@google/model-viewer": "4.1.0", "store2": "^2.14.4", - "type-fest": "^5.0.1" + "type-fest": "^5.3.1" }, "sideEffects": false } diff --git a/libs/design-system/privacy/consent-banner/src/lib/consent-banner.component.html b/libs/design-system/privacy/consent-banner/src/lib/consent-banner.component.html index d17bc45165..3991cc1026 100644 --- a/libs/design-system/privacy/consent-banner/src/lib/consent-banner.component.html +++ b/libs/design-system/privacy/consent-banner/src/lib/consent-banner.component.html @@ -1,41 +1,40 @@
+
-
-
- Manage your privacy preferences - Cookies and similar technologies are used to play videos and to improve this website. -
- +
+ Manage your privacy preferences + Cookies and similar technologies are used to play videos and to improve this website. +
+
+
diff --git a/libs/design-system/privacy/consent-banner/src/lib/consent-banner.component.scss b/libs/design-system/privacy/consent-banner/src/lib/consent-banner.component.scss index 172b292548..b4c50774bd 100644 --- a/libs/design-system/privacy/consent-banner/src/lib/consent-banner.component.scss +++ b/libs/design-system/privacy/consent-banner/src/lib/consent-banner.component.scss @@ -1,15 +1,16 @@ +@use '@angular/material' as mat; @use '../../../../styles/utils' as utils; @use '../../../../styles/vars' as vars; :host { - background-color: utils.with-alpha(vars.$surface-bright, 80%); display: flex; flex-direction: row; - justify-content: space-between; padding: 1.5rem; width: 100%; max-width: 100%; box-sizing: border-box; + background-color: vars.$surface-variant; + border-radius: vars.$corner-large vars.$corner-large 0 0; hra-brand-logo { --hra-brand-logo-text-color: vars.$secondary; @@ -20,33 +21,20 @@ flex-shrink: 0; } - .middle, - .right { - display: flex; - flex-direction: column; - justify-content: center; - } - .middle { - flex: 1; - min-width: 0; - align-self: flex-start; - - .content { - padding: 0 1.5rem; + padding: 0 1.5rem; - .text-area { - display: flex; - flex-direction: column; - row-gap: 1rem; - padding-bottom: 1.5rem; - .text1 { - @include utils.use-font(title, small); - } + .text-area { + display: flex; + flex-direction: column; + row-gap: 1rem; + padding-bottom: 1.5rem; + .text1 { + @include utils.use-font(title, small); + } - .text2 { - @include utils.use-font(label, large); - } + .text2 { + @include utils.use-font(label, large); } } @@ -57,12 +45,16 @@ display: flex; align-items: center; gap: 0.5rem; + width: fit-content; @include utils.use-font(label, large); } } } .right { + display: flex; + flex-direction: column; + justify-content: center; row-gap: 0.75rem; padding-left: 1rem; padding-right: 1.5rem; @@ -88,25 +80,22 @@ white-space: nowrap; } } + + .spacer { + flex-grow: 1; + } } -@media (min-width: 320px) and (max-width: 639px) { +@media (max-width: 639px) { :host { flex-direction: column; row-gap: 2rem; padding: 1.5rem; - .middle, - .right { - align-items: stretch; - } - .middle { - .content { - .text-area { - padding-bottom: 1rem; - gap: 1rem; - } + .text-area { + padding-bottom: 1rem; + gap: 1rem; } .hyperlink { @@ -125,6 +114,7 @@ min-width: auto; row-gap: 0.75rem; width: 100%; + align-items: stretch; .consent-buttons { width: 100%; @@ -149,22 +139,21 @@ .middle { min-width: 11.25rem; max-width: 44.75rem; - .content { - .text1 { - @include utils.use-font(title, small); - } + .text1 { + @include utils.use-font(title, small); + } - .text2 { - @include utils.use-font(label, large); - } + .text2 { + @include utils.use-font(label, large); } } } ::ng-deep .hra-consent-banner-panel { - border-radius: 1.75rem; - box-shadow: 0rem -0.75rem 2.5rem 0rem rgba(0, 0, 0, 0.1); + box-shadow: 0rem -1rem 2rem 2rem utils.with-alpha(vars.$tertiary, 24%); + backdrop-filter: blur(10rem); animation: slideUpFromBottom 600ms cubic-bezier(0.25, 0.8, 0.25, 1); + border-radius: vars.$corner-large vars.$corner-large 0 0; } @keyframes slideUpFromBottom { diff --git a/libs/design-system/privacy/consent-banner/src/lib/consent-banner.component.stories.ts b/libs/design-system/privacy/consent-banner/src/lib/consent-banner.component.stories.ts index d058a0409c..6b565c7ed5 100644 --- a/libs/design-system/privacy/consent-banner/src/lib/consent-banner.component.stories.ts +++ b/libs/design-system/privacy/consent-banner/src/lib/consent-banner.component.stories.ts @@ -29,10 +29,14 @@ export const CNSWebsite: Story = { version: '1.0.0', url: 'https://humanatlas.io/', }), - provideBrandLogos([ - { size: 'regular', src: 'assets/brand/logo/cns-regular.svg', width: 228, height: 39 }, - { size: 'small', src: 'assets/brand/logo/cns-small.svg', width: 84, height: 28 }, - ]), + provideBrandLogos({ + label: 'CNS Website', + url: 'https://cns.iu.edu', + logos: [ + { size: 'regular', src: 'assets/brand/logo/cns-regular.svg', width: 228, height: 39 }, + { size: 'small', src: 'assets/brand/logo/cns-small.svg', width: 84, height: 28 }, + ], + }), ], }), ], diff --git a/libs/design-system/privacy/consent-banner/src/lib/consent-banner.component.ts b/libs/design-system/privacy/consent-banner/src/lib/consent-banner.component.ts index 306e5a0232..306f35a933 100644 --- a/libs/design-system/privacy/consent-banner/src/lib/consent-banner.component.ts +++ b/libs/design-system/privacy/consent-banner/src/lib/consent-banner.component.ts @@ -1,9 +1,17 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, output } from '@angular/core'; import { MatDialogModule } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { HraCommonModule } from '@hra-ui/common'; +import { RouterExtModule } from '@hra-ui/common/router-ext'; import { BrandModule } from '@hra-ui/design-system/brand'; import { ButtonsModule } from '@hra-ui/design-system/buttons'; +import { createInjectionToken } from 'ngxtension/create-injection-token'; + +/** Consent banner configuration */ +export interface ConsentBannerConfig { + /** URL to the privacy policy */ + privacyPolicyUrl?: string; +} /** Result of the consent banner */ export type ConsentBannerResult = 'allow-all' | 'allow-necessary' | 'customize'; @@ -14,17 +22,41 @@ export const CONSENT_BANNER_PANEL_CLASS = 'hra-consent-banner-panel'; /** Aria labelledby id of consent banner component */ export const CONSENT_BANNER_ARIA_LABELLEDBY_ID = 'consentBannerDialogTitle'; +/** Default configuration for the consent banner */ +const DEFAULT_CONSENT_BANNER_CONFIG: Required = { + privacyPolicyUrl: 'https://humanatlas.io/privacy-policy', +}; + +/** Injection token for providing and injecting ConsentBannerConfig */ +const CONSENT_BANNER_CONFIG_TOKEN = createInjectionToken((): ConsentBannerConfig => DEFAULT_CONSENT_BANNER_CONFIG); + +/** Injection function for obtaining the consent banner configuration */ +export const injectConsentBannerConfig = CONSENT_BANNER_CONFIG_TOKEN[0]; + +/** Provider function for supplying new ConsentBannerConfig */ +export const provideConsentBannerConfig = CONSENT_BANNER_CONFIG_TOKEN[1]; + /** Consent Banner Component */ @Component({ selector: 'hra-consent-banner', - imports: [HraCommonModule, MatDialogModule, MatIconModule, BrandModule, ButtonsModule], + imports: [HraCommonModule, MatDialogModule, MatIconModule, BrandModule, ButtonsModule, RouterExtModule], templateUrl: './consent-banner.component.html', styleUrl: './consent-banner.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, + host: { + role: 'region', + 'aria-labelledby': CONSENT_BANNER_ARIA_LABELLEDBY_ID, + }, }) export class ConsentBannerComponent { + /** Emits when one of the actions is clicked */ + readonly buttonClick = output(); + /** * Aria labelledby id */ readonly ariaLabelledbyId = CONSENT_BANNER_ARIA_LABELLEDBY_ID; + + /** Banner configuration */ + protected readonly config = { ...DEFAULT_CONSENT_BANNER_CONFIG, ...injectConsentBannerConfig() }; } diff --git a/libs/design-system/privacy/privacy-preferences/src/lib/privacy-preferences.component.scss b/libs/design-system/privacy/privacy-preferences/src/lib/privacy-preferences.component.scss index 0b9427695e..c36cb75ad4 100644 --- a/libs/design-system/privacy/privacy-preferences/src/lib/privacy-preferences.component.scss +++ b/libs/design-system/privacy/privacy-preferences/src/lib/privacy-preferences.component.scss @@ -4,9 +4,7 @@ :host { display: block; - box-shadow: 0rem 0.3125rem 1rem 0rem utils.with-alpha(vars.$on-background, 24%); - border-radius: 0.5rem; - background-color: vars.$surface-container-low; + background-color: vars.$surface-container-high; .header { display: flex; @@ -16,10 +14,6 @@ button[mat-icon-button] { color: vars.$on-surface; - - mat-icon { - color: inherit; - } } } @@ -82,3 +76,11 @@ } } } + +::ng-deep .hra-privacy-preferences-panel { + @include mat.dialog-overrides( + ( + container-elevation-shadow: 0rem 0.75rem 2rem 1.5rem utils.with-alpha(vars.$tertiary, 24%), + ) + ); +} diff --git a/libs/design-system/privacy/src/lib/privacy-preferences.service.spec.ts b/libs/design-system/privacy/src/lib/privacy-preferences.service.spec.ts index 580ce94ae7..8c6ab31f55 100644 --- a/libs/design-system/privacy/src/lib/privacy-preferences.service.spec.ts +++ b/libs/design-system/privacy/src/lib/privacy-preferences.service.spec.ts @@ -1,9 +1,11 @@ +import { Overlay, OverlayRef } from '@angular/cdk/overlay'; import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { ConsentService } from '@hra-ui/common/analytics'; import { EventCategory } from '@hra-ui/common/analytics/events'; -import { of } from 'rxjs'; +import { ConsentBannerResult } from '@hra-ui/design-system/privacy/consent-banner'; +import { of, ReplaySubject } from 'rxjs'; import { PrivacyPreferencesService } from './privacy-preferences.service'; // Mock store2 @@ -13,6 +15,11 @@ jest.mock('store2', () => ({ get: jest.fn(), set: jest.fn(), }, + session: { + has: jest.fn(), + get: jest.fn(), + set: jest.fn(), + }, })); interface MockStore { @@ -21,6 +28,11 @@ interface MockStore { get: jest.Mock; set: jest.Mock; }; + session: { + has: jest.Mock; + get: jest.Mock; + set: jest.Mock; + }; } describe('PrivacyPreferencesService', () => { @@ -28,6 +40,7 @@ describe('PrivacyPreferencesService', () => { let mockDialog: jest.Mocked; let mockConsentService: jest.Mocked; let mockStore: MockStore; + let consentBannerResult: ReplaySubject; const mockCategories = { [EventCategory.Necessary]: true, @@ -38,6 +51,33 @@ describe('PrivacyPreferencesService', () => { beforeEach(async () => { mockStore = (await import('store2')).default as unknown as MockStore; + consentBannerResult = new ReplaySubject(1); + + const mockComponentRef = { + instance: { + buttonClick: consentBannerResult, + }, + }; + + const mockOverlayRef = { + attach: jest.fn(() => mockComponentRef), + detach: jest.fn(), + }; + + const mockGlobalPositionStrategy = { + bottom: jest.fn().mockReturnThis(), + left: jest.fn().mockReturnThis(), + right: jest.fn().mockReturnThis(), + }; + + const mockPositionBuilder = { + global: jest.fn(() => mockGlobalPositionStrategy), + }; + + const mockOverlay = { + create: jest.fn(() => mockOverlayRef as unknown as OverlayRef), + position: jest.fn(() => mockPositionBuilder), + }; const mockDialogRef = { afterClosed: jest.fn(() => of('allow-all')), @@ -58,6 +98,7 @@ describe('PrivacyPreferencesService', () => { TestBed.configureTestingModule({ providers: [ PrivacyPreferencesService, + { provide: Overlay, useValue: mockOverlay }, { provide: MatDialog, useValue: mockDialogService }, { provide: ConsentService, useValue: mockConsent }, ], @@ -135,47 +176,27 @@ describe('PrivacyPreferencesService', () => { describe('openConsentBanner', () => { it('should not open banner if dialog is already active', () => { - mockDialog.getDialogById.mockReturnValue({} as MatDialogRef); - service.openConsentBanner(); - - expect(mockDialog.open).not.toHaveBeenCalled(); - }); - - it('should open consent banner dialog with correct configuration', () => { - mockDialog.getDialogById.mockReturnValue(undefined); - service.openConsentBanner(); - expect(mockDialog.open).toHaveBeenCalledWith( - expect.any(Function), - expect.objectContaining({ - id: 'consentBannerDialog', - disableClose: true, - hasBackdrop: false, - }), - ); + expect(TestBed.inject(Overlay).create).toHaveBeenCalledTimes(1); }); }); describe('openPrivacyPreferences', () => { it('should not open dialog if one is already active', () => { - mockDialog.getDialogById.mockReturnValue({} as MatDialogRef); - + service.openPrivacyPreferences(); service.openPrivacyPreferences(); - expect(mockDialog.open).not.toHaveBeenCalled(); + expect(mockDialog.open).toHaveBeenCalledTimes(1); }); it('should open privacy preferences dialog with correct configuration', () => { - mockDialog.getDialogById.mockReturnValue(undefined); - service.openPrivacyPreferences('manage'); expect(mockDialog.open).toHaveBeenCalledWith( expect.any(Function), expect.objectContaining({ - id: 'privacyPreferencesDialog', data: expect.objectContaining({ tab: 'manage', }), @@ -205,14 +226,10 @@ describe('PrivacyPreferencesService', () => { describe('handleDialogResult', () => { beforeEach(() => { jest.clearAllMocks(); - mockDialog.getDialogById.mockReturnValue(undefined); }); it('should handle "allow-all" result', () => { - const mockDialogRef = { - afterClosed: jest.fn(() => of('allow-all')), - } as unknown as MatDialogRef; - mockDialog.open.mockReturnValue(mockDialogRef); + consentBannerResult.next('allow-all'); jest.spyOn(service, 'enableSync'); service.openConsentBanner(); @@ -222,10 +239,7 @@ describe('PrivacyPreferencesService', () => { }); it('should handle "allow-necessary" result', () => { - const mockDialogRef = { - afterClosed: jest.fn(() => of('allow-necessary')), - } as unknown as MatDialogRef; - mockDialog.open.mockReturnValue(mockDialogRef); + consentBannerResult.next('allow-necessary'); jest.spyOn(service, 'enableSync'); service.openConsentBanner(); @@ -235,10 +249,7 @@ describe('PrivacyPreferencesService', () => { }); it('should handle "customize" result', () => { - const mockDialogRef = { - afterClosed: jest.fn(() => of('customize')), - } as unknown as MatDialogRef; - mockDialog.open.mockReturnValue(mockDialogRef); + consentBannerResult.next('customize'); jest.spyOn(service, 'openPrivacyPreferences'); service.openConsentBanner(); diff --git a/libs/design-system/privacy/src/lib/privacy-preferences.service.ts b/libs/design-system/privacy/src/lib/privacy-preferences.service.ts index d9e37a178e..9a4ccef9dc 100644 --- a/libs/design-system/privacy/src/lib/privacy-preferences.service.ts +++ b/libs/design-system/privacy/src/lib/privacy-preferences.service.ts @@ -1,9 +1,9 @@ -import { ScrollStrategyOptions } from '@angular/cdk/overlay'; +import { Overlay, ScrollStrategyOptions } from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; import { Injectable, effect, inject, signal } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { ConsentService } from '@hra-ui/common/analytics'; import { - CONSENT_BANNER_ARIA_LABELLEDBY_ID, CONSENT_BANNER_PANEL_CLASS, ConsentBannerComponent, ConsentBannerResult, @@ -18,16 +18,14 @@ import store from 'store2'; /** Key used to store privacy preferences in local storage */ const PRIVACY_PREFERENCES_STORAGE_KEY = '__hra-analytics-privacy-preferences'; -/** ID for the consent banner dialog */ -const CONSENT_BANNER_DIALOG_ID = 'consentBannerDialog'; -/** ID for the privacy preferences dialog */ -const PRIVACY_PREFERENCES_DIALOG_ID = 'privacyPreferencesDialog'; /** Service for managing privacy preferences and consent banner */ @Injectable({ providedIn: 'root', }) export class PrivacyPreferencesService { + /** Reference to Angular CDK overlay service */ + private readonly overlay = inject(Overlay); /** Reference to Angular Material dialog service */ private readonly dialog = inject(MatDialog); /** Reference to consent service */ @@ -36,6 +34,8 @@ export class PrivacyPreferencesService { private readonly repositionScrollStrategy = inject(ScrollStrategyOptions).reposition(); /** Whether to sync preferences to local storage */ private readonly syncEnabled = signal(false); + /** Whether an active dialog is open */ + private readonly hasActiveDialog = signal(false); /** Constructor */ constructor() { @@ -78,24 +78,23 @@ export class PrivacyPreferencesService { return; } - const ref = this.dialog.open(ConsentBannerComponent, { - ariaLabelledBy: CONSENT_BANNER_ARIA_LABELLEDBY_ID, - autoFocus: false, - closeOnNavigation: false, - disableClose: true, + const overlayRef = this.overlay.create({ + disposeOnNavigation: false, hasBackdrop: false, - id: CONSENT_BANNER_DIALOG_ID, - panelClass: CONSENT_BANNER_PANEL_CLASS, minWidth: '100%', - position: { - bottom: '0px', - left: '0px', - right: '0px', - }, + panelClass: CONSENT_BANNER_PANEL_CLASS, + positionStrategy: this.overlay.position().global().bottom('0').left('0').right('0'), scrollStrategy: this.repositionScrollStrategy, }); + const portal = new ComponentPortal(ConsentBannerComponent); + const componentRef = overlayRef.attach(portal); - ref.afterClosed().subscribe((result) => this.handleDialogResult(result)); + componentRef.instance.buttonClick.subscribe((result) => { + this.handleDialogResult(result); + overlayRef.dispose(); + }); + + this.hasActiveDialog.set(true); } /** Open the privacy preferences dialog */ @@ -116,23 +115,20 @@ export class PrivacyPreferencesService { tab, }, hasBackdrop: true, - id: PRIVACY_PREFERENCES_DIALOG_ID, maxWidth: '46.75rem', minWidth: '20rem', + panelClass: 'hra-privacy-preferences-panel', restoreFocus: true, }, ); ref.afterClosed().subscribe((result) => this.handleDialogResult(result)); - } - - /** Check whether a privacy-related dialog is currently open */ - private hasActiveDialog(): boolean { - const ids = [CONSENT_BANNER_DIALOG_ID, PRIVACY_PREFERENCES_DIALOG_ID]; - return ids.some((id) => this.dialog.getDialogById(id) !== undefined); + this.hasActiveDialog.set(true); } private handleDialogResult(result: ConsentBannerResult | PrivacyPreferencesResult = 'dismiss'): void { + this.hasActiveDialog.set(false); + switch (result) { case 'allow-all': this.consent.enableAllCategories(); diff --git a/libs/design-system/search-filter/src/lib/search-filter.component.html b/libs/design-system/search-filter/src/lib/search-filter.component.html index dd5898d32d..4f50afc1f5 100644 --- a/libs/design-system/search-filter/src/lib/search-filter.component.html +++ b/libs/design-system/search-filter/src/lib/search-filter.component.html @@ -1,9 +1,28 @@ - + {{ label() }} search - + + + @if (search().length > 0) { + + } - + diff --git a/libs/design-system/search-filter/src/lib/search-filter.component.scss b/libs/design-system/search-filter/src/lib/search-filter.component.scss index 9966e8abfa..fe0410dd33 100644 --- a/libs/design-system/search-filter/src/lib/search-filter.component.scss +++ b/libs/design-system/search-filter/src/lib/search-filter.component.scss @@ -7,7 +7,7 @@ align-items: center; justify-content: space-between; width: 100%; - gap: 0.5rem; + gap: 1rem; .search-field { flex: 1; diff --git a/libs/design-system/search-filter/src/lib/search-filter.component.spec.ts b/libs/design-system/search-filter/src/lib/search-filter.component.spec.ts index 9ee124666a..77e3171707 100644 --- a/libs/design-system/search-filter/src/lib/search-filter.component.spec.ts +++ b/libs/design-system/search-filter/src/lib/search-filter.component.spec.ts @@ -48,7 +48,7 @@ describe('SearchFilterComponent', () => { }, }); - const input = screen.getByRole('textbox'); + const input = screen.getByRole('searchbox'); await userEvent.type(input, 'kidney'); expect(input).toHaveValue('kidney'); diff --git a/libs/design-system/search-filter/src/lib/search-filter.component.ts b/libs/design-system/search-filter/src/lib/search-filter.component.ts index a017a9bcce..eedcc4e73b 100644 --- a/libs/design-system/search-filter/src/lib/search-filter.component.ts +++ b/libs/design-system/search-filter/src/lib/search-filter.component.ts @@ -1,8 +1,9 @@ import { Component, input, model, numberAttribute } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; import { HraCommonModule } from '@hra-ui/common'; import { ResultsIndicatorComponent } from '@hra-ui/design-system/indicators/results-indicator'; @@ -11,7 +12,15 @@ import { ResultsIndicatorComponent } from '@hra-ui/design-system/indicators/resu */ @Component({ selector: 'hra-search-filter', - imports: [HraCommonModule, FormsModule, MatFormFieldModule, MatInputModule, MatIconModule, ResultsIndicatorComponent], + imports: [ + HraCommonModule, + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + ResultsIndicatorComponent, + ], standalone: true, templateUrl: './search-filter.component.html', styleUrl: './search-filter.component.scss', @@ -28,4 +37,7 @@ export class SearchFilterComponent { /** Number of currently visible/filtered options */ readonly viewingCount = input.required({ transform: numberAttribute }); + + /** Separator for results indicator */ + readonly separator = input('of'); } diff --git a/libs/design-system/search-list/src/lib/search-list.component.html b/libs/design-system/search-list/src/lib/search-list.component.html index dfb715f49c..b312f8f531 100644 --- a/libs/design-system/search-list/src/lib/search-list.component.html +++ b/libs/design-system/search-list/src/lib/search-list.component.html @@ -9,16 +9,16 @@ @for (option of filteredOptions(); track option.id) { @@ -27,8 +27,9 @@
{{ option.secondaryLabel }}
}
- @if (option.count) { - {{ option.count | number }} + @let count = getCount(option); + @if (count !== undefined) { + {{ count | number }} }
diff --git a/libs/design-system/search-list/src/lib/search-list.component.ts b/libs/design-system/search-list/src/lib/search-list.component.ts index c2b54c870a..105cae2184 100644 --- a/libs/design-system/search-list/src/lib/search-list.component.ts +++ b/libs/design-system/search-list/src/lib/search-list.component.ts @@ -57,6 +57,9 @@ export class SearchListComponent { /** Currently selected filters */ readonly selected = model([]); + /** Counts for each filter option */ + readonly counts = input>(); + /** Current search bar value */ readonly search = model(''); @@ -64,13 +67,20 @@ export class SearchListComponent { readonly filteredOptions = computed(() => this.doSearch()); /** - * Updates selected options on update - * @param event Selected options in list + * Updates selected options on option click + * @param event Selected options */ selectionUpdate(event: MatListOption[]): void { this.selected.set(event.map((option) => option.value)); } + /** Gets count for an option, either from the option itself or from the counts input + * @param option Option to get count for + */ + getCount(option: T): number | undefined { + return option.count ?? this.counts()?.[option.id]; + } + /** Filters options according to the search bar value */ private doSearch(): T[] { const searchTerm = this.search().toLowerCase(); diff --git a/libs/design-system/styles/_cns-light-theme.scss b/libs/design-system/styles/_cns-light-theme.scss deleted file mode 100644 index a2cb77d3b9..0000000000 --- a/libs/design-system/styles/_cns-light-theme.scss +++ /dev/null @@ -1,141 +0,0 @@ -@use 'sass:map'; -@use '@angular/material' as mat; -@use 'theming'; -@use 'typography' as typo; -@use 'utils'; -@use 'vars'; - -$_palettes: ( - primary: ( - 0: #000000, - 10: #464954, - 20: #2f2f3b, - 25: #3a3a46, - 30: #464652, - 35: #51515d, - 40: #0f0f0f, - 50: #767683, - 60: #908f9d, - 70: #abaab8, - 80: #d8ff62, - 90: #464954, - 95: #ffffff, - 98: #ffffff, - 99: #ffffff, - 100: #ffffff, - ), - secondary: ( - 0: #000000, - 10: #405d40, - 20: #2f2d4d, - 25: #3a3858, - 30: #454364, - 35: #514f71, - 40: #202a1f, - 50: #767397, - 60: #908db2, - 70: #aba7ce, - 80: #c6c2ea, - 90: #fcfcfc, - 95: #fcfcfc, - 98: #fcf8ff, - 99: #fffbff, - 100: #ffffff, - ), - tertiary: ( - 0: #000000, - 10: #0b190b, - 20: #680015, - 25: #7d001c, - 30: #000000, - 35: #a80029, - 40: #081503, - 50: #ed003e, - 60: #ff5261, - 70: #ff888c, - 80: #ffb3b3, - 90: #000000, - 95: #ffedec, - 98: #fff8f7, - 99: #fffbff, - 100: #ffffff, - ), - neutral: ( - 0: #201e3d, - 10: #201e3d, - 20: #0c0c14, - 25: #3c3b3b, - 30: #484646, - 35: #535252, - 40: #5f5e5e, - 50: #acb5c3, - 60: #929090, - 70: #adaaaa, - 80: #c9c6c5, - 87: #ebf0e9, - 89: #f1f5ef, - 90: #dde3d5, - 91: #fbfefb, - 92: #ebf0e9, - 94: #f1f5ef, - 95: #ffffff, - 96: #fcfcfc, - 98: #fbfefb, - 99: #f6feff, - 100: #ffffff, - ), - neutral-variant: ( - 0: #000000, - 10: #1b1b1c, - 20: #303031, - 25: #26262c, - 30: #26262c, - 35: #26262c, - 40: #5f5e5e, - 50: #b6c3ac, - 60: #929091, - 70: #adabab, - 80: #dde3d5, - 90: #e5e2e2, - 95: #f3f0f0, - 98: #fcf9f9, - 99: #fffbfc, - 100: #ffffff, - ), - error: ( - 0: #000000, - 10: #93000a, - 20: #690005, - 25: #7e0007, - 30: #2f1400, - 35: #a80710, - 40: #ba1a1a, - 50: #de3730, - 60: #ff5449, - 70: #ff897d, - 80: #ffb4ab, - 90: #ffdad6, - 95: #ffedea, - 98: #fff8f7, - 99: #fffbff, - 100: #ffffff, - ), -); - -$_rest: ( - secondary: map.get($_palettes, secondary), - neutral: map.get($_palettes, neutral), - neutral-variant: map.get($_palettes, neutral-variant), - error: map.get($_palettes, error), -); -$_primary: map.merge(map.get($_palettes, primary), $_rest); -$_tertiary: map.merge(map.get($_palettes, tertiary), $_rest); - -$theme: ( - color: ( - theme-type: light, - primary: $_primary, - tertiary: $_tertiary, - ), - typography: typo.$typography, -); diff --git a/libs/design-system/styles/_cns-theme.scss b/libs/design-system/styles/_cns-theme.scss new file mode 100644 index 0000000000..fe7a118aba --- /dev/null +++ b/libs/design-system/styles/_cns-theme.scss @@ -0,0 +1,172 @@ +@use 'sass:map'; +@use 'cns-typography' as typo; + +$_palettes: ( + primary: ( + 0: #000000, + 5: #0b1300, + 10: #141f00, + 15: #1c2a00, + 20: #253600, + 25: #2e4200, + 30: #374e00, + 35: #415a00, + 40: #4a6700, + 50: #5e8200, + 60: #739e00, + 70: #8aba17, + 80: #a5d737, + 90: #c0f452, + 95: #d7ff85, + 98: #f2ffd0, + 99: #faffe5, + 100: #ffffff, + ), + secondary: ( + 0: #000000, + 5: #0b1300, + 10: #141f00, + 15: #1d2a00, + 20: #253500, + 25: #2e4100, + 30: #394d06, + 35: #445913, + 40: #50661f, + 50: #687f36, + 60: #81994d, + 70: #9bb564, + 80: #b6d07d, + 90: #d2ed96, + 95: #e0fba3, + 98: #f2ffcf, + 99: #faffe5, + 100: #ffffff, + ), + tertiary: ( + 0: #000000, + 5: #011500, + 10: #042101, + 15: #0f2c08, + 20: #1a3711, + 25: #25421c, + 30: #304e26, + 35: #3b5a31, + 40: #47663c, + 50: #5f8052, + 60: #789a6a, + 70: #92b583, + 80: #add19c, + 90: #c8edb7, + 95: #d6fcc4, + 98: #edffe0, + 99: #f7ffee, + 100: #ffffff, + ), + neutral: ( + 0: #000000, + 4: #09100c, + 5: #0c130f, + 6: #0e1511, + 10: #171e19, + 12: #18201c, + 15: #1e2520, + 17: #232a25, + 20: #29302b, + 22: #2d342f, + 24: #313833, + 25: #333a35, + 30: #3f4641, + 35: #4b524d, + 40: #58605a, + 50: #717770, + 60: #8c918b, + 70: #a7aba5, + 80: #c2c6bf, + 87: #d6d9d2, + 90: #e0e3dc, + 92: #e6e8e2, + 94: #eceee8, + 95: #eff1eb, + 96: #f1f3ee, + 98: #f7f8f4, + 99: #fcfdf9, + 100: #ffffff, + ), + neutral-variant: ( + 0: #000000, + 4: #050d03, + 5: #071004, + 6: #091206, + 10: #121b10, + 12: #161f15, + 15: #1c251a, + 17: #202a1e, + 20: #263023, + 22: #293428, + 24: #2e382d, + 25: #303a2f, + 30: #3c463a, + 35: #485246, + 40: #545e50, + 50: #6c7668, + 60: #869181, + 70: #a0aa9b, + 80: #bbc4b5, + 87: #d0d8c8, + 90: #dbe2d3, + 92: #e1e8da, + 94: #e8eee2, + 95: #ebf1e5, + 96: #edf3e8, + 98: #f4f8f0, + 99: #fbfdf8, + 100: #ffffff, + ), + error: ( + 0: #000000, + 5: #2d0001, + 10: #410002, + 15: #540003, + 20: #690005, + 25: #7e0007, + 30: #93000a, + 35: #a80710, + 40: #ba1a1a, + 50: #de3730, + 60: #ff5449, + 70: #ff897d, + 80: #ffb4ab, + 90: #ffdad6, + 95: #ffedea, + 98: #fff8f7, + 99: #fffbfb, + 100: #ffffff, + ), +); + +$_rest: ( + secondary: map.get($_palettes, secondary), + neutral: map.get($_palettes, neutral), + neutral-variant: map.get($_palettes, neutral-variant), + error: map.get($_palettes, error), +); +$_primary: map.merge(map.get($_palettes, primary), $_rest); +$_tertiary: map.merge(map.get($_palettes, tertiary), $_rest); + +$theme: ( + color: ( + theme-type: light, + primary: $_primary, + tertiary: $_tertiary, + ), + typography: typo.$typography, +); + +$theme-dark: ( + color: ( + theme-type: dark, + primary: $_primary, + tertiary: $_tertiary, + ), + typography: typo.$typography, +); diff --git a/libs/design-system/styles/_cns-typography.scss b/libs/design-system/styles/_cns-typography.scss new file mode 100644 index 0000000000..0cd41c8523 --- /dev/null +++ b/libs/design-system/styles/_cns-typography.scss @@ -0,0 +1,153 @@ +$_brand: 'Metropolis'; +$_plain: 'Nunito Sans Variable'; +$_plain-mono: 'Roboto Mono'; + +$typography: ( + brand-family: $_brand, + plain-family: $_plain, + mono-family: $_plain-mono, + bold-weight: 700, + semibold-weight: 600, + medium-weight: 500, + regular-weight: 400, + use-system-variables: true, +); + +$overrides: ( + display-large: 500 2.875rem / 3.875rem $_brand, + display-large-font: $_brand, + display-large-line-height: 3.875rem, + display-large-size: 2.875rem, + display-large-tracking: -0.0625rem, + display-large-weight: 500, + + display-medium: 500 2.4375rem / 3.375rem $_brand, + display-medium-font: $_brand, + display-medium-line-height: 3.375rem, + display-medium-size: 2.4375rem, + display-medium-tracking: -0.0469rem, + display-medium-weight: 500, + + display-small: 500 2.25rem / 2.75rem $_brand, + display-small-font: $_brand, + display-small-line-height: 2.75rem, + display-small-size: 2.25rem, + display-small-tracking: -0.0313rem, + display-small-weight: 500, + + headline-large: 500 2rem / 2.5rem $_brand, + headline-large-font: $_brand, + headline-large-line-height: 2.5rem, + headline-large-size: 2rem, + headline-large-tracking: -0.0313rem, + headline-large-weight: 500, + + headline-medium: 500 1.75rem / 2.25rem $_brand, + headline-medium-font: $_brand, + headline-medium-line-height: 2.25rem, + headline-medium-size: 1.75rem, + headline-medium-tracking: -0.4px, + headline-medium-weight: 500, + + headline-small: 500 1.5rem / 2rem $_brand, + headline-small-font: $_brand, + headline-small-line-height: 2rem, + headline-small-size: 1.5rem, + headline-small-tracking: -0.0125rem, + headline-small-weight: 500, + + title-large: 500 1.375rem / 1.75rem $_plain, + title-large-font: $_plain, + title-large-line-height: 1.75rem, + title-large-size: 1.375rem, + title-large-tracking: 0rem, + title-large-weight: 500, + + title-medium: 600 1rem / 1.5rem $_plain, + title-medium-font: $_plain, + title-medium-line-height: 1.5rem, + title-medium-size: 1rem, + title-medium-tracking: 0.0094rem, + title-medium-weight: 600, + + title-small: 600 0.875rem / 1.25rem $_plain, + title-small-font: $_plain, + title-small-line-height: 1.25rem, + title-small-size: 0.875rem, + title-small-tracking: 0.0063rem, + title-small-weight: 600, + + label-large: 600 0.875rem / 1.25rem $_plain, + label-large-font: $_plain, + label-large-line-height: 1.25rem, + label-large-size: 0.875rem, + label-large-tracking: 0.0063rem, + label-large-weight: 600, + + label-medium: 600 0.75rem / 1rem $_plain, + label-medium-font: $_plain, + label-medium-line-height: 1rem, + label-medium-size: 0.75rem, + label-medium-tracking: 0.0313rem, + label-medium-weight: 600, + + label-small: 600 0.6875rem / 1rem $_plain, + label-small-font: $_plain, + label-small-line-height: 1rem, + label-small-size: 0.6875rem, + label-small-tracking: 0.0313rem, + label-small-weight: 600, + + body-large: 400 1rem / 1.5rem $_plain, + body-large-font: $_plain, + body-large-line-height: 1.5rem, + body-large-size: 1rem, + body-large-tracking: 0rem, + body-large-weight: 400, + + body-medium: 400 0.875rem / 1.3125rem $_plain, + body-medium-font: $_plain, + body-medium-line-height: 1.3125rem, + body-medium-size: 0.875rem, + body-medium-tracking: 0.0125rem, + body-medium-weight: 400, + + body-small: 400 0.75rem / 1.125rem $_plain, + body-small-font: $_plain, + body-small-line-height: 1.125rem, + body-small-size: 0.75rem, + body-small-tracking: 0.025rem, + body-small-weight: 400, + + mono-large: 400 1rem / 1.5rem $_plain-mono, + mono-large-font: $_plain-mono, + mono-large-line-height: 1.5rem, + mono-large-size: 1rem, + mono-large-tracking: 0rem, + mono-large-weight: 400, + + mono-medium: 400 0.875rem / 1.3125rem $_plain-mono, + mono-medium-font: $_plain-mono, + mono-medium-line-height: 1.3125rem, + mono-medium-size: 0.875rem, + mono-medium-tracking: 0rem, + mono-medium-weight: 400, + + mono-small: 400 0.75rem / 1.125rem $_plain-mono, + mono-small-font: $_plain-mono, + mono-small-line-height: 1.125rem, + mono-small-size: 0.75rem, + mono-small-tracking: 0rem, + mono-small-weight: 400, +); + +@mixin custom-variants() { + & { + --mat-sys-title-medium-emphasized: 700 1rem / 1.5rem #{$_plain}; + --mat-sys-title-medium-emphasized-font: #{$_plain}; + --mat-sys-title-medium-emphasized-line-height: 1.5rem; + --mat-sys-title-medium-emphasized-size: 1rem; + --mat-sys-title-medium-emphasized-tracking: 0.0094rem; + --mat-sys-title-medium-emphasized-weight: 700; + } +} diff --git a/libs/design-system/styles/_vars.scss b/libs/design-system/styles/_vars.scss index 2e5e0f90e0..5a7829c307 100644 --- a/libs/design-system/styles/_vars.scss +++ b/libs/design-system/styles/_vars.scss @@ -192,6 +192,13 @@ $code-medium-size: var(--mat-sys-code-medium-size); $code-medium-tracking: var(--mat-sys-code-medium-tracking); $code-medium-weight: var(--mat-sys-code-medium-weight); +$title-medium-emphasized: var(--mat-sys-title-medium-emphasized); +$title-medium-emphasized-font: var(--mat-sys-title-medium-emphasized-font); +$title-medium-emphasized-line-height: var(--mat-sys-title-medium-emphasized-line-height); +$title-medium-emphasized-size: var(--mat-sys-title-medium-emphasized-size); +$title-medium-emphasized-tracking: var(--mat-sys-title-medium-emphasized-tracking); +$title-medium-emphasized-weight: var(--mat-sys-title-medium-emphasized-weight); + // Corners $corner-extra-large: var(--mat-sys-corner-extra-large); $corner-extra-large-top: var(--mat-sys-corner-extra-large-top); diff --git a/libs/design-system/styles/overrides/_button-toggle.scss b/libs/design-system/styles/overrides/_button-toggle.scss index cb4cabc2fa..80102b4aa9 100644 --- a/libs/design-system/styles/overrides/_button-toggle.scss +++ b/libs/design-system/styles/overrides/_button-toggle.scss @@ -8,9 +8,9 @@ height: 2.5rem, shape: vars.$corner-extra-small, background-color: transparent, - divider-color: vars.$primary, - selected-state-background-color: utils.with-alpha(vars.$tertiary, 20%), - selected-state-text-color: vars.$secondary, + divider-color: vars.$outline, + selected-state-background-color: vars.$primary-fixed, + selected-state-text-color: vars.$on-primary-fixed, ) ); @@ -64,4 +64,24 @@ padding: 0 1rem; } } + + mat-button-toggle-group { + &:focus-within { + outline: 2px solid vars.$tertiary; + + @include mat.button-toggle-overrides( + ( + selected-state-background-color: color-mix(in srgb, vars.$on-primary-fixed 10%, vars.$primary-fixed), + ) + ); + } + + &:hover { + @include mat.button-toggle-overrides( + ( + selected-state-background-color: color-mix(in srgb, vars.$on-primary-fixed 8%, vars.$primary-fixed), + ) + ); + } + } } diff --git a/libs/design-system/styles/overrides/_chips.scss b/libs/design-system/styles/overrides/_chips.scss index 5f27c1e13b..94875df9ce 100644 --- a/libs/design-system/styles/overrides/_chips.scss +++ b/libs/design-system/styles/overrides/_chips.scss @@ -8,6 +8,7 @@ @include mat.chips-overrides( ( elevated-selected-container-color: vars.$secondary, + elevated-container-color: vars.$surface-container-low, label-text-font: vars.$label-medium-font, label-text-line-height: vars.$label-medium-line-height, label-text-size: vars.$label-medium-size, @@ -15,6 +16,7 @@ label-text-weight: vars.$label-medium-weight, hover-state-layer-opacity: 0, focus-state-layer-opacity: 0, + outline-color: vars.$outline-variant, ) ); @@ -59,6 +61,7 @@ label-text-size: vars.$label-small-size, label-text-tracking: vars.$label-small-tracking, label-text-weight: vars.$label-small-weight, + container-height: 1.75rem, ) ); } diff --git a/libs/design-system/styles/overrides/_select.scss b/libs/design-system/styles/overrides/_select.scss index 5d38b340d3..769716309f 100644 --- a/libs/design-system/styles/overrides/_select.scss +++ b/libs/design-system/styles/overrides/_select.scss @@ -15,6 +15,7 @@ placeholder-text-color: vars.$on-primary, trigger-text-font: vars.$label-medium-font, trigger-text-size: vars.$label-medium-size, + trigger-text-tracking: vars.$label-medium-tracking, trigger-text-weight: vars.$label-medium-weight, trigger-text-line-height: vars.$label-medium-line-height, ) diff --git a/libs/design-system/table/src/lib/table/table.component.html b/libs/design-system/table/src/lib/table/table.component.html index d274b88419..b8d23c54ae 100644 --- a/libs/design-system/table/src/lib/table/table.component.html +++ b/libs/design-system/table/src/lib/table/table.component.html @@ -15,11 +15,13 @@ mat-table matSort aria-label="Table with sort function" + [matSortActive]="initialSort()?.active || ''" + [matSortDirection]="initialSort()?.direction || ''" [dataSource]="dataSource" [matSortDisabled]="!enableSort()" [class.vertical-divider]="verticalDividers()" > - @let templates = { text, numeric, markdown, link, icon, menu, dataExploration }; + @let templates = { text, date, numeric, markdown, link, icon, menu, dataExploration }; @if (enableRowSelection()) { @@ -111,6 +113,10 @@ {{ text }} + + {{ date | date }} + + {{ value | number }} diff --git a/libs/design-system/table/src/lib/table/table.component.spec.ts b/libs/design-system/table/src/lib/table/table.component.spec.ts index 19b497c0be..9c7e5ca028 100644 --- a/libs/design-system/table/src/lib/table/table.component.spec.ts +++ b/libs/design-system/table/src/lib/table/table.component.spec.ts @@ -2,14 +2,22 @@ import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { ErrorHandler, EnvironmentProviders, Provider } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; import { render, screen } from '@testing-library/angular'; import { provideMarkdown } from 'ngx-markdown'; import { unparse } from 'papaparse'; import userEvent from '@testing-library/user-event'; +import saveAs from 'file-saver'; import { TableColumn, TableRow } from '../types/page-table.schema'; import { TableComponent } from './table.component'; +jest.mock('file-saver', () => jest.fn()); + describe('TableComponent', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + const TABLE_COLUMNS: TableColumn[] = [ { column: 'serial_no', @@ -135,4 +143,153 @@ describe('TableComponent', () => { expect(screen.getByText('Bob')).toBeInTheDocument(); expect(screen.getByText('Charlie')).toBeInTheDocument(); }); + + it('should infer columns when columns input is not provided', async () => { + await setup({ + rows: [ + { label: 'Alpha', count: 2 }, + { label: 'Beta', count: 5 }, + ], + }); + + expect(screen.getByText('label')).toBeInTheDocument(); + expect(screen.getByText('count')).toBeInTheDocument(); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('should toggle all rows from the header checkbox and emit selection', async () => { + const { fixture, user } = await setup({ + rows: TABLE_ROWS, + columns: TABLE_COLUMNS, + enableRowSelection: true, + }); + + const selectionChangeSpy = jest.fn(); + fixture.componentInstance.selectionChange.subscribe(selectionChangeSpy); + + const checkboxes = screen.getAllByRole('checkbox'); + await user.click(checkboxes[0]); + + expect(selectionChangeSpy).toHaveBeenCalled(); + const latestSelection = selectionChangeSpy.mock.calls.at(-1)?.[0] as TableRow[]; + expect(latestSelection).toHaveLength(TABLE_ROWS.length); + }); + + it('should toggle a single row selection and emit selected row', async () => { + const { fixture, user } = await setup({ + rows: TABLE_ROWS, + columns: TABLE_COLUMNS, + enableRowSelection: true, + }); + + const selectionChangeSpy = jest.fn(); + fixture.componentInstance.selectionChange.subscribe(selectionChangeSpy); + + const checkboxes = screen.getAllByRole('checkbox'); + await user.click(checkboxes[1]); + + expect(selectionChangeSpy).toHaveBeenCalled(); + const latestSelection = selectionChangeSpy.mock.calls.at(-1)?.[0] as TableRow[]; + expect(latestSelection).toEqual([TABLE_ROWS[0]]); + }); + + it('should emit routeClicked for internal links', async () => { + const routeColumns: TableColumn[] = [ + { + column: 'name', + label: 'Name', + type: { + type: 'link', + urlColumn: 'route', + internal: true, + }, + }, + ]; + const routeRows: TableRow[] = [{ name: 'Open Details', route: '/details/42' }]; + + const { fixture, user } = await setup({ rows: routeRows, columns: routeColumns }); + const routeClickSpy = jest.fn(); + fixture.componentInstance.routeClicked.subscribe(routeClickSpy); + + await user.click(screen.getByText('Open Details')); + + expect(routeClickSpy).toHaveBeenCalledWith('/details/42'); + }); + + it('should emit downloadHovered when menu button is hovered', async () => { + const menuColumns: TableColumn[] = [ + { + column: 'downloads', + label: 'Downloads', + type: { + type: 'menu', + icon: 'download', + options: 'downloadOptions', + tooltip: 'Download files', + }, + }, + ]; + const menuRows: TableRow[] = [ + { + id: 'row-1', + downloads: 'Open menu', + downloadOptions: [{ id: 'opt-1', name: 'CSV', icon: 'download', url: 'https://example.com/data.csv' }], + }, + ]; + + const { fixture, user } = await setup({ rows: menuRows, columns: menuColumns }); + const hoverSpy = jest.fn(); + fixture.componentInstance.downloadHovered.subscribe(hoverSpy); + + const menuButton = screen.getAllByRole('button')[0]; + await user.hover(menuButton); + + expect(hoverSpy).toHaveBeenCalledWith('row-1'); + }); + + it('should download file with filename parsed from url', async () => { + const { fixture } = await setup({ rows: TABLE_ROWS, columns: TABLE_COLUMNS }); + + fixture.componentInstance.download('https://example.com/reports/export.csv'); + + expect(saveAs).toHaveBeenCalledWith('https://example.com/reports/export.csv', 'export.csv'); + }); + + it('should open data exploration preview dialog with title and image url', async () => { + const open = jest.fn(); + const close = jest.fn(); + open.mockReturnValue({ close }); + + const explorationColumns: TableColumn[] = [ + { + column: 'exploreUrl', + label: 'Explore', + type: { + type: 'dataExploration', + titleColumn: 'title', + imageUrlColumn: 'preview', + icon: 'preview', + }, + }, + ]; + const explorationRows: TableRow[] = [ + { + exploreUrl: 'https://example.com/explore/1', + title: 'Sample Dataset', + preview: 'https://example.com/image.png', + }, + ]; + + const { user } = await setup({ rows: explorationRows, columns: explorationColumns }, [ + { provide: MatDialog, useValue: { open } }, + ]); + + await user.click(screen.getByRole('button', { name: 'Preview Sample Dataset' })); + + expect(open).toHaveBeenCalled(); + const [, config] = open.mock.calls[0]; + expect(config.data.title).toBe('Sample Dataset'); + expect(config.data.url).toBe('https://example.com/image.png'); + }); }); diff --git a/libs/design-system/table/src/lib/table/table.component.ts b/libs/design-system/table/src/lib/table/table.component.ts index d1eb6b81f4..0b8a74f46c 100644 --- a/libs/design-system/table/src/lib/table/table.component.ts +++ b/libs/design-system/table/src/lib/table/table.component.ts @@ -33,6 +33,7 @@ import { NgScrollbar } from 'ngx-scrollbar'; import { parse } from 'papaparse'; import { DataExplorationColumnType, + DateColumnType, IconColumnType, LinkColumnType, MarkdownColumnType, @@ -80,6 +81,22 @@ export class TextRowElementDirective { } } +/** Directive for typing the context of Date Row Element */ +@Directive({ + selector: 'ng-template[hraDateRowElement]', +}) +export class DateRowElementDirective { + /* istanbul ignore next */ + + /** Guard for the context of Date Row Element */ + static ngTemplateContextGuard( + _dir: DateRowElementDirective, + _ctx: unknown, + ): _ctx is RowElementContext { + return true; + } +} + /** Directive for typing the context of Link Row Element */ @Directive({ selector: 'ng-template[hraLinkRowElement]', @@ -226,6 +243,9 @@ export class TableComponent { /** Enables sorting */ readonly enableSort = input(false); + /** Initial sort configuration */ + readonly initialSort = input<{ active: string; direction: 'asc' | 'desc' }>(); + /** Enables dividers between columns */ readonly verticalDividers = input(false); @@ -388,17 +408,17 @@ export class TableComponent { * @param options Menu options * @returns Menu options as an array of MenuOptionsType */ - getMenuOptions(options: string | number | boolean | MenuOptionsType[]): MenuOptionsType[] { + getMenuOptions(options: string | Date | number | boolean | MenuOptionsType[]): MenuOptionsType[] { return options as MenuOptionsType[]; } /** Emits a route as string when object label is clicked */ - routeClick(url: string | number | boolean | (string | number | boolean)[]): void { + routeClick(url: string | Date | number | boolean | (string | Date | number | boolean)[]): void { this.routeClicked.emit(url as string); } /** Emits the id of a row when its download button is hovered */ - downloadButtonHover(id: string | number | boolean | (string | number | boolean)[]): void { + downloadButtonHover(id: string | Date | number | boolean | (string | Date | number | boolean)[]): void { this.downloadHovered.emit(id as string); } diff --git a/libs/design-system/table/src/lib/types/page-table.schema.ts b/libs/design-system/table/src/lib/types/page-table.schema.ts index 99c70d1201..fd4d32c4d4 100644 --- a/libs/design-system/table/src/lib/types/page-table.schema.ts +++ b/libs/design-system/table/src/lib/types/page-table.schema.ts @@ -12,7 +12,7 @@ export type TableRow = z.infer; /** Schema for a single table row */ export const TableRowSchema = z - .record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.any())])) + .record(z.string(), z.union([z.string(), z.date(), z.number(), z.boolean(), z.array(z.any())])) .meta({ id: 'TableRow' }); /** Type for Text Column */ @@ -25,6 +25,16 @@ export const TextColumnTypeSchema = z }) .meta({ id: 'TextColumnType' }); +/** Type for Date Column */ +export type DateColumnType = z.infer; + +/** Schema for Date Column */ +export const DateColumnTypeSchema = z + .object({ + type: z.literal('date'), + }) + .meta({ id: 'DateColumnType' }); + /** Type for Numeric Column */ export type NumericColumnType = z.infer; @@ -116,6 +126,7 @@ export const MenuOptionsTypeSchema = z /** Union of Schema Types for Simple Columns */ export const SimpleTableColumnTypeSchema = z.union([ TextColumnTypeSchema.shape.type, + DateColumnTypeSchema.shape.type, NumericColumnTypeSchema.shape.type, MarkdownColumnTypeSchema.shape.type, IconColumnTypeSchema.shape.type, @@ -129,6 +140,7 @@ export type TableColumnType = z.infer; /** Union of Schema Types for Table Columns */ export const TableColumnTypeSchema = z.union([ TextColumnTypeSchema, + DateColumnTypeSchema, NumericColumnTypeSchema, MarkdownColumnTypeSchema, LinkColumnTypeSchema, diff --git a/libs/state/package.json b/libs/state/package.json index f3636429bf..96b7ec07e1 100644 --- a/libs/state/package.json +++ b/libs/state/package.json @@ -10,7 +10,7 @@ "rxjs": "^7.8.2", "zod": "^4.1.3", "immer": "^11.1.4", - "type-fest": "^5.0.1", + "type-fest": "^5.3.1", "@hra-ui/design-system": "1.1.0", "papaparse": "^5.5.3" }, diff --git a/package-lock.json b/package-lock.json index fff34f3987..1865275b9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "@turf/helpers": "^7.2.0", "@vivjs/layers": "^0.16.1", "analytics": "^0.8.19", + "animate.css": "^4.1.1", "axios": "^1.13.5", "bootstrap": "^5.3.8", "cannon-es": "^0.20.0", @@ -495,13 +496,13 @@ "license": "MIT" }, "node_modules/@angular-devkit/architect": { - "version": "0.2101.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2101.4.tgz", - "integrity": "sha512-3yyebORk+ovtO+LfDjIGbGCZhCMDAsyn9vkCljARj3sSshS4blOQBar0g+V3kYAweLT5Gf+rTKbN5jneOkBAFQ==", + "version": "0.2101.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2101.5.tgz", + "integrity": "sha512-eTo6wWzUW5AyBBLTbaUTpBHhGbZhzteErtNGklWkhjicCr/soNH+2mVtvg8bqA8sNreYffK1VXKFsq5NyMh5qg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.1.4", + "@angular-devkit/core": "21.1.5", "rxjs": "7.8.2" }, "bin": { @@ -514,18 +515,18 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "21.1.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-21.1.4.tgz", - "integrity": "sha512-2HPCo6vEu5EIwxxFYhnmdfbktRBoOVQD3q7lG9PMQPf/jRCnyIZ70qSbXbAV96IMDLFl8mLRfY4scoaFMIYGMw==", + "version": "21.1.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-21.1.5.tgz", + "integrity": "sha512-B2jOBAiVl+hA3PLwpxfrbW/gA7SDu9Uv+hQwHYrdwL2XXDVwaQ+c3z9BS3yJDQTkb/TrAJ0sfa2zVLC4b/rHzg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2101.4", - "@angular-devkit/build-webpack": "0.2101.4", - "@angular-devkit/core": "21.1.4", - "@angular/build": "21.1.4", + "@angular-devkit/architect": "0.2101.5", + "@angular-devkit/build-webpack": "0.2101.5", + "@angular-devkit/core": "21.1.5", + "@angular/build": "21.1.5", "@babel/core": "7.28.5", "@babel/generator": "7.28.5", "@babel/helper-annotate-as-pure": "7.27.3", @@ -536,7 +537,7 @@ "@babel/preset-env": "7.28.5", "@babel/runtime": "7.28.4", "@discoveryjs/json-ext": "0.6.3", - "@ngtools/webpack": "21.1.4", + "@ngtools/webpack": "21.1.5", "ansi-colors": "4.1.3", "autoprefixer": "10.4.23", "babel-loader": "10.0.0", @@ -591,7 +592,7 @@ "@angular/platform-browser": "^21.0.0", "@angular/platform-server": "^21.0.0", "@angular/service-worker": "^21.0.0", - "@angular/ssr": "^21.1.4", + "@angular/ssr": "^21.1.5", "@web/test-runner": "^0.20.0", "browser-sync": "^3.0.2", "jest": "^30.2.0", @@ -1267,14 +1268,14 @@ } }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.2101.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.2101.4.tgz", - "integrity": "sha512-lPjPxeEzUha4bnlGzD3KFFf3yxcQjOfV9wwZIa4XLsqjCZsUk95TzHQH7i64OCTw9uKTEQkJBAuO6v2WXHxopw==", + "version": "0.2101.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.2101.5.tgz", + "integrity": "sha512-G3mvUXiSU3DL1QKngq/yXT94Wr+IdqtOM/1VC3NmsV9KX3OSfwfc560dmhY1efqc9gBA5qL+7kLlgV7Kx/Su3A==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@angular-devkit/architect": "0.2101.4", + "@angular-devkit/architect": "0.2101.5", "rxjs": "7.8.2" }, "engines": { @@ -1288,13 +1289,13 @@ } }, "node_modules/@angular-devkit/core": { - "version": "21.1.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.1.4.tgz", - "integrity": "sha512-ObPTI5gYCB1jGxTRhcqZ6oQVUBFVJ8GH4LksVuAiz0nFX7xxpzARWvlhq943EtnlovVlUd9I8fM3RQqjfGVVAQ==", + "version": "21.1.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.1.5.tgz", + "integrity": "sha512-KUKbllHvHefkAbTBjWNpRPyrpBqecW+6HBBAR+XNbKBuFTHkG+gxtuwMXNsvO5KECKwQphvQt5h3g05Xtaf0LQ==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "8.17.1", + "ajv": "8.18.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", "picomatch": "4.0.3", @@ -1316,13 +1317,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "21.1.4", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.1.4.tgz", - "integrity": "sha512-Nqq0ioCUxrbEX+L4KOarETcZZJNnJ1mAJ0ubO4VM91qnn8RBBM9SnQ91590TfC34Szk/wh+3+Uj6KUvTJNuegQ==", + "version": "21.1.5", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.1.5.tgz", + "integrity": "sha512-CGmoorQL5+mVCJEHwHWOrhSd1hFxB3h66i9wUDizJAEQUM3mSml5SiglHArpWY/G4GmFwi6XVe+Jm3U8J/mcFg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.1.4", + "@angular-devkit/core": "21.1.5", "jsonc-parser": "3.3.1", "magic-string": "0.30.21", "ora": "9.0.0", @@ -1506,14 +1507,14 @@ } }, "node_modules/@angular/build": { - "version": "21.1.4", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.1.4.tgz", - "integrity": "sha512-7CAAQPWFMMqod40ox5MOVB/CnoBXFDehyQhs0hls6lu7bOy/M0EDy0v6bERkyNGRz1mihWWBiCV8XzEinrlq1A==", + "version": "21.1.5", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.1.5.tgz", + "integrity": "sha512-v2eDinWKlSKuk5pyMMY8j5TMFW8HA9B1l13TrDDpxsRGAAzekg7TFNyuh1x9Y6Rq4Vn+8/8pCjMUPZigzWbMhQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2101.4", + "@angular-devkit/architect": "0.2101.5", "@babel/core": "7.28.5", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", @@ -1556,7 +1557,7 @@ "@angular/platform-browser": "^21.0.0", "@angular/platform-server": "^21.0.0", "@angular/service-worker": "^21.0.0", - "@angular/ssr": "^21.1.4", + "@angular/ssr": "^21.1.5", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^21.0.0", @@ -2106,19 +2107,19 @@ } }, "node_modules/@angular/cli": { - "version": "21.1.4", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.1.4.tgz", - "integrity": "sha512-XsMHgxTvHGiXXrhYZz3zMZYhYU0gHdpoHKGiEKXwcx+S1KoYbIssyg6oF2Kq49ZaE0OYCTKjnvgDce6ZqdkJ/A==", + "version": "21.1.5", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.1.5.tgz", + "integrity": "sha512-ljqvAzSk8FKMaYW/aZhR+SXjudbQViYYkMlJvJUClGpokjDM9KfJWPX+QZfr2J+piW5yaaHmFaIMddO9QxkUDQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.2101.4", - "@angular-devkit/core": "21.1.4", - "@angular-devkit/schematics": "21.1.4", + "@angular-devkit/architect": "0.2101.5", + "@angular-devkit/core": "21.1.5", + "@angular-devkit/schematics": "21.1.5", "@inquirer/prompts": "7.10.1", "@listr2/prompt-adapter-inquirer": "3.0.5", "@modelcontextprotocol/sdk": "1.26.0", - "@schematics/angular": "21.1.4", + "@schematics/angular": "21.1.5", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.46.2", "ini": "6.0.0", @@ -4772,6 +4773,23 @@ "tinyglobby": "^0.2.14" } }, + "node_modules/@compodoc/compodoc/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@compodoc/compodoc/node_modules/code-block-writer": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", @@ -5877,9 +5895,9 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", "dev": true, "license": "ISC", "dependencies": { @@ -5940,9 +5958,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -5977,9 +5995,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", "dev": true, "license": "ISC", "dependencies": { @@ -5990,9 +6008,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", "dev": true, "license": "MIT", "engines": { @@ -6002,31 +6020,6 @@ "url": "https://eslint.org/donate" } }, - "node_modules/@eslint/markdown": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/@eslint/markdown/-/markdown-7.5.1.tgz", - "integrity": "sha512-R8uZemG9dKTbru/DQRPblbJyXpObwKzo8rv1KYGGuPUPtjM4LXBYM9q5CIZAComzZupws3tWbDwam5AFpPLyJQ==", - "dev": true, - "license": "MIT", - "peer": true, - "workspaces": [ - "examples/*" - ], - "dependencies": { - "@eslint/core": "^0.17.0", - "@eslint/plugin-kit": "^0.4.1", - "github-slugger": "^2.0.0", - "mdast-util-from-markdown": "^2.0.2", - "mdast-util-frontmatter": "^2.0.1", - "mdast-util-gfm": "^3.1.0", - "micromark-extension-frontmatter": "^2.0.0", - "micromark-extension-gfm": "^3.0.0", - "micromark-util-normalize-identifier": "^2.0.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@eslint/object-schema": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", @@ -6073,9 +6066,9 @@ } }, "node_modules/@fontsource-variable/material-symbols-rounded": { - "version": "5.2.35", - "resolved": "https://registry.npmjs.org/@fontsource-variable/material-symbols-rounded/-/material-symbols-rounded-5.2.35.tgz", - "integrity": "sha512-5X1SJWM0aAA0Own0KWUDYFhvWvqZeGOm1tXmYHox0L/TmEzczitOb/Xdq1nCcMNUzIf0x/DGEvpGAEqxaIvUZQ==", + "version": "5.2.36", + "resolved": "https://registry.npmjs.org/@fontsource-variable/material-symbols-rounded/-/material-symbols-rounded-5.2.36.tgz", + "integrity": "sha512-3AUviD/2VKTxG3yetaEt4ViqpM1YSElNIS5/8GWCC5g+ci/GnV2fFyWwQir2g8snYKfIg+S7Ixczg4zOqNB9zw==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -7262,14 +7255,27 @@ } } }, + "node_modules/@jest/reporters/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@jest/reporters/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@jest/reporters/node_modules/foreground-child": { @@ -7319,13 +7325,13 @@ "license": "ISC" }, "node_modules/@jest/reporters/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", + "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -10106,9 +10112,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "21.1.4", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-21.1.4.tgz", - "integrity": "sha512-CgKnMofIVGTwNPqFNZmkmr2aLOFUG/AKm8lauXU+juwSaY7Z28eguFd+J42uVUOnasLxINQY9y7kr9f6deTrcg==", + "version": "21.1.5", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-21.1.5.tgz", + "integrity": "sha512-5nG9v/nEzsaKxgw5NurM6tPKPw0OYsCM3DL4ZI8+TidT55hYbsroTnyBcHBouJ1qlZlQXNtlsjsjBmBDtF7JZA==", "dev": true, "license": "MIT", "peer": true, @@ -13908,9 +13914,9 @@ } }, "node_modules/@ota-meshi/ast-token-store": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@ota-meshi/ast-token-store/-/ast-token-store-0.2.1.tgz", - "integrity": "sha512-+8oB1wcOSWJCR6vAm2ioSLas7SoPwp+8tZ1Tcy8DSVEHMip6jxxlGu6EsRzJLAYVCyzKQ38B5pAqSbon1l1rmA==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@ota-meshi/ast-token-store/-/ast-token-store-0.3.0.tgz", + "integrity": "sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==", "dev": true, "license": "MIT", "engines": { @@ -13918,10 +13924,6 @@ }, "funding": { "url": "https://github.com/sponsors/ota-meshi" - }, - "peerDependencies": { - "@eslint/markdown": "^7.4.0", - "eslint": ">=9.0.0" } }, "node_modules/@oxc-project/types": { @@ -13935,9 +13937,9 @@ } }, "node_modules/@oxc-resolver/binding-android-arm-eabi": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.17.1.tgz", - "integrity": "sha512-+VuZyMYYaap5uDAU1xDU3Kul0FekLqpBS8kI5JozlWfYQKnc/HsZg2gHPkQrj0SC9lt74WMNCfOzZZJlYXSdEQ==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.18.0.tgz", + "integrity": "sha512-EhwJNzbfLwQQIeyak3n08EB3UHknMnjy1dFyL98r3xlorje2uzHOT2vkB5nB1zqtTtzT31uSot3oGZFfODbGUg==", "cpu": [ "arm" ], @@ -13949,9 +13951,9 @@ ] }, "node_modules/@oxc-resolver/binding-android-arm64": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.17.1.tgz", - "integrity": "sha512-YlDDTjvOEKhom/cRSVsXsMVeXVIAM9PJ/x2mfe08rfuS0iIEfJd8PngKbEIhG72WPxleUa+vkEZj9ncmC14z3Q==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.18.0.tgz", + "integrity": "sha512-esOPsT9S9B6vEMMp1qR9Yz5UepQXljoWRJYoyp7GV/4SYQOSTpN0+V2fTruxbMmzqLK+fjCEU2x3SVhc96LQLQ==", "cpu": [ "arm64" ], @@ -13963,9 +13965,9 @@ ] }, "node_modules/@oxc-resolver/binding-darwin-arm64": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.17.1.tgz", - "integrity": "sha512-HOYYLSY4JDk14YkXaz/ApgJYhgDP4KsG8EZpgpOxdszGW9HmIMMY/vXqVKYW74dSH+GQkIXYxBrEh3nv+XODVg==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.18.0.tgz", + "integrity": "sha512-iJknScn8fRLRhGR6VHG31bzOoyLihSDmsJHRjHwRUL0yF1MkLlvzmZ+liKl9MGl+WZkZHaOFT5T1jNlLSWTowQ==", "cpu": [ "arm64" ], @@ -13977,9 +13979,9 @@ ] }, "node_modules/@oxc-resolver/binding-darwin-x64": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.17.1.tgz", - "integrity": "sha512-JHPJbsa5HvPq2/RIdtGlqfaG9zV2WmgvHrKTYmlW0L5esqtKCBuetFudXTBzkNcyD69kSZLzH92AzTr6vFHMFg==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.18.0.tgz", + "integrity": "sha512-3rMweF2GQLzkaUoWgFKy1fRtk0dpj4JDqucoZLJN9IZG+TC+RZg7QMwG5WKMvmEjzdYmOTw1L1XqZDVXF2ksaQ==", "cpu": [ "x64" ], @@ -13991,9 +13993,9 @@ ] }, "node_modules/@oxc-resolver/binding-freebsd-x64": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.17.1.tgz", - "integrity": "sha512-UD1FRC8j8xZstFXYsXwQkNmmg7vUbee006IqxokwDUUA+xEgKZDpLhBEiVKM08Urb+bn7Q0gn6M1pyNR0ng5mg==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.18.0.tgz", + "integrity": "sha512-TfXsFby4QvpGwmUP66+X+XXQsycddZe9ZUUu/vHhq2XGI1EkparCSzjpYW1Nz5fFncbI5oLymQLln/qR+qxyOw==", "cpu": [ "x64" ], @@ -14005,9 +14007,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.17.1.tgz", - "integrity": "sha512-wFWC1wyf2ROFWTxK5x0Enm++DSof3EBQ/ypyAesMDLiYxOOASDoMOZG1ylWUnlKaCt5W7eNOWOzABpdfFf/ssA==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.18.0.tgz", + "integrity": "sha512-WolOILquy9DJsHcfFMHeA5EjTCI9A7JoERFJru4UI2zKZcnfNPo5GApzYwiloscEp/s+fALPmyRntswUns0qHg==", "cpu": [ "arm" ], @@ -14019,9 +14021,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.17.1.tgz", - "integrity": "sha512-k/hUif0GEBk/csSqCfTPXb8AAVs1NNWCa/skBghvNbTtORcWfOVqJ3mM+2pE189+enRm4UnryLREu5ysI0kXEQ==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.18.0.tgz", + "integrity": "sha512-r+5nHJyPdiBqOGTYAFyuq5RtuAQbm4y69GYWNG/uup9Cqr7RG9Ak0YZgGEbkQsc+XBs00ougu/D1+w3UAYIWHA==", "cpu": [ "arm" ], @@ -14033,9 +14035,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.17.1.tgz", - "integrity": "sha512-Cwm6A071ww60QouJ9LoHAwBgEoZzHQ0Qaqk2E7WLfBdiQN9mLXIDhnrpn04hlRElRPhLiu/dtg+o5PPLvaINXQ==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.18.0.tgz", + "integrity": "sha512-bUzg6QxljqMLLwsxYajAQEHW1LYRLdKOg/aykt14PSqUUOmfnOJjPdSLTiHIZCluVzPCQxv1LjoyRcoTAXfQaQ==", "cpu": [ "arm64" ], @@ -14047,9 +14049,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-musl": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.17.1.tgz", - "integrity": "sha512-+hwlE2v3m0r3sk93SchJL1uyaKcPjf+NGO/TD2DZUDo+chXx7FfaEj0nUMewigSt7oZ2sQN9Z4NJOtUa75HE5Q==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.18.0.tgz", + "integrity": "sha512-l43GVwls5+YR8WXOIez5x7Pp/MfhdkMOZOOjFUSWC/9qMnSLX1kd95j9oxDrkWdD321JdHTyd4eau5KQPxZM9w==", "cpu": [ "arm64" ], @@ -14061,9 +14063,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.17.1.tgz", - "integrity": "sha512-bO+rsaE5Ox8cFyeL5Ct5tzot1TnQpFa/Wmu5k+hqBYSH2dNVDGoi0NizBN5QV8kOIC6O5MZr81UG4yW/2FyDTA==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.18.0.tgz", + "integrity": "sha512-ayj7TweYWi/azxWmRpUZGz41kKNvfkXam20UrFhaQDrSNGNqefQRODxhJn0iv6jt4qChh7TUxDIoavR6ftRsjw==", "cpu": [ "ppc64" ], @@ -14075,9 +14077,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.17.1.tgz", - "integrity": "sha512-B/P+hxKQ1oX4YstI9Lyh4PGzqB87Ddqj/A4iyRBbPdXTcxa+WW3oRLx1CsJKLmHPdDk461Hmbghq1Bm3pl+8Aw==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.18.0.tgz", + "integrity": "sha512-2Jz7jpq6BBNlBBup3usZB6sZWEZOBbjWn++/bKC2lpAT+sTEwdTonnf3rNcb+XY7+v53jYB9pM8LEKVXZfr8BA==", "cpu": [ "riscv64" ], @@ -14089,9 +14091,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.17.1.tgz", - "integrity": "sha512-ulp2H3bFXzd/th2maH+QNKj5qgOhJ3v9Yspdf1svTw3CDOuuTl6sRKsWQ7MUw0vnkSNvQndtflBwVXgzZvURsQ==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.18.0.tgz", + "integrity": "sha512-omw8/ISOc6ubR247iEMma4/JRfbY2I+nGJC59oKBhCIEZoyqEg/NmDSBc4ToMH+AsZDucqQUDOCku3k7pBiEag==", "cpu": [ "riscv64" ], @@ -14103,9 +14105,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.17.1.tgz", - "integrity": "sha512-LAXYVe3rKk09Zo9YKF2ZLBcH8sz8Oj+JIyiUxiHtq0hiYLMsN6dOpCf2hzQEjPAmsSEA/hdC1PVKeXo+oma8mQ==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.18.0.tgz", + "integrity": "sha512-uFipBXaS+honSL5r5G/rlvVrkffUjpKwD3S/aIiwp64bylK3+RztgV+mM1blk+OT5gBRG864auhH6jCfrOo3ZA==", "cpu": [ "s390x" ], @@ -14117,9 +14119,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-gnu": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.17.1.tgz", - "integrity": "sha512-3RAhxipMKE8RCSPn7O//sj440i+cYTgYbapLeOoDvQEt6R1QcJjTsFgI4iz99FhVj3YbPxlZmcLB5VW+ipyRTA==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.18.0.tgz", + "integrity": "sha512-bY4uMIoKRv8Ine3UiKLFPWRZ+fPCDamTHZFf5pNOjlfmTJIANtJo0mzWDUdFZLYhVgQdegrDL9etZbTMR8qieg==", "cpu": [ "x64" ], @@ -14131,9 +14133,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-musl": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.17.1.tgz", - "integrity": "sha512-wpjMEubGU8r9VjZTLdZR3aPHaBqTl8Jl8F4DBbgNoZ+yhkhQD1/MGvY70v2TLnAI6kAHSvcqgfvaqKDa2iWsPQ==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.18.0.tgz", + "integrity": "sha512-40IicL/aitfNOWur06x7Do41WcqFJ9VUNAciFjZCXzF6wR2i6uVsi6N19ecqgSRoLYFCAoRYi9F50QteIxCwKQ==", "cpu": [ "x64" ], @@ -14145,9 +14147,9 @@ ] }, "node_modules/@oxc-resolver/binding-openharmony-arm64": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.17.1.tgz", - "integrity": "sha512-XIE4w17RYAVIgx+9Gs3deTREq5tsmalbatYOOBGNdH7n0DfTE600c7wYXsp7ANc3BPDXsInnOzXDEPCvO1F6cg==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.18.0.tgz", + "integrity": "sha512-DJIzYjUnSJtz4Trs/J9TnzivtPcUKn9AeL3YjHlM5+RvK27ZL9xISs3gg2VAo2nWU7ThuadC1jSYkWaZyONMwg==", "cpu": [ "arm64" ], @@ -14159,9 +14161,9 @@ ] }, "node_modules/@oxc-resolver/binding-wasm32-wasi": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.17.1.tgz", - "integrity": "sha512-Lqi5BlHX3zS4bpSOkIbOKVf7DIk6Gvmdifr2OuOI58eUUyP944M8/OyaB09cNpPy9Vukj7nmmhOzj8pwLgAkIg==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.18.0.tgz", + "integrity": "sha512-57+R8Ioqc8g9k80WovoupOoyIOfLEceHTizkUcwOXspXLhiZ67ScM7Q8OuvhDoRRSZzH6yI0qML3WZwMFR3s7g==", "cpu": [ "wasm32" ], @@ -14193,9 +14195,9 @@ } }, "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.17.1.tgz", - "integrity": "sha512-l6lTcLBQVj1HNquFpXSsrkCIM8X5Hlng5YNQJrg00z/KyovvDV5l3OFhoRyZ+aLBQ74zUnMRaJZC7xcBnHyeNg==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.18.0.tgz", + "integrity": "sha512-t9Oa4BPptJqVlHTT1cV1frs+LY/vjsKhHI6ltj2EwoGM1TykJ0WW43UlQaU4SC8N+oTY8JRbAywVMNkfqjSu9w==", "cpu": [ "arm64" ], @@ -14207,9 +14209,9 @@ ] }, "node_modules/@oxc-resolver/binding-win32-ia32-msvc": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.17.1.tgz", - "integrity": "sha512-VTzVtfnCCsU/6GgvursWoyZrhe3Gj/RyXzDWmh4/U1Y3IW0u1FZbp+hCIlBL16pRPbDc5YvXVtCOnA41QOrOoQ==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.18.0.tgz", + "integrity": "sha512-4maf/f6ea5IEtIXqGwSw38srRtVHTre9iKShG4gjzat7c3Iq6B1OppXMj8gNmTuM4n8Xh1hQM9z2hBELccJr1g==", "cpu": [ "ia32" ], @@ -14221,9 +14223,9 @@ ] }, "node_modules/@oxc-resolver/binding-win32-x64-msvc": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.17.1.tgz", - "integrity": "sha512-jRPVU+6/12baj87q2+UGRh30FBVBzqKdJ7rP/mSqiL1kpNQB9yZ1j0+m3sru1m+C8hiFK7lBFwjUtYUBI7+UpQ==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.18.0.tgz", + "integrity": "sha512-EhW8Su3AEACSw5HfzKMmyCtV0oArNrVViPdeOfvVYL9TrkL+/4c8fWHFTBtxUMUyCjhSG5xYNdwty1D/TAgL0Q==", "cpu": [ "x64" ], @@ -15144,9 +15146,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -15158,9 +15160,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -15172,9 +15174,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -15186,9 +15188,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -15200,9 +15202,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -15214,9 +15216,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -15228,9 +15230,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -15242,9 +15244,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -15256,9 +15258,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -15270,9 +15272,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -15284,9 +15286,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -15298,9 +15300,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -15312,9 +15314,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -15326,9 +15328,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -15340,9 +15342,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -15354,9 +15356,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -15368,9 +15370,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -15382,9 +15384,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -15396,9 +15398,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -15410,9 +15412,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -15424,9 +15426,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -15438,9 +15440,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -15452,9 +15454,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -15466,9 +15468,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -15480,9 +15482,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -15494,9 +15496,9 @@ ] }, "node_modules/@rollup/wasm-node": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/wasm-node/-/wasm-node-4.57.1.tgz", - "integrity": "sha512-b0rcJH8ykEanfgTeDtlPubhphIUOx0oaAek+3hizTaFkoC1FBSTsY0GixwB4D5HZ5r3Gt2yI9c8M13OcW/kW5A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/wasm-node/-/wasm-node-4.59.0.tgz", + "integrity": "sha512-cKB/Pe05aJWQYw3UFS79Id+KVXdExBxWful0+CSl24z3ukwOgBSy6l39XZNwfm3vCh/fpUrAAs+T7PsJ6dC8NA==", "dev": true, "license": "MIT", "dependencies": { @@ -16012,14 +16014,14 @@ "license": "Apache-2.0" }, "node_modules/@schematics/angular": { - "version": "21.1.4", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.1.4.tgz", - "integrity": "sha512-I1zdSNzdbrVCWpeE2NsZQmIoa9m0nlw4INgdGIkqUH6FgwvoGKC0RoOxKAmm6HHVJ48FE/sPI13dwAeK89ow5A==", + "version": "21.1.5", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.1.5.tgz", + "integrity": "sha512-AndJ17ePYUoqJqiIF9VaXbGAFfOqDcHuAxhwozsQlWDzwgQSOUC/WWeG9hKVCgMD6tE02Sxr2ova9DiBKsLQNg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.1.4", - "@angular-devkit/schematics": "21.1.4", + "@angular-devkit/core": "21.1.5", + "@angular-devkit/schematics": "21.1.5", "jsonc-parser": "3.3.1" }, "engines": { @@ -16179,9 +16181,9 @@ "license": "MIT" }, "node_modules/@storybook/addon-a11y": { - "version": "10.2.10", - "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.2.10.tgz", - "integrity": "sha512-1S9pDXgvbHhBStGarCvfJ3/rfcaiAcQHRhuM3Nk4WGSIYtC1LCSRuzYdDYU0aNRpdCbCrUA7kUCbqvIE3tH+3Q==", + "version": "10.2.11", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.2.11.tgz", + "integrity": "sha512-11NRtTuOSQbpd5d3a+KWQ13EA4NrWh7vhA8OQ9fwWJBgTd8PuqTzckfgaZTBCN30QQRIvBEYu5uUEkqNkSfleQ==", "dev": true, "license": "MIT", "dependencies": { @@ -16193,7 +16195,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.10" + "storybook": "^10.2.11" } }, "node_modules/@storybook/addon-designs": { @@ -16224,16 +16226,16 @@ } }, "node_modules/@storybook/addon-docs": { - "version": "10.2.10", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.2.10.tgz", - "integrity": "sha512-2wIYtdvZIzPbQ5194M5Igpy8faNbQ135nuO5ZaZ2VuttqGr+IJcGnDP42zYwbAsGs28G8ohpkbSgIzVyJWUhPQ==", + "version": "10.2.11", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.2.11.tgz", + "integrity": "sha512-1zMv2M8gvuPFBi9TbZlKo4jWL8FNCe8tbfah0jcpu+akyvracI78sKsJ8h8RtZa7XcS9qVcOyskPHtOctmHCVQ==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/csf-plugin": "10.2.10", + "@storybook/csf-plugin": "10.2.11", "@storybook/icons": "^2.0.1", - "@storybook/react-dom-shim": "10.2.10", + "@storybook/react-dom-shim": "10.2.11", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" @@ -16243,17 +16245,17 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.10" + "storybook": "^10.2.11" } }, "node_modules/@storybook/angular": { - "version": "10.2.10", - "resolved": "https://registry.npmjs.org/@storybook/angular/-/angular-10.2.10.tgz", - "integrity": "sha512-HNw1ZQLE3cGyzRPdTUB8wqpD+1yV7TMZCxVCsQVLAt/j9S/aDsrZH8vAbicdttLqIOKX7rYy7K+MT5/1l952JA==", + "version": "10.2.11", + "resolved": "https://registry.npmjs.org/@storybook/angular/-/angular-10.2.11.tgz", + "integrity": "sha512-HdlHGm9JBRuhFNK5BwWlsbLEKSfA28NNQddQoAnHiIZH06hCo2OlLY5ivmr7G1Id8fBHajDO3kt1Nhjqg7BwSg==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/builder-webpack5": "10.2.10", + "@storybook/builder-webpack5": "10.2.11", "@storybook/global": "^5.0.0", "telejson": "8.0.0", "ts-dedent": "^2.0.0", @@ -16277,7 +16279,7 @@ "@angular/platform-browser": ">=18.0.0 < 22.0.0", "@angular/platform-browser-dynamic": ">=18.0.0 < 22.0.0", "rxjs": "^6.5.3 || ^7.4.0", - "storybook": "^10.2.10", + "storybook": "^10.2.11", "typescript": "^4.9.0 || ^5.0.0", "zone.js": ">=0.14.0" }, @@ -16294,13 +16296,13 @@ } }, "node_modules/@storybook/builder-webpack5": { - "version": "10.2.10", - "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.2.10.tgz", - "integrity": "sha512-bIHAXiX9NwZlB5dJ2W+rZcwo1Dkmg0JOwL/F/rB9O4IlkjTsoOe/+BcLchfRdqRk7ENCVFNwaq8aXxnKmiIOMQ==", + "version": "10.2.11", + "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.2.11.tgz", + "integrity": "sha512-nC6yvZ/wT9sZk+uVGG3IeFl9l2g/785hdZp9Gh0uNfzgA+FbtXG1FDa3kqGuXitx2AC5/mAgl+0yeDx7RE9S3g==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/core-webpack": "10.2.10", + "@storybook/core-webpack": "10.2.11", "case-sensitive-paths-webpack-plugin": "^2.4.0", "cjs-module-lexer": "^1.2.3", "css-loader": "^7.1.2", @@ -16321,7 +16323,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.10" + "storybook": "^10.2.11" }, "peerDependenciesMeta": { "typescript": { @@ -16330,9 +16332,9 @@ } }, "node_modules/@storybook/builder-webpack5/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -16469,9 +16471,9 @@ "license": "MIT" }, "node_modules/@storybook/builder-webpack5/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", "dev": true, "license": "ISC", "dependencies": { @@ -16542,9 +16544,9 @@ } }, "node_modules/@storybook/core-webpack": { - "version": "10.2.10", - "resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.2.10.tgz", - "integrity": "sha512-bhz20jQWn0UB6GfYeO3oou8w8jXSVs+dgPglsxPr+tOusUuyT5FO270PHixZovVtrHgFAKHLXUEHUNuOvUsMig==", + "version": "10.2.11", + "resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.2.11.tgz", + "integrity": "sha512-GTzp145NcuLTM6PzPCnWvoET160QydwXp3NfAOym+nUFgkSFGoFyM3txzRb3p0zAm2IRTLbY32CeTb3WRliSEw==", "dev": true, "license": "MIT", "dependencies": { @@ -16555,13 +16557,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.10" + "storybook": "^10.2.11" } }, "node_modules/@storybook/csf-plugin": { - "version": "10.2.10", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.2.10.tgz", - "integrity": "sha512-aFvgaNDAnKMjuyhPK5ialT22pPqMN0XfPBNPeeNVPYztngkdKBa8WFqF/umDd47HxAjebq+vn6uId1xHyOHH3g==", + "version": "10.2.11", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.2.11.tgz", + "integrity": "sha512-sHPc5SaTpee+WsTI7N8DUFYo/DJj3BxZNuzmRkPXN3vf/aHOPdGKLZiglIRQvXBucKF2JDSpbYHoz9+702O/cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -16574,7 +16576,7 @@ "peerDependencies": { "esbuild": "*", "rollup": "*", - "storybook": "^10.2.10", + "storybook": "^10.2.11", "vite": "*", "webpack": "*" }, @@ -16612,9 +16614,9 @@ } }, "node_modules/@storybook/react-dom-shim": { - "version": "10.2.10", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.2.10.tgz", - "integrity": "sha512-TmBrhyLHn8B8rvDHKk5uW5BqzO1M1T+fqFNWg88NIAJOoyX4Uc90FIJjDuN1OJmWKGwB5vLmPwaKBYsTe1yS+w==", + "version": "10.2.11", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.2.11.tgz", + "integrity": "sha512-29kqYYLvASuxFdagwMUX0xmPgDKbRYdm4uzYGEFWiKVZvJHarbTThzSaCDNhvoQk4qPp783duO29uHkztEHc8Q==", "dev": true, "license": "MIT", "funding": { @@ -16624,7 +16626,7 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.2.10" + "storybook": "^10.2.11" } }, "node_modules/@storybook/test-runner": { @@ -17268,24 +17270,24 @@ } }, "node_modules/@swagger-api/apidom-reference/node_modules/balanced-match": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", - "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "license": "MIT", "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "node_modules/@swagger-api/apidom-reference/node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "node_modules/@swagger-api/apidom-reference/node_modules/minimatch": { @@ -17415,14 +17417,27 @@ } } }, + "node_modules/@swc/cli/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@swc/cli/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@swc/cli/node_modules/commander": { @@ -17436,13 +17451,13 @@ } }, "node_modules/@swc/cli/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", + "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -17678,9 +17693,9 @@ "license": "Apache-2.0" }, "node_modules/@swc/helpers": { - "version": "0.5.18", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", - "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", + "integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -17947,24 +17962,37 @@ "path-browserify": "^1.0.1" } }, + "node_modules/@ts-morph/common/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "peer": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@ts-morph/common/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "license": "MIT", "peer": true, "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@ts-morph/common/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", + "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", "license": "ISC", "peer": true, "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -20068,17 +20096,6 @@ "@types/d3-selection": "*" } }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/ms": "*" - } - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -20389,23 +20406,12 @@ "license": "MIT" }, "node_modules/@types/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", "dev": true, "license": "MIT" }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/unist": "*" - } - }, "node_modules/@types/mdx": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", @@ -20427,14 +20433,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@types/node": { "version": "25.3.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", @@ -20667,14 +20665,6 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@types/wait-on": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/@types/wait-on/-/wait-on-5.3.4.tgz", @@ -20719,17 +20709,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", - "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/type-utils": "8.56.0", - "@typescript-eslint/utils": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -20742,7 +20732,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.56.0", + "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -20758,16 +20748,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", - "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "engines": { @@ -20783,14 +20773,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", - "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.0", - "@typescript-eslint/types": "^8.56.0", + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", "debug": "^4.4.3" }, "engines": { @@ -20805,14 +20795,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", - "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0" + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -20823,9 +20813,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", - "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", "dev": true, "license": "MIT", "engines": { @@ -20840,15 +20830,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", - "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -20865,9 +20855,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", - "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", "dev": true, "license": "MIT", "engines": { @@ -20879,18 +20869,18 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", - "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.56.0", - "@typescript-eslint/tsconfig-utils": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/visitor-keys": "8.56.0", + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3", - "minimatch": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" @@ -20906,43 +20896,56 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", - "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.56.0", - "@typescript-eslint/types": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0" + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -20957,13 +20960,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", - "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/types": "8.56.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -20975,9 +20978,9 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", - "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -22019,9 +22022,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { @@ -22145,6 +22148,12 @@ "typescript-eslint": "^8.0.0" } }, + "node_modules/animate.css": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/animate.css/-/animate.css-4.1.1.tgz", + "integrity": "sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ==", + "license": "MIT" + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -22477,9 +22486,9 @@ } }, "node_modules/b4a": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.5.tgz", - "integrity": "sha512-iEsKNwDh1wiWTps1/hdkNdmBgDlDVZP5U57ZVOlt+dNFqpc/lpPouCIxZw+DYBgc4P9NDfIZMPNR4CHNhzwLIA==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", "dev": true, "license": "Apache-2.0", "peerDependencies": { @@ -22732,6 +22741,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/bare-events": { @@ -23432,9 +23442,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001770", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", - "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", "dev": true, "funding": [ { @@ -23485,18 +23495,6 @@ "node": ">=4" } }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/cfb": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", @@ -23554,18 +23552,6 @@ "node": ">=10" } }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/chardet": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", @@ -25729,21 +25715,6 @@ "dev": true, "license": "MIT" }, - "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", - "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -26035,21 +26006,6 @@ "node": ">= 4.0.0" } }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -26427,9 +26383,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", "dev": true, "license": "ISC" }, @@ -26793,9 +26749,9 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", "dependencies": { @@ -26805,7 +26761,7 @@ "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", + "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -26885,9 +26841,9 @@ } }, "node_modules/eslint-json-compat-utils": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/eslint-json-compat-utils/-/eslint-json-compat-utils-0.2.1.tgz", - "integrity": "sha512-YzEodbDyW8DX8bImKhAcCeu/L31Dd/70Bidx2Qex9OFUtgzXLqtfWL4Hr5fM/aCCB8QUZLuJur0S9k6UfgFkfg==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/eslint-json-compat-utils/-/eslint-json-compat-utils-0.2.2.tgz", + "integrity": "sha512-KcTUifi8VSSHkrOY0FzB7smuTZRU9T2nCrcCy6k2b+Q77+uylBQVIxN4baVCIWvWJEpud+IsrYgco4JJ6io05g==", "dev": true, "license": "MIT", "dependencies": { @@ -26898,7 +26854,7 @@ }, "peerDependencies": { "eslint": "*", - "jsonc-eslint-parser": "^2.4.0" + "jsonc-eslint-parser": "^2.4.0 || ^3.0.0" }, "peerDependenciesMeta": { "@eslint/json": { @@ -26921,9 +26877,9 @@ } }, "node_modules/eslint-plugin-json-schema-validator": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-json-schema-validator/-/eslint-plugin-json-schema-validator-6.0.3.tgz", - "integrity": "sha512-A7n/l/cMQGx1FmE4AteAJxzfkwAF3cfmutbCCX5En7W9tt8WRT5DA/wgxzJMxsFYJrnOfyVqlnntuPUJnprU7Q==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-json-schema-validator/-/eslint-plugin-json-schema-validator-6.2.0.tgz", + "integrity": "sha512-jHdo7MwJm94Z0lhlBtwa2dtRKBgiJNk3d5HGeyjhHszmlnCXsEu3blTjGWO0tzWg04UANoygB10CaRYcTBZCsg==", "dev": true, "license": "MIT", "dependencies": { @@ -26932,7 +26888,7 @@ "debug": "^4.3.1", "eslint-json-compat-utils": "^0.2.1", "json-schema-migrate-x": "^2.1.0", - "jsonc-eslint-parser": "^2.0.0", + "jsonc-eslint-parser": "^3.1.0", "minimatch": "^10.0.0", "synckit": "^0.11.1", "toml-eslint-parser": "^1.0.0", @@ -26949,6 +26905,37 @@ "eslint": ">=9.38.0" } }, + "node_modules/eslint-plugin-json-schema-validator/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-json-schema-validator/node_modules/jsonc-eslint-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-3.1.0.tgz", + "integrity": "sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.5.0", + "eslint-visitor-keys": "^5.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + } + }, "node_modules/eslint-plugin-jsonc": { "version": "2.21.1", "resolved": "https://registry.npmjs.org/eslint-plugin-jsonc/-/eslint-plugin-jsonc-2.21.1.tgz", @@ -26977,9 +26964,9 @@ } }, "node_modules/eslint-plugin-storybook": { - "version": "10.2.10", - "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.2.10.tgz", - "integrity": "sha512-aWkoh2rhTaEsMA4yB1iVIcISM5wb0uffp09ZqhwpoD4GAngCs131uq6un+QdnOMc7vXyAnBBfsuhtOj8WwCUgw==", + "version": "10.2.11", + "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.2.11.tgz", + "integrity": "sha512-M86Fa6tErRYeSQSZIA/eZWkkS7sjrWVf5KwSD856vxpvZTP4t4+hDFfo/pleH10XiHdL6/Xu3cvj2t5XIPCwhA==", "dev": true, "license": "MIT", "dependencies": { @@ -26987,19 +26974,19 @@ }, "peerDependencies": { "eslint": ">=8", - "storybook": "^10.2.10" + "storybook": "^10.2.11" } }, "node_modules/eslint-plugin-yml": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-yml/-/eslint-plugin-yml-3.2.1.tgz", - "integrity": "sha512-/oFj7MWk56AKLelLSUb7zN1OKDI9kR+uKEzbf/sGu7Bov0tJs3qwtMcu+VCcEtFAFD7KZe0LSYhyy0Uq8hKF9g==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-yml/-/eslint-plugin-yml-3.3.0.tgz", + "integrity": "sha512-kRja5paNrMfZnbNqDbZSFrSHz5x7jmGBQq7d6z/+wRvWD4Y0yb1fbjojBg3ReMewFhBB7nD2nPC86+m3HmILJA==", "dev": true, "license": "MIT", "dependencies": { "@eslint/core": "^1.0.1", "@eslint/plugin-kit": "^0.6.0", - "@ota-meshi/ast-token-store": "^0.2.1", + "@ota-meshi/ast-token-store": "^0.3.0", "debug": "^4.3.2", "diff-sequences": "^29.0.0", "escape-string-regexp": "5.0.0", @@ -27067,9 +27054,9 @@ } }, "node_modules/eslint-scope": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.0.tgz", - "integrity": "sha512-CkWE42hOJsNj9FJRaoMX9waUFYhqY4jmyLFdAdzZr6VaCg3ynLYx4WnOdkaIifGfH4gsUcBTn4OZbHXkpLD0FQ==", + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.1.tgz", + "integrity": "sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -27099,9 +27086,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -27153,9 +27140,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", "dev": true, "license": "ISC", "dependencies": { @@ -27818,21 +27805,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fault": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", - "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "format": "^0.2.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/faye-websocket": { "version": "0.11.4", "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", @@ -27946,36 +27918,55 @@ } }, "node_modules/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.5.tgz", + "integrity": "sha512-ct/ckWBV/9Dg3MlvCXsLcSUyoWwv9mCKqlhLNB2DAuXR/NZolSXlQqP5dyy6guWlPXBhodZyZ5lGPQcbQDxrEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "minimatch": "^5.0.1" + "minimatch": "^10.2.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=10" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/filename-reserved-regex": { @@ -28370,9 +28361,9 @@ } }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -28474,9 +28465,9 @@ "license": "MIT" }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", "dev": true, "license": "ISC", "dependencies": { @@ -28567,16 +28558,6 @@ "node": ">= 14.17" } }, - "node_modules/format": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", - "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.4.x" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -28984,26 +28965,26 @@ "license": "BSD-2-Clause" }, "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", - "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { @@ -29384,9 +29365,9 @@ } }, "node_modules/hono": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz", - "integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz", + "integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==", "dev": true, "license": "MIT", "engines": { @@ -30968,14 +30949,27 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/jest-config/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/jest-config/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/jest-config/node_modules/foreground-child": { @@ -31025,13 +31019,13 @@ "license": "ISC" }, "node_modules/jest-config/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", + "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -31729,14 +31723,27 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-runtime/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/jest-runtime/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/jest-runtime/node_modules/cjs-module-lexer": { @@ -31793,13 +31800,13 @@ "license": "ISC" }, "node_modules/jest-runtime/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", + "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -32554,9 +32561,9 @@ } }, "node_modules/katex": { - "version": "0.16.28", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.28.tgz", - "integrity": "sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==", + "version": "0.16.33", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.33.tgz", + "integrity": "sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -33427,18 +33434,6 @@ "dev": true, "license": "MIT" }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -33608,18 +33603,6 @@ "dev": true, "license": "MIT" }, - "node_modules/markdown-table": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", - "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/marked": { "version": "17.0.3", "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz", @@ -33675,265 +33658,6 @@ "@math.gl/core": "3.6.3" } }, - "node_modules/mdast-util-find-and-replace": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", - "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "escape-string-regexp": "^5.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", - "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-frontmatter": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-frontmatter/-/mdast-util-frontmatter-2.0.1.tgz", - "integrity": "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "escape-string-regexp": "^5.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-extension-frontmatter": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-frontmatter/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mdast-util-gfm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", - "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-gfm-autolink-literal": "^2.0.0", - "mdast-util-gfm-footnote": "^2.0.0", - "mdast-util-gfm-strikethrough": "^2.0.0", - "mdast-util-gfm-table": "^2.0.0", - "mdast-util-gfm-task-list-item": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-autolink-literal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", - "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", - "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "markdown-table": "^3.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-task-list-item": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", - "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", - "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", @@ -34060,643 +33784,6 @@ "node": ">= 0.6" } }, - "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", - "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-frontmatter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-frontmatter/-/micromark-extension-frontmatter-2.0.0.tgz", - "integrity": "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "fault": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", - "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "micromark-extension-gfm-autolink-literal": "^2.0.0", - "micromark-extension-gfm-footnote": "^2.0.0", - "micromark-extension-gfm-strikethrough": "^2.0.0", - "micromark-extension-gfm-table": "^2.0.0", - "micromark-extension-gfm-tagfilter": "^2.0.0", - "micromark-extension-gfm-task-list-item": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", - "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-strikethrough": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", - "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-table": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", - "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-tagfilter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", - "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-task-list-item": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", - "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", - "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", - "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", - "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", - "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", - "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", - "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", - "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", - "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", - "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", - "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", - "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", - "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", - "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "peer": true - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -34913,9 +34000,9 @@ } }, "node_modules/minipass-fetch": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.1.tgz", - "integrity": "sha512-yHK8pb0iCGat0lDrs/D6RZmCdaBT64tULXjdxjSMAqoDi18Q3qKEUTHypHQZQd9+FYpIS+lkvpq6C/R6SbUeRw==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -34927,7 +34014,7 @@ "node": "^20.17.0 || >=22.9.0" }, "optionalDependencies": { - "encoding": "^0.1.13" + "iconv-lite": "^0.7.2" } }, "node_modules/minipass-flush": { @@ -51562,9 +50649,9 @@ } }, "node_modules/npm-packlist": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.3.tgz", - "integrity": "sha512-zPukTwJMOu5X5uvm0fztwS5Zxyvmk38H/LfidkOMt3gbZVCyro2cD/ETzwzVPcWZA3JOyPznfUN/nkyFiyUbxg==", + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.4.tgz", + "integrity": "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==", "dev": true, "license": "ISC", "dependencies": { @@ -52282,9 +51369,9 @@ } }, "node_modules/nyc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", "dev": true, "license": "ISC", "dependencies": { @@ -52683,35 +51770,35 @@ } }, "node_modules/oxc-resolver": { - "version": "11.17.1", - "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.17.1.tgz", - "integrity": "sha512-pyRXK9kH81zKlirHufkFhOFBZRks8iAMLwPH8gU7lvKFiuzUH9L8MxDEllazwOb8fjXMcWjY1PMDfMJ2/yh5cw==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.18.0.tgz", + "integrity": "sha512-Fv/b05AfhpYoCDvsog6tgsDm2yIwIeJafpMFLncNwKHRYu+Y1xQu5Q/rgUn7xBfuhNgjtPO7C0jCf7p2fLDj1g==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxc-resolver/binding-android-arm-eabi": "11.17.1", - "@oxc-resolver/binding-android-arm64": "11.17.1", - "@oxc-resolver/binding-darwin-arm64": "11.17.1", - "@oxc-resolver/binding-darwin-x64": "11.17.1", - "@oxc-resolver/binding-freebsd-x64": "11.17.1", - "@oxc-resolver/binding-linux-arm-gnueabihf": "11.17.1", - "@oxc-resolver/binding-linux-arm-musleabihf": "11.17.1", - "@oxc-resolver/binding-linux-arm64-gnu": "11.17.1", - "@oxc-resolver/binding-linux-arm64-musl": "11.17.1", - "@oxc-resolver/binding-linux-ppc64-gnu": "11.17.1", - "@oxc-resolver/binding-linux-riscv64-gnu": "11.17.1", - "@oxc-resolver/binding-linux-riscv64-musl": "11.17.1", - "@oxc-resolver/binding-linux-s390x-gnu": "11.17.1", - "@oxc-resolver/binding-linux-x64-gnu": "11.17.1", - "@oxc-resolver/binding-linux-x64-musl": "11.17.1", - "@oxc-resolver/binding-openharmony-arm64": "11.17.1", - "@oxc-resolver/binding-wasm32-wasi": "11.17.1", - "@oxc-resolver/binding-win32-arm64-msvc": "11.17.1", - "@oxc-resolver/binding-win32-ia32-msvc": "11.17.1", - "@oxc-resolver/binding-win32-x64-msvc": "11.17.1" + "@oxc-resolver/binding-android-arm-eabi": "11.18.0", + "@oxc-resolver/binding-android-arm64": "11.18.0", + "@oxc-resolver/binding-darwin-arm64": "11.18.0", + "@oxc-resolver/binding-darwin-x64": "11.18.0", + "@oxc-resolver/binding-freebsd-x64": "11.18.0", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.18.0", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.18.0", + "@oxc-resolver/binding-linux-arm64-gnu": "11.18.0", + "@oxc-resolver/binding-linux-arm64-musl": "11.18.0", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.18.0", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.18.0", + "@oxc-resolver/binding-linux-riscv64-musl": "11.18.0", + "@oxc-resolver/binding-linux-s390x-gnu": "11.18.0", + "@oxc-resolver/binding-linux-x64-gnu": "11.18.0", + "@oxc-resolver/binding-linux-x64-musl": "11.18.0", + "@oxc-resolver/binding-openharmony-arm64": "11.18.0", + "@oxc-resolver/binding-wasm32-wasi": "11.18.0", + "@oxc-resolver/binding-win32-arm64-msvc": "11.18.0", + "@oxc-resolver/binding-win32-ia32-msvc": "11.18.0", + "@oxc-resolver/binding-win32-x64-msvc": "11.18.0" } }, "node_modules/p-cancelable": { @@ -55385,9 +54472,9 @@ } }, "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", "dev": true, "license": "ISC", "dependencies": { @@ -55436,9 +54523,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -55452,31 +54539,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -57094,9 +56181,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.22", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", - "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", "dev": true, "license": "CC0-1.0" }, @@ -57259,9 +56346,9 @@ "license": "MIT" }, "node_modules/storybook": { - "version": "10.2.10", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.2.10.tgz", - "integrity": "sha512-N4U42qKgzMHS7DjqLz5bY4P7rnvJtYkWFCyKspZr3FhPUuy6CWOae3aYC2BjXkHrdug0Jyta6VxFTuB1tYUKhg==", + "version": "10.2.11", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.2.11.tgz", + "integrity": "sha512-uBK2CdmCZmwdHIE+Xk4hO9p3KQxJYrr5+/s1ybQU6m4GVInyp0od0E0rXEUmFDFcehLiYOCgE1kUPP9dZOC0Wg==", "dev": true, "license": "MIT", "dependencies": { @@ -57295,9 +56382,9 @@ } }, "node_modules/storybook-addon-pseudo-states": { - "version": "10.2.10", - "resolved": "https://registry.npmjs.org/storybook-addon-pseudo-states/-/storybook-addon-pseudo-states-10.2.10.tgz", - "integrity": "sha512-TqfAvPKZnnCxuNrWTKo/DEZtLES5ie48Cnvi68lfqaXm7Cw6kHMQ0r+W2uR5Aqioq7QbhdJSJB0jY0wL7O0ohg==", + "version": "10.2.11", + "resolved": "https://registry.npmjs.org/storybook-addon-pseudo-states/-/storybook-addon-pseudo-states-10.2.11.tgz", + "integrity": "sha512-Lp5pjGRJ2/b4ViPxJ/3l64Dd9GIc+qS/aAs8XkPkjGoovb2bvXl/URVfrS1IBr+rAVP/8y/D+VjsnyLKGnPQZw==", "dev": true, "license": "MIT", "funding": { @@ -57305,7 +56392,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.10" + "storybook": "^10.2.11" } }, "node_modules/storybook/node_modules/open": { @@ -57823,18 +56910,18 @@ } }, "node_modules/swagger-client": { - "version": "3.36.1", - "resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.36.1.tgz", - "integrity": "sha512-bcYpeN4P3sOoKi22zsxIlL9lSgouBAmQmL5hH4g5yeOvyTUvq1+OFtGTs0l1C5Dkb0ZN+2vNgp0FBAFulmUklA==", + "version": "3.36.2", + "resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.36.2.tgz", + "integrity": "sha512-M+m0TpZTWtVMvd0Qiq5W2ABLmY6w8PnnqnMIKgT/nYS/w6hZ4s1sBCCS/w4SOjmAyy0QT/kl3tNh2jPWIkaI3A==", "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "^7.22.15", "@scarf/scarf": "=1.4.0", - "@swagger-api/apidom-core": "^1.3.0", - "@swagger-api/apidom-error": "^1.3.0", - "@swagger-api/apidom-json-pointer": "^1.3.0", - "@swagger-api/apidom-ns-openapi-3-1": "^1.3.0", - "@swagger-api/apidom-reference": "^1.3.0", + "@swagger-api/apidom-core": "^1.5.1", + "@swagger-api/apidom-error": "^1.5.1", + "@swagger-api/apidom-json-pointer": "^1.5.1", + "@swagger-api/apidom-ns-openapi-3-1": "^1.5.1", + "@swagger-api/apidom-reference": "^1.5.1", "@swaggerexpert/cookie": "^2.0.2", "deepmerge": "~4.3.0", "fast-json-patch": "^3.0.0-1", @@ -58188,9 +57275,9 @@ } }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", "dev": true, "license": "ISC", "dependencies": { @@ -58459,9 +57546,9 @@ } }, "node_modules/toml-eslint-parser/node_modules/eslint-visitor-keys": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", - "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -58639,18 +57726,15 @@ "license": "ISC" }, "node_modules/ts-checker-rspack-plugin": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/ts-checker-rspack-plugin/-/ts-checker-rspack-plugin-1.2.6.tgz", - "integrity": "sha512-aAJIfoNr2cPu8G6mqp/oPoNlUT/LgNoqt2n3SsbxWG0TwQogbjsYsr2f/fdsufUDoGDu8Jolmpf3L4PmIH/cEg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-checker-rspack-plugin/-/ts-checker-rspack-plugin-1.3.0.tgz", + "integrity": "sha512-89oK/BtApjdid1j9CGjPGiYry+EZBhsnTAM481/8ipgr/y2IOgCbW1HPnan+fs5FnzlpUgf9dWGNZ4Ayw3Bd8A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", "@rspack/lite-tapable": "^1.1.0", "chokidar": "^3.6.0", - "is-glob": "^4.0.3", - "memfs": "^4.51.1", - "minimatch": "^9.0.5", + "memfs": "^4.56.10", "picocolors": "^1.1.1" }, "peerDependencies": { @@ -58663,16 +57747,6 @@ } } }, - "node_modules/ts-checker-rspack-plugin/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/ts-checker-rspack-plugin/node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -58741,22 +57815,6 @@ "tslib": "2" } }, - "node_modules/ts-checker-rspack-plugin/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/ts-checker-rspack-plugin/node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -59164,16 +58222,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", - "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", + "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.56.0", - "@typescript-eslint/parser": "8.56.0", - "@typescript-eslint/typescript-estree": "8.56.0", - "@typescript-eslint/utils": "8.56.0" + "@typescript-eslint/eslint-plugin": "8.56.1", + "@typescript-eslint/parser": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -59355,69 +58413,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -61572,9 +60567,9 @@ } }, "node_modules/yaml-eslint-parser/node_modules/eslint-visitor-keys": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", - "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -61819,18 +60814,6 @@ "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.1.0.tgz", "integrity": "sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==", "license": "MIT AND BSD-3-Clause" - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } } } } diff --git a/package.json b/package.json index aa4538a098..d74443a424 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@turf/helpers": "^7.2.0", "@vivjs/layers": "^0.16.1", "analytics": "^0.8.19", + "animate.css": "^4.1.1", "axios": "^1.13.5", "bootstrap": "^5.3.8", "cannon-es": "^0.20.0",