diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..f4b92806 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.next +.git +.env +Dockerfile +docker-compose.yml \ No newline at end of file diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 00000000..a23ef9dd --- /dev/null +++ b/.env.local.example @@ -0,0 +1,29 @@ +# Authentication System variables +KEYCLOAK_CLIENT_ID='keycloakid' +KEYCLOAK_CLIENT_SECRET='keycloaksecret' +AUTH_ISSUER=https://keycloakdomain.com/auth/realms/keycloakrealm +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET='nextauthsecret' +END_SESSION_URL=https://keycloakdomain.com/auth/realms/keycloakrealm/protocol/openid-connect/logout +REFRESH_TOKEN_URL=https://keycloakdomain.com/auth/realms/keycloakrealm/protocol/openid-connect/token + +# # Backend System variables +NEXT_PUBLIC_BACKEND_URL= https://backendurl +BACKEND_URL= https://backendurl + +NEXT_PUBLIC_BACKEND_GRAPHQL_URL=https://backendurl/api/graphql +BACKEND_GRAPHQL_URL=https://backendurl/api/graphql + +NEXT_PUBLIC_ENABLE_ACCESSMODEL = 'false' +NEXT_PUBLIC_ANALYTICS_URL ='https://analyticsurl' + + +# # Sentry feature related env varibale +SENTRY_FEATURE_ENABLED='true' +SENTRY_AUTH_TOKEN='sentryauthtoken' +SENTRY_DSN_URL=https://sentrydsnurl +NEXT_PUBLIC_SENTRY_DSN_URL=https://sentrydsnurl +SENTRY_ORG_NAME='orgname' +SENTRY_PROJECT_NAME='projectname' +NEXT_PUBLIC_PLATFORM_URL = 'https://platformurl' + diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..79a05aea --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,84 @@ +name: Bug Report +description: File a bug report to help us improve +title: "[Bug]: " +labels: ["bug", "triage"] +assignees: [] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: input + id: contact + attributes: + label: Contact Details + description: How can we get in touch with you if we need more info? + placeholder: ex. email@example.com + validations: + required: false + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Also tell us, what did you expect to happen? + placeholder: Tell us what you see! + value: "A bug happened!" + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to Reproduce + description: Please provide detailed steps to reproduce the issue + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true + - type: dropdown + id: version + attributes: + label: Version + description: What version of our software are you running? + options: + - main (latest) + - dev + - Other (please specify in description) + validations: + required: true + - type: dropdown + id: browsers + attributes: + label: What browsers are you seeing the problem on? + multiple: true + options: + - Firefox + - Chrome + - Safari + - Microsoft Edge + - Other + - type: dropdown + id: device + attributes: + label: What device are you using? + multiple: true + options: + - Desktop + - Mobile + - Tablet + - type: textarea + id: logs + attributes: + label: Relevant log output + description: Please copy and paste any relevant log output from browser console. This will be automatically formatted into code, so no need for backticks. + render: shell + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/CivicDataLab/DataSpaceFrontend/blob/main/CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..515aab88 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: GitHub Discussions + url: https://github.com/CivicDataLab/DataSpaceFrontend/discussions + about: Ask questions and discuss ideas with the community + - name: Security Issues + url: mailto:tech@civicdatalab.in + about: Please report security vulnerabilities via email diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..0e2e7734 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,63 @@ +name: Feature Request +description: Suggest an idea for this project +title: "[Feature]: " +labels: ["enhancement", "triage"] +assignees: [] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a new feature! + - type: textarea + id: problem + attributes: + label: Is your feature request related to a problem? + description: A clear and concise description of what the problem is. + placeholder: I'm always frustrated when [...] + validations: + required: false + - type: textarea + id: solution + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Describe alternatives you've considered + description: A clear and concise description of any alternative solutions or features you've considered. + validations: + required: false + - type: dropdown + id: component + attributes: + label: Which component does this relate to? + multiple: true + options: + - UI/UX + - Authentication + - Data Visualization + - Search + - Navigation + - Performance + - Accessibility + - Documentation + - Testing + - Other + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Add any other context or screenshots about the feature request here. + validations: + required: false + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/CivicDataLab/DataSpaceFrontend/blob/main/CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..c6c6cff0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,79 @@ +# Pull Request + +## Description + +Brief description of the changes introduced by this PR. + +## Type of Change + +Please delete options that are not relevant. + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Performance improvement +- [ ] Code refactoring +- [ ] Test improvements +- [ ] UI/UX improvements + +## Related Issues + +Fixes #(issue number) +Closes #(issue number) +Relates to #(issue number) + +## Changes Made + +- [ ] Change 1 +- [ ] Change 2 +- [ ] Change 3 + +## Testing + +- [ ] I have tested this change manually +- [ ] I have added/updated tests for my changes +- [ ] All existing tests pass +- [ ] I have tested on multiple browsers (if applicable) +- [ ] I have tested on mobile devices (if applicable) + +### Test Instructions + +Please describe how reviewers can test your changes: + +1. Step 1 +2. Step 2 +3. Step 3 + +## Screenshots (if applicable) + +Add screenshots to help explain your changes, especially for UI changes. + +## Checklist + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have run `npm run lint` and fixed any issues +- [ ] I have run `npm run build` successfully +- [ ] I have tested the changes in development mode +- [ ] Any dependent changes have been merged and published in downstream modules + +## Performance Impact + +- [ ] No performance impact +- [ ] Positive performance impact +- [ ] Negative performance impact (please explain) + +## Accessibility + +- [ ] I have tested keyboard navigation +- [ ] I have tested with screen readers (if applicable) +- [ ] I have ensured proper color contrast +- [ ] I have added appropriate ARIA labels (if applicable) + +## Additional Notes + +Add any additional notes or context about the PR here. diff --git a/.github/workflows/deploy-Dataspace-dev.yml b/.github/workflows/deploy-Dataspace-dev.yml index b5179a78..8de90ceb 100644 --- a/.github/workflows/deploy-Dataspace-dev.yml +++ b/.github/workflows/deploy-Dataspace-dev.yml @@ -4,20 +4,20 @@ on: push: branches: ['dev'] env: - KEYCLOAK_CLIENT_ID: ${{secrets.KEYCLOAK_CLIENT_ID}} - KEYCLOAK_CLIENT_SECRET: ${{secrets.KEYCLOAK_CLIENT_SECRET}} - AUTH_ISSUER: ${{secrets.AUTH_ISSUER}} - NEXTAUTH_URL: 'https://dev.civicdataspace.in/' - NEXT_PUBLIC_NEXTAUTH_URL: 'https://dev.civicdataspace.in/' + KEYCLOAK_CLIENT_ID: 'dataspace' + KEYCLOAK_CLIENT_SECRET: 'Q2iHhyXNOqOu7Xaln7Z45QrDnbff13eu' + AUTH_ISSUER: 'https://opub-kc.civicdatalab.in/auth/realms/DataSpace' + NEXTAUTH_URL: 'https://dev.civicdataspace.in' + NEXT_PUBLIC_NEXTAUTH_URL: 'https://dev.civicdataspace.in' NEXTAUTH_SECRET: ${{secrets.NEXTAUTH_SECRET}} - END_SESSION_URL: ${{secrets.END_SESSION_URL}} - REFRESH_TOKEN_URL: ${{secrets.REFRESH_TOKEN_URL}} - NEXT_PUBLIC_BACKEND_URL: ${{secrets.NEXT_PUBLIC_BACKEND_URL_DEV_DS}} - BACKEND_GRAPHQL_URL: ${{secrets.BACKEND_GRAPHQL_URL_DEV_DS}} + END_SESSION_URL: 'https://opub-kc.civicdatalab.in/auth/realms/DataSpace/protocol/openid-connect/logout' + REFRESH_TOKEN_URL: 'https://opub-kc.civicdatalab.in/auth/realms/DataSpace/protocol/openid-connect/token' + NEXT_PUBLIC_BACKEND_URL: 'https://dev.api.civicdataspace.in' + BACKEND_GRAPHQL_URL: 'https://dev.api.civicdataspace.in/api/graphql' NEXT_PUBLIC_ENABLE_ACCESSMODEL: ${{secrets.NEXT_PUBLIC_ENABLE_ACCESSMODEL_DS}} - NEXT_PUBLIC_BACKEND_GRAPHQL_URL: ${{secrets.NEXT_PUBLIC_BACKEND_GRAPHQL_URL_DEV_DS}} - BACKEND_URL: ${{secrets.BACKEND_URL_DEV}} - NEXT_PUBLIC_PLATFORM_URL: ${{secrets.NEXT_PUBLIC_PLATFORM_URL_DEV}} + NEXT_PUBLIC_BACKEND_GRAPHQL_URL: 'https://dev.api.civicdataspace.in/api/graphql' + BACKEND_URL: 'https://dev.api.civicdataspace.in' + NEXT_PUBLIC_PLATFORM_URL: 'https://dev.civicdataspace.in' NEXT_PUBLIC_ANALYTICS_URL: ${{secrets.NEXT_PUBLIC_ANALYTICS_URL}} diff --git a/.github/workflows/pre-merge.yml b/.github/workflows/pre-merge.yml index e964c3cc..3f93afa3 100644 --- a/.github/workflows/pre-merge.yml +++ b/.github/workflows/pre-merge.yml @@ -22,7 +22,6 @@ env: NEXT_PUBLIC_PLATFORM_URL: ${{secrets.NEXT_PUBLIC_PLATFORM_URL_DEV}} NEXT_PUBLIC_ANALYTICS_URL: ${{secrets.NEXT_PUBLIC_ANALYTICS_URL}} - jobs: build: runs-on: ubuntu-latest @@ -31,9 +30,6 @@ jobs: matrix: node-version: [20.x] - env: - BACKEND_GRAPHQL_URL: ${{secrets.BACKEND_GRAPHQL_URL_DS}} - steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} @@ -42,6 +38,37 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'npm' - - run: npm ci --force - - run: npm run generate - - run: npm run build --if-present + - name: Install dependencies + run: npm ci --force + + - name: Generate GraphQL types (CI-safe) + run: | + # Ensure generated directory exists + mkdir -p ./gql/generated + + # Try to generate with timeout and fallback + timeout 60s npm run generate:ci || { + echo "GraphQL codegen failed or timed out, checking for existing files..." + if [ -d "./gql/generated" ] && [ "$(ls -A ./gql/generated 2>/dev/null)" ]; then + echo "Using existing generated files" + else + echo "Creating minimal generated files for build to proceed" + echo "// Auto-generated fallback file for CI builds" > ./gql/generated/index.ts + echo "export type Maybe = T | null;" >> ./gql/generated/index.ts + echo "export type Scalars = {" >> ./gql/generated/index.ts + echo " ID: string;" >> ./gql/generated/index.ts + echo " String: string;" >> ./gql/generated/index.ts + echo " Boolean: boolean;" >> ./gql/generated/index.ts + echo " Int: number;" >> ./gql/generated/index.ts + echo " Float: number;" >> ./gql/generated/index.ts + echo "};" >> ./gql/generated/index.ts + echo "export {};" >> ./gql/generated/index.ts + echo "Created fallback generated files" + fi + } + env: + BACKEND_GRAPHQL_URL: ${{secrets.BACKEND_GRAPHQL_URL_DEV_DS}} + NODE_ENV: 'production' + + - name: Build application + run: npm run build --if-present diff --git a/.gitignore b/.gitignore index 4d463706..19766ba8 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ yarn-debug.log* yarn-error.log* # local env files -.env*.local* +.env*.local # vercel .vercel @@ -39,4 +39,5 @@ next-env.d.ts # generated graphql files /gql/generated/gql.ts -/gql/generated/graphql.ts \ No newline at end of file +/gql/generated/graphql.ts +/gql/generated/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..29c671d7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +#Creates a layer from node:alpine image. +# FROM chub.cloud.gov.in/mit6c0-ogd/node-16:nic_server +FROM node:alpine + + +# RUN echo 'deb http://deb.debian.org/debian stretch main' >> apk update \ +# apk add curl git nano wget screen vim + +#Creates directories +RUN mkdir -p /usr/src/app + +# env + +#Sets the working directory for any RUN, CMD, ENTRYPOINT, COPY, and ADD commands +WORKDIR /usr/src/app + +##Copy new files or directories into the filesystem of the container +COPY . /usr/src/app + +#Execute commands in a new layer on top of the current image and commit the results +RUN npm install -- +# RUN npm run build + +# Expose port 3000 to host +EXPOSE 3000 + +#Allows you to configure a container that will run as an executable +CMD npm run build && npm start diff --git a/README.md b/README.md index cb7b38ba..700cba3e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # Data Exchange Frontend +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Next.js](https://img.shields.io/badge/Next.js-14.0+-black.svg)](https://nextjs.org/) +[![React](https://img.shields.io/badge/React-18.2+-blue.svg)](https://reactjs.org/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue.svg)](https://www.typescriptlang.org/) +[![Issues](https://img.shields.io/github/issues/CivicDataLab/DataSpaceFrontend)](https://github.com/CivicDataLab/DataSpaceFrontend/issues) +[![Contributors](https://img.shields.io/github/contributors/CivicDataLab/DataSpaceFrontend)](https://github.com/CivicDataLab/DataSpaceFrontend/graphs/contributors) + A platform to speed up the development of Open Data Exchange. ## Getting Started @@ -9,13 +16,13 @@ To get started, you can clone the repository and run it locally using the follow 1. Clone the repository: ```bash -git clone https://github.com/CivicDataLab/data-exchange.git +git clone https://github.com/CivicDataLab/DataSpaceFrontend.git ``` 2. Install dependencies ```bash -cd data-exchange +cd DataSpaceFrontend npm i ``` @@ -48,7 +55,7 @@ Data Exchange uses Next.js new `app` directory. For more information on how to u ## Community -We use Github Discussions to discuss ideas, proposals and questions about the project. You can [head over there](https://github.com/CivicDataLab/data-exchange/discussions) to interact with the community. +We use Github Discussions to discuss ideas, proposals and questions about the project. You can [head over there](https://github.com/CivicDataLab/DataSpaceFrontend/discussions) to interact with the community. Our [Code of Conduct](CODE_OF_CONDUCT.md) applies to all community channels. diff --git a/app/[locale]/(user)/about-us/page.tsx b/app/[locale]/(user)/about-us/page.tsx index 2e85d639..c5991b2a 100644 --- a/app/[locale]/(user)/about-us/page.tsx +++ b/app/[locale]/(user)/about-us/page.tsx @@ -1,12 +1,64 @@ import Image from 'next/image'; import { Text } from 'opub-ui'; +import { generateJsonLd, generatePageMetadata } from '@/lib/utils'; import BreadCrumbs from '@/components/BreadCrumbs'; +import JsonLd from '@/components/JsonLd'; import Team from './components/Team'; +export const generateMetadata = () => + generatePageMetadata({ + title: 'About CivicDataSpace | Empowering Public Good with Open Data', + description: + 'Learn about CivicDataSpace — an open-source platform built to foster inclusive, interoperable, and AI-ready data ecosystems for public good.', + keywords: [ + 'CivicDataSpace', + 'About CivicDataSpace', + 'Open Data', + 'CivicTech', + 'Data for Public Good', + 'Inclusive Data', + 'AI-ready Data', + 'CivicDataLab', + 'Open Source Platform', + ], + openGraph: { + type: 'website', + locale: 'en_US', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/about`, + title: 'About CivicDataSpace | Empowering Public Good with Open Data', + description: + 'Explore the mission, vision, and team behind CivicDataSpace — an open-source initiative to unlock the power of data for civic impact.', + siteName: 'CivicDataSpace', + image: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/og.png`, + }, + }); + const About = () => { + const jsonLd = generateJsonLd({ + '@context': 'https://schema.org', + '@type': 'AboutPage', + name: 'About CivicDataSpace', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/about`, + description: + 'Learn more about CivicDataSpace – an open-source platform enabling data collaboratives and civic innovation for the public good.', + about: { + '@type': 'WebApplication', + name: 'CivicDataSpace', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/about`, + description: + 'CivicDataSpace is an open-source platform that enables inclusive, interoperable, and AI-ready data collaboratives to drive public good.', + }, + publisher: { + '@type': 'Organization', + name: 'CivicDataLab', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/about`, + }, + }); + return (
+ { className="h-full w-full object-contain" /> -
+ {/*
The People Behind CivicDataSpace
{
-
+
*/}
); diff --git a/app/[locale]/(user)/components/Content.tsx b/app/[locale]/(user)/components/Content.tsx index 4a38efbc..c1cac24a 100644 --- a/app/[locale]/(user)/components/Content.tsx +++ b/app/[locale]/(user)/components/Content.tsx @@ -18,6 +18,7 @@ const statsInfo: any = graphql(` totalPublishedDatasets totalPublishers totalPublishedUsecases + totalOrganizations } } `); @@ -49,38 +50,51 @@ export const Content = () => { { label: 'Datasets', count: Stats?.data?.stats?.totalPublishedDatasets, + link: '/datasets', }, { label: 'Use Cases', count: Stats?.data?.stats?.totalPublishedUsecases, + link: '/usecases', }, { label: 'Publishers', count: Stats?.data?.stats?.totalPublishers, + link: '/publishers', }, + // { + // label: 'Users', + // count: Stats?.data?.stats?.totalUsers, + // }, { - label: 'Users', - count: Stats?.data?.stats?.totalUsers, + label: 'Organizations', + count: Stats?.data?.stats?.totalOrganizations, + link: '/publishers', }, ]; const Sectors = [ - 'Budgets', - 'Child Rights', - 'Disaster Risk Reduction', - 'Climate Finance', - 'Law And Justice', + 'Public Finance', + 'Law and Justice', + 'Climate Action', 'Urban Development', + 'Gender', + 'Coastal', + 'Disaster Risk Reduction', + 'Child Rights' ]; + return (
-
+
- Share knowledge resources, datasets, and AI use-cases in one open, - collaborative platform for data changemakers. + An Open-Source Platform for Collaborative Data-Driven Change + + + Share datasets, knowledge resources, and AI use-cases for data changemakers.
{Stats.isLoading ? ( @@ -90,17 +104,22 @@ export const Content = () => { ) : (
{Metrics.map((item, index) => ( -
- - {item.count} - - - {item.label} - -
+ +
+ + {item.count} + + + {item.label} + +
+ ))}
)} diff --git a/app/[locale]/(user)/components/Datasets.tsx b/app/[locale]/(user)/components/Datasets.tsx index 44ac3fd5..b822dea1 100644 --- a/app/[locale]/(user)/components/Datasets.tsx +++ b/app/[locale]/(user)/components/Datasets.tsx @@ -53,11 +53,15 @@ const Datasets = () => { return (
-
- Popular Datasets -
- - Discover high-impact datasets that are helping users power research, analysis, and action. +
+
+ Popular Datasets + + Discover high-impact datasets that are helping users power research, + analysis, and action. + +
+
-
+
diff --git a/app/[locale]/(user)/components/ListingComponent.tsx b/app/[locale]/(user)/components/ListingComponent.tsx index 2898fc68..5946684d 100644 --- a/app/[locale]/(user)/components/ListingComponent.tsx +++ b/app/[locale]/(user)/components/ListingComponent.tsx @@ -1,3 +1,5 @@ +'use client' + import React, { useEffect, useReducer, useRef, useState } from 'react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; @@ -20,6 +22,7 @@ import { Icons } from '@/components/icons'; import { Loading } from '@/components/loading'; import Filter from '../datasets/components/FIlter/Filter'; import Styles from '../datasets/dataset.module.scss'; +import { fetchData } from '@/fetch'; // Interfaces interface Bucket { @@ -185,25 +188,25 @@ const useUrlParams = ( // Listing Component Props interface ListingProps { - fetchDatasets: (variables: string) => Promise<{ - results: any[]; - total: number; - aggregations: Aggregations; - }>; - breadcrumbData: { href: string; label: string }[]; + type: string; + breadcrumbData?: { href: string; label: string }[]; headerComponent?: React.ReactNode; categoryName?: string; categoryDescription?: string; categoryImage?: string; + placeholder: string; + redirectionURL: string; } const ListingComponent: React.FC = ({ - fetchDatasets, + type, breadcrumbData, headerComponent, categoryName, categoryDescription, categoryImage, + placeholder, + redirectionURL, }) => { const [facets, setFacets] = useState<{ results: any[]; @@ -225,7 +228,7 @@ const ListingComponent: React.FC = ({ if (variables) { const currentFetchId = ++latestFetchId.current; - fetchDatasets(variables) + fetchData(type,variables) .then((res) => { // Only set if this is the latest call if (currentFetchId === latestFetchId.current) { @@ -236,7 +239,7 @@ const ListingComponent: React.FC = ({ console.error(err); }); } - }, [variables, fetchDatasets]); + }, [variables, type]); const [hasMounted, setHasMounted] = useState(false); @@ -289,7 +292,7 @@ const ListingComponent: React.FC = ({ return (
- + {breadcrumbData && }
{/* Optional Category Header */} {(categoryName || categoryDescription || categoryImage) && ( @@ -348,7 +351,7 @@ const ListingComponent: React.FC = ({ label="Search" name="Search" className={cn(Styles.Search)} - placeholder="Start typing to search for any Dataset" + placeholder={placeholder} onSubmit={(value) => handleSearch(value)} onClear={(value) => handleSearch(value)} /> @@ -488,13 +491,17 @@ const ListingComponent: React.FC = ({ value: formatDate(item.modified), tooltip: 'Date', }, - { + ]; + + if (item.download_count > 0) { + MetadataContent.push({ icon: Icons.download, label: 'Download', - value: item.download_count.toString(), + value: item.download_count?.toString() || '0', tooltip: 'Download', - }, - ]; + }); + } + if (Geography) { MetadataContent.push({ icon: Icons.globe, @@ -513,11 +520,11 @@ const ListingComponent: React.FC = ({ }); } - const FooterContent = [ + const FooterContent = [ { - icon: `/Sectors/${item.sectors[0]}.svg`, + icon: `/Sectors/${item.sectors?.[0]}.svg`, label: 'Sectors', - tooltip: `${item.sectors[0]}`, + tooltip: `${item.sectors?.[0]}`, }, ...(item.has_charts && view !== 'expanded' ? [ @@ -542,8 +549,13 @@ const ListingComponent: React.FC = ({ tag: item.tags, formats: item.formats, footerContent: FooterContent, + imageUrl: '', }; + if (item.logo) { + commonProps.imageUrl = `${process.env.NEXT_PUBLIC_BACKEND_URL}/${item.logo}`; + } + return ( = ({ view === 'expanded' ? 'expanded' : 'collapsed' } iconColor="warning" - href={`/datasets/${item.id}`} + href={`${redirectionURL}/${item.id}`} /> ); })} diff --git a/app/[locale]/(user)/components/Sectors.tsx b/app/[locale]/(user)/components/Sectors.tsx index d562b01a..9d8ac532 100644 --- a/app/[locale]/(user)/components/Sectors.tsx +++ b/app/[locale]/(user)/components/Sectors.tsx @@ -37,12 +37,15 @@ const Sectors = () => { } return (
-
- Explore Sectors -
- - Browse use cases and datasets organized by sector to find what matters most to your domain. +
+
+ Explore Sectors + + Browse use cases and datasets organized by sector to find what + matters most to your domain. +
+
@@ -59,7 +64,7 @@ const Sectors = () => {
) : ( -
+
{data?.activeSectors.map((sectors: any) => ( { return (
-
- Recent UseCases -
- - Explore freshly updated data use cases gaining momentum across CivicDataSpace +
+
+ Recent UseCases + + Explore freshly updated data use cases gaining momentum across + CivicDataSpace +
+
-
+
@@ -123,56 +128,58 @@ const UseCasesListingPage = () => { {getUseCasesList && getUseCasesList?.data?.publishedUseCases.length > 0 && - getUseCasesList?.data?.publishedUseCases.map((item: any, index: any) => ( - - - meta.metadataItem?.label === 'Geography' - )?.value, - }, - ]} - footerContent={[ - { - icon: `/Sectors/${item?.sectors[0]?.name}.svg`, - label: 'Sectors', - }, - { - icon: item.isIndividualUsecase - ? item?.user?.profilePicture - ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${item.user.profilePicture.url}` - : '/profile.png' - : item?.organization?.logo - ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${item.organization.logo.url}` - : '/org.png', - label: 'Published by', - }, - ]} - imageUrl={`${process.env.NEXT_PUBLIC_BACKEND_URL}/${item.logo?.path.replace('/code/files/', '')}`} - description={item.summary} - iconColor="warning" - variation={'collapsed'} - /> - - ))} + getUseCasesList?.data?.publishedUseCases.map( + (item: any, index: any) => ( + + + meta.metadataItem?.label === 'Geography' + )?.value, + }, + ]} + footerContent={[ + { + icon: `/Sectors/${item?.sectors[0]?.name}.svg`, + label: 'Sectors', + }, + { + icon: item.isIndividualUsecase + ? item?.user?.profilePicture + ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${item.user.profilePicture.url}` + : '/profile.png' + : item?.organization?.logo + ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${item.organization.logo.url}` + : '/org.png', + label: 'Published by', + }, + ]} + imageUrl={`${process.env.NEXT_PUBLIC_BACKEND_URL}/${item.logo?.path.replace('/code/files/', '')}`} + description={item.summary} + iconColor="warning" + variation={'collapsed'} + /> + + ) + )} )} diff --git a/app/[locale]/(user)/datasets/[datasetIdentifier]/DatasetDetailsPage.tsx b/app/[locale]/(user)/datasets/[datasetIdentifier]/DatasetDetailsPage.tsx new file mode 100644 index 00000000..6e4ff8d4 --- /dev/null +++ b/app/[locale]/(user)/datasets/[datasetIdentifier]/DatasetDetailsPage.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { graphql } from '@/gql'; +import { useQuery } from '@tanstack/react-query'; +import { Spinner } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { generateJsonLd } from '@/lib/utils'; +import BreadCrumbs from '@/components/BreadCrumbs'; +import JsonLd from '@/components/JsonLd'; +import Details from './components/Details'; +import Metadata from './components/Metadata'; +import PrimaryData from './components/PrimaryData'; +import Resources from './components/Resources'; +import SimilarDatasets from './components/SimilarDatasets'; + +const datasetQuery: any = graphql(` + query getDataset($datasetId: UUID!) { + getDataset(datasetId: $datasetId) { + tags { + id + value + } + id + downloadCount + title + description + created + modified + isIndividualDataset + user { + fullName + id + profilePicture { + url + } + } + metadata { + metadataItem { + id + label + dataType + } + value + } + license + resources { + id + created + modified + type + name + description + } + organization { + name + logo { + url + } + slug + id + } + sectors { + name + } + formats + } + } +`); + +export default function DatasetDetailsPage({ + datasetId, +}: { + datasetId: string; +}) { + const Datasetdetails: { data: any; isLoading: any } = useQuery( + [`details_${datasetId}`], + () => GraphQL(datasetQuery, {}, { datasetId: datasetId }) + ); + + const jsonLd = generateJsonLd({ + '@context': 'https://schema.org', + '@type': 'Dataset', + name: Datasetdetails?.data?.getDataset?.title, + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/datasets/${datasetId}`, + description: Datasetdetails?.data?.getDataset?.description, + publisher: { + '@type': 'Organization', + name: 'CivicDataSpace', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/datasets`, + }, + }); + + return ( + <> + +
+ +
+
+ {Datasetdetails.isLoading ? ( +
+ +
+ ) : ( + + )} +
+ + +
+
+ {Datasetdetails.isLoading ? ( +
+ +
+ ) : ( + + )} +
+
+
+ + ); +} diff --git a/app/[locale]/(user)/datasets/[datasetIdentifier]/components/Metadata/index.tsx b/app/[locale]/(user)/datasets/[datasetIdentifier]/components/Metadata/index.tsx index 743ad154..fbeec857 100644 --- a/app/[locale]/(user)/datasets/[datasetIdentifier]/components/Metadata/index.tsx +++ b/app/[locale]/(user)/datasets/[datasetIdentifier]/components/Metadata/index.tsx @@ -2,9 +2,10 @@ import React, { useEffect, useState } from 'react'; import Image from 'next/image'; import Link from 'next/link'; import { Button, Divider, Icon, Tag, Text, Tooltip } from 'opub-ui'; -import Styles from '../../../dataset.module.scss' + import { cn, formatDate, getWebsiteTitle } from '@/lib/utils'; import { Icons } from '@/components/icons'; +import Styles from '../../../dataset.module.scss'; interface MetadataProps { data: any; @@ -77,6 +78,19 @@ const MetadataComponent: React.FC = ({ data, setOpen }) => { : data?.organization?.logo ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${data.organization.logo.url}` : '/org.png'; + const getPublisherURL = (data: any) => { + if (!data) return '/publishers'; + + if (data.isIndividualDataset && data.user) { + return `/publishers/${data.user.fullName + '_' + data.user.id}`; + } + + if (data.organization) { + return `/publishers/organization/${data.organization.slug + '_' + data.organization.id}`; + } + + return '/publishers'; + }; return (
@@ -102,15 +116,19 @@ const MetadataComponent: React.FC = ({ data, setOpen }) => {
- { + + { +
@@ -123,15 +141,17 @@ const MetadataComponent: React.FC = ({ data, setOpen }) => { : data.organization.name } > - - {data.isIndividualDataset - ? data.user.fullName - : data.organization.name} - + + + {data.isIndividualDataset + ? data.user.fullName + : data.organization.name} + +
diff --git a/app/[locale]/(user)/datasets/[datasetIdentifier]/components/Resources/index.tsx b/app/[locale]/(user)/datasets/[datasetIdentifier]/components/Resources/index.tsx index 1334762b..b42e3ab6 100644 --- a/app/[locale]/(user)/datasets/[datasetIdentifier]/components/Resources/index.tsx +++ b/app/[locale]/(user)/datasets/[datasetIdentifier]/components/Resources/index.tsx @@ -212,7 +212,7 @@ const Resources = () => { getResourceDetails.data?.datasetResources?.length > 0 ? (
- Files in this Dataset + Files in this Dataset All files associated with this Dataset which can be downloaded{' '} diff --git a/app/[locale]/(user)/datasets/[datasetIdentifier]/page.tsx b/app/[locale]/(user)/datasets/[datasetIdentifier]/page.tsx index cafdc0f3..f4803aa1 100644 --- a/app/[locale]/(user)/datasets/[datasetIdentifier]/page.tsx +++ b/app/[locale]/(user)/datasets/[datasetIdentifier]/page.tsx @@ -1,133 +1,60 @@ -'use client'; - -import { useRef, useState } from 'react'; -import { useParams } from 'next/navigation'; import { graphql } from '@/gql'; -import { useQuery } from '@tanstack/react-query'; -import { Spinner } from 'opub-ui'; import { GraphQL } from '@/lib/api'; -import BreadCrumbs from '@/components/BreadCrumbs'; -import Details from './components/Details'; -import Metadata from './components/Metadata'; -import PrimaryData from './components/PrimaryData'; -import Resources from './components/Resources'; -import SimilarDatasets from './components/SimilarDatasets'; +import { generatePageMetadata } from '@/lib/utils'; +import DatasetDetailsPage from './DatasetDetailsPage'; -const datasetQuery: any = graphql(` - query getDataset($datasetId: UUID!) { +const datasetMetaQuery: any = graphql(` + query getDatasetInfo($datasetId: UUID!) { getDataset(datasetId: $datasetId) { - tags { - id - value - } - id - downloadCount title description - created - modified - isIndividualDataset - user { - fullName + id + tags { id - profilePicture { - url - } - } - metadata { - metadataItem { - id - label - dataType - } value } - license - resources { - id - created - modified - type - name - description - } - organization { - name - logo { - url - } - slug - id - } - sectors { - name - } - formats } } `); -const DatasetDetailsPage = () => { - const params = useParams(); - - const Datasetdetails: { data: any; isLoading: any } = useQuery( - [`${params.datasetIdentifier}`], - () => - GraphQL( - datasetQuery, - { - // Entity Headers if present - }, - { datasetId: params.datasetIdentifier } - ) - ); - - return ( -
- -
-
- {Datasetdetails.isLoading ? ( -
- -
- ) : ( - - )} -
- - -
-
- {Datasetdetails.isLoading ? ( -
- -
- ) : ( -
- -
- )} -
-
- - {/*
- -
*/} -
- ); -}; - -export default DatasetDetailsPage; +export async function generateMetadata({ + params, +}: { + params: { datasetIdentifier: string }; +}) { + try { + const res: any = await GraphQL( + datasetMetaQuery, + {}, + { datasetId: params.datasetIdentifier } + ); + + const dataset = res?.getDataset; + return generatePageMetadata({ + title: `${dataset?.title} | Dataset | CivicDataSpace`, + description: dataset?.description, + keywords: dataset?.tags?.map((tag: any) => tag.value) || [], + openGraph: { + type: 'dataset', + locale: 'en_US', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/datasets/${params.datasetIdentifier}`, + title: dataset?.title, + description: dataset?.description, + siteName: 'CivicDataSpace', + image: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/og.png`, + }, + }); + } catch (e) { + console.error('Metadata fetch error', e); + return generatePageMetadata({ title: 'Dataset Details' }); + } +} + +export default function Page({ + params, +}: { + params: { datasetIdentifier: string }; +}) { + return ; +} diff --git a/app/[locale]/(user)/datasets/page.tsx b/app/[locale]/(user)/datasets/page.tsx index e7856ce6..acfb0125 100644 --- a/app/[locale]/(user)/datasets/page.tsx +++ b/app/[locale]/(user)/datasets/page.tsx @@ -1,21 +1,65 @@ -'use client'; - import React from 'react'; -import { fetchDatasets } from '@/fetch'; + +import { generateJsonLd, generatePageMetadata } from '@/lib/utils'; +import JsonLd from '@/components/JsonLd'; import ListingComponent from '../components/ListingComponent'; +export const generateMetadata = () => + generatePageMetadata({ + title: 'Browse Open Datasets | CivicDataSpace', + description: + 'Discover and explore a comprehensive collection of open datasets for research, policy-making, and civic innovation. Filter by sector, format.', + keywords: [ + 'Open Datasets', + 'Data Discovery', + 'Public Data', + 'Research Data', + 'Civic Data', + 'Dataset Search', + ], + openGraph: { + type: 'website', + locale: 'en_US', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/datasets`, + title: 'Browse Open Datasets | CivicDataSpace', + description: + 'Explore thousands of open datasets across sectors like health, education, governance, and environment on CivicDataSpace', + siteName: 'CivicDataSpace', + image: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/og.png`, // from /public/og.png + }, + }); + const DatasetsListing = () => { const breadcrumbData = [ { href: '/', label: 'Home' }, { href: '#', label: 'Dataset Listing' }, ]; + const jsonLd = generateJsonLd({ + '@context': 'https://schema.org', + '@type': 'CollectionPage', + name: 'Browse Open Datasets | CivicDataSpace', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/datasets`, + description: + 'Explore a wide range of public datasets for research, policy, and civic innovation.', + publisher: { + '@type': 'Organization', + name: 'CivicDataSpace', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/datasets`, + }, + }); + return ( - + <> + + + ); }; -export default DatasetsListing; \ No newline at end of file +export default DatasetsListing; diff --git a/app/[locale]/(user)/page.tsx b/app/[locale]/(user)/page.tsx index 0e72623e..04e8a872 100644 --- a/app/[locale]/(user)/page.tsx +++ b/app/[locale]/(user)/page.tsx @@ -1,15 +1,69 @@ +import { generateJsonLd, generatePageMetadata } from '@/lib/utils'; +import JsonLd from '@/components/JsonLd'; import { Content } from './components/Content'; import Datasets from './components/Datasets'; import Sectors from './components/Sectors'; import UseCases from './components/UseCases'; +export const generateMetadata = () => + generatePageMetadata({ + title: 'CivicDataSpace – Empowering Public Good with Open Data', + description: + 'CivicDataSpace is an open-source platform enabling inclusive and AI-ready data collaborative. Explore datasets, use cases, and insights for public good.', + keywords: [ + 'CivicDataSpace', + 'Open Data', + 'Data Collaborative', + 'Public Datasets', + 'AI-ready data', + 'CivicTech', + 'CivicDataLab', + ], + openGraph: { + type: 'website', + locale: 'en_US', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}`, + title: 'CivicDataSpace – Empowering Public Good with Open Data', + description: + 'Explore CivicDataSpace, an open-source platform to make data inclusive, interoperable, and impactful for researchers, policymakers, and civic actors.', + siteName: 'CivicDataSpace', + image: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/og.png`, // from /public/og.png + }, + }); + export default async function Home() { + const jsonLd = generateJsonLd({ + '@context': 'https://schema.org', + '@type': 'WebSite', + name: 'CivicDataSpace', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}`, + description: + 'CivicDataSpace is an open-source platform that enables AI-ready data collaboratives and empowers public good through inclusive civic datasets and use cases.', + publisher: { + '@type': 'Organization', + name: 'CivicDataLab', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/about`, + logo: { + '@type': 'ImageObject', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/cdl_logo.png`, + }, + }, + potentialAction: { + '@type': 'SearchAction', + target: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/datasets?query={search_term_string}`, + 'query-input': 'required name=search_term_string', + }, + }); + return ( -
- - - - -
+ <> + +
+ + + + +
+ ); } diff --git a/app/[locale]/(user)/publishers/PublishersListingClient.tsx b/app/[locale]/(user)/publishers/PublishersListingClient.tsx new file mode 100644 index 00000000..0ce41f92 --- /dev/null +++ b/app/[locale]/(user)/publishers/PublishersListingClient.tsx @@ -0,0 +1,192 @@ +'use client'; + +import { useState } from 'react'; +import Image from 'next/image'; +import { graphql } from '@/gql'; +import { useQuery } from '@tanstack/react-query'; +import { Button, ButtonGroup, Spinner, Text } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { cn, generateJsonLd } from '@/lib/utils'; +import BreadCrumbs from '@/components/BreadCrumbs'; +import JsonLd from '@/components/JsonLd'; +import PublisherCard from './PublisherCard'; + +const getAllPublishers: any = graphql(` + query PublishersList { + getPublishers { + __typename + ... on TypeOrganization { + name + id + description + logo { + url + } + membersCount + publishedUseCasesCount + publishedDatasetsCount + } + ... on TypeUser { + fullName + id + bio + profilePicture { + url + } + publishedUseCasesCount + publishedDatasetsCount + } + } + } +`); + +const PublishersListingPage = () => { + const [type, setType] = useState<'all' | 'org' | 'pub'>('all'); + const Details: { + data: any; + isLoading: boolean; + isError: boolean; + refetch: any; + } = useQuery(['publishers_list_page'], () => + GraphQL(getAllPublishers, {}, []) + ); + + type PublisherType = 'all' | 'org' | 'pub'; + const publisherButtons: { key: PublisherType; label: string }[] = [ + { key: 'all', label: 'All Publishers' }, + { key: 'org', label: 'Organizations' }, + { key: 'pub', label: 'Individual Publishers' }, + ]; + + const filteredPublishers = Details?.data?.getPublishers?.filter( + (publisher: any) => { + if (type === 'all') return true; + if (type === 'pub') return publisher.__typename === 'TypeUser'; + if (type === 'org') return publisher.__typename === 'TypeOrganization'; + return false; + } + ); + + const jsonLd = generateJsonLd({ + '@context': 'https://schema.org', + '@type': 'Dataset', + name: Details?.data?.getPublishers?.title, + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/publishers`, + description: Details?.data?.getPublishers?.description, + publisher: { + '@type': 'Organization', + name: 'CivicDataSpace', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/publishers`, + }, + }); + + return ( + <> + +
+ + <> + <> +
+
+
+
+ + Our Publishers{' '} + + + Meet the data providers powering CivicDataSpace — explore + individual and organizational publishers across domains + who are opening up data for impact and transparency. + +
+
+ {'s1'} + {'s1'} + {'s1'} +
+
+
+ +
+ + Explore Publishers + +
+ +
+ {publisherButtons.map((btn) => ( + + ))} +
+
+
+ {Details.isLoading ? ( +
+ +
+ ) : ( + Details.data && + Details.data.getPublishers.length > 0 && ( + + ) + )} +
+
+ + +
+ + ); +}; + +export default PublishersListingPage; diff --git a/app/[locale]/(user)/publishers/[publisherSlug]/PublisherPageClient.tsx b/app/[locale]/(user)/publishers/[publisherSlug]/PublisherPageClient.tsx new file mode 100644 index 00000000..2ea84bad --- /dev/null +++ b/app/[locale]/(user)/publishers/[publisherSlug]/PublisherPageClient.tsx @@ -0,0 +1,107 @@ +'use client'; + +import React from 'react'; +import { graphql } from '@/gql'; +import { useQuery } from '@tanstack/react-query'; +import { Spinner } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { generateJsonLd } from '@/lib/utils'; +import BreadCrumbs from '@/components/BreadCrumbs'; +import JsonLd from '@/components/JsonLd'; +import ProfileDetails from '../components/ProfileDetails'; +import SidebarCard from '../components/SidebarCard'; + +const userInfoQuery: any = graphql(` + query UserData($userId: ID!) { + userById(userId: $userId) { + id + bio + dateJoined + contributedSectorsCount + location + twitterProfile + githubProfile + fullName + profilePicture { + url + } + publishedUseCasesCount + publishedDatasetsCount + linkedinProfile + } + } +`); + +const PublisherPageClient = ({ publisherSlug }: { publisherSlug: string }) => { + const userInfo: any = useQuery([`${publisherSlug}`], () => + GraphQL( + userInfoQuery, + { + // Entity Headers if present + }, + { userId: publisherSlug } + ) + ); + + const jsonLd = generateJsonLd({ + '@context': 'https://schema.org', + '@type': 'Organization', + name: userInfo?.data?.userById?.fullName, + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/publishers/${publisherSlug}`, + description: userInfo?.data?.userById?.bio, + logo: { + '@type': 'ImageObject', + url: userInfo?.data?.userById?.profilePicture?.url + ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${userInfo?.data?.userById?.profilePicture?.url}` + : `${process.env.NEXT_PUBLIC_PLATFORM_URL}/og.png`, + }, + publisher: { + '@type': 'Organization', + name: 'CivicDataLab', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/about`, + }, + }); + + return ( +
+ + + { +
+
+
+ {userInfo?.isLoading ? ( +
+ +
+ ) : ( + + )} +
+
+ {userInfo?.isLoading ? ( +
+ +
+ ) : ( + + )} +
+
+
+ } +
+ ); +}; + +export default PublisherPageClient; diff --git a/app/[locale]/(user)/publishers/[publisherSlug]/page.tsx b/app/[locale]/(user)/publishers/[publisherSlug]/page.tsx index 3add1068..b527b770 100644 --- a/app/[locale]/(user)/publishers/[publisherSlug]/page.tsx +++ b/app/[locale]/(user)/publishers/[publisherSlug]/page.tsx @@ -1,84 +1,71 @@ -'use client'; - -import React from 'react'; -import { useParams } from 'next/navigation'; +import { Metadata } from 'next'; import { graphql } from '@/gql'; -import { useQuery } from '@tanstack/react-query'; -import { Spinner } from 'opub-ui'; import { GraphQL } from '@/lib/api'; -import BreadCrumbs from '@/components/BreadCrumbs'; -import ProfileDetails from '../components/ProfileDetails'; -import SidebarCard from '../components/SidebarCard'; +import { extractPublisherId, generatePageMetadata } from '@/lib/utils'; +import PublisherPageClient from './PublisherPageClient'; -const userInfoQuery: any = graphql(` - query UserData($userId: ID!) { +const userInfo = graphql(` + query Userdetails($userId: ID!) { userById(userId: $userId) { id bio - dateJoined - contributedSectorsCount - location - twitterProfile - githubProfile fullName profilePicture { url } - publishedUseCasesCount - publishedDatasetsCount - linkedinProfile } } `); -const PublisherPage = () => { - const params = useParams(); - const userInfo: any = useQuery([`${params.publisherSlug}`], () => - GraphQL( - userInfoQuery, - { - // Entity Headers if present - }, - { userId: params.publisherSlug } - ) +export async function generateMetadata({ + params, +}: { + params: { publisherSlug: string }; +}): Promise { + const data = await GraphQL( + userInfo, + {}, + { userId: extractPublisherId(params.publisherSlug) } ); + const user = data.userById; + + return generatePageMetadata({ + title: `${user?.fullName} | Publisher on CivicDataSpace`, + description: + user?.bio || 'Explore datasets and use cases by this publisher.', + keywords: [ + 'CivicDataSpace Publisher', + 'Open Data Contributor', + 'Use Case Publisher', + 'Dataset Publisher', + 'CivicTech', + 'Open Government Data', + ], + openGraph: { + title: `${user?.fullName} | Publisher on CivicDataSpace`, + description: + user?.bio || 'Explore datasets and use cases by this publisher.', + type: 'profile', + siteName: 'CivicDataSpace', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/publishers/${params.publisherSlug}`, + image: user?.profilePicture?.url + ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${user.profilePicture.url}` + : `${process.env.NEXT_PUBLIC_PLATFORM_URL}/og.png`, + locale: 'en_US', + }, + }); +} + +export default function PublisherPage({ + params, +}: { + params: { publisherSlug: string }; +}) { return ( -
- - { -
-
-
- {userInfo?.isLoading ? ( -
- -
- ) : ( - - )} -
-
- {userInfo?.isLoading ? ( -
- -
- ) : ( - - )} -
-
-
- } -
+ ); -}; - -export default PublisherPage; +} diff --git a/app/[locale]/(user)/publishers/organization/[organizationSlug]/OrgPageClient.tsx b/app/[locale]/(user)/publishers/organization/[organizationSlug]/OrgPageClient.tsx new file mode 100644 index 00000000..3b489f4f --- /dev/null +++ b/app/[locale]/(user)/publishers/organization/[organizationSlug]/OrgPageClient.tsx @@ -0,0 +1,108 @@ +'use client'; + +import React from 'react'; +import { graphql } from '@/gql'; +import { useQuery } from '@tanstack/react-query'; +import { Spinner } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { generateJsonLd } from '@/lib/utils'; +import BreadCrumbs from '@/components/BreadCrumbs'; +import JsonLd from '@/components/JsonLd'; +import ProfileDetails from '../../components/ProfileDetails'; +import SidebarCard from '../../components/SidebarCard'; + +const orgInfoQuery = graphql(` + query organizationData($id: String!) { + organization(id: $id) { + id + created + description + contributedSectorsCount + location + twitterProfile + githubProfile + name + logo { + url + } + publishedUseCasesCount + publishedDatasetsCount + linkedinProfile + } + } +`); + +const OrgPageClient = ({ organizationSlug }: { organizationSlug: string }) => { + const { data, isLoading } = useQuery( + [`org_details_${organizationSlug}`], + () => + GraphQL( + orgInfoQuery, + {}, + { + id: organizationSlug, + } + ), + { refetchOnMount: true } + ); + + const org = data?.organization; + const jsonLd = generateJsonLd({ + '@context': 'https://schema.org', + '@type': 'Organization', + name: org?.name, + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/publishers/organization/${organizationSlug}`, + description: org?.description, + logo: { + '@type': 'ImageObject', + url: org?.logo?.url + ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${org.logo.url}` + : `${process.env.NEXT_PUBLIC_PLATFORM_URL}/og.png`, + }, + publisher: { + '@type': 'Organization', + name: 'CivicDataLab', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/about`, + }, + }); + return ( +
+ + +
+
+
+ {isLoading ? ( +
+ +
+ ) : ( + + )} +
+
+ {isLoading ? ( +
+ +
+ ) : ( + + )} +
+
+
+
+ ); +}; + +export default OrgPageClient; diff --git a/app/[locale]/(user)/publishers/organization/[organizationSlug]/page.tsx b/app/[locale]/(user)/publishers/organization/[organizationSlug]/page.tsx index bd5c5a42..5e9f25a9 100644 --- a/app/[locale]/(user)/publishers/organization/[organizationSlug]/page.tsx +++ b/app/[locale]/(user)/publishers/organization/[organizationSlug]/page.tsx @@ -1,89 +1,71 @@ -'use client'; - -import React from 'react'; -import { useParams } from 'next/navigation'; +import { Metadata } from 'next'; import { graphql } from '@/gql'; -import { useQuery } from '@tanstack/react-query'; import { GraphQL } from '@/lib/api'; -import { Loading } from '@/components/loading'; -import ProfileDetails from '../../components/ProfileDetails'; -import { Spinner } from 'opub-ui'; -import SidebarCard from '../../components/SidebarCard'; -import BreadCrumbs from '@/components/BreadCrumbs'; +import { extractPublisherId, generatePageMetadata } from '@/lib/utils'; +import OrgPageClient from './OrgPageClient'; -const orgInfoQuery: any = graphql(` - query organizationData($id: String!) { +const orgDataQuery = graphql(` + query orgData($id: String!) { organization(id: $id) { id - created - description - contributedSectorsCount - location - twitterProfile - githubProfile name + description logo { url } - publishedUseCasesCount - publishedDatasetsCount - linkedinProfile } } `); -const OrgPage = () => { - const params = useParams(); - const organizationInfo: any = useQuery([`${params.publisherSlug}`], () => - GraphQL( - orgInfoQuery, - { - // Entity Headers if present - }, - { id: params.organizationSlug } - ) +export async function generateMetadata({ + params, +}: { + params: { organizationSlug: string }; +}): Promise { + const data = await GraphQL( + orgDataQuery, + {}, + { id: extractPublisherId(params.organizationSlug) } ); - return ( -
- - { -
-
-
- {organizationInfo?.isLoading ? ( -
- -
- ) : ( - - )} -
-
- {organizationInfo?.isLoading ? ( -
- -
- ) : ( - + const org = data.organization; - )} -
-
-
- } -
- ); -}; + return generatePageMetadata({ + title: `${org?.name} | Publisher on CivicDataSpace`, + description: + org?.description || 'Explore datasets and use cases by this publisher.', + keywords: [ + 'CivicDataSpace Publisher', + 'Open Data Contributor', + 'Use Case Publisher', + 'Dataset Publisher', + 'CivicTech', + 'Open Government Data', + ], + openGraph: { + title: `${org?.name} | Publisher on CivicDataSpace`, + description: + org?.description || 'Explore datasets and use cases by this publisher.', + type: 'profile', + siteName: 'CivicDataSpace', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/publishers/${params.organizationSlug}`, + image: org?.logo?.url + ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${org.logo.url}` + : `${process.env.NEXT_PUBLIC_PLATFORM_URL}/og.png`, + locale: 'en_US', + }, + }); +} -export default OrgPage; +export default function OrgPage({ + params, +}: { + params: { organizationSlug: string }; +}) { + return ( + + ); +} diff --git a/app/[locale]/(user)/publishers/page.tsx b/app/[locale]/(user)/publishers/page.tsx index 31116f32..3fa9d30b 100644 --- a/app/[locale]/(user)/publishers/page.tsx +++ b/app/[locale]/(user)/publishers/page.tsx @@ -1,175 +1,32 @@ -'use client'; - -import { useState } from 'react'; -import Image from 'next/image'; -import { graphql } from '@/gql'; -import { useQuery } from '@tanstack/react-query'; -import { Button, ButtonGroup, Spinner, Text } from 'opub-ui'; - -import { GraphQL } from '@/lib/api'; -import { cn } from '@/lib/utils'; -import BreadCrumbs from '@/components/BreadCrumbs'; -import PublisherCard from './PublisherCard'; - -const getAllPublishers: any = graphql(` - query PublishersList { - getPublishers { - __typename - ... on TypeOrganization { - name - id - description - logo { - url - } - membersCount - publishedUseCasesCount - publishedDatasetsCount - } - ... on TypeUser { - fullName - id - bio - profilePicture { - url - } - publishedUseCasesCount - publishedDatasetsCount - } - } - } -`); - -const PublishersListingPage = () => { - const [type, setType] = useState<'all' | 'org' | 'pub'>('all'); - const Details: { - data: any; - isLoading: boolean; - isError: boolean; - refetch: any; - } = useQuery(['publishers_list_page'], () => - GraphQL(getAllPublishers, {}, []) - ); - - type PublisherType = 'all' | 'org' | 'pub'; - const publisherButtons: { key: PublisherType; label: string }[] = [ - { key: 'all', label: 'All Publishers' }, - { key: 'org', label: 'Organizations' }, - { key: 'pub', label: 'Individual Publishers' }, - ]; - - const filteredPublishers = Details?.data?.getPublishers?.filter( - (publisher: any) => { - if (type === 'all') return true; - if (type === 'pub') return publisher.__typename === 'TypeUser'; - if (type === 'org') return publisher.__typename === 'TypeOrganization'; - return false; - } - ); - - return ( -
- - <> - <> -
-
-
-
- - Our Publishers{' '} - - - Meet the data providers powering CivicDataSpace — explore - individual and organizational publishers across domains who - are opening up data for impact and transparency. - -
-
- {'s1'} - {'s1'} - {'s1'} -
-
-
- -
- - Explore Publishers - -
- -
- {publisherButtons.map((btn) => ( - - ))} -
-
-
- {Details.isLoading ? ( -
- -
- ) : ( - Details.data && - Details.data.getPublishers.length > 0 && ( - - ) - )} -
-
- - -
- ); -}; - -export default PublishersListingPage; +import { generatePageMetadata } from '@/lib/utils'; +import PublishersListingClient from './PublishersListingClient'; + +export const generateMetadata = () => + generatePageMetadata({ + title: 'Explore Data Publishers | CivicDataSpace', + description: + 'Discover individual and organizational publishers who are driving open data for impact, transparency, and collaboration on CivicDataSpace.', + keywords: [ + 'Data Publishers', + 'Open Data Contributors', + 'Organizations', + 'Individual Publishers', + 'Civic Data', + 'Transparency', + 'CivicDataSpace', + ], + openGraph: { + type: 'website', + locale: 'en_US', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/publishers`, + title: 'Explore Data Publishers | CivicDataSpace', + description: + 'Meet the individuals and organizations opening up datasets for public good across sectors like governance, climate, and health.', + siteName: 'CivicDataSpace', + image: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/og.png`, + }, + }); + +export default function Page() { + return ; +} diff --git a/app/[locale]/(user)/sectors/SectorsListing.tsx b/app/[locale]/(user)/sectors/SectorsListing.tsx new file mode 100644 index 00000000..f7cd9d48 --- /dev/null +++ b/app/[locale]/(user)/sectors/SectorsListing.tsx @@ -0,0 +1,262 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { graphql } from '@/gql'; +import { + Ordering, + SectorOrder, + SectorsListsQuery, +} from '@/gql/generated/graphql'; +import { useQuery } from '@tanstack/react-query'; +import { Divider, SearchInput, Select, Spinner, Text, Tooltip } from 'opub-ui'; + +import { GraphQL } from '@/lib/api'; +import { cn, generateJsonLd } from '@/lib/utils'; +import BreadCrumbs from '@/components/BreadCrumbs'; +import { ErrorPage } from '@/components/error'; +import JsonLd from '@/components/JsonLd'; +import Styles from '../datasets/dataset.module.scss'; + +const sectorsListQueryDoc: any = graphql(` + query SectorsLists($order: SectorOrder, $filters: SectorFilter) { + activeSectors(order: $order, filters: $filters) { + id + name + description + slug + datasetCount + } + } +`); + +const SectorsListing = () => { + const [sort, setSort] = useState({ name: Ordering.Asc }); + const [searchText, setSearchText] = useState(''); + + const { data, isLoading, isError, refetch } = useQuery( + ['sectors_list_page', sort], + () => + GraphQL( + sectorsListQueryDoc, + {}, + { filters: searchText ? { search: searchText } : {}, order: sort } + ) as Promise + ); + + function capitalizeWords(name: any) { + return name + .split('-') + .map((word: any) => word.charAt(0).toUpperCase() + word.slice(1)) + .join('+'); + } + + useEffect(() => { + refetch(); + }, [searchText]); + + const handleSortChange = (e: string) => { + const [field, direction] = e.split('_'); + const formattedSort: SectorOrder = { + [field]: direction.toUpperCase() as Ordering, + }; + setSort(formattedSort); + }; + + const jsonLd = generateJsonLd({ + '@context': 'https://schema.org', + '@type': 'WebPage', + name: 'CivicDataLab', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/sectors`, + description: + 'Browse datasets and real-world use cases across key sectors like Climate Action, Gender Equality, Law and Justice, and Urban Development. Discover insights that drive data-informed governance and civic innovation.', + }); + + return ( +
+ + + <> + <> +
+
+
+
+ + Our Sectors + + + Browse our thematic sectors - from disaster risk reduction + to open budgets - to discover curated datasets and use cases + driving data-informed decisions across fields. + +
+
+ {'s1'} + {'s1'} + {'s1'} +
+
+
+
+
+ + Explore Sectors + +
+
+ { + setSearchText(e); + }} + onClear={() => { + setSearchText(''); + }} + name={'Start typing to search for any sector'} + /> +
+ + Sort : + + { - handleSortChange(e); - }} - /> -
-
-
-
- {isLoading ? ( -
- -
- ) : data && data?.activeSectors?.length > 0 ? ( - <> -
- {data?.activeSectors.map((sectors: any) => ( - -
-
- {'Sectors -
-
-
- - {sectors.name} - - -
-
- - {sectors.datasetCount} - - Datasets -
-
-
- - ))} -
- - ) : isError ? ( - - ) : ( - <> - )} -
-
- - -
- ); -}; - -export default SectorsListingPage; +import React from 'react'; + +import { generatePageMetadata } from '@/lib/utils'; +import SectorsListing from './SectorsListing'; + +export const generateMetadata = () => + generatePageMetadata({ + title: 'Explore Sector-Wise Open Data | CivicDataSpace', + description: + 'Browse datasets and real-world use cases across key sectors like Climate Action, Gender Equality, Law and Justice, and Urban Development. Discover insights that drive data-informed governance and civic innovation.', + keywords: [ + 'Sector Data', + 'Open Data by Sector', + 'Climate Action Datasets', + 'Gender Equality Data', + 'Public Finance', + 'Child Rights', + 'Disaster Risk Reduction', + 'Law and Justice', + 'Urban Development', + 'Coastal Data', + 'CivicDataSpace Sectors', + ], + openGraph: { + type: 'website', + locale: 'en_US', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/sectors`, + title: 'Explore Sector-Wise Open Data | CivicDataSpace', + description: + 'Explore datasets and civic use cases organized by sectors including climate, gender, governance, and more — curated to support researchers, policymakers, and the public.', + siteName: 'CivicDataSpace', + image: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/og.png`, + }, + }); + +export default function SectorsPage() { + return ; +} diff --git a/app/[locale]/(user)/usecases/UseCasesListingClient.tsx b/app/[locale]/(user)/usecases/UseCasesListingClient.tsx new file mode 100644 index 00000000..ff7ff8a0 --- /dev/null +++ b/app/[locale]/(user)/usecases/UseCasesListingClient.tsx @@ -0,0 +1,59 @@ +'use client'; + +import Image from 'next/image'; +import { Text } from 'opub-ui'; + +import BreadCrumbs from '@/components/BreadCrumbs'; +import ListingComponent from '../components/ListingComponent'; + +const breadcrumbData = [ + { href: '/', label: 'Home' }, + { href: '#', label: 'Use Cases' }, +]; + +const UseCasesListingClient = () => { + return ( +
+ +
+
+
+ + Our Use Cases + + + By use case, we mean any data-led intervention across sectors that + can address challenges from hyper-local to global levels + effectively. + +
+ {'Usecase +
+
+
+
+ + Explore Use Cases + +
+ +
+
+ ); +}; + +export default UseCasesListingClient; diff --git a/app/[locale]/(user)/usecases/[useCaseSlug]/UsecaseDetailsClient.tsx b/app/[locale]/(user)/usecases/[useCaseSlug]/UsecaseDetailsClient.tsx new file mode 100644 index 00000000..111c83a8 --- /dev/null +++ b/app/[locale]/(user)/usecases/[useCaseSlug]/UsecaseDetailsClient.tsx @@ -0,0 +1,410 @@ +'use client'; + +import Image from 'next/image'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { graphql } from '@/gql'; +import { TypeDataset, TypeUseCase } from '@/gql/generated/graphql'; +import { useQuery } from '@tanstack/react-query'; +import { Card, Text } from 'opub-ui'; + +import { GraphQLPublic } from '@/lib/api'; +import { formatDate, generateJsonLd } from '@/lib/utils'; +import BreadCrumbs from '@/components/BreadCrumbs'; +import { Icons } from '@/components/icons'; +import JsonLd from '@/components/JsonLd'; +import { Loading } from '@/components/loading'; +import PrimaryDetails from '../components/Details'; +import Metadata from '../components/Metadata'; +import Dashboards from './Dashboards'; + +const UseCasedetails = graphql(` + query UseCasedetails($pk: ID!) { + useCase(pk: $pk) { + id + title + summary + created + startedOn + isIndividualUsecase + user { + fullName + email + id + profilePicture { + url + } + } + organization { + name + slug + id + contactEmail + logo { + url + } + } + website + metadata { + metadataItem { + id + label + dataType + } + id + value + } + sectors { + id + name + } + runningStatus + tags { + id + value + } + publishers { + name + contactEmail + logo { + url + } + } + platformUrl + logo { + name + path + } + datasets { + title + id + isIndividualDataset + user { + fullName + id + profilePicture { + url + } + } + downloadCount + description + organization { + name + logo { + url + } + } + metadata { + metadataItem { + id + label + dataType + } + id + value + } + sectors { + name + } + modified + } + contactEmail + status + slug + modified + contributors { + id + fullName + profilePicture { + url + } + } + supportingOrganizations { + id + slug + name + logo { + url + } + } + partnerOrganizations { + id + slug + name + logo { + url + } + } + } + } +`); + +const UseCaseDetailClient = () => { + const params = useParams(); + + const { + data: UseCaseDetails, + isLoading, + error, + } = useQuery<{ useCase: TypeUseCase }>( + [`fetch_UsecaseDetails_${params.useCaseSlug}`], + async () => { + const result = await GraphQLPublic( + UseCasedetails as any, + {}, + { + pk: params.useCaseSlug, + } + ) as { useCase: TypeUseCase }; + return result; + }, + { + refetchOnMount: true, + refetchOnReconnect: true, + retry: (failureCount) => { + return failureCount < 3; + }, + } + ); + const datasets = UseCaseDetails?.useCase?.datasets || []; // Fallback to an empty array + + const hasSupportingOrganizations = + UseCaseDetails?.useCase?.supportingOrganizations && + UseCaseDetails?.useCase?.supportingOrganizations?.length > 0; + const hasPartnerOrganizations = + UseCaseDetails?.useCase?.partnerOrganizations && + UseCaseDetails?.useCase?.partnerOrganizations?.length > 0; + const hasContributors = + UseCaseDetails?.useCase?.contributors && + UseCaseDetails?.useCase?.contributors?.length > 0; + + const jsonLd = generateJsonLd({ + '@context': 'https://schema.org', + '@type': 'WebPage', + name: 'CivicDataLab', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/usecases/${params.useCaseSlug}`, + description: + UseCaseDetails?.useCase?.summary || + `Explore open data and curated datasets in the ${UseCaseDetails?.useCase?.title} sector.`, + publisher: { + '@type': 'Organization', + name: 'CivicDataSpace', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/usecases/${params.useCaseSlug}`, + }, + }); + + return ( + <> + +
+ {isLoading ? ( +
+ +
+ ) : error ? ( +
+
+ + Error Loading Use Case + + + {(error as any)?.message?.includes('401') || (error as any)?.message?.includes('403') + ? 'You do not have permission to view this use case. Please log in or contact the administrator.' + : 'Failed to load use case details. Please try again later.'} + +
+
+ ) : !UseCaseDetails?.useCase ? ( +
+
+ Use Case Not Found + + The requested use case could not be found. + +
+
+ ) : ( + <> + +
+
+
+ +
+
+ +
+
+
+
+ Datasets in this Use Case + + Explore datasets related to this use case{' '} + +
+
+ {/*
*/} + {datasets.length > 0 && + datasets.map((dataset: TypeDataset) => ( + + meta.metadataItem?.label === 'Geography' + )?.value || '', + }, + ]} + href={`/datasets/${dataset.id}`} + footerContent={[ + { + icon: `/Sectors/${dataset.sectors[0]?.name}.svg`, + label: 'Sectors', + }, + { + icon: dataset.isIndividualDataset + ? dataset?.user?.profilePicture + ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${dataset.user.profilePicture.url}` + : '/profile.png' + : dataset?.organization?.logo + ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${dataset.organization.logo.url}` + : '/org.png', + label: 'Published by', + }, + ]} + description={dataset.description || ''} + /> + ))} +
+
+
+ + {(hasSupportingOrganizations || + hasPartnerOrganizations || + hasContributors) && ( +
+
+ {hasSupportingOrganizations && ( +
+ + Supported by + +
+ {UseCaseDetails?.useCase?.supportingOrganizations?.map( + (org: any) => ( + +
+ {org.name} +
+ + ) + )} +
+
+ )} + {hasPartnerOrganizations && ( +
+ + Partnered by + +
+ {UseCaseDetails?.useCase?.partnerOrganizations?.map( + (org: any) => ( + +
+ {org.name} +
+ + ) + )} +
+
+ )} +
+ {hasContributors && ( +
+
+ + Contributors{' '} + + + Publisher and Contributors who have added to the Use + Case + +
+
+ {UseCaseDetails?.useCase?.contributors?.map( + (contributor: any) => ( + + {contributor.fullName} + + ) + )} +
+
+ )} +
+ )} + + )} +
+ + ); +}; + +export default UseCaseDetailClient; diff --git a/app/[locale]/(user)/usecases/[useCaseSlug]/page.tsx b/app/[locale]/(user)/usecases/[useCaseSlug]/page.tsx index de3f0e97..ed9223dc 100644 --- a/app/[locale]/(user)/usecases/[useCaseSlug]/page.tsx +++ b/app/[locale]/(user)/usecases/[useCaseSlug]/page.tsx @@ -1,348 +1,74 @@ -'use client'; - -import Image from 'next/image'; -import { useParams } from 'next/navigation'; +import { Metadata } from 'next'; import { graphql } from '@/gql'; -import { TypeDataset, TypeUseCase } from '@/gql/generated/graphql'; -import { useQuery } from '@tanstack/react-query'; -import { Card, Text } from 'opub-ui'; -import { GraphQL } from '@/lib/api'; -import { formatDate } from '@/lib/utils'; -import BreadCrumbs from '@/components/BreadCrumbs'; -import { Icons } from '@/components/icons'; -import { Loading } from '@/components/loading'; -import PrimaryDetails from '../components/Details'; -import Metadata from '../components/Metadata'; -import Dashboards from './Dashboards'; +import { GraphQLPublic } from '@/lib/api'; +import { generatePageMetadata } from '@/lib/utils'; +import UseCaseDetailClient from './UsecaseDetailsClient'; -const UseCasedetails: any = graphql(` - query UseCasedetails($pk: ID!) { +const UseCaseInfoQuery = graphql(` + query UseCaseInfo($pk: ID!) { useCase(pk: $pk) { id title summary - created - startedOn - isIndividualUsecase - user { - fullName - email - profilePicture { - url - } - } - organization { - name - contactEmail - logo { - url - } - } - website - metadata { - metadataItem { - id - label - dataType - } - id - value - } - sectors { - id - name - } - runningStatus - tags { - id - value - } - publishers { - name - contactEmail - logo { - url - } - } - platformUrl + slug logo { - name path } - datasets { - title - id - isIndividualDataset - user { - fullName - id - profilePicture { - url - } - } - downloadCount - description - organization { - name - logo { - url - } - } - metadata { - metadataItem { - id - label - dataType - } - id - value - } - sectors { - name - } - modified - } - contactEmail - status - slug - modified - contributors { - id - fullName - profilePicture { - url - } - } - supportingOrganizations { - id - name - logo { - url - } - } - partnerOrganizations { + tags { id - name - logo { - url - } + value } } } `); -const UseCaseDetailPage = () => { - const params = useParams(); - const { - data: UseCaseDetails, - isLoading, - refetch, - } = useQuery<{ useCase: TypeUseCase }>( - [`fetch_UsecaseDetails_${params.useCaseSlug}`], - () => - GraphQL( - UseCasedetails, - {}, - { - pk: params.useCaseSlug, - } - ), - { - refetchOnMount: true, - refetchOnReconnect: true, - } - ); - const datasets = UseCaseDetails?.useCase?.datasets || []; // Fallback to an empty array - - const hasSupportingOrganizations = - UseCaseDetails?.useCase?.supportingOrganizations && - UseCaseDetails?.useCase?.supportingOrganizations?.length > 0; - const hasPartnerOrganizations = - UseCaseDetails?.useCase?.partnerOrganizations && - UseCaseDetails?.useCase?.partnerOrganizations?.length > 0; - const hasContributors = - UseCaseDetails?.useCase?.contributors && - UseCaseDetails?.useCase?.contributors?.length > 0; - console.log(UseCaseDetails); +export async function generateMetadata({ + params, +}: { + params: { useCaseSlug: string }; +}): Promise { + try { + const data = await GraphQLPublic(UseCaseInfoQuery, {}, { pk: params.useCaseSlug }); + const UseCase = data?.useCase; - return ( -
- {isLoading ? ( -
- -
- ) : ( - <> - -
-
-
- -
-
- -
-
-
-
- Datasets in this Use Case - - All Datasets related to this Use Case - -
-
- {/*
*/} - {datasets.length > 0 && - datasets.map((dataset: TypeDataset) => ( - - meta.metadataItem?.label === 'Geography' - )?.value || '', - }, - ]} - href={`/datasets/${dataset.id}`} - footerContent={[ - { - icon: `/Sectors/${dataset.sectors[0]?.name}.svg`, - label: 'Sectors', - }, - { - icon: dataset.isIndividualDataset - ? dataset?.user?.profilePicture - ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${dataset.user.profilePicture.url}` - : '/profile.png' - : dataset?.organization?.logo - ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${dataset.organization.logo.url}` - : '/org.png', - label: 'Published by', - }, - ]} - description={dataset.description || ''} - /> - ))} -
-
-
- - {(hasSupportingOrganizations || - hasPartnerOrganizations || - hasContributors) && ( -
-
- {hasSupportingOrganizations && ( -
- - Supported by - -
- {UseCaseDetails?.useCase?.supportingOrganizations?.map( - (org: any) => ( -
- {org.name} -
- ) - )} -
-
- )} - {hasPartnerOrganizations && ( -
- - Partnered by - -
- {UseCaseDetails?.useCase?.partnerOrganizations?.map( - (org: any) => ( -
- {org.name} -
- ) - )} -
-
- )} -
- {hasContributors && ( -
-
- - Contributors{' '} - - - Publisher and Contributors who have added to the Use Case - -
-
- {UseCaseDetails?.useCase?.contributors?.map( - (contributor: any) => ( - {contributor.fullName} - ) - )} -
-
- )} -
- )} - - )} -
- ); -}; + return generatePageMetadata({ + title: `${UseCase?.title} | Sector Data | CivicDataSpace`, + description: + UseCase?.summary || + `Explore open data and curated datasets in the ${UseCase?.title} sector.`, + keywords: UseCase?.tags?.map((tag: any) => tag.value) || [], + openGraph: { + type: 'article', + locale: 'en_US', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/usecases/${params.useCaseSlug}`, + title: `${UseCase?.title} | Sector Data | CivicDataSpace`, + description: + UseCase?.summary || + `Explore open data and curated datasets in the ${UseCase?.title} sector.`, + siteName: 'CivicDataSpace', + image: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/og.png`, + }, + }); + } catch (error) { + // Fallback to generic metadata if the API call fails + return generatePageMetadata({ + title: `Use Case Details | CivicDataSpace`, + description: `Explore open data and curated datasets in this use case.`, + keywords: ['usecase', 'data', 'civic', 'open data'], + openGraph: { + type: 'article', + locale: 'en_US', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/usecases/${params.useCaseSlug}`, + title: `Use Case Details | CivicDataSpace`, + description: `Explore open data and curated datasets in this use case.`, + siteName: 'CivicDataSpace', + image: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/og.png`, + }, + }); + } +} -export default UseCaseDetailPage; +export default function Page() { + return ; +} diff --git a/app/[locale]/(user)/usecases/components/Details.tsx b/app/[locale]/(user)/usecases/components/Details.tsx index d7007c31..1a55a1d1 100644 --- a/app/[locale]/(user)/usecases/components/Details.tsx +++ b/app/[locale]/(user)/usecases/components/Details.tsx @@ -72,7 +72,7 @@ const PrimaryDetails = ({ data, isLoading }: { data: any; isLoading: any }) => {
- GEOGRAPHIES + Geographies
{
Summary
- + {data.useCase.summary}
diff --git a/app/[locale]/(user)/usecases/components/Metadata.tsx b/app/[locale]/(user)/usecases/components/Metadata.tsx index 9999233c..2f427b52 100644 --- a/app/[locale]/(user)/usecases/components/Metadata.tsx +++ b/app/[locale]/(user)/usecases/components/Metadata.tsx @@ -29,15 +29,39 @@ const Metadata = ({ data, setOpen }: { data: any; setOpen?: any }) => { fetchTitle(); } }, [data.useCase.platformUrl]); + + const getOrganizationLink = () => { + if (!data) return '/publishers'; + + if (data.useCase.isIndividualUsecase && data.useCase.user) { + return `/publishers/${data.useCase.user.fullName + '_' + data.useCase.user.id}`; + } + + if (data.useCase.organization) { + return `/publishers/organization/${data.useCase.organization.slug + '_' + data.useCase.organization.id}`; + } + + return '/publishers'; + }; + const metadata = [ { label: data.useCase.isIndividualUsecase ? 'Publisher' : 'Organization', - value: data.useCase.isIndividualUsecase - ? data.useCase.user.fullName - : data?.useCase.organization?.name, - tooltipContent: data.useCase.isIndividualUsecase - ? data.useCase.user.fullName - : data?.useCase.organization?.name, + value: ( + + + {data.useCase.isIndividualUsecase + ? data.useCase.user.fullName + : data?.useCase.organization?.name} + + + ), }, { label: 'Contact', @@ -162,19 +186,21 @@ const Metadata = ({ data, setOpen }: { data: any; setOpen?: any }) => {
-
- { -
+ +
+ { +
+
{metadata.map((item, index) => (
diff --git a/app/[locale]/(user)/usecases/page.tsx b/app/[locale]/(user)/usecases/page.tsx index c8dce012..70e1b30b 100644 --- a/app/[locale]/(user)/usecases/page.tsx +++ b/app/[locale]/(user)/usecases/page.tsx @@ -1,181 +1,56 @@ -'use client'; +import React from 'react'; -import Image from 'next/image'; -import { graphql } from '@/gql'; -import { useQuery } from '@tanstack/react-query'; -import { Card, Spinner, Text } from 'opub-ui'; +import { generateJsonLd, generatePageMetadata } from '@/lib/utils'; +import JsonLd from '@/components/JsonLd'; +import UseCasesListingClient from './UseCasesListingClient'; -import { GraphQL } from '@/lib/api'; -import { cn, formatDate } from '@/lib/utils'; -import BreadCrumbs from '@/components/BreadCrumbs'; -import { Icons } from '@/components/icons'; -import { Loading } from '@/components/loading'; -import Styles from '../page.module.scss'; - -const useCasesListQueryDoc: any = graphql(` - query UseCasesList($filters: UseCaseFilter) { - publishedUseCases(filters: $filters) { - id - title - summary - slug - datasetCount - isIndividualUsecase - user { - fullName - profilePicture { - url - } - } - organization { - name - logo { - url - } - } - logo { - path - } - metadata { - metadataItem { - id - label - dataType - } - id - value - } - publishers { - logo { - path - } - name - } - sectors { - id - name - } - created - modified - website - contactEmail - } - } -`); - -const UseCasesListingPage = () => { - const getUseCasesList: { - data: any; - isLoading: boolean; - error: any; - isError: boolean; - } = useQuery([`useCases_list_page`], () => - GraphQL( - useCasesListQueryDoc, - {}, - { - filters: { status: 'PUBLISHED' }, - } - ) - ); +export const generateMetadata = () => + generatePageMetadata({ + title: 'Explore Real-World Use Cases | CivicDataSpace', + description: + 'Discover data-driven interventions across sectors like climate, gender, governance, and education. Our use cases highlight how open data solves real-world problems.', + keywords: [ + 'Data Use Cases', + 'CivicTech', + 'Open Data Applications', + 'Policy Use Cases', + 'Climate Data Use Case', + 'Gender Equality Data', + 'Urban Planning', + 'CivicDataSpace Use Cases', + ], + openGraph: { + type: 'website', + locale: 'en_US', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/usecases`, + title: 'Explore Real-World Use Cases | CivicDataSpace', + description: + 'Explore impactful data-led interventions solving real-world challenges — from climate change to justice and finance.', + siteName: 'CivicDataSpace', + image: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/og.png`, + }, + }); +const UseCasesPage = () => { + const jsonLd = generateJsonLd({ + '@context': 'https://schema.org', + '@type': 'WebPage', + name: 'CivicDataLab', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/usecases`, + description: + 'Discover data-driven interventions across sectors like climate, gender, governance, and education. Our use cases highlight how open data solves real-world problems.', + publisher: { + '@type': 'Organization', + name: 'CivicDataSpace', + url: `${process.env.NEXT_PUBLIC_PLATFORM_URL}/usecases`, + }, + }); return ( -
- -
-
-
- - Our Use Cases - - - By Use case we mean any specific sector or domain data led - interventions that can be applied to address some of the most - pressing concerns from hyper-local to the global level - simultaneously. - -
- {'Usecase -
-
-
-
- Explore Use Cases -
- {getUseCasesList.isLoading ? ( -
- -
- ) : ( -
- {getUseCasesList && - getUseCasesList?.data?.publishedUseCases.length > 0 && - getUseCasesList?.data?.publishedUseCases.map((item: any, index: any) => ( - meta.metadataItem?.label === 'Geography' - )?.value, - }, - ]} - footerContent={[ - { - icon: `/Sectors/${item?.sectors[0]?.name}.svg`, - label: 'Sectors', - }, - { - icon: item.isIndividualUsecase - ? item?.user?.profilePicture - ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${item.user.profilePicture.url}` - : '/profile.png' - : item?.organization?.logo - ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${item.organization.logo.url}` - : '/org.png', - label: 'Published by', - }, - ]} - imageUrl={`${process.env.NEXT_PUBLIC_BACKEND_URL}/${item.logo?.path.replace('/code/files/', '')}`} - description={item.summary} - iconColor="warning" - variation={'collapsed'} - /> - ))} -
- )} -
-
+ <> + + + ); }; -export default UseCasesListingPage; +export default UseCasesPage; diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/[chartID]/components/ChartGenVizPreview.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/[chartID]/components/ChartGenVizPreview.tsx index 19e89561..68ba9b57 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/[chartID]/components/ChartGenVizPreview.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/[chartID]/components/ChartGenVizPreview.tsx @@ -9,6 +9,7 @@ import * as echarts from 'echarts/core'; import { Button, Dialog, + Form, Label, Popover, Select, @@ -241,7 +242,7 @@ const ChartGenVizPreview = ({ params }: { params: any }) => { name: '', schema: [], }, - type: ChartTypes.BarVertical, + type: ChartTypes.Bar, chart: {}, }); @@ -304,7 +305,7 @@ const ChartGenVizPreview = ({ params }: { params: any }) => { refetch: any; error: any; isError: boolean; - } = useQuery([`chartDetailsForViz`], () => + } = useQuery([`chartDetailsForViz-${JSON.stringify(chartData)}`], () => GraphQL( getResourceChartForViz, { @@ -320,8 +321,6 @@ const ChartGenVizPreview = ({ params }: { params: any }) => { if (chartDetailsRes?.data?.resourceChart) { const chartRes = chartDetailsRes?.data?.resourceChart; - console.log('chartData updated :::::::::', chartRes); - setChartData({ chartId: params.chartID, name: chartRes?.name, @@ -447,7 +446,11 @@ const ChartGenVizPreview = ({ params }: { params: any }) => { chartId: params.chartID, resource: value, name: chartData.name, - options: chartData.options, + options: { + ...chartData.options, + xAxisColumn: '', + yAxisColumn: [], + }, type: chartData.type, filters: chartData.filters, }, @@ -679,22 +682,25 @@ const ChartGenVizPreview = ({ params }: { params: any }) => {
-
-
- {chartData.chart?.options && - Object.keys(chartData.chart?.options).length > 0 ? ( + {/* Chart Preview */} +
+ {chartData.chart?.options && + Object.keys(chartData.chart?.options).length > 0 ? ( +
- ) : ( -
- No Chart Data -
- )} -
+
+ ) : ( +
+ No Valid Chart Data +
+ )}
-
+ + {/* Chart Customization */} +
{ columnLabel={''} columnColor={''} onSubmit={(e) => { - console.log( - 'addYAxisColumn :::::::::', - e, - chartData.options.yAxisColumn - ); if ( chartData.options.yAxisColumn === undefined || chartData.options.yAxisColumn?.findIndex( @@ -1053,62 +1054,65 @@ const YaxisColumnForm = ({ return (
- {/* Y axis Column */} - { + setYAxisColumn(e); + }} + required + /> + + {/* Label for specific element */} + { + setYAxisColumnLabel(e); }} - > - Save - -
+ /> + + {/* Color for specific element */} + { + setYAxisColumnColor(e); + }} + /> + +
+ + + +
+
); }; diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartEditor.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartEditor.tsx index 944d4be4..41e7ac43 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartEditor.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartEditor.tsx @@ -14,7 +14,7 @@ import { DropZone, Form, Icon, - Label, + Labelled, Select, Spinner, Tag, @@ -173,6 +173,8 @@ const ChartImageUpload = ({ dataset: selectedDataset, image: files, }); + } else { + toast('Required fields missing. Please fill all required fields.'); } }; @@ -187,91 +189,100 @@ const ChartImageUpload = ({
- { - return { - label: item.title, - value: item.id, - }; - })} - displaySelected - // selectedValue={selectedDataset} - onChange={(e) => { - setSelectedDataset(e); - }} - required - /> - - { - setFiles(val[0]); - }} - outline - allowMultiple={false} - className="bg-greyExtralight" - errorOverlayText={files ? undefined : 'Please select a file'} - required - > - {files ? ( -
- - {files.name} - -
- ) : ( - - - Drag and drop - -
- Select File -
- - *only one image can be added. - - - Recommended resolution of 16:9 - (1280x720), (1920x1080) - - - Maximum file size: 100MB - -
+ + { + return { + label: item.title, + value: item.id, + }; + })} + displaySelected + // selectedValue={selectedDataset} + onChange={(e) => { + setSelectedDataset(e); + }} + required + /> + + + + { + setFiles(val[0]); + }} + outline + allowMultiple={false} + className="bg-greyExtralight" + errorOverlayText={files ? undefined : 'Please select a file'} + required + > + {files ? ( +
+ + {files.name} + +
+ ) : ( + - Supported File Types: + Drag and drop -
- {['PNG', 'JPG', 'SVG', 'TIFF'].map((item, index) => ( - - {item} - - ))} +
+ Select File +
+ + *only one image can be added. + + + Recommended resolution of 16:9 - (1280x720), (1920x1080) + + + Maximum file size: 100MB + +
+ + Supported File Types: + +
+ {['PNG', 'JPG', 'SVG', 'TIFF'].map((item, index) => ( + + {item} + + ))} +
-
- } - actionTitle={''} - /> - )} -
+ } + actionTitle={''} + /> + )} + +
-
@@ -537,31 +548,37 @@ const ChartCreateViz = ({ label: 'BAR', value: 'BAR', icon: 'chartBar', + disabled: false, }, { label: 'LINE', value: 'LINE', icon: 'chartLine', + disabled: false, }, { label: 'TREEMAP', value: 'TREEMAP', icon: 'chartTreeMap', + disabled: false, }, { label: 'BIG NUMBER', value: 'BIG_NUMBER', icon: 'chartBigNumber', + disabled: true, }, { label: 'MAP', value: 'MAP', icon: 'chartMap', + disabled: true, }, { label: 'MAP POLYGON', value: 'MAP_POLYGON', icon: 'chartMapPolygon', + disabled: true, }, ]; @@ -572,7 +589,7 @@ const ChartCreateViz = ({ type: selectedChartType, }); } else { - toast('Please select a resource and chart type'); + toast('Required fields missing. Please fill all required fields.'); } }; @@ -586,69 +603,84 @@ const ChartCreateViz = ({
- item.id === chartDataset) - ?.resources?.map((item: any) => { + + item.id === chartDataset) + ?.resources?.map((item: any) => { + return { + label: item.name, + value: item.id, + }; + })} + onChange={(e) => { + setChartResource(e); + }} + value={chartResource || ''} + /> + +
- + +
+ {chartTypes.map((chartType, index) => ( + + ))} +
+
+ {/* */} -
- {chartTypes.map((chartType, index) => ( - - ))} -
-
diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartForm.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartForm.tsx index 817f034c..83f04279 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartForm.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartForm.tsx @@ -24,15 +24,9 @@ const ChartForm: React.FC = ({ const isAssamChart = chartData.type === ChartTypes.AssamDistrict || chartData.type === ChartTypes.AssamRc; - const isGroupedChart = - chartData.type === ChartTypes.GroupedBarVertical || - chartData.type === ChartTypes.GroupedBarHorizontal || - chartData.type === ChartTypes.Multiline; const isBarOrLineChart = - chartData.type === ChartTypes.BarVertical || - chartData.type === ChartTypes.BarHorizontal || - chartData.type === ChartTypes.Line; + chartData.type === ChartTypes.Bar || chartData.type === ChartTypes.Line; useEffect(() => { if ( @@ -278,7 +272,7 @@ const ChartForm: React.FC = ({ />
- {(isBarOrLineChart || isGroupedChart) && ( + {isBarOrLineChart && (
{chartData?.options?.yAxisColumn?.map((column, index) => ( @@ -332,7 +326,7 @@ const ChartForm: React.FC = ({ onBlur={() => handleSave(chartData)} />
- {isGroupedChart && index > 0 && ( + {index > 0 && ( @@ -340,7 +334,7 @@ const ChartForm: React.FC = ({
))}
- {isGroupedChart && ( + {isBarOrLineChart && ( diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartsList.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartsList.tsx index 51be50b8..d0fe89b0 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartsList.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartsList.tsx @@ -127,9 +127,9 @@ const ChartsList: React.FC = ({ } = useQuery([`chartList`], () => GraphQL( getAllCharts, - { + params.entityType !== 'self' ? { [params.entityType]: params.entitySlug, - }, + } : {}, [] ) ); diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartsVisualize.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartsVisualize.tsx index 75c06590..c930ffa5 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartsVisualize.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/charts/components/ChartsVisualize.tsx @@ -182,7 +182,7 @@ const ChartsVisualize: React.FC = ({ timeColumn: '', }, resource: '', - type: ChartTypes.BarVertical, + type: ChartTypes.Bar, chart: {}, }); @@ -278,8 +278,8 @@ const ChartsVisualize: React.FC = ({ timeColumn: '', yAxisColumn: [], }; - case ChartTypes.BarVertical: - case ChartTypes.BarHorizontal: + case ChartTypes.Bar: + case ChartTypes.Line: return { ...baseOptions, yAxisColumn: [{ fieldName: '', label: '', color: '#000000' }], diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/components/EditMetadata.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/components/EditMetadata.tsx index a1231a8e..60f756d7 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/components/EditMetadata.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/components/EditMetadata.tsx @@ -238,7 +238,7 @@ export function EditMetadata({ id }: { id: string }) { setFormData(updatedData); setPreviousFormData(updatedData); } else { - toast( + toast( 'Error: ' + (res.addUpdateDatasetMetadata?.errors?.fieldErrors ? res.addUpdateDatasetMetadata?.errors?.fieldErrors[0] @@ -322,51 +322,79 @@ export function EditMetadata({ id }: { id: string }) { }; const handleSave = (updatedData: any) => { - if (JSON.stringify(updatedData) !== JSON.stringify(previousFormData)) { - setPreviousFormData(updatedData); + const changedFields: any = {}; - const transformedValues = Object.keys(updatedData)?.reduce( - (acc: any, key) => { - acc[key] = Array.isArray(updatedData[key]) - ? updatedData[key] - .map((item: any) => item?.value || item) - .join(', ') - : updatedData[key]; - return acc; - }, - {} - ); + for (const key in updatedData) { + const newValue = updatedData[key]; + const prevValue = previousFormData[key]; - updateMetadataMutation.mutate({ - UpdateMetadataInput: { - dataset: id, - metadata: [ - ...Object.keys(transformedValues) - .filter( - (valueItem) => - ![ - 'sectors', - 'description', - 'tags', - 'isPublic', - 'license', - ].includes(valueItem) && transformedValues[valueItem] !== '' - ) - .map((key) => { - return { - id: key, - value: transformedValues[key], - }; - }), - ], - ...(updatedData.license && { license: updatedData.license }), - accessType: updatedData.accessType || 'PUBLIC', - description: updatedData.description || '', - tags: updatedData.tags?.map((item: any) => item.label) || [], - sectors: updatedData.sectors?.map((item: any) => item.value) || [], - }, - }); + const isArray = Array.isArray(newValue); + + const normalize = (val: any) => + isArray ? val?.map((item: any) => item?.value || item) : val; + + const newNormalized = normalize(newValue); + const prevNormalized = normalize(prevValue); + + const hasChanged = isArray + ? JSON.stringify(newNormalized) !== JSON.stringify(prevNormalized) + : newNormalized !== prevNormalized; + + if (hasChanged) { + changedFields[key] = newValue; + } } + + // Exit early if nothing changed + if (Object.keys(changedFields).length === 0) return; + + setPreviousFormData(updatedData); // Update local copy + + const transformedValues = Object.keys(changedFields).reduce( + (acc: any, key) => { + acc[key] = Array.isArray(changedFields[key]) + ? changedFields[key] + .map((item: any) => item?.value || item) + .join(', ') + : changedFields[key]; + return acc; + }, + {} + ); + + updateMetadataMutation.mutate({ + UpdateMetadataInput: { + dataset: id, + metadata: Object.keys(transformedValues) + .filter( + (key) => + ![ + 'sectors', + 'description', + 'tags', + 'isPublic', + 'license', + ].includes(key) && transformedValues[key] !== '' + ) + .map((key) => ({ + id: key, + value: transformedValues[key], + })), + ...(changedFields.license && { license: changedFields.license }), + ...(changedFields.accessType && { + accessType: changedFields.accessType, + }), + ...(changedFields.description !== undefined && { + description: changedFields.description, + }), + ...(changedFields.tags && { + tags: changedFields.tags.map((item: any) => item.label), + }), + ...(changedFields.sectors && { + sectors: changedFields.sectors.map((item: any) => item.value), + }), + }, + }); }; function renderInputField(metadataFormItem: any) { @@ -522,7 +550,7 @@ export function EditMetadata({ id }: { id: string }) { name="description" label="Description *" value={formData.description} - helpText="Character limit: 1000" + helpText={`Character limit: ${formData?.description?.length}/1000`} onChange={(e) => handleChange('description', e)} onBlur={() => handleSave(formData)} // Save on blur /> diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/EditResource.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/EditResource.tsx index 856f6672..3433b7ae 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/EditResource.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/EditResource.tsx @@ -94,17 +94,29 @@ export const EditResource = ({ refetch, allResources }: EditProps) => { const [schema, setSchema] = React.useState([]); const resourceDetailsQuery = useQuery( - [`fetch_resource_details_${resourceId}`], - () => - GraphQL( + // Use a stable key when resourceId is empty/invalid + resourceId && resourceId.trim() + ? [`fetch_resource_details_${resourceId}`] + : ['fetch_resource_details_disabled'], + () => { + if (!resourceId || !resourceId.trim()) { + // Return a rejected promise or throw an error to prevent execution + return Promise.reject(new Error('No resource ID provided')); + } + return GraphQL( resourceDetails, { [params.entityType]: params.entitySlug, }, { resourceId: resourceId } - ), + ); + }, { - enabled: !!resourceId, + enabled: !!(resourceId && resourceId.trim()), + // Prevent retries when there's no resourceId + retry: false, + // Don't refetch when resourceId is empty + refetchOnWindowFocus: !!(resourceId && resourceId.trim()), } ); @@ -213,9 +225,9 @@ export const EditResource = ({ refetch, allResources }: EditProps) => { columns: [], }); - // useEffect(() => { - // resourceDetailsQuery.refetch(); - // }, []); + useEffect(() => { + resourceDetailsQuery.refetch(); + }, []); React.useEffect(() => { const ResourceData = resourceDetailsQuery.data?.resourceById; @@ -232,14 +244,12 @@ export const EditResource = ({ refetch, allResources }: EditProps) => { }); }, [resourceDetailsQuery.data]); - useEffect(() => { const schemaData = resourceDetailsQuery.data?.resourceById?.schema; if (schemaData && Array.isArray(schemaData)) { setSchema(schemaData); } }, [resourceDetailsQuery.data]); - const handleResourceChange = (e: any) => { setResourceId(e, { shallow: false }); @@ -378,6 +388,7 @@ export const EditResource = ({ refetch, allResources }: EditProps) => { setResourceName(text)} + helpText={`Character limit: ${resourceName?.length}/200`} onBlur={saveResource} multiline={2} label="Data File Name" diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/ResourceDropzone.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/ResourceDropzone.tsx index 84af7726..9131b96c 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/ResourceDropzone.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/ResourceDropzone.tsx @@ -58,7 +58,7 @@ export const ResourceDropzone = ({ reload }: { reload: () => void }) => { - Maximum File Size Limit : 25 MB + Maximum File Size Limit : 50 MB
Supported File Types: diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/page.tsx index 219023bb..e410d50d 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/page.tsx @@ -14,6 +14,7 @@ import { Loading } from '@/components/loading'; import { ActionBar } from './components/action-bar'; import { Content } from './components/content'; import { Navigation } from './components/navigate-org-datasets'; +import { formatDate } from '@/lib/utils'; const allDatasetsQueryDoc: any = graphql(` query allDatasetsQuery($filters: DatasetFilter, $order: DatasetOrder) { @@ -239,16 +240,8 @@ export default function DatasetPage({ return { title: item.title, id: item.id, - created: new Date(item.created).toLocaleString('en-GB', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }), - modified: new Date(item.modified).toLocaleString('en-GB', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }), + created: formatDate(item.created), + modified: formatDate(item.modified), }; }); }; diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/layout.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/layout.tsx index d2927978..ef0c2790 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/layout.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/layout.tsx @@ -24,16 +24,26 @@ export default function OrgDashboardLayout({ children }: DashboardLayoutProps) { const params = useParams<{ entityType: string; entitySlug: string }>(); const { setEntityDetails, entityDetails, userDetails } = useDashboardStore(); - const EntityDetailsQryRes: { data: any; isLoading: boolean; error: any } = - useQuery([`entity_details_${params.entityType}`], () => - GraphQL( - params.entityType === 'organization' && getOrgDetailsQryDoc, - { - [params.entityType]: params.entitySlug, - }, - { slug: params.entitySlug } - ) - ); + const EntityDetailsQryRes: { + data: any; + isLoading: boolean; + error: any; + refetch: any; + } = useQuery([`entity_details_${params.entityType}`], () => + GraphQL( + params.entityType === 'organization' && getOrgDetailsQryDoc, + { + [params.entityType]: params.entitySlug, + }, + { slug: params.entitySlug } + ) + ); + + + useEffect(() => { + EntityDetailsQryRes.refetch(); + }, []); + useEffect(() => { if (EntityDetailsQryRes.data) { diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/userProfile.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/userProfile.tsx index 570ecc1c..62e32906 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/userProfile.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/profile/userProfile.tsx @@ -35,6 +35,23 @@ const updateUserMutation: any = graphql(` } `); +const githubRegex = /^https:\/\/github\.com\/[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/; +const linkedinRegex = /^https:\/\/(?:www\.)?linkedin\.com\/in\/[a-zA-Z0-9-]+\/?$/; +const twitterRegex = /^https:\/\/(?:www\.)?(?:twitter\.com|x\.com)\/[a-zA-Z0-9_]+\/?$/; + +const prettyField = (f: string) => { + switch (f) { + case 'github_profile': + return 'GitHub URL'; + case 'linkedin_profile': + return 'LinkedIn URL'; + case 'twitter_profile': + return 'Twitter URL'; + default: + return f.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + } +}; + const UserProfile = () => { const params = useParams<{ entityType: string; entitySlug: string }>(); @@ -70,9 +87,11 @@ const UserProfile = () => { const { mutate, isLoading: editMutationLoading } = useMutation( (input: { input: UpdateUserInput }) => - GraphQL(updateUserMutation, { - [params.entityType]: params.entitySlug, - }, input), + GraphQL( + updateUserMutation, + { [params.entityType]: params.entitySlug }, + input + ), { onSuccess: (res: any) => { toast('User details updated successfully'); @@ -92,8 +111,36 @@ const UserProfile = () => { me: res.updateUser, }); }, + onError: (error: any) => { - toast(`Error: ${error.message}`); + if (typeof error?.message === 'string') { + const message: string = error.message; + + // Try to extract field errors + const tryField = (field: string) => { + const m = message.match( + new RegExp(`'${field}'\\s*:\\s*\\['([^']+)'\\]`) + ); + if (m?.[1]) { + const prettyName = prettyField(field); + const errorMsg = `${prettyName}: ${m[1]}`; + toast.error(errorMsg); + } + return Boolean(m?.[1]); + }; + + const anyMatched = + tryField('github_profile') || + tryField('linkedin_profile') || + tryField('twitter_profile'); + + if (!anyMatched) { + toast.error(`Error: ${message}`); + } + return; + } + + toast.error('An unexpected error occurred.'); }, } ); @@ -112,24 +159,36 @@ const UserProfile = () => { if (!formValidation) { toast('Please fill all the required fields'); return; - } else { - const inputData: UpdateUserInput = { - firstName: formData.firstName, - lastName: formData.lastName, - bio: formData.bio, - email: formData.email, - githubProfile: formData.githubProfile, - linkedinProfile: formData.linkedinProfile, - twitterProfile: formData.twitterProfile, - location: formData.location, - }; - - // Only add logo if it has changed - if (formData.profilePicture instanceof File) { - inputData.profilePicture = formData.profilePicture; - } - mutate({ input: inputData }); } + if (formData.githubProfile && !githubRegex.test(formData.githubProfile)) { + toast.error('GitHub URL: Enter a valid URL.'); + return; + } + if (formData.linkedinProfile && !linkedinRegex.test(formData.linkedinProfile)) { + toast.error('LinkedIn URL: Enter a valid URL.'); + return; + } + if (formData.twitterProfile && !twitterRegex.test(formData.twitterProfile)) { + toast.error('Twitter URL: Enter a valid URL.'); + return; + } + + const inputData: UpdateUserInput = { + firstName: formData.firstName, + lastName: formData.lastName, + bio: formData.bio, + email: formData.email, + githubProfile: formData.githubProfile, + linkedinProfile: formData.linkedinProfile, + twitterProfile: formData.twitterProfile, + location: formData.location, + }; + + // Only add logo if it has changed + if (formData.profilePicture instanceof File) { + inputData.profilePicture = formData.profilePicture; + } + mutate({ input: inputData }); }; return ( @@ -182,6 +241,7 @@ const UserProfile = () => { label="Github Profile" name="githubProfile" type="url" + placeholder="https://github.com/username" value={formData.githubProfile} onChange={(e) => setFormData({ ...formData, githubProfile: e })} /> @@ -189,6 +249,7 @@ const UserProfile = () => { label="Linkedin Profile" name="linkedinProfile" type="url" + placeholder="https://linkedin.com/in/username" value={formData.linkedinProfile} onChange={(e) => setFormData({ ...formData, linkedinProfile: e })} /> @@ -196,6 +257,7 @@ const UserProfile = () => { label="Twitter Profile" name="twitterProfile" type="url" + placeholder="https://twitter.com/username" value={formData.twitterProfile} onChange={(e) => setFormData({ ...formData, twitterProfile: e })} /> diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/details/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/details/page.tsx index dadb9b71..36398205 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/details/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/edit/[id]/details/page.tsx @@ -109,7 +109,7 @@ const Details = () => { const runningStatus = [ { - label: 'Intitated', + label: 'Initiated', value: 'INITIATED', }, { @@ -230,7 +230,7 @@ const Details = () => { name="summary" value={formData.summary} multiline={7} - helpText="Character limit: 10000" + helpText={`Character limit: ${formData?.summary?.length}/10000`} onChange={(e) => handleChange('summary', e)} onBlur={() => handleSave(formData)} /> diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/page.tsx index c05550bf..28e60d4c 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/usecases/page.tsx @@ -9,6 +9,7 @@ import { Button, DataTable, Icon, IconButton, Text, toast } from 'opub-ui'; import { twMerge } from 'tailwind-merge'; import { GraphQL } from '@/lib/api'; +import { formatDate } from '@/lib/utils'; import { Icons } from '@/components/icons'; import { LinkButton } from '@/components/Link'; import { Loading } from '@/components/loading'; @@ -184,14 +185,21 @@ export default function DatasetPage({ header: 'Title', cell: ({ row }: any) => navigationTab === 'published' ? ( - {row.original.title} + + {row.original.title} + ) : ( - {row.original.title} + + {row.original.title} + ), }, @@ -235,16 +243,8 @@ export default function DatasetPage({ return { title: item.title, id: item.id, - created: new Date(item.created).toLocaleString('en-GB', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }), - modified: new Date(item.modified).toLocaleString('en-GB', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }), + created: formatDate(item.created), + modified: formatDate(item.modified), }; }); }; diff --git a/app/[locale]/dashboard/[entityType]/page.tsx b/app/[locale]/dashboard/[entityType]/page.tsx index 649fc0d2..54d4e469 100644 --- a/app/[locale]/dashboard/[entityType]/page.tsx +++ b/app/[locale]/dashboard/[entityType]/page.tsx @@ -169,6 +169,7 @@ const Page = () => {
@@ -328,7 +329,7 @@ const EntityCard = ({ entityItem, params }: any) => {
- + {entityItem.name}
diff --git a/app/[locale]/dashboard/components/main-footer.tsx b/app/[locale]/dashboard/components/main-footer.tsx index c7064f34..0f5c43c5 100644 --- a/app/[locale]/dashboard/components/main-footer.tsx +++ b/app/[locale]/dashboard/components/main-footer.tsx @@ -9,114 +9,79 @@ import styles from './styles.module.scss'; const MainFooter = () => { const socialMedia = [ { - icon: Icons.twitter, - link: 'https://twitter.com/civicdatalab', + icon: Icons.github, + link: 'https://github.com/civicdatalab', }, { icon: Icons.linkedin, link: 'https://www.linkedin.com/company/civicdatalab', }, { - icon: Icons.facebook, - link: 'https://facebook.com/civicdatalab', + icon: Icons.twitter, + link: 'https://twitter.com/civicdatalab', }, { - icon: Icons.github, - link: 'https://github.com/civicdatalab', + icon: Icons.facebook, + link: 'https://facebook.com/civicdatalab', }, ]; return ( - <> -
-
-
- {' '} - -
-
- {/* Static Logo */} -
- Logo -
- - {/* Globe GIF on Hover */} -
- Globe -
-
- - CivicDataSpace - -
+
+
+
+ + About Us + + + Contact Us + +
+
+ {socialMedia.map((item, index) => ( + + -
-
- {' '} - - Follow Us - -
- -
- {socialMedia.map((item, index) => ( - - - - ))} -
-
-
-
-
- - About Us - - - Contact Us - -
-
- made by - - logo - -
-
+ ))} +
+
+ {socialMedia.map((item, index) => ( + + + + ))} +
+
+ Made in India. A DataSpace product by + + CivicDataLab + CDL logo +
- +
); }; -export default MainFooter; +export default MainFooter; \ No newline at end of file diff --git a/app/[locale]/dashboard/components/main-nav.tsx b/app/[locale]/dashboard/components/main-nav.tsx index 5339885c..882dc20a 100644 --- a/app/[locale]/dashboard/components/main-nav.tsx +++ b/app/[locale]/dashboard/components/main-nav.tsx @@ -139,11 +139,11 @@ export function MainNav({ hideSearch = false }) {
-
+
{/* Static Logo */} -
+
Logo {/* Globe GIF on Hover */} -
+ {/*
Globe -
+
*/}
- + {/* CivicDataSpace - + */}
@@ -258,17 +258,25 @@ export const ProfileContent = ({ {session.user.name} ) : ( - + +
)} diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 51284f2e..4478891c 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -14,53 +14,53 @@ export function generateStaticParams() { return locales.all.map((locale) => ({ locale })); } -export async function generateMetadata() { - return { - metadataBase: new URL(siteConfig.url), - title: { - default: siteConfig.name, - template: `%s | ${siteConfig.name}`, - }, - description: siteConfig.description, - keywords: [ - 'Next.js', - 'React', - 'Server Components', - 'Radix UI', - 'OPub', - 'Open Publishing', - ], - authors: [ - { - name: 'CivicDataLab', - url: 'https://civicdatalab.in/', - }, - ], - creator: 'CivicDataLab', - openGraph: { - type: 'website', - locale: 'en_US', - url: siteConfig.url, - title: siteConfig.name, - description: siteConfig.description, - siteName: siteConfig.name, - images: [`${siteConfig.url}/og.png`], - }, - twitter: { - card: 'summary_large_image', - title: siteConfig.name, - description: siteConfig.description, - images: [`${siteConfig.url}/og.png`], - creator: 'CivicDataLab', - }, - icons: { - icon: '/favicon.ico', - shortcut: '/favicon-16x16.png', - apple: `${siteConfig.url}/apple-touch-icon.png`, - }, - manifest: `${siteConfig.url}/site.webmanifest`, - }; -} +// export async function generateMetadata() { +// return { +// metadataBase: new URL(siteConfig.url), +// title: { +// default: siteConfig.name, +// template: `%s | ${siteConfig.name}`, +// }, +// description: siteConfig.description, +// keywords: [ +// 'Next.js', +// 'React', +// 'Server Components', +// 'Radix UI', +// 'OPub', +// 'Open Publishing', +// ], +// authors: [ +// { +// name: 'CivicDataLab', +// url: 'https://civicdatalab.in/', +// }, +// ], +// creator: 'CivicDataLab', +// openGraph: { +// type: 'website', +// locale: 'en_US', +// url: siteConfig.url, +// title: siteConfig.name, +// description: siteConfig.description, +// siteName: siteConfig.name, +// images: [`${siteConfig.url}/og.png`], +// }, +// twitter: { +// card: 'summary_large_image', +// title: siteConfig.name, +// description: siteConfig.description, +// images: [`${siteConfig.url}/og.png`], +// creator: 'CivicDataLab', +// }, +// icons: { +// icon: '/favicon.ico', +// shortcut: '/favicon-16x16.png', +// apple: `${siteConfig.url}/apple-touch-icon.png`, +// }, +// manifest: `${siteConfig.url}/site.webmanifest`, +// }; +// } export default async function LocaleLayout({ children, diff --git a/app/robots.txt/route.ts b/app/robots.txt/route.ts new file mode 100644 index 00000000..d39fbc53 --- /dev/null +++ b/app/robots.txt/route.ts @@ -0,0 +1,22 @@ +// app/robots.txt/route.ts +import { isSitemapEnabled } from '@/lib/utils'; + +export async function GET() { + const baseUrl = process.env.NEXTAUTH_URL; + + const robotsTxt = `User-agent: * + Allow: / + + Sitemap: ${baseUrl}/sitemap/main.xml + `; + + if (!isSitemapEnabled()) { + return new Response('Sitemaps are not enabled', { status: 404 }); + } + + return new Response(robotsTxt, { + headers: { + 'Content-Type': 'text/plain', + }, + }); +} diff --git a/app/sitemap/[entityPage]/route.ts b/app/sitemap/[entityPage]/route.ts new file mode 100644 index 00000000..7b6a8f1d --- /dev/null +++ b/app/sitemap/[entityPage]/route.ts @@ -0,0 +1,140 @@ +// app/sitemap-[entity]-[page].xml/route.ts +import { type NextRequest } from 'next/server'; + +import { ENTITY_CONFIG, getSiteMapConfig, isSitemapEnabled } from '@/lib/utils'; +import { getGraphqlEntityCount, getSearchEntityCount } from '@/lib/sitemap-utils'; + +interface EntityItem { + id: string; + slug?: string; + updated_at?: string; + __typename?: 'TypeUser' | 'TypeOrganization'; +} + +async function fetchEntityData( + entity: string, + page: number +): Promise { + const config = ENTITY_CONFIG[entity]; + + // If no config is found, return empty array + if (!config) return []; + + if (config.source === 'search') { + // Fetch entity based on general rest query + const response = await getSearchEntityCount( + entity, + getSiteMapConfig().itemsPerPage, + page + ); + if (!response || !response.list) return []; + return response.list; + } else if (config.source === 'graphql') { + // Fetch entity based on graphql query + const response = await getGraphqlEntityCount(entity, config); + if (!response || !response.list) return []; + return response.list; + } else { + return []; + } +} + +function generateEntitySitemap(items: EntityItem[], entity: string): string { + const baseUrl = process.env.NEXTAUTH_URL; + const config = ENTITY_CONFIG[entity]; + + if (!config) { + return ` + + `; + } + + const urls = items + ?.map((item) => { + console.log(item, entity); + + // Function to handle loc or URLs for different types of entities especially for contributors or organizations + const getLoc = () => { + if (item.__typename === 'TypeOrganization') { + return `${baseUrl}/${config.path}/organization/${item.id}`; + } else if (item.__typename === 'TypeUser') { + return `${baseUrl}/${config.path}/${item.id}`; + } else { + return `${baseUrl}/${config.path}/${item.slug || item.id}`; + } + }; + + const loc = getLoc(); + const lastmod = item.updated_at + ? new Date(item.updated_at).toISOString() + : new Date().toISOString(); + + return ` + + ${loc} + ${lastmod} + weekly + ${config.priority} + + `; + }) + .join(''); + + return `\n\n${urls}\n`; +} + +export async function GET( + request: NextRequest, + { params }: { params: { entityPage: string } } +) { + // Check if sitemaps are enabled via feature flag + if (!isSitemapEnabled()) { + return new Response('Sitemaps are not enabled', { status: 404 }); + } + + try { + const { entityPage } = params; + + const m = entityPage.match(/^([a-zA-Z0-9_]+)-(\d+)\.xml$/); + if (!m) { + return new Response('Invalid Route', { status: 404 }); + } + + const entity = m[1]; + const pageNumber = Number(m[2]); + + if (!ENTITY_CONFIG[entity]) { + return new Response('Entity not found', { status: 404 }); + } + + if (isNaN(pageNumber) || pageNumber < 1) { + return new Response('Invalid page number', { status: 400 }); + } + + const items = await fetchEntityData(entity, pageNumber); + const sitemap = generateEntitySitemap(items, entity); + + const flags = getSiteMapConfig(); + return new Response(sitemap, { + headers: { + 'Content-Type': 'application/xml', + 'Cache-Control': `public, max-age=${flags.childCacheDuration}`, + }, + }); + } catch (error) { + console.error('Error generating entity sitemap:', error); + + const errorSitemap = ` + + `; + + return new Response(errorSitemap, { + status: 500, + headers: { + 'Content-Type': 'application/xml', + }, + }); + } +} + +export const dynamic = 'force-dynamic'; diff --git a/app/sitemap/main.xml/route.ts b/app/sitemap/main.xml/route.ts new file mode 100644 index 00000000..23ccefc2 --- /dev/null +++ b/app/sitemap/main.xml/route.ts @@ -0,0 +1,132 @@ +// app/sitemap.xml/route.ts +import { + getSiteMapConfig, + isSitemapEnabled, +} from '@/lib/utils'; +import { getAllEntityCounts } from '@/lib/sitemap-utils'; + +function generateStaticUrls(): string { + const baseUrl = process.env.NEXTAUTH_URL; + + const staticPages = [ + { path: '', priority: '1.0', changefreq: 'daily' }, + { path: '/datasets', priority: '0.9', changefreq: 'daily' }, + { path: '/usecases', priority: '0.8', changefreq: 'weekly' }, + { path: '/publishers', priority: '0.7', changefreq: 'weekly' }, + { path: '/sectors', priority: '0.7', changefreq: 'weekly' }, + ]; + + return staticPages + .map( + (page) => ` + + ${baseUrl}${page.path} + ${page.changefreq} + ${page.priority} + ` + ) + .join(''); +} + +function generateSitemapIndex( + sitemapUrls: string[], + staticUrls: string +): string { + const sitemapEntries = sitemapUrls + .map( + (url) => + ` + + ${url} + ${new Date().toISOString()} + ` + ) + .join(''); + + return `\n + ${staticUrls}\n${sitemapEntries}\n`; +} + +export async function GET() { + // Check if sitemaps are enabled via feature flag + if (!isSitemapEnabled()) { + return new Response('Sitemaps are not enabled', { status: 404 }); + } + + try { + const flags = getSiteMapConfig(); + const ITEMS_PER_SITEMAP = flags.itemsPerPage; + + // Fetch counts for all entities + // const [sectorsCount] = await Promise.all([ + // getGraphqlEntityCount({ sectors: ENTITY_CONFIG.sectors }), + // ]); + + const baseUrl = process.env.NEXTAUTH_URL; + + // Generate sitemap URLs for each entity + const sitemapUrls: string[] = []; + + const entityCounts = await getAllEntityCounts(); + + // Datasets sitemaps + if (entityCounts.datasets > 0) { + const datasetPages = Math.ceil(entityCounts.datasets / ITEMS_PER_SITEMAP); + for (let i = 1; i <= datasetPages; i++) { + sitemapUrls.push(`${baseUrl}/sitemap/datasets-${i}.xml`); + } + } + + // Usecases sitemaps + const usecasePages = Math.ceil(entityCounts.usecases / ITEMS_PER_SITEMAP); + for (let i = 1; i <= usecasePages; i++) { + sitemapUrls.push(`${baseUrl}/sitemap/usecases-${i}.xml`); + } + + // Contributors sitemaps + const contributorPages = Math.ceil( + entityCounts.contributors / ITEMS_PER_SITEMAP + ); + for (let i = 1; i <= contributorPages; i++) { + sitemapUrls.push(`${baseUrl}/sitemap/contributors-${i}.xml`); + } + + // Sectors sitemaps + if (entityCounts.sectors > 0) { + const sectorPages = Math.ceil(entityCounts.sectors / ITEMS_PER_SITEMAP); + for (let i = 1; i <= sectorPages; i++) { + sitemapUrls.push(`${baseUrl}/sitemap/sectors-${i}.xml`); + } + } + + const sitemapIndex = generateSitemapIndex( + sitemapUrls, + generateStaticUrls() + ); + + return new Response(sitemapIndex, { + status: 200, + headers: { + 'Content-Type': 'application/xml', + 'Cache-Control': `public, max-age=${flags.cacheDuration}`, + }, + }); + + // return new Response(JSON.stringify(entityCounts), { status: 200 }); + } catch (error) { + console.error('Error generating sitemap index:', error); + + const errorSitemap = ` + +`; + + return new Response(errorSitemap, { + status: 500, + headers: { + 'Content-Type': 'application/xml', + }, + }); + } +} + +export const dynamic = 'force-dynamic'; diff --git a/components/JsonLd.tsx b/components/JsonLd.tsx new file mode 100644 index 00000000..fb7a9252 --- /dev/null +++ b/components/JsonLd.tsx @@ -0,0 +1,10 @@ +type JsonLdProps = { + json: string; + }; + + export default function JsonLd({ json }: JsonLdProps) { + return ( +