From 49f12b62f086c0e5d801add8b8b9621666d64d05 Mon Sep 17 00:00:00 2001 From: Mike Decker Date: Tue, 2 Apr 2024 12:46:13 -0700 Subject: [PATCH] Initial Summer --- .env.example | 18 + .eslintrc.json | 24 + .github/pull_request_template.md | 66 + .github/workflows/build_lint.yml | 30 + .gitignore | 45 + .gitpod.yml | 94 + .nvmrc | 1 + .storybook/main.ts | 44 + .storybook/preview.ts | 16 + .../config-pages/GlobalMessage.stories.tsx | 45 + .../config-pages/LocalFooter.stories.tsx | 81 + .../config-pages/SuperFooter.stories.tsx | 36 + .../stories/elements/Accordion.stories.tsx | 48 + .../stories/elements/Address.stories.tsx | 36 + .../stories/elements/Button.stories.tsx | 42 + .storybook/stories/elements/Email.stories.tsx | 20 + .../stories/elements/Headers.stories.tsx | 93 + .../stories/elements/Oembed.stories.tsx | 20 + .../stories/elements/SelectList.stories.tsx | 61 + .../stories/elements/Telephone.stories.tsx | 20 + .../elements/lockup/Lockup.stories.tsx | 142 + .storybook/stories/menu/MainMenu.stories.tsx | 47 + .storybook/stories/menu/SideMenu.stories.tsx | 54 + .../stories/nodes/StanfordCourse.data.tsx | 58 + .../stories/nodes/StanfordEvent.data.tsx | 74 + .../nodes/StanfordEventSeries.data.tsx | 76 + .../stories/nodes/StanfordNews.data.tsx | 16 + .../stories/nodes/StanfordPage.data.tsx | 16 + .../stories/nodes/StanfordPerson.data.tsx | 59 + .../stories/nodes/StanfordPolicy.data.tsx | 24 + .../nodes/StanfordPublication.data.tsx | 38 + .../nodes/cards/BasicPageCard.stories.tsx | 49 + .../nodes/cards/CourseCard.stories.tsx | 53 + .../stories/nodes/cards/EventCard.stories.tsx | 74 + .../nodes/cards/EventSeriesCard.stories.tsx | 47 + .../stories/nodes/cards/NewsCard.stories.tsx | 67 + .../nodes/cards/PersonCard.stories.tsx | 51 + .../nodes/cards/PolicyCard.stories.tsx | 47 + .../nodes/cards/PublicationCard.stories.tsx | 50 + .../list-item/BasicPageListItem.stories.tsx | 45 + .../list-item/CourseListItem.stories.tsx | 47 + .../nodes/list-item/EventListItem.stories.tsx | 60 + .../list-item/EventSeriesListItem.stories.tsx | 41 + .../nodes/list-item/NewsListItem.stories.tsx | 60 + .../list-item/PersonListItem.stories.tsx | 44 + .../list-item/PolicyListItem.stories.tsx | 41 + .../list-item/PublicationListItem.stories.tsx | 45 + .../stories/paragraphs/Banner.stories.tsx | 47 + .../stories/paragraphs/Card.stories.tsx | 52 + .../stories/paragraphs/Gallery.stories.tsx | 63 + .../paragraphs/MediaWithCaption.stories.tsx | 50 + .../stories/paragraphs/Wysiwyg.stories.tsx | 167 + .storybook/stories/storybook-entities.tsx | 115 + .storybook/storybook.css | 14 + .yarn/releases/yarn-4.1.1.cjs | 893 + .yarnrc.yml | 3 + README.md | 162 + app/@modal/(.)gallery/[...uuid]/page.tsx | 79 + app/@modal/default.tsx | 3 + app/[...slug]/metadata.tsx | 162 + app/[...slug]/page.tsx | 37 + app/api/draft/route.tsx | 32 + app/api/revalidate/route.tsx | 21 + app/error.tsx | 15 + app/gallery/[...uuid]/page.tsx | 56 + app/layout.tsx | 73 + app/not-found.tsx | 13 + app/page.tsx | 39 + app/preview/[...slug]/page.tsx | 32 + app/search/algolia-search.tsx | 178 + app/search/page.tsx | 72 + app/search/search-results.tsx | 101 + app/sitemap.tsx | 34 + codegen.ts | 30 + middleware.tsx | 32 + next.config.js | 86 + package.json | 79 + postcss.config.js | 6 + public/favicon.ico | Bin 0 -> 34494 bytes public/robots.txt | 4 + .../config-pages/global-message.tsx | 61 + src/components/config-pages/local-footer.tsx | 251 + src/components/config-pages/super-footer.tsx | 54 + src/components/elements/accordion.tsx | 91 + src/components/elements/action-link.tsx | 21 + src/components/elements/address.tsx | 51 + src/components/elements/back-to-top.tsx | 34 + src/components/elements/button.tsx | 91 + .../elements/drupal-window-sync.tsx | 25 + src/components/elements/email.tsx | 23 + src/components/elements/headers.tsx | 79 + .../elements/icons/FacebookIcon.tsx | 15 + .../elements/icons/InstagramIcon.tsx | 14 + .../elements/icons/LinkedInIcon.tsx | 15 + src/components/elements/icons/TwitterIcon.tsx | 15 + src/components/elements/icons/YoutubeIcon.tsx | 12 + .../elements/interception-modal.tsx | 58 + src/components/elements/link.tsx | 55 + src/components/elements/load-more-list.tsx | 81 + src/components/elements/lockup/lockup-a.tsx | 29 + src/components/elements/lockup/lockup-b.tsx | 24 + src/components/elements/lockup/lockup-d.tsx | 24 + src/components/elements/lockup/lockup-e.tsx | 25 + src/components/elements/lockup/lockup-h.tsx | 25 + src/components/elements/lockup/lockup-i.tsx | 25 + .../elements/lockup/lockup-logo.tsx | 23 + src/components/elements/lockup/lockup-m.tsx | 24 + src/components/elements/lockup/lockup-o.tsx | 15 + src/components/elements/lockup/lockup-p.tsx | 24 + src/components/elements/lockup/lockup-r.tsx | 19 + src/components/elements/lockup/lockup-s.tsx | 21 + src/components/elements/lockup/lockup-t.tsx | 22 + src/components/elements/lockup/lockup.tsx | 111 + src/components/elements/ombed.tsx | 33 + src/components/elements/paged-list.tsx | 132 + src/components/elements/select-list.tsx | 200 + src/components/elements/string-with-lines.tsx | 22 + src/components/elements/tabs.tsx | 139 + src/components/elements/telephone.tsx | 23 + .../elements/unpublished-banner.tsx | 20 + src/components/elements/user-analytics.tsx | 18 + src/components/elements/wysiwyg.tsx | 277 + src/components/global/page-footer.tsx | 103 + src/components/global/page-header.tsx | 46 + src/components/images/stanford-wordmark.tsx | 73 + src/components/layouts/interior-page.tsx | 41 + src/components/menu/main-menu.tsx | 195 + src/components/menu/side-nav.tsx | 74 + src/components/nodes/cards/node-card.tsx | 49 + .../stanford-course/stanford-course-card.tsx | 35 + .../stanford-event-series-card.tsx | 31 + .../stanford-event/stanford-event-card.tsx | 178 + .../stanford-news/stanford-news-card.tsx | 56 + .../stanford-page/stanford-page-card.tsx | 38 + .../stanford-person/stanford-person-card.tsx | 47 + .../stanford-policy/stanford-policy-card.tsx | 40 + .../stanford-publication-card.tsx | 43 + .../nodes/list-item/node-list-item.tsx | 52 + .../stanford-course-list-item.tsx | 51 + .../stanford-event-series-list-item.tsx | 29 + .../stanford-event-list-item.tsx | 94 + .../stanford-news/stanford-news-list-item.tsx | 78 + .../stanford-page/stanford-page-list-item.tsx | 54 + .../stanford-person-list-item.tsx | 47 + .../stanford-policy-list-item.tsx | 29 + .../stanford-publication-list-item.tsx | 42 + src/components/nodes/pages/node-page.tsx | 40 + .../stanford-course/stanford-course-page.tsx | 58 + .../stanford-event-series-page.tsx | 40 + .../stanford-event/stanford-event-page.tsx | 150 + .../pages/stanford-news/social-icons.tsx | 59 + .../stanford-news/stanford-news-page.tsx | 89 + .../stanford-page/stanford-page-page.tsx | 49 + .../stanford-person/stanford-person-page.tsx | 185 + .../stanford-policy/stanford-policy-page.tsx | 97 + .../pages/stanford-publication/citation.tsx | 30 + .../stanford-publication-page.tsx | 51 + .../paragraphs/get-paragraph-behaviors.tsx | 7 + src/components/paragraphs/paragraph.tsx | 53 + src/components/paragraphs/rows/one-column.tsx | 18 + src/components/paragraphs/rows/rows.tsx | 75 + .../paragraphs/rows/three-column.tsx | 24 + src/components/paragraphs/rows/two-column.tsx | 29 + .../stanford-banner/banner-paragraph.tsx | 72 + .../stanford-card/card-paragraph.tsx | 80 + .../stanford-entity/entity-paragraph.tsx | 57 + .../stanford-gallery/gallery-paragraph.tsx | 80 + .../stanford-lists/list-paragraph.tsx | 207 + .../media-caption-paragraph.tsx | 48 + .../page-title-banner-paragraph.tsx | 26 + .../person-cta-paragraph.tsx | 45 + .../stanford-schedule/schedule-paragraph.tsx | 52 + .../stanford-spacer/spacer-paragraph.tsx | 16 + .../stanford-wysiwyg/wysiwyg-paragraph.tsx | 12 + src/components/patterns/hero-banner.tsx | 58 + src/components/patterns/image-card.tsx | 57 + src/components/search/site-search-form.tsx | 29 + src/components/tools/editorially.tsx | 25 + src/components/tools/mathjax.tsx | 9 + src/components/views/card-view-grid.tsx | 29 + .../shared-tags/shared-tags-card-view.tsx | 20 + .../stanford-courses/course-card-view.tsx | 20 + .../stanford-courses/course-list-view.tsx | 29 + .../stanford-events/events-card-view.tsx | 20 + .../events-filtered-list-view.tsx | 89 + .../stanford-events/events-list-view.tsx | 40 + .../views/stanford-news/news-card-view.tsx | 20 + .../views/stanford-news/news-list-view.tsx | 29 + .../views/stanford-page/page-card-view.tsx | 20 + .../views/stanford-page/page-list-view.tsx | 28 + .../stanford-person/person-card-view.tsx | 20 + .../publications-apa-view.tsx | 20 + .../publications-chicago-view.tsx | 29 + src/components/views/view.tsx | 120 + src/lib/drupal/deserialize.tsx | 9 + src/lib/drupal/drupal-jsonapi.d.tsx | 40 + src/lib/drupal/get-access-token.tsx | 45 + src/lib/drupal/get-cache.tsx | 3 + src/lib/drupal/get-search-index.tsx | 21 + src/lib/drupal/get-taxonomy-tree.tsx | 27 + src/lib/drupal/utils.tsx | 50 + src/lib/gql/__generated__/drupal.d.ts | 4016 ++++ src/lib/gql/__generated__/queries.ts | 1532 ++ src/lib/gql/entity-queries.drupal.gql | 316 + src/lib/gql/fragments.drupal.gql | 662 + src/lib/gql/gql-client.tsx | 19 + src/lib/gql/gql-queries.tsx | 118 + src/lib/gql/menu.drupal.gql | 26 + src/lib/gql/route-query.drupal.gql | 36 + src/lib/gql/view-queries.drupal.gql | 149 + src/lib/hooks/useActiveTrail.tsx | 39 + src/lib/hooks/useFocusOnRender.tsx | 30 + src/lib/hooks/useOutsideClick.tsx | 21 + src/lib/hooks/usePagination.tsx | 79 + src/styles/centered-container.tsx | 56 + src/styles/fonts.tsx | 7 + src/styles/index.css | 16 + src/styles/typography/global-message.tsx | 16 + src/styles/typography/local-footer.tsx | 20 + src/styles/typography/wysiwyg.tsx | 15 + tailwind.config.js | 57 + tsconfig.json | 31 + vercel.json | 3 + yarn.lock | 17929 ++++++++++++++++ 224 files changed, 36709 insertions(+) create mode 100644 .env.example create mode 100644 .eslintrc.json create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/build_lint.yml create mode 100644 .gitignore create mode 100644 .gitpod.yml create mode 100644 .nvmrc create mode 100644 .storybook/main.ts create mode 100644 .storybook/preview.ts create mode 100644 .storybook/stories/config-pages/GlobalMessage.stories.tsx create mode 100644 .storybook/stories/config-pages/LocalFooter.stories.tsx create mode 100644 .storybook/stories/config-pages/SuperFooter.stories.tsx create mode 100644 .storybook/stories/elements/Accordion.stories.tsx create mode 100644 .storybook/stories/elements/Address.stories.tsx create mode 100644 .storybook/stories/elements/Button.stories.tsx create mode 100644 .storybook/stories/elements/Email.stories.tsx create mode 100644 .storybook/stories/elements/Headers.stories.tsx create mode 100644 .storybook/stories/elements/Oembed.stories.tsx create mode 100644 .storybook/stories/elements/SelectList.stories.tsx create mode 100644 .storybook/stories/elements/Telephone.stories.tsx create mode 100644 .storybook/stories/elements/lockup/Lockup.stories.tsx create mode 100644 .storybook/stories/menu/MainMenu.stories.tsx create mode 100644 .storybook/stories/menu/SideMenu.stories.tsx create mode 100644 .storybook/stories/nodes/StanfordCourse.data.tsx create mode 100644 .storybook/stories/nodes/StanfordEvent.data.tsx create mode 100644 .storybook/stories/nodes/StanfordEventSeries.data.tsx create mode 100644 .storybook/stories/nodes/StanfordNews.data.tsx create mode 100644 .storybook/stories/nodes/StanfordPage.data.tsx create mode 100644 .storybook/stories/nodes/StanfordPerson.data.tsx create mode 100644 .storybook/stories/nodes/StanfordPolicy.data.tsx create mode 100644 .storybook/stories/nodes/StanfordPublication.data.tsx create mode 100644 .storybook/stories/nodes/cards/BasicPageCard.stories.tsx create mode 100644 .storybook/stories/nodes/cards/CourseCard.stories.tsx create mode 100644 .storybook/stories/nodes/cards/EventCard.stories.tsx create mode 100644 .storybook/stories/nodes/cards/EventSeriesCard.stories.tsx create mode 100644 .storybook/stories/nodes/cards/NewsCard.stories.tsx create mode 100644 .storybook/stories/nodes/cards/PersonCard.stories.tsx create mode 100644 .storybook/stories/nodes/cards/PolicyCard.stories.tsx create mode 100644 .storybook/stories/nodes/cards/PublicationCard.stories.tsx create mode 100644 .storybook/stories/nodes/list-item/BasicPageListItem.stories.tsx create mode 100644 .storybook/stories/nodes/list-item/CourseListItem.stories.tsx create mode 100644 .storybook/stories/nodes/list-item/EventListItem.stories.tsx create mode 100644 .storybook/stories/nodes/list-item/EventSeriesListItem.stories.tsx create mode 100644 .storybook/stories/nodes/list-item/NewsListItem.stories.tsx create mode 100644 .storybook/stories/nodes/list-item/PersonListItem.stories.tsx create mode 100644 .storybook/stories/nodes/list-item/PolicyListItem.stories.tsx create mode 100644 .storybook/stories/nodes/list-item/PublicationListItem.stories.tsx create mode 100644 .storybook/stories/paragraphs/Banner.stories.tsx create mode 100644 .storybook/stories/paragraphs/Card.stories.tsx create mode 100644 .storybook/stories/paragraphs/Gallery.stories.tsx create mode 100644 .storybook/stories/paragraphs/MediaWithCaption.stories.tsx create mode 100644 .storybook/stories/paragraphs/Wysiwyg.stories.tsx create mode 100644 .storybook/stories/storybook-entities.tsx create mode 100644 .storybook/storybook.css create mode 100755 .yarn/releases/yarn-4.1.1.cjs create mode 100644 .yarnrc.yml create mode 100644 README.md create mode 100644 app/@modal/(.)gallery/[...uuid]/page.tsx create mode 100644 app/@modal/default.tsx create mode 100644 app/[...slug]/metadata.tsx create mode 100644 app/[...slug]/page.tsx create mode 100644 app/api/draft/route.tsx create mode 100644 app/api/revalidate/route.tsx create mode 100644 app/error.tsx create mode 100644 app/gallery/[...uuid]/page.tsx create mode 100644 app/layout.tsx create mode 100644 app/not-found.tsx create mode 100644 app/page.tsx create mode 100644 app/preview/[...slug]/page.tsx create mode 100644 app/search/algolia-search.tsx create mode 100644 app/search/page.tsx create mode 100644 app/search/search-results.tsx create mode 100644 app/sitemap.tsx create mode 100644 codegen.ts create mode 100644 middleware.tsx create mode 100644 next.config.js create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 public/favicon.ico create mode 100644 public/robots.txt create mode 100644 src/components/config-pages/global-message.tsx create mode 100644 src/components/config-pages/local-footer.tsx create mode 100644 src/components/config-pages/super-footer.tsx create mode 100644 src/components/elements/accordion.tsx create mode 100644 src/components/elements/action-link.tsx create mode 100644 src/components/elements/address.tsx create mode 100644 src/components/elements/back-to-top.tsx create mode 100644 src/components/elements/button.tsx create mode 100644 src/components/elements/drupal-window-sync.tsx create mode 100644 src/components/elements/email.tsx create mode 100644 src/components/elements/headers.tsx create mode 100644 src/components/elements/icons/FacebookIcon.tsx create mode 100644 src/components/elements/icons/InstagramIcon.tsx create mode 100644 src/components/elements/icons/LinkedInIcon.tsx create mode 100644 src/components/elements/icons/TwitterIcon.tsx create mode 100644 src/components/elements/icons/YoutubeIcon.tsx create mode 100644 src/components/elements/interception-modal.tsx create mode 100644 src/components/elements/link.tsx create mode 100644 src/components/elements/load-more-list.tsx create mode 100644 src/components/elements/lockup/lockup-a.tsx create mode 100644 src/components/elements/lockup/lockup-b.tsx create mode 100644 src/components/elements/lockup/lockup-d.tsx create mode 100644 src/components/elements/lockup/lockup-e.tsx create mode 100644 src/components/elements/lockup/lockup-h.tsx create mode 100644 src/components/elements/lockup/lockup-i.tsx create mode 100644 src/components/elements/lockup/lockup-logo.tsx create mode 100644 src/components/elements/lockup/lockup-m.tsx create mode 100644 src/components/elements/lockup/lockup-o.tsx create mode 100644 src/components/elements/lockup/lockup-p.tsx create mode 100644 src/components/elements/lockup/lockup-r.tsx create mode 100644 src/components/elements/lockup/lockup-s.tsx create mode 100644 src/components/elements/lockup/lockup-t.tsx create mode 100644 src/components/elements/lockup/lockup.tsx create mode 100644 src/components/elements/ombed.tsx create mode 100644 src/components/elements/paged-list.tsx create mode 100644 src/components/elements/select-list.tsx create mode 100644 src/components/elements/string-with-lines.tsx create mode 100644 src/components/elements/tabs.tsx create mode 100644 src/components/elements/telephone.tsx create mode 100644 src/components/elements/unpublished-banner.tsx create mode 100644 src/components/elements/user-analytics.tsx create mode 100644 src/components/elements/wysiwyg.tsx create mode 100644 src/components/global/page-footer.tsx create mode 100644 src/components/global/page-header.tsx create mode 100644 src/components/images/stanford-wordmark.tsx create mode 100644 src/components/layouts/interior-page.tsx create mode 100644 src/components/menu/main-menu.tsx create mode 100644 src/components/menu/side-nav.tsx create mode 100644 src/components/nodes/cards/node-card.tsx create mode 100644 src/components/nodes/cards/stanford-course/stanford-course-card.tsx create mode 100644 src/components/nodes/cards/stanford-event-series/stanford-event-series-card.tsx create mode 100644 src/components/nodes/cards/stanford-event/stanford-event-card.tsx create mode 100644 src/components/nodes/cards/stanford-news/stanford-news-card.tsx create mode 100644 src/components/nodes/cards/stanford-page/stanford-page-card.tsx create mode 100644 src/components/nodes/cards/stanford-person/stanford-person-card.tsx create mode 100644 src/components/nodes/cards/stanford-policy/stanford-policy-card.tsx create mode 100644 src/components/nodes/cards/stanford-publication/stanford-publication-card.tsx create mode 100644 src/components/nodes/list-item/node-list-item.tsx create mode 100644 src/components/nodes/list-item/stanford-course/stanford-course-list-item.tsx create mode 100644 src/components/nodes/list-item/stanford-event-series/stanford-event-series-list-item.tsx create mode 100644 src/components/nodes/list-item/stanford-event/stanford-event-list-item.tsx create mode 100644 src/components/nodes/list-item/stanford-news/stanford-news-list-item.tsx create mode 100644 src/components/nodes/list-item/stanford-page/stanford-page-list-item.tsx create mode 100644 src/components/nodes/list-item/stanford-person/stanford-person-list-item.tsx create mode 100644 src/components/nodes/list-item/stanford-policy/stanford-policy-list-item.tsx create mode 100644 src/components/nodes/list-item/stanford-publication/stanford-publication-list-item.tsx create mode 100644 src/components/nodes/pages/node-page.tsx create mode 100644 src/components/nodes/pages/stanford-course/stanford-course-page.tsx create mode 100644 src/components/nodes/pages/stanford-event-series/stanford-event-series-page.tsx create mode 100644 src/components/nodes/pages/stanford-event/stanford-event-page.tsx create mode 100644 src/components/nodes/pages/stanford-news/social-icons.tsx create mode 100644 src/components/nodes/pages/stanford-news/stanford-news-page.tsx create mode 100644 src/components/nodes/pages/stanford-page/stanford-page-page.tsx create mode 100644 src/components/nodes/pages/stanford-person/stanford-person-page.tsx create mode 100644 src/components/nodes/pages/stanford-policy/stanford-policy-page.tsx create mode 100644 src/components/nodes/pages/stanford-publication/citation.tsx create mode 100644 src/components/nodes/pages/stanford-publication/stanford-publication-page.tsx create mode 100644 src/components/paragraphs/get-paragraph-behaviors.tsx create mode 100644 src/components/paragraphs/paragraph.tsx create mode 100644 src/components/paragraphs/rows/one-column.tsx create mode 100644 src/components/paragraphs/rows/rows.tsx create mode 100644 src/components/paragraphs/rows/three-column.tsx create mode 100644 src/components/paragraphs/rows/two-column.tsx create mode 100644 src/components/paragraphs/stanford-banner/banner-paragraph.tsx create mode 100644 src/components/paragraphs/stanford-card/card-paragraph.tsx create mode 100644 src/components/paragraphs/stanford-entity/entity-paragraph.tsx create mode 100644 src/components/paragraphs/stanford-gallery/gallery-paragraph.tsx create mode 100644 src/components/paragraphs/stanford-lists/list-paragraph.tsx create mode 100644 src/components/paragraphs/stanford-media-caption/media-caption-paragraph.tsx create mode 100644 src/components/paragraphs/stanford-page-title-banner/page-title-banner-paragraph.tsx create mode 100644 src/components/paragraphs/stanford-person-cta/person-cta-paragraph.tsx create mode 100644 src/components/paragraphs/stanford-schedule/schedule-paragraph.tsx create mode 100644 src/components/paragraphs/stanford-spacer/spacer-paragraph.tsx create mode 100644 src/components/paragraphs/stanford-wysiwyg/wysiwyg-paragraph.tsx create mode 100644 src/components/patterns/hero-banner.tsx create mode 100644 src/components/patterns/image-card.tsx create mode 100644 src/components/search/site-search-form.tsx create mode 100644 src/components/tools/editorially.tsx create mode 100644 src/components/tools/mathjax.tsx create mode 100644 src/components/views/card-view-grid.tsx create mode 100644 src/components/views/shared-tags/shared-tags-card-view.tsx create mode 100644 src/components/views/stanford-courses/course-card-view.tsx create mode 100644 src/components/views/stanford-courses/course-list-view.tsx create mode 100644 src/components/views/stanford-events/events-card-view.tsx create mode 100644 src/components/views/stanford-events/events-filtered-list-view.tsx create mode 100644 src/components/views/stanford-events/events-list-view.tsx create mode 100644 src/components/views/stanford-news/news-card-view.tsx create mode 100644 src/components/views/stanford-news/news-list-view.tsx create mode 100644 src/components/views/stanford-page/page-card-view.tsx create mode 100644 src/components/views/stanford-page/page-list-view.tsx create mode 100644 src/components/views/stanford-person/person-card-view.tsx create mode 100644 src/components/views/stanford-publications/publications-apa-view.tsx create mode 100644 src/components/views/stanford-publications/publications-chicago-view.tsx create mode 100644 src/components/views/view.tsx create mode 100644 src/lib/drupal/deserialize.tsx create mode 100644 src/lib/drupal/drupal-jsonapi.d.tsx create mode 100644 src/lib/drupal/get-access-token.tsx create mode 100644 src/lib/drupal/get-cache.tsx create mode 100644 src/lib/drupal/get-search-index.tsx create mode 100644 src/lib/drupal/get-taxonomy-tree.tsx create mode 100644 src/lib/drupal/utils.tsx create mode 100644 src/lib/gql/__generated__/drupal.d.ts create mode 100644 src/lib/gql/__generated__/queries.ts create mode 100644 src/lib/gql/entity-queries.drupal.gql create mode 100644 src/lib/gql/fragments.drupal.gql create mode 100644 src/lib/gql/gql-client.tsx create mode 100644 src/lib/gql/gql-queries.tsx create mode 100644 src/lib/gql/menu.drupal.gql create mode 100644 src/lib/gql/route-query.drupal.gql create mode 100644 src/lib/gql/view-queries.drupal.gql create mode 100644 src/lib/hooks/useActiveTrail.tsx create mode 100644 src/lib/hooks/useFocusOnRender.tsx create mode 100644 src/lib/hooks/useOutsideClick.tsx create mode 100644 src/lib/hooks/usePagination.tsx create mode 100644 src/styles/centered-container.tsx create mode 100644 src/styles/fonts.tsx create mode 100644 src/styles/index.css create mode 100644 src/styles/typography/global-message.tsx create mode 100644 src/styles/typography/local-footer.tsx create mode 100644 src/styles/typography/wysiwyg.tsx create mode 100644 tailwind.config.js create mode 100644 tsconfig.json create mode 100644 vercel.json create mode 100644 yarn.lock diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..35b52dfb --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# See https://next-drupal.org/docs/environment-variables +NEXT_PUBLIC_DRUPAL_BASE_URL=https://dev.next-drupal.org + +# Required for On-demand Revalidation. +#DRUPAL_REVALIDATE_SECRET=DRUPAL_REVALIDATE_SECRET + +# Draft mode credentials. +#DRUPAL_PREVIEW_SECRET=DRUPAL_PREVIEW_SECRET +#DRUPAL_DRAFT_CLIENT=DRUPAL_DRAFT_CLIENT +#DRUPAL_DRAFT_SECRET=DRUPAL_DRAFT_SECRET + +# Change this to 'true' to fetch all pages and build each one. +# Recommended to enable this for production environment. +#BUILD_COMPLETE=false + +# Set this environment variable on the production environment only at launch. This will be used for the +# /sitemap.xml and allow search indexing on the site. +#NEXT_PUBLIC_DOMAIN=http://localhost:3000 \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..acbf2659 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,24 @@ +{ + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module", + "project": "./tsconfig.json" + }, + "extends": [ + "next/core-web-vitals", + "plugin:storybook/recommended", + "plugin:deprecation/recommended" + ], + "rules": { + "@typescript-eslint/no-unused-vars": "off", + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": [ + "warn", + { "vars": "all", "varsIgnorePattern": "^_", "args": "after-used", "argsIgnorePattern": "^_" } + ], + "no-console": ["error", { "allow": ["warn"] }] + }, + "plugins": ["unused-imports"], + "ignorePatterns": ["**/__generated__/**/*"] +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..b48a7ff8 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,66 @@ +# NOT READY FOR REVIEW +- (Edit the above to reflect status) + +# Summary +- TL;DR - what's this PR for? + +# Review By (Date) +- When does this need to be reviewed by? + +# Criticality +- How critical is this PR on a 1-10 scale? Also see [Severity Assessment](https://stanfordits.atlassian.net/browse/D8CORE-1705). +- E.g., it affects one site, or every site and product? + +# Urgency +- How urgent is this? (Normal, High) + +# Review Tasks + +## Setup tasks and/or behavior to test + +1. Check out this branch +2. Rebuild Cache and import config `drush cr ; drush ci` +3. Navigate to... +4. Verify... + +### Site Configuration Sync + +- Is there a config:export in this PR that changes the config sync directory? + +## Front End Validation +- [ ] Design is approved by @ user? +- [ ] HTML validation: Is the markup using the appropriate semantic tags and [passes validation](https://validator.w3.org/nu/)? Or, [QA request ticket created](https://github.com/SU-SWS/template_warehouse/blob/master/jira_templates/QA_request_template.txt)? +- [ ] Cross-browser testing: Has been performed? Or, [QA request ticket created](https://github.com/SU-SWS/template_warehouse/blob/master/jira_templates/QA_request_template.txt)? +- [ ] Automated accessibility: Scans performed? Or, [QA request ticket created](https://github.com/SU-SWS/template_warehouse/blob/master/jira_templates/QA_request_template.txt)? +- [ ] Manual accessibility: Manually tested? Or, [QA request ticket created](https://github.com/SU-SWS/template_warehouse/blob/master/jira_templates/QA_request_template.txt)? + +## Backend / Functional Validation +### Code +- [ ] Are the naming conventions following our standards? +- [ ] Does the code have sufficient inline comments? +- [ ] Is there anything in this code that would be hidden or hard to discover through the UI? +- [ ] Are there any [code smells](https://blog.codinghorror.com/code-smells/)? +- [ ] Are tests provided? eg (unit, behat, or codeception) + +### Code security +- [ ] Are all [forms properly sanitized](https://www.drupal.org/docs/8/security/drupal-8-sanitizing-output)? +- [ ] Any obvious [security flaws or new areas for attack](https://www.drupal.org/docs/8/security)? + +## General +- [ ] Is there anything included in this PR that is not related to the problem it is trying to solve? +- [ ] Is the approach to the problem appropriate? + +# Affected Projects or Products +- Does this PR impact any particular projects, products, or modules? + +# Associated Issues and/or People +- JIRA ticket(s) +- Other PRs +- Any other contextual information that might be helpful (e.g., description of a bug that this PR fixes, new functionality that it adds, etc.) +- Anyone who should be notified? (`@mention` them here) + +# Resources +- [AMP Tool](https://stanford.levelaccess.net/index.php) +- [Accessibility Manual Test Script](https://docs.google.com/document/d/1ZXJ9RIUNXsS674ow9j3qJ2g1OAkCjmqMXl0Gs8XHEPQ/edit?usp=sharing) +- [HTML Validator](https://validator.w3.org/) +- [Browserstack](https://live.browserstack.com/dashboard) and link to [Browserstack Credentials](https://asconfluence.stanford.edu/confluence/display/SWS/External+Account+Credentials) diff --git a/.github/workflows/build_lint.yml b/.github/workflows/build_lint.yml new file mode 100644 index 00000000..e580724b --- /dev/null +++ b/.github/workflows/build_lint.yml @@ -0,0 +1,30 @@ + +name: Build & Lint +on: [push] +jobs: + lint: + name: Lint & TS Check + runs-on: ubuntu-latest + container: + image: node:20 + env: + NEXT_PUBLIC_DRUPAL_BASE_URL: ${{ secrets.NEXT_PUBLIC_DRUPAL_BASE_URL }} + steps: + - uses: actions/checkout@v4 + - name: Restore Cache + uses: actions/cache@v4 + with: + path: | + node_modules + key: 1.x-${{ hashFiles('package.json') }}-${{ hashFiles('yarn.lock') }} + restore-keys: | + 1.x-${{ hashFiles('package.json') }}-${{ hashFiles('yarn.lock') }} + 1.x-${{ hashFiles('package.json') }}- + 1.x- + - name: Lint + run: | + yarn + yarn lint + - name: Build + run: | + yarn build \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..38ef9f94 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +.idea + +# Yarn files. See https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored. +.yarn/* +!.yarn/cache +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 00000000..c6abe744 --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,94 @@ + +additionalRepositories: + - url: https://github.com/SU-SWS/ace-stanfordlagunita/ + checkoutLocation: back +checkoutLocation: front +ports: + - name: database + description: Mysql database + port: 3306 + onOpen: ignore + visibility: private + - port: 33060 + onOpen: ignore + visibility: private + - name: drupal + description: Drupal backend + port: 8001 + onOpen: ignore + visibility: public + - name: frontend + description: NextJS frontend + port: 3000 + onOpen: ignore + visibility: public + - port: 8002-9999 + onOpen: ignore +image: pookmish/drupal8ci:gitpod +tasks: + - name: Drupal Prep + init: > + eval $(gp env -e APACHE_DOCROOT_IN_REPO=../back/docroot) && + cd /workspace/back && + rm -rf config/default && + mkdir -p config/default && + touch config/default/core.extension.yml && + composer install --no-interaction && + mkdir -p blt && + cp .gitpod/blt.yml blt/local.blt.yml && + find docroot/sites/ -name 'local*' | xargs rm -rf && + export NEXT_PUBLIC_DRUPAL_BASE_URL=`gp url 8001` && + export PREVIEW_URL=${NEXT_PUBLIC_DRUPAL_BASE_URL#"https://"} && + blt blt:telemetry:disable --no-interaction && + blt settings && + blt drupal:install --site=supress -n && + drush @supress.local cset system.theme default stanford_profile_admin_theme -y && + cd /workspace/front && + cp .env.example .env.local && + sed -i 's/#DRUPAL_REVALIDATE_SECRET/DRUPAL_REVALIDATE_SECRET/' .env.local && + sed -i 's/#DRUPAL_PREVIEW_SECRET/DRUPAL_PREVIEW_SECRET/' .env.local && + yarn install + command: | + cd /workspace/back && + echo 'Restarting Apache' && + eval $(gp env -e APACHE_DOCROOT_IN_REPO=../back/docroot) && + apache2ctl restart && + gp ports await 8001 && + find docroot -name 'local.drush.yml' | xargs rm && + export NEXT_PUBLIC_DRUPAL_BASE_URL=`gp url 8001` && + export PREVIEW_URL=${NEXT_PUBLIC_DRUPAL_BASE_URL#"https://"} && + echo " docroot/sites/local.sites.php && + blt blt:telemetry:disable --no-interaction && + echo 'Establishing Settings' && + blt settings && + echo 'Logging Into Drupal' && + drush @supress.local uli --uri=$NEXT_PUBLIC_DRUPAL_BASE_URL && + drush @supress.local uli --uri=$NEXT_PUBLIC_DRUPAL_BASE_URL | xargs gp preview --external && + git config core.fileMode false && + echo 'Connecting Drupal to Frontend' && + drush @supress.local su-next-connect "$(gp url 3000)" --preview-secret=DRUPAL_PREVIEW_SECRET --revalidation-secret=DRUPAL_REVALIDATION_SECRET && + cd /workspace/front && + yarn install && + yarn config set --home enableTelemetry 0 && + yarn next telemetry disable && + sed -i -r "s|NEXT_PUBLIC_DRUPAL_BASE_URL.*|NEXT_PUBLIC_DRUPAL_BASE_URL=$NEXT_PUBLIC_DRUPAL_BASE_URL|g" .env.local && + yarn dev & + gp ports await 3000 && + gp url 3000 | xargs gp preview --external + - name: SSH Keys + before: | + git remote set-url origin $(echo $GITPOD_WORKSPACE_CONTEXT | jq -r .repository.cloneUrl | sed -E 's|^.*.com/(.*)$|git@github.com:\1|') + mkdir -p ~/.ssh + if [[ ! -z $SSH_PUBLIC_KEY ]]; then + echo $SSH_PUBLIC_KEY | base64 -d > ~/.ssh/id_rsa.pub && chmod 644 ~/.ssh/id_rsa.pub + fi + if [[ ! -z $SSH_PRIVATE_KEY ]]; then + echo $SSH_PRIVATE_KEY | base64 -d > ~/.ssh/id_rsa && chmod 600 ~/.ssh/id_rsa + fi + if [[ ! -z $GITCONFIG ]]; then + echo $GITCONFIG | base64 -d > ~/.gitconfig && chmod 644 ~/.gitconfig + fi + +vscode: + extensions: + - bradlc.vscode-tailwindcss \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..209e3ef4 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 00000000..227e8f62 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,44 @@ +import { TsconfigPathsPlugin } from "tsconfig-paths-webpack-plugin"; +import path from "path"; +import type {StorybookConfig} from "@storybook/nextjs"; + +const config: StorybookConfig = { + framework: { + name: "@storybook/nextjs", + options: { + builder: { + useSWC: true, + }, + }, + }, + typescript: { + reactDocgen: 'react-docgen', + check: false, + }, + stories: [ + "./stories/**/*.mdx", + "./stories/**/*.stories.@(js|jsx|ts|tsx)" + ], + addons: [ + "@storybook/addon-links", + "@storybook/addon-essentials", + "@storybook/addon-interactions", + '@storybook/addon-styling', + { + name: '@storybook/addon-styling', + options: { + // Check out https://github.com/storybookjs/addon-styling/blob/main/docs/api.md + // For more details on this addon's options. + postCss: true, + }, + }, + ], + docs: { + autodocs: "tag", + }, + webpackFinal: async (config) => { + if (config.resolve) config.resolve.plugins = [new TsconfigPathsPlugin()]; + return config + }, +}; +export default config; diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 00000000..fa0e2274 --- /dev/null +++ b/.storybook/preview.ts @@ -0,0 +1,16 @@ +import type { Preview } from "@storybook/react"; +import '../src/styles/index.css'; +import './storybook.css'; + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: "^on[A-Z].*" }, + controls: { + matchers: { + date: /date/, + }, + }, + }, +}; + +export default preview; diff --git a/.storybook/stories/config-pages/GlobalMessage.stories.tsx b/.storybook/stories/config-pages/GlobalMessage.stories.tsx new file mode 100644 index 00000000..24bad3d0 --- /dev/null +++ b/.storybook/stories/config-pages/GlobalMessage.stories.tsx @@ -0,0 +1,45 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import GlobalMessage from "@components/config-pages/global-message"; +import {ComponentProps} from "react"; +import {Link, Text} from "@lib/gql/__generated__/drupal"; + +type ComponentStoryProps = ComponentProps & { + messageText?: Text["processed"] + linkUrl?: Link["url"] + linkTitle?: Link["title"] +} + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction +const meta: Meta = { + title: 'Design/Config Pages/Global Message', + component: GlobalMessage, + tags: ['autodocs'], + argTypes: { + suGlobalMsgType: { + options: ['info', 'success', 'warning', 'error', 'plain'], + control: {type: 'select'} + }, + suGlobalMsgEnabled: {control: "boolean"} + } +}; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args +export const SuccessMessage: Story = { + render: ({linkUrl, linkTitle, messageText, ...args}) => { + if (messageText) args.suGlobalMsgMessage = {processed: messageText} + if (linkUrl && linkTitle) args.suGlobalMsgLink = {url: linkUrl, title: linkTitle, internal: false} + return + }, + args: { + suGlobalMsgType: 'success', + messageText: '

Rutrum nec ipsum lacus portaest cursus orci dolor gravida gravida eget nulla ipsum elementum leo enim vivamus quam lorem tempus quis cursus sem nec pellentesque. Link text

Button text

Secondary text

', + suGlobalMsgLabel: 'Placerat lacus ut eget leo.', + suGlobalMsgHeader: 'Accumsan eget amet id sollicitudin.', + linkTitle: 'Sem quisque placerat quis suspendisse.', + linkUrl: '#', + suGlobalMsgEnabled: true, + }, +}; diff --git a/.storybook/stories/config-pages/LocalFooter.stories.tsx b/.storybook/stories/config-pages/LocalFooter.stories.tsx new file mode 100644 index 00000000..495d97cd --- /dev/null +++ b/.storybook/stories/config-pages/LocalFooter.stories.tsx @@ -0,0 +1,81 @@ +import type {Meta, StoryObj} from '@storybook/react'; + +import LocalFooter from "@components/config-pages/local-footer"; +import {ComponentProps} from "react"; + +type ComponentStoryProps = ComponentProps & {} + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction +const meta: Meta = { + title: 'Design/Config Pages/Local Footer', + component: LocalFooter, + tags: ['autodocs'], + argTypes: { + suLocalFootLocOp: { + description: "Lockup Options", + options: ['a', 'b', 'd', 'e', 'h', 'i', 'm', 'o', 'p', 'r', 's', 't', 'none'], + control: {type: "select"} + }, + suFooterEnabled: {control: "boolean"} + } +}; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args +export const LocalFooterDisplay: Story = { + args: { + suFooterEnabled: true, + suLocalFootAction: [ + {title: "Action link 1", url: "https://localhost", internal: false}, + {title: "Action link 2", url: "https://localhost", internal: false} + ], + suLocalFootAddress: { + additionalName: "additional_name", + addressLine1: "address_line1", + addressLine2: "address_line2", + administrativeArea: "administrative_area", + country: {code: "country_code"}, + familyName: "family_name", + givenName: "given_name", + locality: "locality", + organization: "organization", + postalCode: "postal_code", + sortingCode: "sorting_code", + }, + suLocalFootFButton: "suLocalFoot_f_button", + suLocalFootFIntro: {processed: "suLocalFoot_f_intro"}, + suLocalFootFMethod: "suLocalFoot_f_method", + suLocalFootFUrl: {title: "Form Action url", url: "https://localhost", internal: false}, + suLocalFootLine1: "suLocalFoot_line_1", + suLocalFootLine2: "suLocalFoot_line_2", + suLocalFootLine3: "suLocalFoot_line_3", + suLocalFootLine4: "suLocalFoot_line_4", + suLocalFootLine5: "suLocalFoot_line_5", + suLocalFootLocImg: null, + suLocalFootLocLink: {title: "suLocalFoot_loc_link", url: "https://localhost", internal: false}, + suLocalFootPrCo: {processed: "suLocalFoot_pr_co"}, + suLocalFootPrimary: [ + {title: "Primary link 1", url: "https://localhost", internal: false}, + {title: "Primary link 2", url: "https://localhost", internal: false} + ], + suLocalFootPrimeH: "suLocalFoot_prime_h", + suLocalFootSeCo: {processed: "suLocalFoot_se_co"}, + suLocalFootSecond: [ + {title: "Second Link 1", url: "https://localhost", internal: false}, + {title: "Second Link 2", url: "https://localhost", internal: false} + ], + suLocalFootSecondH: "suLocalFoot_second_h", + suLocalFootSocial: [ + {title: "Facebook", url: "https://localhost", internal: false}, + {title: "YouTube", url: "https://localhost", internal: false} + ], + suLocalFootSunetT: "suLocalFoot_sunet_t", + suLocalFootTr2Co: {processed: "suLocalFoot_tr2_co"}, + suLocalFootTrCo: {processed: "suLocalFoot_tr_co"}, + suLocalFootUseLoc: true, + suLocalFootUseLogo: true, + suLocalFootLocOp: "suLocalFoot_loc_op", + }, +}; diff --git a/.storybook/stories/config-pages/SuperFooter.stories.tsx b/.storybook/stories/config-pages/SuperFooter.stories.tsx new file mode 100644 index 00000000..6e759c6e --- /dev/null +++ b/.storybook/stories/config-pages/SuperFooter.stories.tsx @@ -0,0 +1,36 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import SuperFooter from "@components/config-pages/super-footer"; +import {ComponentProps} from "react"; +import {Text} from "@lib/gql/__generated__/drupal"; + +type ComponentStoryProps = ComponentProps & { + footerHtml?: Text["processed"] +} + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction +const meta: Meta = { + title: 'Design/Config Pages/Super Footer', + component: SuperFooter, + tags: ['autodocs'], + argTypes: { + suSuperFootEnabled: {control: "boolean"} + } +}; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args +export const SuperFooterDisplay: Story = { + render: ({footerHtml, ...args}) => { + if (footerHtml) args.suSuperFootText = {processed: footerHtml} + return + }, + args: { + suSuperFootEnabled: true, + suSuperFootIntranet: {title: "suSuperFoot_intranet", url: "http://localhost", internal: false}, + suSuperFootLink: [{title: "suSuperFoot_link", url: "http://localhost", internal: false}], + footerHtml: "suSuperFoot_text", + suSuperFootTitle: "suSuperFoot_title", + }, +}; diff --git a/.storybook/stories/elements/Accordion.stories.tsx b/.storybook/stories/elements/Accordion.stories.tsx new file mode 100644 index 00000000..a6871a87 --- /dev/null +++ b/.storybook/stories/elements/Accordion.stories.tsx @@ -0,0 +1,48 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import Accordion from "@components/elements/accordion"; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction +const meta: Meta = { + title: 'Design/Elements/Accordion', + component: Accordion, + tags: ['autodocs'], + argTypes: { + button: { + control: "text" + }, + onClick: { + table: { + disable: true, + } + }, + buttonProps: { + table: { + disable: true, + } + }, + panelProps: { + table: { + disable: true, + } + }, + isVisible: { + table: { + disable: true, + } + } + } +}; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args +export const AccordionElement: Story = { + render: ({onClick, ...args}) => { + return + }, + args: { + button: "Id arcu nec vel tempus rutrum.", + children: "Mi amet tempus congue erat fusce euismod eros cursus morbi amet amet diam tristique bibendum hendrerit sed commodo quisque cursus scelerisque morbi placerat tristique magna." + }, +}; diff --git a/.storybook/stories/elements/Address.stories.tsx b/.storybook/stories/elements/Address.stories.tsx new file mode 100644 index 00000000..3645fbca --- /dev/null +++ b/.storybook/stories/elements/Address.stories.tsx @@ -0,0 +1,36 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import Address from "@components/elements/address"; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction +const meta: Meta = { + title: 'Design/Elements/Address', + component: Address, + tags: ['autodocs'], + argTypes: { + singleLine: {control: "boolean"} + } +}; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args +export const AddressElement: Story = { + args: { + addressLine1: "addressLine1", + addressLine2: "addressLine2", + administrativeArea: "administrative_area", + country: {code: "country_code"}, + locality: "locality", + organization: "organization", + postalCode: "postal_code", + singleLine: false, + }, +}; + +export const OnelineAddress: Story = { + args: { + ...AddressElement.args, + singleLine: true, + } +} \ No newline at end of file diff --git a/.storybook/stories/elements/Button.stories.tsx b/.storybook/stories/elements/Button.stories.tsx new file mode 100644 index 00000000..675a8cf6 --- /dev/null +++ b/.storybook/stories/elements/Button.stories.tsx @@ -0,0 +1,42 @@ +import type {Meta, StoryObj} from '@storybook/react'; + +import Button from "@components/elements/button"; + +// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction +const meta: Meta = { + title: 'Design/Elements/Button', + component: Button, + tags: ['autodocs'], + argTypes: { + href: { + description: "Link url" + }, + buttonElem: { + description: "Use a + + ) +} + +export default ErrorPage \ No newline at end of file diff --git a/app/gallery/[...uuid]/page.tsx b/app/gallery/[...uuid]/page.tsx new file mode 100644 index 00000000..ac4c6b88 --- /dev/null +++ b/app/gallery/[...uuid]/page.tsx @@ -0,0 +1,56 @@ +import {H1} from "@components/elements/headers"; +import {graphqlClient} from "@lib/gql/gql-client"; +import {notFound} from "next/navigation"; +import {ParagraphStanfordGallery} from "@lib/gql/__generated__/drupal"; +import Image from "next/image"; + +export const metadata = { + title: 'Gallery Image', + robots: { + index: false + } +} + +type Props = { + params: { uuid: string[] } +} + +const Page = async ({params: {uuid}}: Props) => { + const [paragraphId, mediaUuid] = uuid + + const paragraphQuery = await graphqlClient().Paragraph({uuid: paragraphId}); + if (paragraphQuery.paragraph?.__typename !== "ParagraphStanfordGallery") notFound(); + + const paragraph = paragraphQuery.paragraph as ParagraphStanfordGallery; + let galleryImages = mediaUuid ? paragraph.suGalleryImages?.filter(image => image.id === mediaUuid) : paragraph.suGalleryImages; + + galleryImages = galleryImages?.filter(image => !!image.suGalleryImage?.url) + + return ( +
+

{paragraph.suGalleryHeadline || "Media"}

+ {galleryImages?.map(galleryImage => { + if (!galleryImage.suGalleryImage?.url) return; + + return ( +
+ {""} + + {galleryImage.suGalleryCaption && +
+ {galleryImage.suGalleryCaption} +
+ } +
+ ) + })} +
+ ) +} + +export default Page \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 00000000..dc5ec287 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,73 @@ +import '../src/styles/index.css'; +import BackToTop from "@components/elements/back-to-top"; +import PageFooter from "@components/global/page-footer"; +import PageHeader from "@components/global/page-header"; +import {Icon} from "next/dist/lib/metadata/types/metadata-types"; +import {sourceSans3} from "../src/styles/fonts"; +import DrupalWindowSync from "@components/elements/drupal-window-sync"; +import {isPreviewMode} from "@lib/drupal/utils"; +import UserAnalytics from "@components/elements/user-analytics"; + +const appleIcons: Icon[] = [60, 72, 76, 114, 120, 144, 152, 180].map(size => ({ + url: `https://www-media.stanford.edu/assets/favicon/apple-touch-icon-${size}x${size}.png`, + sizes: `${size}x${size}`, +})); + +const icons: Icon[] = [16, 32, 96, 128, 192, 196].map(size => ({ + url: size === 128 ? `https://www-media.stanford.edu/assets/favicon/favicon-${size}.png` : `https://www-media.stanford.edu/assets/favicon/favicon-${size}x${size}.png`, + sizes: `${size}x${size}` +})); + +/** + * Metadata that does not change often. + */ +export const metadata = { + metadataBase: new URL('https://somesite.stanford.edu'), + title: 'Stanford University', + openGraph: { + type: 'website', + locale: 'en_IE', + url: 'https://somesite.stanford.edu', + siteName: '[Stanford University]', + }, + twitter: { + card: 'summary_large_image', + }, + icons: { + icon: [{url: '/favicon.ico'}, ...icons], + apple: appleIcons + } +} + +// https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config +export const revalidate = false; + +const RootLayout = ({children, modal}: { children: React.ReactNode, modal: React.ReactNode }) => { + const isPreview = isPreviewMode(); + return ( + + {/* Add Google Analytics and SiteImprove when not in preview mode. */} + {!isPreview && + + } + + + + +
+ +
+ {children} + +
+ + +
+ {modal} + + + ) +} +export default RootLayout; \ No newline at end of file diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 00000000..5a46f80c --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,13 @@ +import {H1} from "@components/elements/headers"; + +const NotFound = () => { + return ( +
+

Page not found

+

+ Unable to find the page you were looking for. +

+
+ ) +} +export default NotFound; \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 00000000..226d54dc --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,39 @@ +import Rows from "@components/paragraphs/rows/rows"; +import {notFound} from "next/navigation"; +import {getEntityFromPath} from "@lib/gql/gql-queries"; +import {NodeStanfordPage, NodeUnion} from "@lib/gql/__generated__/drupal.d"; +import {isPreviewMode} from "@lib/drupal/utils"; +import {Metadata} from "next"; +import {getNodeMetadata} from "./[...slug]/metadata"; +import BannerParagraph from "@components/paragraphs/stanford-banner/banner-paragraph"; + +// https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config +export const revalidate = false; +export const dynamic = 'force-static'; + +const Home = async () => { + const {entity, error} = await getEntityFromPath('/', isPreviewMode()); + + if (error) throw new Error(error); + if (!entity) notFound(); + + return ( +
+ {entity.suPageBanner?.__typename === "ParagraphStanfordBanner" && +
+ +
+ } + {entity.suPageComponents && + + } +
+ ) +} + +export const generateMetadata = async (): Promise => { + const {entity} = await getEntityFromPath('/') + return entity ? getNodeMetadata(entity) : {}; +} + +export default Home; \ No newline at end of file diff --git a/app/preview/[...slug]/page.tsx b/app/preview/[...slug]/page.tsx new file mode 100644 index 00000000..a8ea004d --- /dev/null +++ b/app/preview/[...slug]/page.tsx @@ -0,0 +1,32 @@ +import NodePage from "@components/nodes/pages/node-page"; +import UnpublishedBanner from "@components/elements/unpublished-banner"; +import {NodeUnion} from "@lib/gql/__generated__/drupal.d"; +import {getEntityFromPath} from "@lib/gql/gql-queries"; +import {notFound} from "next/navigation"; +import Editori11y from "@components/tools/editorially"; +import {getPathFromContext, isPreviewMode, PageProps} from "@lib/drupal/utils"; + +const Page = async ({params}: PageProps) => { + const path = getPathFromContext({params}) + if (!isPreviewMode()) notFound(); + + const { entity, error} = await getEntityFromPath(path, true) + + if (error) throw new Error(error); + if (!entity) notFound(); + + return ( + <> + + + Preview Mode + + + Unpublished Page + + + + ) +} + +export default Page; \ No newline at end of file diff --git a/app/search/algolia-search.tsx b/app/search/algolia-search.tsx new file mode 100644 index 00000000..79afb95f --- /dev/null +++ b/app/search/algolia-search.tsx @@ -0,0 +1,178 @@ +"use client"; + +import algoliasearch from 'algoliasearch/lite'; +import {useHits, useSearchBox} from "react-instantsearch"; +import {InstantSearchNext} from 'react-instantsearch-nextjs'; +import Link from "@components/elements/link"; +import {H2} from "@components/elements/headers"; +import Image from "next/image"; +import {useRef} from "react"; +import Button from "@components/elements/button"; +import {UseSearchBoxProps} from "react-instantsearch"; +import {useRouter, useSearchParams} from "next/navigation"; +import {UseHitsProps} from "react-instantsearch-core/dist/es/connectors/useHits"; + +type Props = { + appId: string + searchIndex: string + searchApiKey: string +} + +const AlgoliaSearch = ({appId, searchIndex, searchApiKey}: Props) => { + const searchClient = algoliasearch(appId, searchApiKey); + const searchParams = useSearchParams(); + + return ( +
+ +
+ + +
+
+
+ ) +} + +const HitList = (props: UseHitsProps) => { + const {hits} = useHits(props); + if (hits.length === 0) { + return ( +

No results for your search. Please try another search.

+ ) + } + + return ( +
    + {hits.map(hit => +
  • + +
  • + )} +
+ ) +} + +type AlgoliaHit = { + url: string + title: string + summary?: string + photo?: string + updated?: number +} + +const Hit = ({hit}: { hit: AlgoliaHit }) => { + const hitUrl = new URL(hit.url); + + return ( +
+
+

+ + {hit.title} + +

+

{hit.summary}

+ + {hit.updated && +
+ Last Updated: {new Date(hit.updated * 1000).toLocaleDateString('en-us', { + month: "long", + day: "numeric", + year: "numeric" + })} +
+ } +
+ + {hit.photo && +
+ +
+ } +
+ ) +} + + +const SearchBox = (props?: UseSearchBoxProps) => { + const router = useRouter(); + const {query, refine} = useSearchBox(props); + const inputRef = useRef(null); + + if (query) { + router.replace(`?q=${query}`, {scroll: false}) + } + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + inputRef.current?.blur(); + refine(inputRef.current?.value || ""); + }} + onReset={(event) => { + event.preventDefault(); + event.stopPropagation(); + refine(''); + + if (inputRef.current) { + inputRef.current.value = ''; + inputRef.current.focus(); + } + }} + > +
+ + +
+
+ + +
+
Showing results for {query}
+
+ ); +} + +export default AlgoliaSearch; \ No newline at end of file diff --git a/app/search/page.tsx b/app/search/page.tsx new file mode 100644 index 00000000..96dcbb46 --- /dev/null +++ b/app/search/page.tsx @@ -0,0 +1,72 @@ +import {getSearchIndex} from "@lib/drupal/get-search-index"; +import SearchResults, {SearchResult} from "./search-results"; +import {H1} from "@components/elements/headers"; +import {DrupalNode} from "next-drupal"; +import {Suspense} from "react"; +import {DrupalJsonApiParams} from "drupal-jsonapi-params"; +import {getConfigPage} from "@lib/gql/gql-queries"; +import {StanfordBasicSiteSetting} from "@lib/gql/__generated__/drupal.d"; +import AlgoliaSearch from "./algolia-search"; + +// https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config +export const revalidate = false; + +export const metadata = { + title: "Search", + description: "Search the site", + robots: { + index: false, + follow: false, + noarchive: true, + } +} +const Page = async ({searchParams}: { searchParams?: { [_key: string]: string } }) => { + + const siteSettingsConfig = await getConfigPage('StanfordBasicSiteSetting') + + const search = async (searchString: string): Promise => { + "use server"; + + const params = new DrupalJsonApiParams(); + params.addCustomParam({'filter[fulltext]': searchString}) + + // This still uses JSON API because GraphQL doesn't have an easy way to search for content. + const searchResults: DrupalNode[] = await getSearchIndex('full_site_content', {params: params.getQueryObject()}); + + return searchResults.map(node => ({ + id: node.id, + title: node.title, + path: node.path.alias, + changed: node.changed, + })).slice(0, 20) + } + + const initialResults = await search(searchParams?.q || ''); + + const algoliaConfigured = siteSettingsConfig?.suSiteAlgolia && + siteSettingsConfig?.suSiteAlgoliaId && + siteSettingsConfig?.suSiteAlgoliaIndex && + siteSettingsConfig?.suSiteAlgoliaSearch; + + return ( +
+

Search

+ + {!algoliaConfigured && + }> + + + } + + {(siteSettingsConfig?.suSiteAlgoliaId && siteSettingsConfig?.suSiteAlgoliaIndex && siteSettingsConfig?.suSiteAlgoliaSearch) && + + } +
+ ) +} + +export default Page; \ No newline at end of file diff --git a/app/search/search-results.tsx b/app/search/search-results.tsx new file mode 100644 index 00000000..78240d17 --- /dev/null +++ b/app/search/search-results.tsx @@ -0,0 +1,101 @@ +"use client"; + +import {FormEvent, useRef, useState} from "react"; +import Link from "@components/elements/link"; +import {ArrowPathIcon} from "@heroicons/react/20/solid"; +import {useRouter} from "next/navigation"; + +export type SearchResult = { + id: string + title: string + path: string + changed: string +} + +type SearchState = { + results: SearchResult[], + searchString: string + isLoading: boolean +} + +type Props = { + search: (_search: string) => Promise + initialSearchString: string + initialResults: SearchResult[] +} + +const SearchResults = ({search, initialSearchString, initialResults}: Props) => { + const router = useRouter(); + const inputRef = useRef(null); + + const [searchState, setSearchState] = useState({ + results: initialResults, + searchString: initialSearchString || '', + isLoading: false + }); + + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + setSearchState({...searchState, isLoading: true}) + + const searchString = inputRef.current?.value || ''; + router.push(`/search?q=${searchString}`, {scroll: false}) + + search(searchString).then(results => { + setSearchState({results, searchString, isLoading: false}) + }); + } + + return ( +
+
+ + + +
+ +
+ Showing {searchState.results.length} {!searchState.searchString ? 'suggestions.' : `results for ${searchState.searchString}.`} +
+ {searchState.isLoading && +
+ +
+ } + {searchState.results.length === 0 &&
No results found for your search. Please try another keyword.
} + + {searchState.results.length > 0 && +
    + {searchState.results.map(result => +
  • + + {result.title} + +
    Last + Updated: {new Date(result.changed).toLocaleDateString('en-us', { + month: 'long', + day: 'numeric', + year: 'numeric' + })}
    +
  • + )} +
+ } +
+ ) +} +export default SearchResults \ No newline at end of file diff --git a/app/sitemap.tsx b/app/sitemap.tsx new file mode 100644 index 00000000..867299c3 --- /dev/null +++ b/app/sitemap.tsx @@ -0,0 +1,34 @@ +import {MetadataRoute} from "next"; +import {graphqlClient} from "@lib/gql/gql-client"; +import {NodeUnion} from "@lib/gql/__generated__/drupal"; + +// https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config +export const revalidate = false; +export const dynamic = 'force-static'; + +const Sitemap = async (): Promise => { + const nodeQuery = await graphqlClient({next: {tags: ['paths']}}).AllNodes(); + const nodes: NodeUnion[] = []; + + nodeQuery.nodeStanfordCourses.nodes.map(node => nodes.push(node as NodeUnion)); + nodeQuery.nodeStanfordEventSeriesItems.nodes.map(node => nodes.push(node as NodeUnion)); + nodeQuery.nodeStanfordEvents.nodes.map(node => nodes.push(node as NodeUnion)); + nodeQuery.nodeStanfordNewsItems.nodes.map(node => nodes.push(node as NodeUnion)); + nodeQuery.nodeStanfordPages.nodes.map(node => nodes.push(node as NodeUnion)); + nodeQuery.nodeStanfordPeople.nodes.map(node => nodes.push(node as NodeUnion)); + nodeQuery.nodeStanfordPolicies.nodes.map(node => nodes.push(node as NodeUnion)); + + const sitemap: MetadataRoute.Sitemap = []; + + nodes.map(node => sitemap.push({ + url: `${process.env.NEXT_PUBLIC_DOMAIN || ''}${node.path}`, + lastModified: new Date(node.changed.time), + priority: node.__typename === "NodeStanfordPage" ? 1 : .8, + changeFrequency: node.__typename === "NodeStanfordPage" ? "weekly": "monthly" + })); + + return sitemap; +} + + +export default Sitemap; \ No newline at end of file diff --git a/codegen.ts b/codegen.ts new file mode 100644 index 00000000..c1a8c387 --- /dev/null +++ b/codegen.ts @@ -0,0 +1,30 @@ +import {CodegenConfig} from '@graphql-codegen/cli'; + +const config: CodegenConfig = { + overwrite: true, + schema: `${process.env.NEXT_PUBLIC_DRUPAL_BASE_URL}/graphql` as string, + documents: 'src/lib/gql/*.drupal.gql', + generates: { + 'src/lib/gql/__generated__/drupal.d.ts': { + plugins: [ + 'typescript', + 'typescript-operations', + {add: {content: "/** THIS IS GENERATED FILE. DO NOT MODIFY IT DIRECTLY, RUN 'yarn graphql' INSTEAD. **/"}} + ], + }, + 'src/lib/gql/__generated__/queries.ts': { + preset: 'import-types', + plugins: [ + 'typescript-graphql-request', + {add: {content: "/** THIS IS GENERATED FILE. DO NOT MODIFY IT DIRECTLY, RUN 'yarn graphql' INSTEAD. **/"}} + ], + presetConfig: { + typesPath: './drupal.d', + importTypesNamespace: 'DrupalTypes' + }, + }, + + }, +}; + +export default config; \ No newline at end of file diff --git a/middleware.tsx b/middleware.tsx new file mode 100644 index 00000000..b88de8b7 --- /dev/null +++ b/middleware.tsx @@ -0,0 +1,32 @@ +import {NextRequest, NextResponse} from "next/server"; + +export const middleware = (request: NextRequest) => { + const response = NextResponse.next() + + if (request.nextUrl.pathname.startsWith('/api/preview')) { + const midnight = new Date(); + midnight.setHours(23); + midnight.setMinutes(59); + + // Set the cookie to expire at midnight tonight. + response.cookies.set({ + name: 'addEditoria11y', + value: "true", + path: '/', + maxAge: Math.round(midnight.getTime() / 1000) - Math.round(new Date().getTime() / 1000) + }) + return response; + } + + if (!request.nextUrl.pathname.startsWith('/api') && request.nextUrl.searchParams.get('slug')) { + return NextResponse.redirect(request.nextUrl.origin + request.nextUrl.pathname); + } + + return response; +} + +export const config = { + matcher: [ + '/api/preview', + ], +} diff --git a/next.config.js b/next.config.js new file mode 100644 index 00000000..63206ebc --- /dev/null +++ b/next.config.js @@ -0,0 +1,86 @@ +const drupalUrl = new URL(process.env.NEXT_PUBLIC_DRUPAL_BASE_URL); + +const nextConfig = { + experimental: {}, + typescript: { + // Disable build errors since dev dependencies aren't loaded on prod. Rely on GitHub actions to throw any errors. + ignoreBuildErrors: process.env.CI !== 'true', + }, + images: { + remotePatterns: [ + { + // Allow any stanford domain for images, but require https. + protocol: 'https', + hostname: '**.stanford.edu', + }, + { + protocol: drupalUrl.protocol.replace(':', ''), + hostname: drupalUrl.hostname, + }, + { + protocol: 'https', + hostname: 'localist-images.azureedge.net' + }, + { + hostname: '**.gitpod.io' + } + ], + }, + logging: { + fetches: { + fullUrl: true, + } + }, + async rewrites() { + return { + beforeFiles: [ + { + source: '/wp-:path*', + destination: '/not-found', + } + ] + }; + }, + async redirects() { + return [ + { + source: '/home', + destination: '/', + permanent: true + }, + { + source: '/user/:slug*', + destination: process.env.NEXT_PUBLIC_DRUPAL_BASE_URL + '/user/login', + permanent: true, + }, + { + source: '/saml/login', + destination: process.env.NEXT_PUBLIC_DRUPAL_BASE_URL + '/user/login', + permanent: true, + }, + ] + }, + async headers() { + if (process.env.NEXT_PUBLIC_DOMAIN) { + return []; + } + return [ + { + source: '/:path*', + headers: [ + { + key: 'X-Robots-Tag', + value: 'noindex,nofollow,noarchive', + }, + ], + }, + ]; + } +}; + +module.exports = nextConfig; + +if (process.env.ANALYZE === 'true') { + const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: true }); + module.exports = withBundleAnalyzer(nextConfig); +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..8f475de2 --- /dev/null +++ b/package.json @@ -0,0 +1,79 @@ +{ + "name": "decoupled-cardinalsites", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "NODE_OPTIONS='--inspect' DEV=true next dev", + "build": "next build", + "analyze": "ANALYZE=true next build", + "preview": "next build && next start", + "start": "next start", + "lint": "next lint && tsc", + "storybook": "storybook dev -p 6006", + "graphql": "DOTENV_CONFIG_PATH=./.env.local graphql-codegen --config codegen.ts -r dotenv/config" + }, + "dependencies": { + "@formkit/auto-animate": "^0.8.1", + "@heroicons/react": "^2.1.3", + "@js-temporal/polyfill": "^0.4.4", + "@mui/base": "^5.0.0-beta.41", + "@next/third-parties": "^14.1.4", + "@tailwindcss/container-queries": "^0.1.1", + "@types/node": "^20.11.30", + "@types/react": "^18.2.72", + "@types/react-dom": "^18.2.22", + "algoliasearch": "^4.23.1", + "autoprefixer": "^10.4.19", + "axios": "^1.6.8", + "clsx": "^2.1.0", + "decanter": "^7.2.0", + "drupal-jsonapi-params": "^2.3.1", + "eslint": "^8.57.0", + "eslint-config-next": "^14.1.4", + "graphql": "^16.8.1", + "graphql-request": "^6.1.0", + "graphql-tag": "^2.12.6", + "html-entities": "^2.5.2", + "html-react-parser": "^5.1.9", + "next": "^14.2.0-canary.43", + "next-drupal": "^1.6.0", + "postcss": "^8.4.38", + "qs": "^6.12.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-focus-lock": "^2.11.2", + "react-instantsearch": "^7.7.0", + "react-instantsearch-nextjs": "^0.1.14", + "react-tiny-oembed": "^1.1.0", + "sharp": "^0.33.3", + "tailwind-merge": "^2.2.2", + "tailwindcss": "^3.4.1", + "typescript": "^5.4.3", + "usehooks-ts": "^3.0.2" + }, + "devDependencies": { + "@graphql-codegen/add": "^5.0.2", + "@graphql-codegen/cli": "^5.0.2", + "@graphql-codegen/import-types-preset": "^3.0.0", + "@graphql-codegen/typescript-graphql-request": "^6.2.0", + "@graphql-codegen/typescript-operations": "^4.2.0", + "@next/bundle-analyzer": "^14.1.4", + "@storybook/addon-essentials": "^8.0.4", + "@storybook/addon-interactions": "^8.0.4", + "@storybook/addon-links": "^8.0.4", + "@storybook/addon-styling": "^1.3.7", + "@storybook/blocks": "^8.0.4", + "@storybook/nextjs": "^8.0.4", + "@storybook/react": "^8.0.4", + "@storybook/testing-library": "^0.2.2", + "concurrently": "^8.2.2", + "encoding": "^0.1.13", + "eslint-plugin-deprecation": "^2.0.0", + "eslint-plugin-storybook": "^0.8.0", + "eslint-plugin-unused-imports": "^3.1.0", + "react-docgen": "^7.0.3", + "storybook": "^8.0.4", + "tsconfig-paths-webpack-plugin": "^4.1.0" + }, + "packageManager": "yarn@4.1.1" +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 00000000..33ad091d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..49e70f583a0361b624512ab7db68417b10ad4e0a GIT binary patch literal 34494 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 { + if (!suGlobalMsgEnabled) return; + + const wrapperClasses = clsx({ + 'bg-digital-blue-dark text-white': suGlobalMsgType === 'info', + 'bg-illuminating-dark': suGlobalMsgType === 'warning', + 'bg-digital-green text-white': suGlobalMsgType === 'success', + 'bg-foggy-light': suGlobalMsgType === 'plain', + 'bg-digital-red text-white': suGlobalMsgType === 'error', + }); + + return ( +
+
+
+ + {suGlobalMsgLabel}: +
+
+ {suGlobalMsgHeader &&

{suGlobalMsgHeader}

} + + + + {suGlobalMsgLink?.url && + + {suGlobalMsgLink.title} + + } +
+
+
+ ) +} + +const MessageIcon = ({messageType}: { messageType: StanfordGlobalMessage['suGlobalMsgType'] }) => { + switch (messageType) { + case 'info': + return + case 'success': + return + case 'plain': + return ; + } + return ; +} + +export default GlobalMessage; \ No newline at end of file diff --git a/src/components/config-pages/local-footer.tsx b/src/components/config-pages/local-footer.tsx new file mode 100644 index 00000000..55694ff0 --- /dev/null +++ b/src/components/config-pages/local-footer.tsx @@ -0,0 +1,251 @@ +import Address from "@components/elements/address"; +import Link from "@components/elements/link"; +import Wysiwyg from "@components/elements/wysiwyg"; +import LockupLogo from "@components/elements/lockup/lockup-logo"; +import LockupA from "@components/elements/lockup/lockup-a"; +import LockupB from "@components/elements/lockup/lockup-b"; +import LockupD from "@components/elements/lockup/lockup-d"; +import LockupE from "@components/elements/lockup/lockup-e"; +import LockupH from "@components/elements/lockup/lockup-h"; +import LockupI from "@components/elements/lockup/lockup-i"; +import LockupM from "@components/elements/lockup/lockup-m"; +import LockupO from "@components/elements/lockup/lockup-o"; +import LockupP from "@components/elements/lockup/lockup-p"; +import LockupR from "@components/elements/lockup/lockup-r"; +import LockupS from "@components/elements/lockup/lockup-s"; +import LockupT from "@components/elements/lockup/lockup-t"; +import {JSX} from "react"; +import {H2} from "@components/elements/headers"; +import TwitterIcon from "@components/elements/icons/TwitterIcon"; +import YoutubeIcon from "@components/elements/icons/YoutubeIcon"; +import FacebookIcon from "@components/elements/icons/FacebookIcon"; +import { Maybe, StanfordLocalFooter} from "@lib/gql/__generated__/drupal.d"; +import {buildUrl} from "@lib/drupal/utils"; + +const LocalFooter = ({ + suFooterEnabled, + suLocalFootAction, + suLocalFootAddress, + suLocalFootLine1, + suLocalFootLine2, + suLocalFootLine3, + suLocalFootLine4, + suLocalFootLine5, + suLocalFootLocImg, + suLocalFootLocOp, + suLocalFootPrCo, + suLocalFootPrimary, + suLocalFootPrimeH, + suLocalFootSeCo, + suLocalFootSecond, + suLocalFootSecondH, + suLocalFootSocial, + suLocalFootTr2Co, + suLocalFootTrCo, + suLocalFootUseLoc, + suLocalFootUseLogo, +}: StanfordLocalFooter) => { + if (!suFooterEnabled) return; + + const lockupProps = { + useDefault: suLocalFootUseLoc, + lockupOption: suLocalFootLocOp, + line1: suLocalFootLine1, + line2: suLocalFootLine2, + line3: suLocalFootLine3, + line4: suLocalFootLine4, + line5: suLocalFootLine5, + logoUrl: !suLocalFootUseLogo && suLocalFootLocImg?.url ? buildUrl(suLocalFootLocImg?.url).toString() : undefined, + } + + return ( +
+
+
+ +
+ +
+
+ + {suLocalFootAddress && +
+ } + + {suLocalFootAction && +
    + {suLocalFootAction.map((link, index) => { + if (!link.url) return; + return ( +
  • + + {link.title} + +
  • + ) + })} +
+ } + + {suLocalFootSocial && +
    + {suLocalFootSocial.map((link, index) => { + if (!link.url) return; + return ( +
  • + + + {link.title} + +
  • + ) + })} +
+ } + + +
+ +
+ {suLocalFootPrimeH && +

{suLocalFootPrimeH}

} + {suLocalFootPrimary && +
    + {suLocalFootPrimary.map((link, index) => { + if (!link.url) return; + return ( +
  • + + {link.title} + +
  • + ) + })} +
+ } + + +
+ +
+ {suLocalFootSecondH && +

{suLocalFootSecondH}

} + + {suLocalFootSecond && +
    + {suLocalFootSecond.map((link, index) => { + if (!link.url) return; + return ( +
  • + + {link.title} + +
  • + ) + })} +
+ } + + + +
+ + + +
+
+
+ ) +} + +const SocialIcon = ({url}: { url: string }) => { + if (url.includes('twitter.com')) return + if (url.includes('youtube.com')) return + if (url.includes('facebook')) return + return null; +} + +export interface FooterLockupProps { + useDefault?: Maybe + siteName?: Maybe + lockupOption?: Maybe + line1?: Maybe + line2?: Maybe + line3?: Maybe + line4?: Maybe + line5?: Maybe + logoUrl?: Maybe +} + +const FooterLockup = ({useDefault = true, siteName, lockupOption, ...props}: FooterLockupProps): JSX.Element => { + const lockupProps = { + ...props + } + + lockupOption = useDefault ? 'default' : lockupOption + + switch (lockupOption) { + case 'none': + return ( +
+ + + +
+ ) + + case 'a': + return ; + + case 'b': + return ; + + case 'd': + return ; + + case 'e': + return ; + + case 'h': + return ; + + case 'i': + return ; + + case 'm': + return ; + + case 'o': + return ; + + case 'p': + return ; + + case 'r': + return ; + + case 's': + return ; + + case 't': + return ; + } + + + return ( +
+ + + +
+
+ {siteName || "University"} +
+ +
+ ) + +} +export default LocalFooter; \ No newline at end of file diff --git a/src/components/config-pages/super-footer.tsx b/src/components/config-pages/super-footer.tsx new file mode 100644 index 00000000..396362f9 --- /dev/null +++ b/src/components/config-pages/super-footer.tsx @@ -0,0 +1,54 @@ +import Wysiwyg from "@components/elements/wysiwyg"; +import Link from "@components/elements/link"; +import {LockClosedIcon} from "@heroicons/react/24/outline"; +import {H2} from "@components/elements/headers"; +import {StanfordSuperFooter} from "@lib/gql/__generated__/drupal.d"; + +const SuperFooter = ({suSuperFootEnabled, suSuperFootTitle, suSuperFootText, suSuperFootLink, suSuperFootIntranet}: StanfordSuperFooter ) => { + if (!suSuperFootEnabled) return + + return ( +
+
+
+ {suSuperFootTitle && +

{suSuperFootTitle}

+ } + + +
+ +
+
+ {suSuperFootLink && + <> + {suSuperFootLink.map((link, index) => { + if (!link.url) return; + return ( + + {link.title} + + ) + })} + + } + + {suSuperFootIntranet?.url && + + {suSuperFootIntranet.title} + + + } +
+
+
+
+ ) +} +export default SuperFooter; \ No newline at end of file diff --git a/src/components/elements/accordion.tsx b/src/components/elements/accordion.tsx new file mode 100644 index 00000000..be0083a2 --- /dev/null +++ b/src/components/elements/accordion.tsx @@ -0,0 +1,91 @@ +"use client"; + +import {HTMLAttributes, JSX, useId} from "react"; +import {useBoolean} from "usehooks-ts"; +import {H2, H3, H4} from "@components/elements/headers"; +import {ChevronDownIcon} from "@heroicons/react/20/solid"; +import {clsx} from "clsx"; +import {twMerge} from "tailwind-merge"; + +type Props = HTMLAttributes & { + /** + * Button clickable element or string. + */ + button: JSX.Element | string + /** + * Heading level element. + */ + headingLevel?: 'h2' | 'h3' | 'h4' + /** + * If the accordion should be visible on first render. + */ + initiallyVisible?: boolean + /** + * Button click event if the component is controlled. + */ + onClick?: () => void + /** + * Panel visibility state if the component is controlled. + */ + isVisible?: boolean + /** + * Extra attributes on the button element. + */ + buttonProps?: HTMLAttributes + /** + * Extra attributes on the panel element. + */ + panelProps?: HTMLAttributes +} + +const Accordion = ({ + button, + children, + headingLevel = 'h2', + onClick, + isVisible, + initiallyVisible = false, + buttonProps, + panelProps, + ...props +}: Props) => { + const {value: expanded, toggle: toggleExpanded} = useBoolean(initiallyVisible) + const id = useId(); + + const onButtonClick = () => { + onClick ? onClick() : toggleExpanded() + } + + // When the accordion is externally controlled. + const isExpanded = onClick ? isVisible : expanded; + + const Heading = headingLevel === 'h2' ? H2 : headingLevel === 'h3' ? H3 : H4; + return ( +
+ + + + +
+ {children} +
+
+ ) +} +export default Accordion; \ No newline at end of file diff --git a/src/components/elements/action-link.tsx b/src/components/elements/action-link.tsx new file mode 100644 index 00000000..7d8ec90b --- /dev/null +++ b/src/components/elements/action-link.tsx @@ -0,0 +1,21 @@ +import Link from "@components/elements/link"; +import {ChevronRightIcon} from "@heroicons/react/20/solid"; +import {HtmlHTMLAttributes} from "react"; +import {twMerge} from "tailwind-merge"; + +type Props = HtmlHTMLAttributes & { + /** + * Link url. + */ + href: string +} + +const ActionLink = ({children, ...props}: Props) => { + return ( + + {children} + + + ) +} +export default ActionLink; \ No newline at end of file diff --git a/src/components/elements/address.tsx b/src/components/elements/address.tsx new file mode 100644 index 00000000..6facc2df --- /dev/null +++ b/src/components/elements/address.tsx @@ -0,0 +1,51 @@ +import {Address as AddressType} from "@lib/gql/__generated__/drupal.d"; +import {HTMLAttributes} from "react"; + +type Props = AddressType & HTMLAttributes & { + singleLine?: boolean +} + +const Address = ({ + additionalName: _a, + addressLine1, + addressLine2, + administrativeArea, + country, + locality, + organization, + postalCode, + dependentLocality: _d, + familyName: _f, + givenName: _g, + langcode: _l, + sortingCode: _s, + singleLine = false, + ...props +}: Props) => { + + if (singleLine) { + const parts = [ + organization, + addressLine1, + addressLine2, + locality, + `${administrativeArea} ${postalCode}`, + `${country?.code}` + ]; + return ( +
{parts.filter(part => !!part).join(', ')}
+ ) + } + + return ( +
+ {organization &&
{organization}
} + {(addressLine1) &&
{addressLine1}
} + {(addressLine2) &&
{addressLine2}
} + {(locality && (administrativeArea) && (postalCode)) && +
{locality}, {administrativeArea} {postalCode}
} + {(country?.code) &&
{country?.code}
} +
+ ) +} +export default Address; \ No newline at end of file diff --git a/src/components/elements/back-to-top.tsx b/src/components/elements/back-to-top.tsx new file mode 100644 index 00000000..b25b2f22 --- /dev/null +++ b/src/components/elements/back-to-top.tsx @@ -0,0 +1,34 @@ +"use client"; + +import Button from "@components/elements/button"; +import {ChevronUpIcon} from "@heroicons/react/20/solid"; +import {useBoolean, useDebounceCallback, useEventListener} from "usehooks-ts"; +import {useCallback} from "react"; + +const BackToTop = () => { + const {value, setFalse, setTrue} = useBoolean(false) + + const onScroll = useCallback(() => { + if (window.scrollY > 1500) setTrue(); + if (window.scrollY <= 1500) setFalse(); + }, [setTrue, setFalse]) + + useEventListener('scroll', useDebounceCallback(onScroll, 200)) + + return ( + + ) +} +export default BackToTop; \ No newline at end of file diff --git a/src/components/elements/button.tsx b/src/components/elements/button.tsx new file mode 100644 index 00000000..d2bf5df1 --- /dev/null +++ b/src/components/elements/button.tsx @@ -0,0 +1,91 @@ +import Link from "@components/elements/link"; +import {twMerge} from 'tailwind-merge' +import {HtmlHTMLAttributes, MouseEventHandler} from "react"; +import {Maybe} from "@lib/gql/__generated__/drupal.d"; +import {clsx} from "clsx"; + +type Props = HtmlHTMLAttributes & { + /** + * Link URL. + */ + href?: Maybe + /** + * If the element should be a + ) + } + + return ( + + {children} + + ) +} + +export default Button \ No newline at end of file diff --git a/src/components/elements/drupal-window-sync.tsx b/src/components/elements/drupal-window-sync.tsx new file mode 100644 index 00000000..64466f74 --- /dev/null +++ b/src/components/elements/drupal-window-sync.tsx @@ -0,0 +1,25 @@ +"use client"; + +import {usePathname} from "next/navigation"; +import {useIsClient} from "usehooks-ts"; + +const DrupalWindowSync = () => { + const pathname = usePathname(); + if (!useIsClient()) return; + + if ( + pathname && + !pathname?.startsWith('/gallery/') && + !pathname?.startsWith('/preview/') && + window && + window.top !== window.self + ) { + window.parent.postMessage({ + type: "NEXT_DRUPAL_ROUTE_SYNC", + path: pathname + }, process.env.NEXT_PUBLIC_DRUPAL_BASE_URL as string) + } + return null; +} + +export default DrupalWindowSync \ No newline at end of file diff --git a/src/components/elements/email.tsx b/src/components/elements/email.tsx new file mode 100644 index 00000000..fb31e728 --- /dev/null +++ b/src/components/elements/email.tsx @@ -0,0 +1,23 @@ +"use client" + +import {HtmlHTMLAttributes} from "react"; +import {useIsClient} from "usehooks-ts"; + +type Props = HtmlHTMLAttributes & { + /** + * Email address string. + */ + email: string +} + +const Email = ({email, ...props}: Props) => { + const isClient = useIsClient(); + if (!isClient) return; + + return ( + + {email} + + ) +} +export default Email \ No newline at end of file diff --git a/src/components/elements/headers.tsx b/src/components/elements/headers.tsx new file mode 100644 index 00000000..3a728362 --- /dev/null +++ b/src/components/elements/headers.tsx @@ -0,0 +1,79 @@ +import {HtmlHTMLAttributes} from "react"; +import {twMerge} from "tailwind-merge"; + +type Props = HtmlHTMLAttributes + +const headingLinkClasses = "[&_a]:text-digital-red [&_a]:hocus:text-black [&_a]:hocus:underline"; + +export const H1 = ({children, className, ...props}: Props) => { + return ( +

+ {children} +

+ ) +} + +export const H2 = ({children, className, ...props}: Props) => { + return ( +

+ {children} +

+ ) +} + +export const H3 = ({children, className, ...props}: Props) => { + return ( +

+ {children} +

+ ) +} + +export const H4 = ({children, className, ...props}: Props) => { + return ( +

+ {children} +

+ ) +} + +export const H5 = ({children, className, ...props}: Props) => { + return ( +
+ {children} +
+ ) +} + +export const H6 = ({children, className, ...props}: Props) => { + return ( +
+ {children} +
+ ) +} + +type HeadingProps = Props & { + /** + * Which heading level to display. + */ + level?: 1 | 2 | 3 | 4 | 5 | 6 +} + +const Heading = ({children, level = 1, ...props}: HeadingProps) => { + switch (level) { + case 1: + return

{children}

+ case 2: + return

{children}

+ case 3: + return

{children}

+ case 4: + return

{children}

+ case 5: + return
{children}
+ case 6: + return
{children}
+ } +} +export default Heading; \ No newline at end of file diff --git a/src/components/elements/icons/FacebookIcon.tsx b/src/components/elements/icons/FacebookIcon.tsx new file mode 100644 index 00000000..75105df6 --- /dev/null +++ b/src/components/elements/icons/FacebookIcon.tsx @@ -0,0 +1,15 @@ +import {HTMLAttributes} from "react"; + +const FacebookIcon = (props: HTMLAttributes) => ( + + + +) +export default FacebookIcon; \ No newline at end of file diff --git a/src/components/elements/icons/InstagramIcon.tsx b/src/components/elements/icons/InstagramIcon.tsx new file mode 100644 index 00000000..2540ed08 --- /dev/null +++ b/src/components/elements/icons/InstagramIcon.tsx @@ -0,0 +1,14 @@ +import {HTMLAttributes} from "react"; + +const InstagramIcon = (props: HTMLAttributes) => { + return ( + + Instagram icon + + + ) +} + +export default InstagramIcon; \ No newline at end of file diff --git a/src/components/elements/icons/LinkedInIcon.tsx b/src/components/elements/icons/LinkedInIcon.tsx new file mode 100644 index 00000000..f5ff42c0 --- /dev/null +++ b/src/components/elements/icons/LinkedInIcon.tsx @@ -0,0 +1,15 @@ +import {HTMLAttributes} from "react"; + +const LinkedInIcon = (props: HTMLAttributes) => ( + + + +) +export default LinkedInIcon; \ No newline at end of file diff --git a/src/components/elements/icons/TwitterIcon.tsx b/src/components/elements/icons/TwitterIcon.tsx new file mode 100644 index 00000000..404776b5 --- /dev/null +++ b/src/components/elements/icons/TwitterIcon.tsx @@ -0,0 +1,15 @@ +import {HTMLAttributes} from "react"; + +const TwitterIcon = (props: HTMLAttributes) => ( + + + +) +export default TwitterIcon; \ No newline at end of file diff --git a/src/components/elements/icons/YoutubeIcon.tsx b/src/components/elements/icons/YoutubeIcon.tsx new file mode 100644 index 00000000..db9e0d68 --- /dev/null +++ b/src/components/elements/icons/YoutubeIcon.tsx @@ -0,0 +1,12 @@ +import {HTMLAttributes} from "react"; + +const YoutubeIcon = (props: HTMLAttributes) => { + return ( + + + + ) +} +export default YoutubeIcon; \ No newline at end of file diff --git a/src/components/elements/interception-modal.tsx b/src/components/elements/interception-modal.tsx new file mode 100644 index 00000000..a9ace618 --- /dev/null +++ b/src/components/elements/interception-modal.tsx @@ -0,0 +1,58 @@ +"use client"; + +import React, {HtmlHTMLAttributes, useCallback, useRef} from "react"; +import {useRouter} from "next/navigation"; +import ReactFocusLock from "react-focus-lock"; +import {XMarkIcon} from "@heroicons/react/20/solid"; +import {useEventListener, useScrollLock} from "usehooks-ts"; +import {twMerge} from "tailwind-merge"; + +const InterceptionModal = ({children, ...props}: HtmlHTMLAttributes) => { + const overlay = useRef(null); + const wrapper = useRef(null); + const router = useRouter(); + useScrollLock() + + const onDismiss = useCallback(() => router.back(), [router]); + + const onClick = useCallback((e: React.MouseEvent) => { + if (e.target === overlay.current || e.target === wrapper.current) onDismiss(); + }, [onDismiss, overlay, wrapper]); + + const onKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === "Escape") onDismiss(); + }, [onDismiss]); + + useEventListener("keydown", onKeyDown) + + return ( + + +
+ {children} +
+ + +
+
+ + ); +} + + +export default InterceptionModal; \ No newline at end of file diff --git a/src/components/elements/link.tsx b/src/components/elements/link.tsx new file mode 100644 index 00000000..486c6bcb --- /dev/null +++ b/src/components/elements/link.tsx @@ -0,0 +1,55 @@ +import {HtmlHTMLAttributes} from "react"; +import Link from "next/link"; +import {EnvelopeIcon} from "@heroicons/react/24/outline"; +import ActionLink from "@components/elements/action-link"; +import Button from "@components/elements/button"; +import {LinkProps} from "next/dist/client/link"; + +type Props = HtmlHTMLAttributes & LinkProps & { + /** + * Link URL. + */ + href: string +} + +const DrupalLink = ({href, className, children, ...props}: Props) => { + // Make sure all links have a href. + href = href || '#' + const drupalBase: string = (process.env.NEXT_PUBLIC_DRUPAL_BASE_URL || '').replace(/\/$/, ''); + + if (!href.indexOf('/files/')) { + href = href.replace(drupalBase, '').replace('', '/'); + } + + if (className?.includes('link--action')) { + return ( + + {children} + + ) + } + + if (className?.includes('button')) { + return ( + + ) + } + + return ( + + {children} + {href.startsWith('mailto') && + + } + + ) +} + +export default DrupalLink as typeof Link; diff --git a/src/components/elements/load-more-list.tsx b/src/components/elements/load-more-list.tsx new file mode 100644 index 00000000..8890c898 --- /dev/null +++ b/src/components/elements/load-more-list.tsx @@ -0,0 +1,81 @@ +"use client"; + +import {useLayoutEffect, useRef, HtmlHTMLAttributes, JSX, useId} from "react"; +import Button from "@components/elements/button"; +import {useAutoAnimate} from "@formkit/auto-animate/react"; +import {useBoolean, useCounter} from "usehooks-ts"; +import useFocusOnRender from "@lib/hooks/useFocusOnRender"; + +type Props = HtmlHTMLAttributes & { + /** + * Load more button text/element. + */ + buttonText?: string | JSX.Element + /** + * Attributes for the
    container. + */ + ulProps?: HtmlHTMLAttributes + /** + * Attributes for each
  • element. + */ + liProps?: HtmlHTMLAttributes + /** + * The number of items per page. + */ + itemsPerPage?: number +} + +const LoadMoreList = ({buttonText, children, ulProps, liProps, itemsPerPage = 10, ...props}: Props) => { + const id = useId(); + const {count: shownItems, setCount: setShownItems} = useCounter(itemsPerPage) + const {value: focusOnElement, setTrue: enableFocusElement, setFalse: disableFocusElement} = useBoolean(false) + + const focusItemRef = useRef(null); + const [animationParent] = useAutoAnimate(); + + const showMoreItems = () => { + enableFocusElement(); + setShownItems(shownItems + itemsPerPage); + } + + const setFocusOnItem = useFocusOnRender(focusItemRef, false); + + useLayoutEffect(() => { + if (focusOnElement) setFocusOnItem() + }, [focusOnElement, setFocusOnItem]); + + const focusingItem = shownItems - itemsPerPage; + const items = Array.isArray(children) ? children : [children] + const itemsToShow = items.slice(0, shownItems); + return ( +
    +
      + + {itemsToShow.map((item, i) => +
    • + {item} +
    • + )} +
    + + {items.length > itemsPerPage && + + Showing {itemsToShow.length} of {items.length} total items. + + } + + {items.length > shownItems && + + } +
    + ) +} +export default LoadMoreList; \ No newline at end of file diff --git a/src/components/elements/lockup/lockup-a.tsx b/src/components/elements/lockup/lockup-a.tsx new file mode 100644 index 00000000..a117b138 --- /dev/null +++ b/src/components/elements/lockup/lockup-a.tsx @@ -0,0 +1,29 @@ +import Link from "@components/elements/link"; +import LockupLogo from "@components/elements/lockup/lockup-logo"; +import {FooterLockupProps} from "@components/config-pages/local-footer"; + +const LockupA = ({line1, line5, siteName, logoUrl}: FooterLockupProps) => { + return ( +
    + +
    +
    + +
    + +
    +
    + {line1 || siteName} +
    +
    + + {line5 && +
    + {line5} +
    + } + +
    + ) +} +export default LockupA; \ No newline at end of file diff --git a/src/components/elements/lockup/lockup-b.tsx b/src/components/elements/lockup/lockup-b.tsx new file mode 100644 index 00000000..9529d61b --- /dev/null +++ b/src/components/elements/lockup/lockup-b.tsx @@ -0,0 +1,24 @@ +import Link from "@components/elements/link"; +import LockupLogo from "@components/elements/lockup/lockup-logo"; +import {FooterLockupProps} from "@components/config-pages/local-footer"; + +const LockupB = ({line1, line2, siteName, logoUrl}: FooterLockupProps) => { + return ( +
    + +
    +
    + +
    + +
    +
    +
    {line1 || siteName}
    +
    {line2}
    +
    +
    + +
    + ) +} +export default LockupB; \ No newline at end of file diff --git a/src/components/elements/lockup/lockup-d.tsx b/src/components/elements/lockup/lockup-d.tsx new file mode 100644 index 00000000..949bbc53 --- /dev/null +++ b/src/components/elements/lockup/lockup-d.tsx @@ -0,0 +1,24 @@ +import Link from "@components/elements/link"; +import LockupLogo from "@components/elements/lockup/lockup-logo"; +import {FooterLockupProps} from "@components/config-pages/local-footer"; + +const LockupD = ({line1, line3, siteName, logoUrl}: FooterLockupProps) => { + return ( +
    + +
    +
    + +
    + +
    +
    +
    {line1 || siteName}
    +
    {line3}
    +
    +
    + +
    + ) +} +export default LockupD; \ No newline at end of file diff --git a/src/components/elements/lockup/lockup-e.tsx b/src/components/elements/lockup/lockup-e.tsx new file mode 100644 index 00000000..8c530f62 --- /dev/null +++ b/src/components/elements/lockup/lockup-e.tsx @@ -0,0 +1,25 @@ +import Link from "@components/elements/link"; +import LockupLogo from "@components/elements/lockup/lockup-logo"; +import {FooterLockupProps} from "@components/config-pages/local-footer"; + +const LockupE = ({line1, line2, line3, siteName, logoUrl}: FooterLockupProps) => { + return ( +
    + +
    +
    + +
    + +
    +
    +
    {line1 || siteName}
    +
    {line2 || siteName}
    +
    {line3}
    +
    +
    + +
    + ) +} +export default LockupE; \ No newline at end of file diff --git a/src/components/elements/lockup/lockup-h.tsx b/src/components/elements/lockup/lockup-h.tsx new file mode 100644 index 00000000..056888c2 --- /dev/null +++ b/src/components/elements/lockup/lockup-h.tsx @@ -0,0 +1,25 @@ +import Link from "@components/elements/link"; +import LockupLogo from "@components/elements/lockup/lockup-logo"; +import {FooterLockupProps} from "@components/config-pages/local-footer"; + +const LockupH = ({line1, line3, line4, siteName, logoUrl}: FooterLockupProps) => { + return ( +
    + +
    +
    + +
    {line4}
    +
    + +
    +
    +
    {line1 || siteName}
    +
    {line3}
    +
    +
    + +
    + ) +} +export default LockupH; \ No newline at end of file diff --git a/src/components/elements/lockup/lockup-i.tsx b/src/components/elements/lockup/lockup-i.tsx new file mode 100644 index 00000000..45ea75f8 --- /dev/null +++ b/src/components/elements/lockup/lockup-i.tsx @@ -0,0 +1,25 @@ +import Link from "@components/elements/link"; +import LockupLogo from "@components/elements/lockup/lockup-logo"; +import {FooterLockupProps} from "@components/config-pages/local-footer"; + +const LockupI = ({line1, line3, line4, siteName, logoUrl}: FooterLockupProps) => { + return ( +
    + +
    +
    + +
    {line4}
    +
    + +
    +
    +
    {line1 || siteName}
    +
    {line3}
    +
    +
    + +
    + ) +} +export default LockupI; \ No newline at end of file diff --git a/src/components/elements/lockup/lockup-logo.tsx b/src/components/elements/lockup/lockup-logo.tsx new file mode 100644 index 00000000..078e1b27 --- /dev/null +++ b/src/components/elements/lockup/lockup-logo.tsx @@ -0,0 +1,23 @@ +import {Maybe} from "@lib/gql/__generated__/drupal.d"; +import StanfordWordMark from "@components/images/stanford-wordmark"; + +const LockupLogo = ({logoUrl, siteName = ''}: { logoUrl?: Maybe, siteName?: Maybe }) => { + return ( + <> + {logoUrl && + + {`${siteName} + + } + {!logoUrl && + + } + + ) +} + +export default LockupLogo; \ No newline at end of file diff --git a/src/components/elements/lockup/lockup-m.tsx b/src/components/elements/lockup/lockup-m.tsx new file mode 100644 index 00000000..8ff84fec --- /dev/null +++ b/src/components/elements/lockup/lockup-m.tsx @@ -0,0 +1,24 @@ +import Link from "@components/elements/link"; +import LockupLogo from "@components/elements/lockup/lockup-logo"; +import {FooterLockupProps} from "@components/config-pages/local-footer"; + +const LockupM = ({line1, line2, siteName, logoUrl}: FooterLockupProps) => { + return ( +
    + +
    +
    + +
    + +
    +
    +
    {line1 || siteName}
    +
    {line2}
    +
    +
    + +
    + ) +} +export default LockupM; \ No newline at end of file diff --git a/src/components/elements/lockup/lockup-o.tsx b/src/components/elements/lockup/lockup-o.tsx new file mode 100644 index 00000000..e046f0b1 --- /dev/null +++ b/src/components/elements/lockup/lockup-o.tsx @@ -0,0 +1,15 @@ +import Link from "@components/elements/link"; +import LockupLogo from "@components/elements/lockup/lockup-logo"; +import {FooterLockupProps} from "@components/config-pages/local-footer"; + +const LockupO = ({line4, siteName, logoUrl}: FooterLockupProps) => { + return ( +
    + + +
    {line4}
    + +
    + ) +} +export default LockupO; \ No newline at end of file diff --git a/src/components/elements/lockup/lockup-p.tsx b/src/components/elements/lockup/lockup-p.tsx new file mode 100644 index 00000000..fe46c83f --- /dev/null +++ b/src/components/elements/lockup/lockup-p.tsx @@ -0,0 +1,24 @@ +import Link from "@components/elements/link"; +import LockupLogo from "@components/elements/lockup/lockup-logo"; +import {FooterLockupProps} from "@components/config-pages/local-footer"; + +const LockupP = ({line1, line4, siteName, logoUrl}: FooterLockupProps) => { + return ( +
    + +
    +
    + +
    {line4}
    +
    + +
    +
    + {line1 || siteName} +
    +
    + +
    + ) +} +export default LockupP; \ No newline at end of file diff --git a/src/components/elements/lockup/lockup-r.tsx b/src/components/elements/lockup/lockup-r.tsx new file mode 100644 index 00000000..085d7097 --- /dev/null +++ b/src/components/elements/lockup/lockup-r.tsx @@ -0,0 +1,19 @@ +import Link from "@components/elements/link"; +import LockupLogo from "@components/elements/lockup/lockup-logo"; +import {FooterLockupProps} from "@components/config-pages/local-footer"; + +const LockupR = ({line5, siteName, logoUrl}: FooterLockupProps) => { + return ( +
    + +
    +
    + +
    {line5}
    +
    +
    + +
    + ) +} +export default LockupR; \ No newline at end of file diff --git a/src/components/elements/lockup/lockup-s.tsx b/src/components/elements/lockup/lockup-s.tsx new file mode 100644 index 00000000..5c889fb5 --- /dev/null +++ b/src/components/elements/lockup/lockup-s.tsx @@ -0,0 +1,21 @@ +import Link from "@components/elements/link"; +import LockupLogo from "@components/elements/lockup/lockup-logo"; +import {FooterLockupProps} from "@components/config-pages/local-footer"; + +const LockupS = ({line1, line2, line4, siteName, logoUrl}: FooterLockupProps) => { + return ( +
    + + + +
    {line4}
    +
    +
    {line1 || siteName}
    +
    {line2}
    +
    + + +
    + ) +} +export default LockupS; \ No newline at end of file diff --git a/src/components/elements/lockup/lockup-t.tsx b/src/components/elements/lockup/lockup-t.tsx new file mode 100644 index 00000000..4eba4c70 --- /dev/null +++ b/src/components/elements/lockup/lockup-t.tsx @@ -0,0 +1,22 @@ +import Link from "@components/elements/link"; +import LockupLogo from "@components/elements/lockup/lockup-logo"; +import {FooterLockupProps} from "@components/config-pages/local-footer"; + +const LockupT = ({line1, line2, line3, line4, siteName, logoUrl}: FooterLockupProps) => { + return ( +
    + + + +
    {line4}
    +
    +
    {line1 || siteName}
    +
    {line2}
    +
    {line3}
    +
    + + +
    + ) +} +export default LockupT; \ No newline at end of file diff --git a/src/components/elements/lockup/lockup.tsx b/src/components/elements/lockup/lockup.tsx new file mode 100644 index 00000000..b7ecb241 --- /dev/null +++ b/src/components/elements/lockup/lockup.tsx @@ -0,0 +1,111 @@ +import Link from "@components/elements/link"; +import LockupA from "@components/elements/lockup/lockup-a"; +import LockupB from "@components/elements/lockup/lockup-b"; +import LockupD from "@components/elements/lockup/lockup-d"; +import LockupE from "@components/elements/lockup/lockup-e"; +import LockupH from "@components/elements/lockup/lockup-h"; +import LockupI from "@components/elements/lockup/lockup-i"; +import LockupM from "@components/elements/lockup/lockup-m"; +import LockupO from "@components/elements/lockup/lockup-o"; +import LockupP from "@components/elements/lockup/lockup-p"; +import LockupR from "@components/elements/lockup/lockup-r"; +import LockupS from "@components/elements/lockup/lockup-s"; +import LockupT from "@components/elements/lockup/lockup-t"; +import LockupLogo from "@components/elements/lockup/lockup-logo"; +import {LockupSetting, StanfordBasicSiteSetting} from "@lib/gql/__generated__/drupal.d"; + +type Props = + Omit & + Omit + +export const Lockup = ({ + suLockupEnabled, + suUseThemeLogo, + suUploadLogoImage, + suSiteName, + suLine1, + suLine2, + suLine3, + suLine4, + suLine5, + suLockupOptions +}: Props) => { + const logoUrl = !suUseThemeLogo ? suUploadLogoImage?.url : undefined; + const lockupProps = { + line1: suLine1, + line2: suLine2, + line3: suLine3, + line4: suLine4, + line5: suLine5, + siteName: suSiteName || "Stanford", + logoUrl: logoUrl, + } + + if (!suLockupEnabled) { + return ( +
    + +
    +
    + +
    +
    + {suSiteName || "University"} +
    +
    + +
    + ) + } + + switch (suLockupOptions) { + case 'a': + return ; + + case 'b': + return ; + + case 'd': + return ; + + case 'e': + return ; + + case 'h': + return ; + + case 'i': + return ; + + case 'm': + return ; + + case 'o': + return ; + + case 'p': + return ; + + case 'r': + return ; + + case 's': + return ; + + case 't': + return ; + + case 'none': + default: + return ( +
    + + + +
    + ) + } +} +export default Lockup; \ No newline at end of file diff --git a/src/components/elements/ombed.tsx b/src/components/elements/ombed.tsx new file mode 100644 index 00000000..1c82c4a1 --- /dev/null +++ b/src/components/elements/ombed.tsx @@ -0,0 +1,33 @@ +"use client"; + +import {SignalIcon} from "@heroicons/react/20/solid"; +import Embed from "react-tiny-oembed"; +import {HtmlHTMLAttributes} from "react"; +import {useIntersectionObserver} from "usehooks-ts"; +import {twMerge} from "tailwind-merge"; + +type Props = HtmlHTMLAttributes & { + /** + * Oembed URL. + */ + url: string +} + +const Oembed = ({url, ...props}: Props) => { + const {isIntersecting, ref} = useIntersectionObserver({freezeOnceVisible: true}) + return ( +
    + {isIntersecting && }/>} +
    + ) +} + +const Loading = () => { + return ( +
    + +
    + ) +} + +export default Oembed; \ No newline at end of file diff --git a/src/components/elements/paged-list.tsx b/src/components/elements/paged-list.tsx new file mode 100644 index 00000000..a1df5042 --- /dev/null +++ b/src/components/elements/paged-list.tsx @@ -0,0 +1,132 @@ +"use client"; + +import {useLayoutEffect, useRef, HtmlHTMLAttributes, useEffect} from "react"; +import Button from "@components/elements/button"; +import {useAutoAnimate} from "@formkit/auto-animate/react"; +import {useBoolean, useCounter} from "usehooks-ts"; +import {useRouter, useSearchParams} from "next/navigation"; +import usePagination from "@lib/hooks/usePagination"; +import useFocusOnRender from "@lib/hooks/useFocusOnRender"; + +type Props = HtmlHTMLAttributes & { + /** + * Attributes for the
      container. + */ + ulProps?: HtmlHTMLAttributes + /** + * Attributes for each
    • element. + */ + liProps?: HtmlHTMLAttributes, + /** + * The number of items per page. + */ + itemsPerPage?: number + /** + * URL parameter used to save the users page position. + */ + pageKey?: string +} + +const PagedList = ({children, ulProps, liProps, itemsPerPage = 10, pageKey = 'page', ...props}: Props) => { + const items = Array.isArray(children) ? children : [children] + + const router = useRouter(); + const searchParams = useSearchParams() + + // Use the GET param for page, but make sure that it is between 1 and the last page. If it's a string or a number + // outside the range, fix the value, so it works as expected. + const {count: page, setCount: setPage} = useCounter(Math.max(1, Math.min(Math.ceil(items.length / itemsPerPage), parseInt(searchParams.get(pageKey) || '') || 1))) + const {value: focusOnElement, setTrue: enableFocusElement, setFalse: disableFocusElement} = useBoolean(false) + + const focusItemRef = useRef(null); + const [animationParent] = useAutoAnimate(); + + const goToPage = (page: number) => { + enableFocusElement(); + setPage(page); + } + + const setFocusOnItem = useFocusOnRender(focusItemRef, false); + + useLayoutEffect(() => { + if (focusOnElement) setFocusOnItem() + }, [focusOnElement, setFocusOnItem]); + + useEffect(() => { + // Use search params to retain any other parameters. + const params = new URLSearchParams(searchParams.toString()); + if (page > 1) { + params.set(pageKey, `${page}`) + } else { + params.delete(pageKey) + } + + router.replace(`?${params.toString()}`, {scroll: false}) + }, [router, page, pageKey, searchParams]); + const paginationButtons = usePagination(items.length, page, itemsPerPage, 2); + + return ( +
      +
        + {items.slice((page - 1) * itemsPerPage, page * itemsPerPage).map((item, i) => +
      • + {item} +
      • + )} +
      + + {paginationButtons.length > 1 && + + } +
      + ) +} + +const PaginationButton = ({page, currentPage, total, onClick}: { + page: number | string + currentPage: number + total: number + onClick: () => void +}) => { + if (page === 0) { + return ( +
    • + More pages available + ... +
    • + ) + } + + return ( +
    • + +
    • + ) +} + +export default PagedList; \ No newline at end of file diff --git a/src/components/elements/select-list.tsx b/src/components/elements/select-list.tsx new file mode 100644 index 00000000..618a05dc --- /dev/null +++ b/src/components/elements/select-list.tsx @@ -0,0 +1,200 @@ +"use client"; + +import {useSelect, SelectOptionDefinition, SelectProvider, SelectValue} from '@mui/base/useSelect'; +import {useOption} from '@mui/base/useOption'; +import { + FocusEvent, + KeyboardEvent, + MouseEvent, + ReactNode, + RefObject, + useEffect, + useId, + useLayoutEffect, + useRef, + useState +} from "react"; +import {ChevronDownIcon} from "@heroicons/react/20/solid"; +import {useIsClient} from "usehooks-ts"; +import {Maybe} from "@lib/gql/__generated__/drupal.d"; + +interface OptionProps { + rootRef: RefObject + children?: ReactNode; + value: string; + disabled?: boolean; +} + +const renderSelectedValue = (value: SelectValue, options: SelectOptionDefinition[]) => { + + if (Array.isArray(value)) { + return value.map(item => + + {renderSelectedValue(item, options)} + + ); + } + const selectedOption = options.find((option) => option.value === value); + return selectedOption ? selectedOption.label : null; +} + +function CustomOption(props: OptionProps) { + + const {children, value, rootRef, disabled = false} = props; + const {getRootProps, highlighted, selected} = useOption({rootRef: rootRef, value, disabled, label: children}); + + const {id, ...otherProps}: { id: string } = getRootProps(); + const selectedStyles = "bg-archway text-white " + (highlighted ? "underline" : "") + const highlightedStyles = "bg-black-10 text-black underline" + + useEffect(() => { + if (highlighted && id && rootRef?.current?.parentElement) { + const item = document.getElementById(id); + if (item) { + const itemTop = item?.offsetTop; + const itemHeight = item?.offsetHeight; + const parentScrollTop = rootRef.current.parentElement.scrollTop + const parentHeight = rootRef.current.parentElement.offsetHeight; + + if (itemTop < parentScrollTop) { + rootRef.current.parentElement.scrollTop = itemTop; + } + + if ((itemTop + itemHeight) > parentScrollTop + parentHeight) { + rootRef.current.parentElement.scrollTop = itemTop - parentHeight + itemHeight; + } + } + } + }, [rootRef, id, highlighted]) + + return ( +
    • + {children} +
    • + ); +} + +interface Props { + options: SelectOptionDefinition[]; + label?: Maybe + ariaLabelledby?: Maybe + defaultValue?: SelectValue + onChange?: (_event: MouseEvent | KeyboardEvent | FocusEvent | null, _value: SelectValue) => void; + multiple?: boolean + disabled?: boolean + value?: SelectValue + required?: boolean + emptyValue?: Maybe + emptyLabel?: Maybe + name?: Maybe +} + +const SelectList = ({ + options = [], + label, + multiple, + ariaLabelledby, + required, + defaultValue, + name, + emptyValue, + emptyLabel = "- None -", + ...props +}: Props) => { + const labelId = useId(); + const labeledBy = ariaLabelledby || labelId; + + const inputRef = useRef(null); + const listboxRef = useRef(null); + const [listboxVisible, setListboxVisible] = useState(false); + const isClient = useIsClient() + + const {getButtonProps, getListboxProps, contextValue, value} = useSelect({ + listboxRef, + onOpenChange: setListboxVisible, + open: listboxVisible, + defaultValue, + multiple, + ...props + }); + + useEffect(() => listboxRef.current?.focus(), [listboxVisible]); + + useLayoutEffect(() => { + const parentContainer = listboxRef.current?.parentElement?.getBoundingClientRect(); + if (parentContainer && (parentContainer.bottom > window.innerHeight || parentContainer.top < 0)) { + listboxRef.current?.parentElement?.scrollIntoView({behavior: "smooth", block: "end", inline: "nearest"}); + } + }, [listboxVisible, value]) + + const optionChosen = (multiple && value) ? value.length > 0 : !!value; + + // With Mui and Next.js 14, an error occurs on the server rendering. To avoid that issue, only render the component on the client. + if (!isClient) return null; + + return ( +
      + + +
      +
        + + {(!required && !multiple) && + + {emptyLabel} + + } + + {options.map((option) => { + return ( + + {option.label} + + ); + })} + +
      +
      + {name && + + } +
      + ); +} + + +export default SelectList; \ No newline at end of file diff --git a/src/components/elements/string-with-lines.tsx b/src/components/elements/string-with-lines.tsx new file mode 100644 index 00000000..452e7dcf --- /dev/null +++ b/src/components/elements/string-with-lines.tsx @@ -0,0 +1,22 @@ +type Props = { + /** + * New line delimited string. + */ + text: string + /** + * Key prefix to split up each line. + */ + key: string +} +const StringWithLines = ({text, key}: Props) => { + return ( + <> + {text.split('\n').map((line, i) => +

      + {line} +

      + )} + + ) +} +export default StringWithLines; \ No newline at end of file diff --git a/src/components/elements/tabs.tsx b/src/components/elements/tabs.tsx new file mode 100644 index 00000000..d1ddaaba --- /dev/null +++ b/src/components/elements/tabs.tsx @@ -0,0 +1,139 @@ +"use client"; + +import {TabsProvider, useTabs} from '@mui/base/useTabs'; +import {useTab} from '@mui/base/useTab'; +import {useTabPanel} from '@mui/base/useTabPanel'; +import {TabsListProvider, useTabsList} from '@mui/base/useTabsList'; +import {HTMLAttributes, ReactNode, SyntheticEvent, useRef} from "react"; +import {UseTabParameters} from "@mui/base/useTab/useTab.types"; +import {clsx} from "clsx"; +import {twMerge} from "tailwind-merge"; +import {UseTabsParameters} from "@mui/base/useTabs/useTabs.types"; +import {UseTabsListParameters} from "@mui/base/useTabsList/useTabsList.types"; +import {UseTabPanelParameters} from "@mui/base/useTabPanel/useTabPanel.types"; +import {useRouter, useSearchParams} from "next/navigation"; + +// View the API for all the tab components here: https://mui.com/base-ui/react-tabs/hooks-api/. +type TabsProps = HTMLAttributes & { + /** + * The query parameter in the URL for sharing or reloading. + */ + paramId?: string + /** + * Default tab for initial rendering. + */ + defaultTab?: UseTabsParameters["defaultValue"] + /** + * Which direction the tabs are displayed. + */ + orientation?: UseTabsParameters["orientation"] +} + +export const Tabs = ({paramId = 'tab', orientation, defaultTab, children, ...props}: TabsProps) => { + const searchParams = useSearchParams(); + const router = useRouter(); + const onChange = (_e: SyntheticEvent | null, value: number | string | null) => { + const params = new URLSearchParams(searchParams); + value ? params.set(paramId, `${value}`) : params.delete(paramId); + router.replace(`?${params.toString()}`, {scroll: false}) + } + const paramValue = searchParams.get(paramId) + const initialTab = defaultTab || (paramValue && parseInt(paramValue)) + + const {contextValue} = useTabs({orientation, defaultValue: initialTab || 0, onChange, selectionFollowsFocus: true}) + + return ( + +
      + {children} +
      +
      + ) +} + +type TabsListProps = Omit & { + /** + * components. + */ + children: ReactNode + /** + * Classes for the tab list. + */ + className?: HTMLAttributes["className"] + /** + * Attributes for the tab list. + */ + containerProps?: Omit, "className"> +} + +export const TabsList = ({containerProps, className, children, ...props}: TabsListProps) => { + const rootRef = useRef(null); + const {contextValue, orientation, getRootProps} = useTabsList({...props, rootRef}); + const isVertical = orientation === "vertical"; + return ( + +
      + {children} +
      +
      + ) +} + +type TabProps = UseTabParameters & { + /** + * React node or string for the tab. + */ + children: ReactNode + /** + * Classes for the button element. + */ + className?: HTMLAttributes["className"] + /** + * Extra attributes for the button element. + */ + buttonProps?: HTMLAttributes +} + +export const Tab = ({buttonProps, className, children, ...props}: TabProps) => { + const rootRef = useRef(null); + const {selected, getRootProps} = useTab({...props, rootRef}); + + return ( + + ) +} + +type TabPanelProps = UseTabPanelParameters & { + /** + * Panel contents. + */ + children: ReactNode + /** + * Classes for the panel. + */ + className?: HTMLAttributes["className"] + /** + * Extra attributes for the panel. + */ + panelProps?: HTMLAttributes +} + +export const TabPanel = ({panelProps, className, children}: TabPanelProps) => { + const rootRef = useRef(null); + const {getRootProps} = useTabPanel({rootRef}); + return ( +
      + {children} +
      + ) +} \ No newline at end of file diff --git a/src/components/elements/telephone.tsx b/src/components/elements/telephone.tsx new file mode 100644 index 00000000..17c5f6db --- /dev/null +++ b/src/components/elements/telephone.tsx @@ -0,0 +1,23 @@ +"use client" + +import {HtmlHTMLAttributes} from "react"; +import {useIsClient} from "usehooks-ts"; + +type Props = HtmlHTMLAttributes & { + /** + * Telephone number. + */ + tel: string +} + +const Telephone = ({tel, ...props}: Props) => { + const isClient = useIsClient(); + if (!isClient) return; + return ( + + + {tel} + + ) +} +export default Telephone \ No newline at end of file diff --git a/src/components/elements/unpublished-banner.tsx b/src/components/elements/unpublished-banner.tsx new file mode 100644 index 00000000..a3d1a2a9 --- /dev/null +++ b/src/components/elements/unpublished-banner.tsx @@ -0,0 +1,20 @@ +import {HTMLAttributes} from "react"; +import {twMerge} from "tailwind-merge"; + +type Props = HTMLAttributes & { + /** + * If the item is published or not. + */ + status?: boolean +} +const UnpublishedBanner = ({status, children, ...props}: Props) => { + if (status !== false) return; + return ( +
      +
      + {children} +
      +
      + ) +} +export default UnpublishedBanner; \ No newline at end of file diff --git a/src/components/elements/user-analytics.tsx b/src/components/elements/user-analytics.tsx new file mode 100644 index 00000000..9a2c6998 --- /dev/null +++ b/src/components/elements/user-analytics.tsx @@ -0,0 +1,18 @@ +import {getConfigPage} from "@lib/gql/gql-queries"; +import {StanfordBasicSiteSetting} from "@lib/gql/__generated__/drupal"; +import Script from "next/script"; +import {GoogleAnalytics} from "@next/third-parties/google"; +import {isPreviewMode} from "@lib/drupal/utils"; + +const UserAnalytics = async () => { + if (isPreviewMode()) return; + const siteSettingsConfig = await getConfigPage('StanfordBasicSiteSetting') + if (!siteSettingsConfig?.suGoogleAnalytics) return; + return ( + <> +