diff --git a/ui/.eslintrc.json b/ui/.eslintrc.json new file mode 100644 index 000000000..2e37b61f1 --- /dev/null +++ b/ui/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": ["next","next/core-web-vitals"], + "rules": { + "react/display-name": "off" + } +} diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 000000000..8f322f0d8 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# 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/ui/README.md b/ui/README.md new file mode 100644 index 000000000..ff41fa7be --- /dev/null +++ b/ui/README.md @@ -0,0 +1,21 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. diff --git a/ui/next.config.js b/ui/next.config.js new file mode 100644 index 000000000..23d33869d --- /dev/null +++ b/ui/next.config.js @@ -0,0 +1,8 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + largePageDataBytes: 256 * 100000, + }, +} + +module.exports = nextConfig diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 000000000..279ba34f4 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,36 @@ +{ + "name": "ui", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@ant-design/cssinjs": "^1.17.2", + "antd": "^5.10.1", + "moment": "^2.29.4", + "next": "13.5.5", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-html-parser": "^2.0.2" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react-html-parser": "^2.0.4", + "@types/react-latex": "^2.0.2", + "autoprefixer": "^10", + "eslint": "^8", + "eslint-config-next": "13.5.5", + "postcss": "^8", + "react-latex": "^2.0.0", + "tailwindcss": "^3", + "typescript": "^5" + }, + "resolutions": { + "**/@types/react": "^18.2.29", + "**/@types/react-dom": "^18.2.14" + } +} diff --git a/ui/postcss.config.js b/ui/postcss.config.js new file mode 100644 index 000000000..33ad091d2 --- /dev/null +++ b/ui/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/ui/public/images/apple-touch-icon.png b/ui/public/images/apple-touch-icon.png new file mode 100644 index 000000000..f7f2cecab Binary files /dev/null and b/ui/public/images/apple-touch-icon.png differ diff --git a/ui/public/images/background.jpeg b/ui/public/images/background.jpeg new file mode 100644 index 000000000..5ade4d167 Binary files /dev/null and b/ui/public/images/background.jpeg differ diff --git a/ui/public/images/favicon-16x16.png b/ui/public/images/favicon-16x16.png new file mode 100644 index 000000000..029d603b0 Binary files /dev/null and b/ui/public/images/favicon-16x16.png differ diff --git a/ui/public/images/favicon-32x32.png b/ui/public/images/favicon-32x32.png new file mode 100644 index 000000000..3bc5f26da Binary files /dev/null and b/ui/public/images/favicon-32x32.png differ diff --git a/ui/public/images/favicon.ico b/ui/public/images/favicon.ico new file mode 100644 index 000000000..029d603b0 Binary files /dev/null and b/ui/public/images/favicon.ico differ diff --git a/ui/public/images/logo.png b/ui/public/images/logo.png new file mode 100644 index 000000000..b8a74d6da Binary files /dev/null and b/ui/public/images/logo.png differ diff --git a/ui/public/images/orcid-icon.png b/ui/public/images/orcid-icon.png new file mode 100644 index 000000000..a51fcf466 Binary files /dev/null and b/ui/public/images/orcid-icon.png differ diff --git a/ui/src/components/detail/DetailPageInfo.tsx b/ui/src/components/detail/DetailPageInfo.tsx new file mode 100644 index 000000000..3a332a0fc --- /dev/null +++ b/ui/src/components/detail/DetailPageInfo.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import moment from "moment"; +import { Divider } from "antd"; + +import { JournalInfo, ArticleIdentifier, License, Result } from "@/types/types"; +import { resolveIdentifierLink } from "@/utils/utils"; +import FulltextFiles from "../shared/FulltextFiles"; +import PublicationInfo from "../shared/PublicationInfo"; + +const DetailPageInfo = ({ article }: { article: Result }) => { + const { artid, publisher } = + (article?.publication_info?.[0] as JournalInfo) || {}; + + const renderIdentifierLinks = (identifiers: ArticleIdentifier[]) => { + return identifiers?.map((identifier) => ( +
+
{identifier?.identifier_type}:
+ {identifier?.identifier_type === "arXiv" && ( +
+ {article?.article_arxiv_category?.map( + (category) => category?.primary && category?.category + )} +
+ )} +
+ + {identifier?.identifier_value} + +
+
+ )); + }; + + const renderLicenses = (licenses: License[]) => { + return licenses?.map((licence) => ( + + {licence?.name} + + )); + }; + + return ( +
+
Published on:
+
{moment(article?.publication_date).format("DD MMMM YYYY")}
+
Created on:
+
{moment(article?._created_at).format("DD MMMM YYYY")}
+
Publisher:
+
{publisher ?? "IOP"}
+
Published in:
+
+ +
+ {artid &&
Article ID: {artid}
} + {renderIdentifierLinks(article?.article_identifiers)} +
Copyrights:
+
{article?.copyright?.[0]?.statement}
+
Licence:
+
{renderLicenses(article?.related_licenses)}
+ + + +
Fulltext files:
+
+ +
+
+ ); +}; + +export default DetailPageInfo; diff --git a/ui/src/components/home/TabContent.tsx b/ui/src/components/home/TabContent.tsx new file mode 100644 index 000000000..f2c2bf427 --- /dev/null +++ b/ui/src/components/home/TabContent.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import Link from 'next/link'; + +import { Country, Journal } from "@/types/types"; + +const TabContent = ({ data }: { data: Journal[] | Country[] }) => { + return ( +
+ +
+ ); +}; + +export default TabContent; diff --git a/ui/src/components/search/ResultItem.tsx b/ui/src/components/search/ResultItem.tsx new file mode 100644 index 000000000..1e0258feb --- /dev/null +++ b/ui/src/components/search/ResultItem.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { FilePdfOutlined, FileTextOutlined } from "@ant-design/icons"; +import ReactHtmlParser from "react-html-parser"; + +import { ArticleIdentifier, Result } from "@/types/types"; +import PublicationInfo from "../shared/PublicationInfo"; +import Authors from "../shared/Authors"; +import { resolveIdentifierLink } from "@/utils/utils"; +import FulltextFiles from "../shared/FulltextFiles"; + +const ResultItem = ({ article }: { article: Result }) => { + const renderIdentifierLinks = (identifiers: ArticleIdentifier[]) => { + return identifiers.map((identifier) => ( + + {identifier?.identifier_type}:{" "} + + {identifier?.identifier_value} + {" "} + + )); + }; + + return ( +
  • + + {article?.title} + +
    + + + {" "} + - {article?.publication_date} + +
    +

    + {ReactHtmlParser(article?.abstract)} +

    +
    +
    + +

    + {renderIdentifierLinks(article?.article_identifiers)} +

    +
    +
    + +
    +
    +
  • + ); +}; + +export default ResultItem; diff --git a/ui/src/components/search/SearchResults.tsx b/ui/src/components/search/SearchResults.tsx new file mode 100644 index 000000000..dcfd1f675 --- /dev/null +++ b/ui/src/components/search/SearchResults.tsx @@ -0,0 +1,21 @@ +import React from "react"; + +import { Result } from "@/types/types"; +import ResultItem from "./ResultItem"; + +function SearchResults({ results, count }: { results: Result[]; count: number }) { + return ( +
    +

    Found {count} results.

    + +
    + ); +} + +export default SearchResults; diff --git a/ui/src/components/shared/Authors.tsx b/ui/src/components/shared/Authors.tsx new file mode 100644 index 000000000..ed0f4c2de --- /dev/null +++ b/ui/src/components/shared/Authors.tsx @@ -0,0 +1,115 @@ +import React, { useState } from "react"; +import Image from "next/image"; +import { Modal } from "antd"; + +import OrcidIcon from "../../../public/images/orcid-icon.png"; +import { Affiliation, Author } from "@/types/types"; + +const Authors = ({ + authors, + page, + className = "", + affiliations = false, +}: { + authors: Author[]; + page: "search" | "detail"; + className?: string; + affiliations?: boolean; +}) => { + const [modalVisible, setModalVisible] = useState(false); + + const renderAuthorsOrEtAl = (authors: Author[]) => { + return authors?.length > 5 + ? authors.slice(0, 5).concat({ first_name: "et al" }) + : authors; + }; + + const formatAuthorName = (author: Author) => { + const fullName = + page === "search" + ? [author?.last_name, author?.first_name]?.filter(Boolean)?.join(", ") + : [author?.first_name, author?.last_name]?.filter(Boolean)?.join(" "); + + if (page === "detail") { + return {fullName}; + } + return fullName; + }; + + const formatAffiliations = (affiliations: Affiliation[]) => { + return affiliations + ?.map((aff) => aff?.value) + ?.filter(Boolean) + ?.join(", "); + }; + + const renderAuthorsModalButton = (authors: Author[]) => ( + + {" - "} + setModalVisible(true)}> + Show all {authors?.length} authors + + + ); + + return ( +

    + {renderAuthorsOrEtAl(authors)?.map((author, index) => ( + + {index ? " ; " : ""} + {formatAuthorName(author)} + {affiliations && + author?.affiliations && + author?.affiliations?.length > 0 && ( + <> + {" "} + ( + + {formatAffiliations(author?.affiliations)} + + ) + + )} + {author?.orcid && ( + + Author's Orcid profile + + )} + + ))} + + {authors && authors?.length > 5 && renderAuthorsModalButton(authors)} + + setModalVisible(false)} + footer={null} + style={{ top: 0 }} + > + {authors?.map((author, index) => ( +

    + {formatAuthorName(author)} + {affiliations && + author?.affiliations && + author?.affiliations?.length > 0 && + ` (${formatAffiliations(author?.affiliations)})`} +

    + ))} + +

    + ); +}; + +export default Authors; diff --git a/ui/src/components/shared/Footer.tsx b/ui/src/components/shared/Footer.tsx new file mode 100644 index 000000000..ae7509a92 --- /dev/null +++ b/ui/src/components/shared/Footer.tsx @@ -0,0 +1,37 @@ +import React from "react"; + +const Footer = () => { + const renderFooterLink = (href: string, text: string) => ( + + {text} + + ); + + return ( +
    +
    +
    +
    + {renderFooterLink("https://scoap3.org/", "SCOAP3 website")} |{" "} + {renderFooterLink("https://scoap3.org/scoap3-repository/", "About the repository")} |{" "} + {renderFooterLink("https://scoap3.org/scoap3-repository/repository-help-2/", "Search help")} +
    +
    + Articles in the SCOAP3 repository are released under a{" "} + {renderFooterLink("https://creativecommons.org/licenses/by/3.0/", "CC-BY")} license. Metadata are provided by the corresponding publishers and released under the a{" "} + {renderFooterLink("https://creativecommons.org/publicdomain/zero/1.0/", "CC0")} waiver. +
    +
    + Repository contact:{" "} + {renderFooterLink("mailto:repo.admin@scoap3.org", "repo.admin@scoap3.org")} +
    +
    +
    +

    SCOAP3

    +
    +
    +
    + ); +}; + +export default Footer; diff --git a/ui/src/components/shared/FulltextFiles.tsx b/ui/src/components/shared/FulltextFiles.tsx new file mode 100644 index 000000000..b8eaf7d9c --- /dev/null +++ b/ui/src/components/shared/FulltextFiles.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import { FilePdfOutlined, FileTextOutlined } from "@ant-design/icons"; + +import { File } from "../../types/types"; + +type Extension = "pdf" | "pdfa" | "xml"; + +const supportedExtensions: Extension[] = ["pdf", "pdfa", "xml"]; + +const isSupportedExtension = (value: string): value is Extension => { + return supportedExtensions.includes(value as Extension); +}; + +const formatsWithIcons: Record = { + pdf: { + label: " PDF", + icon: , + }, + pdfa: { + label: " PDF/A", + icon: , + }, + xml: { + label: " XML", + icon: , + }, +}; + +type FulltextFilesProps = { + files: File[]; + size?: "small" | "big"; +}; + +const FulltextFiles: React.FC = ({ files, size = "big" }) => { + const re = /(?:\.([^.]+))?$/; + + return ( + <> + {files?.map((file) => { + let fileExtension = re.exec(file?.file)?.[1]; + + if (file?.file?.slice(-6, -4) === '_a' || file?.file?.slice(-6, -4) === '.a') { + fileExtension = 'pdfa'; + } + + if (fileExtension === undefined) { + return null; + } + + if (isSupportedExtension(fileExtension)) { + const { icon, label } = formatsWithIcons[fileExtension]; + return ( + + {icon} + {label} + + ); + } + + return null; + })} + + ); +}; + +export default FulltextFiles; diff --git a/ui/src/components/shared/Header.tsx b/ui/src/components/shared/Header.tsx new file mode 100644 index 000000000..c7f084531 --- /dev/null +++ b/ui/src/components/shared/Header.tsx @@ -0,0 +1,118 @@ +import React, { useState } from "react"; +import Image from "next/image"; +import Link from 'next/link'; +import { Button } from "antd"; +import { MenuOutlined } from "@ant-design/icons"; + +import { MenuItem } from "@/types/types"; + +const Menu = ({ + items, + mobile = false, + collapsed = true, +}: { + items: MenuItem[]; + mobile?: boolean; + collapsed?: boolean; +}) => ( +
      + {items.map((item: MenuItem) => ( +
    • + {item.label} +
    • + ))} +
    +); + +const Header = () => { + const [collapsed, setCollapsed] = useState(true); + + const toggleCollapsed = () => { + setCollapsed(!collapsed); + }; + + const headerItems = [ + { + key: 1, + label: Home, + }, + { + key: 2, + label: ( + + ScoapĀ³ project + + ), + }, + { + key: 3, + label: Partners, + }, + { + key: 4, + label: ( + + About + + ), + }, + { + key: 5, + label: ( + + Help + + ), + }, + { + key: 6, + label: ( + + Documentation + + ), + }, + { + key: 7, + label: Login, + }, + ]; + + return ( +
    +
    + + Logo of Scope3 + + + +
    + +
    + ); +}; + +export default Header; diff --git a/ui/src/components/shared/Layout.tsx b/ui/src/components/shared/Layout.tsx new file mode 100644 index 000000000..6c0744788 --- /dev/null +++ b/ui/src/components/shared/Layout.tsx @@ -0,0 +1,16 @@ +import React from "react"; + +import Footer from "./Footer"; +import Header from "./Header"; + +const Layout = ({ children }: { children: React.ReactNode }) => { + return ( +
    +
    +
    {children}
    +
    +
    + ); +}; + +export default Layout; diff --git a/ui/src/components/shared/PublicationInfo.tsx b/ui/src/components/shared/PublicationInfo.tsx new file mode 100644 index 000000000..ace8ca094 --- /dev/null +++ b/ui/src/components/shared/PublicationInfo.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import ReactHtmlParser from "react-html-parser"; + +import { JournalInfo } from "@/types/types"; + +const PublicationInfo = ({ + data, + page, +}: { + data: JournalInfo; + page: "search" | "detail"; +}) => { + const { + journal_title, + journal_volume, + journal_issue, + volume_year, + page_start, + page_end, + publisher, + } = data || {}; + + let publicationText = ""; + + if (page === "search") { + publicationText += "Published in: "; + } + + if (journal_title) { + publicationText += `${journal_title}`; + } + + if (journal_volume) { + publicationText += `, Volume ${journal_volume} (${volume_year})`; + } + + if (journal_issue) { + publicationText += ` Issue ${journal_issue}`; + } + + if (page_start && page === "search") { + publicationText += " ("; + } + + if (page_start) { + publicationText += `Page${ + page_end ? "s" : "" + } ${+page_start}${page_end ? `-${+page_end}` : ""}`; + } + + if (page_start && page === "search") { + publicationText += ")"; + } + + if (page === "search" && publisher) { + const publisherBold = `${publisher}`; + publicationText += ` by ${publisherBold}`; + } + + return ( +

    + {ReactHtmlParser(publicationText)} +

    + ); +}; + +export default PublicationInfo; diff --git a/ui/src/components/shared/SearchBar.tsx b/ui/src/components/shared/SearchBar.tsx new file mode 100644 index 000000000..610f7769f --- /dev/null +++ b/ui/src/components/shared/SearchBar.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { useRouter } from "next/navigation"; +import { Input } from "antd"; +import { useState } from "react"; + +const SearchBar = ({ + placeholder = "Search", + className, + value = '', +}: { + placeholder?: string; + className?: string; + value?: string | undefined; +}) => { + const [val, setVal] = useState(value); + + const { Search } = Input; + const router = useRouter(); + + return ( + router.push(`/search?q=${val}`)} + placeholder={placeholder} + enterButton + className={className} + value={val} + onChange={(e) => setVal(e?.target?.value)} + /> + ); +}; + +export default SearchBar; diff --git a/ui/src/pages/_app.tsx b/ui/src/pages/_app.tsx new file mode 100644 index 000000000..8b44503b5 --- /dev/null +++ b/ui/src/pages/_app.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { ConfigProvider } from "antd"; +import Head from "next/head"; + +import "@/styles/globals.css"; +import theme from "../theme/themeConfig"; +import Layout from "@/components/shared/Layout"; + +const App: React.FC = ({ Component, pageProps }) => ( + + + + SCOAP3 Repository + + + + +); + +export default App; diff --git a/ui/src/pages/_document.tsx b/ui/src/pages/_document.tsx new file mode 100644 index 000000000..3d8317068 --- /dev/null +++ b/ui/src/pages/_document.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { createCache, extractStyle, StyleProvider } from "@ant-design/cssinjs"; +import Document, { Head, Html, Main, NextScript } from "next/document"; +import type { DocumentContext } from "next/document"; + +const MyDocument = () => ( + + + + + + + + +
    + + + +); + +const enhanceAppWithStyles = (App: any) => (props: any) => ( + + + +); + +MyDocument.getInitialProps = async (ctx: DocumentContext) => { + const originalRenderPage = ctx.renderPage; + ctx.renderPage = () => + originalRenderPage({ + enhanceApp: enhanceAppWithStyles, + }); + + const initialProps = await Document.getInitialProps(ctx); + const style = extractStyle(createCache(), true); + return { + ...initialProps, + styles: ( + <> + {initialProps.styles} +