├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── meaningful_project.md │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── accept_issue.yml │ ├── test-frontend.yml │ ├── push-to-internal.yml │ └── deploy.yml ├── public ├── robots.txt ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-96x96.png ├── meaningfulcode-logo.png ├── manifest.json ├── sitemap.xml └── img │ └── adrien.svg ├── src ├── types │ ├── gtag.d.ts │ └── mui.d.ts ├── app │ ├── favicon.ico │ ├── about │ │ ├── page.test.tsx │ │ └── page.tsx │ ├── get-started │ │ ├── page.test.tsx │ │ └── page.tsx │ ├── submit-project │ │ ├── page.test.tsx │ │ ├── submitProject.ts │ │ ├── page.tsx │ │ └── SubmitProjectForm.tsx │ ├── [[...category]] │ │ ├── ProjectPlaceholder.tsx │ │ ├── getProjects.ts │ │ ├── ProjectCardListIcon.tsx │ │ ├── PlaceholderProjectsContainer.tsx │ │ ├── projectUrl.ts │ │ ├── HeaderAndMenus.tsx │ │ ├── ProjectsGrid.tsx │ │ ├── page.tsx │ │ ├── ProjectsContainer.tsx │ │ ├── MockProjects.ts │ │ ├── style.css │ │ ├── ProjectsSortingMenu.tsx │ │ ├── ProjectCard.tsx │ │ └── HeaderText.tsx │ ├── find-project │ │ ├── invokeAgent.ts │ │ ├── page.tsx │ │ └── FindProjectForm.tsx │ ├── layout.css │ └── layout.tsx ├── constants │ └── constants.ts ├── utils │ ├── shuffle.ts │ ├── date.test.tsx │ ├── getHost.ts │ ├── shuffle.test.js │ ├── date.tsx │ └── getHost.test.ts ├── models │ └── Project.ts ├── components │ ├── GaPageEvent.tsx │ ├── Emoji.tsx │ ├── GitHubButton.tsx │ ├── LanguageDropdown.tsx │ ├── MediaCard.tsx │ ├── Logo.tsx │ ├── CategoryButton.test.tsx │ ├── Header.test.tsx │ ├── CategoryButton.tsx │ ├── Footer.test.tsx │ ├── Footer.tsx │ ├── MobileMenu.tsx │ ├── CategoryIcon.tsx │ └── Header.tsx ├── theme.ts └── PatchCssStyle.tsx ├── .gitmodules ├── .eslintrc.json ├── .vercelignore ├── .husky └── pre-commit ├── next.config.js ├── next-env.d.ts ├── .prettierrc ├── .gitignore ├── tsconfig.json ├── package.json ├── README.md ├── jest.config.js └── LICENSE /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [pixep] 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-Agent: * 2 | Disallow: -------------------------------------------------------------------------------- /src/types/gtag.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | gtag(...args: any[]): void; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meaningful-Code/meaningfulcode-frontend/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meaningful-Code/meaningfulcode-frontend/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meaningful-Code/meaningfulcode-frontend/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meaningful-Code/meaningfulcode-frontend/HEAD/public/favicon-96x96.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "src/app/api"] 2 | path = src/app/api 3 | url = git@github.com:Meaningful-Code/meaningfulcode-api.git -------------------------------------------------------------------------------- /public/meaningfulcode-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meaningful-Code/meaningfulcode-frontend/HEAD/public/meaningfulcode-logo.png -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals", 4 | "plugin:react/recommended", 5 | "plugin:jsx-a11y/recommended" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vercelignore: -------------------------------------------------------------------------------- 1 | /* 2 | *.key 3 | .env.* 4 | 5 | src/app/api/_test* 6 | 7 | !public 8 | !src 9 | 10 | !next.config.js 11 | !package.json 12 | !tsconfig.json 13 | !yarn.lock -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | set -e 5 | 6 | yarn -s test --watchAll=false 7 | 8 | yarn -s run lint 9 | 10 | yarn -s run build 11 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | compiler: { 5 | styledComponents: true, 6 | }, 7 | }; 8 | 9 | module.exports = nextConfig; 10 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /src/constants/constants.ts: -------------------------------------------------------------------------------- 1 | export const RECAPTCHA_SITE_KEY = '6LeuSEYeAAAAAJrZY05dnjlIkU-3EAe4JqDdd3wz'; 2 | export const GOOGLE_TAG_ID = 'G-C9SQS63TJQ'; 3 | export const PRODUCTION_HOST = 'https://meaningfulcode.org'; 4 | export const LOCAL_DEV_HOST = 'http://localhost:3000'; 5 | -------------------------------------------------------------------------------- /src/utils/shuffle.ts: -------------------------------------------------------------------------------- 1 | export default function shuffle(array: Type[]) { 2 | const length = array.length; 3 | for (let i = length - 1; i > 0; i--) { 4 | const j = Math.floor(Math.random() * (i + 1)); 5 | [array[i], array[j]] = [array[j], array[i]]; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 89, 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "trailingComma": "es5", 7 | "bracketSpacing": true, 8 | "newline-before-return": true, 9 | "no-duplicate-variable": [true, "check-parameters"], 10 | "no-var-keyword": true 11 | } -------------------------------------------------------------------------------- /src/app/about/page.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import '@testing-library/jest-dom'; 4 | 5 | import About from './page'; 6 | 7 | describe('Page: GetStarted', () => { 8 | it('renders', async () => { 9 | render(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/app/get-started/page.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import '@testing-library/jest-dom'; 4 | 5 | import GetStarted from './page'; 6 | 7 | describe('Page: GetStarted', () => { 8 | it('renders', async () => { 9 | render(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/app/submit-project/page.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import '@testing-library/jest-dom'; 4 | 5 | import SubmitProject from './page'; 6 | 7 | describe('Page: SubmitProject', () => { 8 | it('renders', async () => { 9 | render(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Meaningful Code", 3 | "name": "Meaningful Code: Find Open-source projects, make a difference", 4 | "icons": [ 5 | { 6 | "src": "/meaningfulcode-logo.png", 7 | "sizes": "128x128", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#ffffff", 14 | "background_color": "#ffffff" 15 | } -------------------------------------------------------------------------------- /src/models/Project.ts: -------------------------------------------------------------------------------- 1 | export const categories = [ 2 | 'environment', 3 | 'health', 4 | 'society', 5 | 'education', 6 | 'humanitarian', 7 | 'accessibility', 8 | ]; 9 | 10 | export type Project = { 11 | name: string; 12 | owner: string; 13 | categories: string[]; 14 | languages: string[]; 15 | stars: number; 16 | description?: string; 17 | url: string; 18 | websiteUrl: string; 19 | // In seconds since the epoch 20 | lastCommitTimestamp: number; 21 | }; 22 | -------------------------------------------------------------------------------- /src/utils/date.test.tsx: -------------------------------------------------------------------------------- 1 | import { formatLastUpdateAge } from './date'; 2 | 3 | describe('utils: formatLastUpdateAge', () => { 4 | test.each([ 5 | [null, 'never'], 6 | [0, 'today'], 7 | [1, '1 day ago'], 8 | [6, '6 days ago'], 9 | [33, '1 month ago'], 10 | [67, '2 months ago'], 11 | [400, '1 year ago'], 12 | [800, '2 years ago'], 13 | ])('converts %i days readable output %s', (days, expected) => { 14 | expect(formatLastUpdateAge(days)).toBe(expected); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/types/mui.d.ts: -------------------------------------------------------------------------------- 1 | import '@mui/material/styles/createPalette'; 2 | declare module '@mui/material/styles/createPalette' { 3 | interface PaletteOptions { 4 | neutral: PaletteColorOptions; 5 | cat_all: PaletteColorOptions; 6 | cat_environment: PaletteColorOptions; 7 | cat_humanitarian: PaletteColorOptions; 8 | cat_accessibility: PaletteColorOptions; 9 | cat_society: PaletteColorOptions; 10 | cat_health: PaletteColorOptions; 11 | cat_education: PaletteColorOptions; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/GaPageEvent.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useEffect } from 'react'; 4 | import { usePathname } from 'next/navigation'; 5 | 6 | import { GOOGLE_TAG_ID } from '@/constants/constants'; 7 | 8 | const GaPageEvent = () => { 9 | const pathname = usePathname(); 10 | useEffect(() => { 11 | if (!window.gtag) { 12 | return; 13 | } 14 | window.gtag('config', GOOGLE_TAG_ID, { 15 | page_path: pathname, 16 | }); 17 | }, [pathname]); 18 | return <>; 19 | }; 20 | 21 | export default GaPageEvent; 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | # next-env.d.ts 37 | -------------------------------------------------------------------------------- /src/components/Emoji.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type EmojiProps = { 4 | label?: string; 5 | symbol: string; 6 | }; 7 | 8 | // Thank you Sean 9 | // https://medium.com/@seanmcp/%EF%B8%8F-how-to-use-emojis-in-react-d23bbf608bf7 10 | function Emoji(props: EmojiProps) { 11 | const { label, symbol } = props; 12 | 13 | return ( 14 | 20 | {symbol} 21 | 22 | ); 23 | } 24 | 25 | export default Emoji; 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/meaningful_project.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: New meaningful project 3 | about: Suggest a meaningful project to be listed 4 | title: 'Meaningful project: ' 5 | labels: 'meaningful project' 6 | assignees: '' 7 | 8 | --- 9 | **Project information** 10 | - Name: 11 | - Website: 12 | - Category: Accessibility/Education/Environment/Health/Humanitarian/Society 13 | - Source repository: e.g. GitHub URL 14 | 15 | **How is this project meaningful/impactful?** 16 | Optional 17 | 18 | **How to contribute?** 19 | Optional 20 | 21 | **Additional context** 22 | Anything else worth mentioning? 23 | -------------------------------------------------------------------------------- /.github/workflows/accept_issue.yml: -------------------------------------------------------------------------------- 1 | name: label-action-test 2 | run-name: ${{ github.actor }} approved an issue 3 | on: 4 | issues: 5 | types: 6 | - labeled 7 | jobs: 8 | submit-project: 9 | if: github.event.label.name == 'approved' 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: REST API with curl 13 | run: | 14 | curl -X POST -H "Content-Type: application/json" -d '{"action":"add", "token":"${{ secrets.MC_SERVER_TOKEN }}"", "issueNum": "${{ github.event.issue.number }}", "issueBody": "${{ github.event.issue.body }}"}' https://meaningfulcode.org/api/projects -------------------------------------------------------------------------------- /.github/workflows/test-frontend.yml: -------------------------------------------------------------------------------- 1 | name: Test frontend 2 | 3 | on: 4 | push: 5 | branches: [main, develop] 6 | pull_request: 7 | branches: [main, develop] 8 | 9 | jobs: 10 | test-frontend: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: 'Setup node' 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: '20' 18 | - name: Install dependencies 19 | run: yarn 20 | - name: Generate production build 21 | run: yarn build 22 | - name: Run automated tests 23 | run: yarn test 24 | -------------------------------------------------------------------------------- /.github/workflows/push-to-internal.yml: -------------------------------------------------------------------------------- 1 | name: Push to private deploy repository 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | 8 | jobs: 9 | push: 10 | if: github.actor == github.repository_owner 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Push changes 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GH_PUSH }} 20 | run: | 21 | git remote add target https://${{ secrets.GH_PUSH }}@github.com/Meaningful-Code/meaningfulcode-deploy.git 22 | git push target HEAD:${{ github.ref }} 23 | -------------------------------------------------------------------------------- /src/utils/getHost.ts: -------------------------------------------------------------------------------- 1 | import { PRODUCTION_HOST, LOCAL_DEV_HOST } from '@/constants/constants'; 2 | 3 | export default function getHost() { 4 | if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'test') { 5 | throw new Error('getHost() should only be called on the server side'); 6 | } 7 | 8 | if (process.env.NEXT_PUBLIC_VERCEL_ENV) { 9 | if (process.env.NEXT_PUBLIC_VERCEL_ENV === 'production') { 10 | return PRODUCTION_HOST; 11 | } else { 12 | return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`; 13 | } 14 | } else if (process.env.MEANINGFUL_DEV) { 15 | return LOCAL_DEV_HOST; 16 | } else { 17 | return PRODUCTION_HOST; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/GitHubButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Button from '@mui/material/Button'; 4 | import Link from '@mui/material/Link'; 5 | 6 | import GitHubIcon from '@mui/icons-material/GitHub'; 7 | import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; 8 | 9 | type GitHubButtonProps = { 10 | url: string; 11 | }; 12 | 13 | export default function GitHubButton(props: GitHubButtonProps) { 14 | const { url } = props; 15 | return ( 16 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: 'Feature: ' 5 | labels: 'enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "baseUrl": ".", 23 | "paths": { 24 | "@/*": ["./src/*"] 25 | } 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /src/app/[[...category]]/ProjectPlaceholder.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Card from '@mui/material/Card'; 3 | import Grid from '@mui/material/Grid'; 4 | import Skeleton from '@mui/material/Skeleton'; 5 | 6 | export default function ProjectPlaceholder() { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/app/find-project/invokeAgent.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | export default async function invokeAgent( 4 | host: string, 5 | prompt: string, 6 | recaptcha: string 7 | ) { 8 | try { 9 | const endpoint = `${host}/api/invoke-agent`; 10 | const data = { prompt, recaptcha }; 11 | const res = await fetch(endpoint, { 12 | method: 'POST', 13 | headers: { 14 | 'Content-Type': 'application/json', 15 | }, 16 | body: JSON.stringify(data), 17 | }); 18 | 19 | if (!res.ok) { 20 | const errData = await res.json(); 21 | const { reason } = errData; 22 | throw new Error(`Failed to invoke agent: ${reason}`); 23 | } 24 | 25 | const { answer } = await res.json(); 26 | return answer; 27 | } catch (err: any) { 28 | // eslint-disable-next-line no-console 29 | console.log(`Error invoking agent: ${err}`); 30 | throw new Error(`Failed to invoke agent`); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/shuffle.test.js: -------------------------------------------------------------------------------- 1 | import shuffle from './shuffle'; // adjust the import according to your file structure 2 | 3 | describe('shuffle function', () => { 4 | const original = [1, 2, 3, 4, 5]; 5 | 6 | it('should return an array with the same length', () => { 7 | const array = [...original]; 8 | shuffle(array); 9 | expect(array.length).toEqual(array.length); 10 | }); 11 | 12 | it('should contain the same elements', () => { 13 | const array = [...original]; 14 | shuffle(array); 15 | expect(array.sort()).toEqual(array.sort()); 16 | }); 17 | 18 | it('should shuffle the elements', () => { 19 | const array = [...original]; 20 | shuffle(array); 21 | let differentOrder = false; 22 | for (let i = 0; i < 100; i++) { 23 | if (!array.every((value, index) => value === original[index])) { 24 | differentOrder = true; 25 | break; 26 | } 27 | } 28 | expect(differentOrder).toBeTruthy(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/app/[[...category]]/getProjects.ts: -------------------------------------------------------------------------------- 1 | import getMockProjects from './MockProjects'; 2 | import { Project } from '@/models/Project'; 3 | import getHost from '@/utils/getHost'; 4 | 5 | const projectsEndpoint = `${getHost()}/api/projects`; 6 | 7 | export default async function getProjects(): Promise { 8 | if (process.env.API_MODE === 'stub') { 9 | return getMockProjects(); 10 | } 11 | 12 | // start a timer to measure the duration of the fetch 13 | const res = await fetch(projectsEndpoint, { next: { revalidate: 4 * 3600 } }); 14 | if (!res.ok) { 15 | const error = `An error occured while getting projects from "${projectsEndpoint}": ${res.statusText}`; 16 | throw new Error(error); 17 | } 18 | const data = await res.json(); 19 | 20 | // check that data is an array 21 | if (!Array.isArray(data)) { 22 | const error = `Project data received in invalid format "${projectsEndpoint}": ${data}`; 23 | throw new Error(error); 24 | } 25 | return data; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/LanguageDropdown.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Autocomplete from '@mui/material/Autocomplete'; 3 | import Grid from '@mui/material/Grid'; 4 | import TextField from '@mui/material/TextField'; 5 | 6 | type LanguageFilterButtonProps = { 7 | languages: string[]; 8 | language: string | null; 9 | onChange: (event: React.SyntheticEvent) => void; 10 | }; 11 | 12 | export default function LanguageDropdown(props: LanguageFilterButtonProps) { 13 | const { languages, language, onChange } = props; 14 | 15 | return ( 16 | 17 | ( 21 | // eslint-disable-next-line react/jsx-props-no-spreading 22 | 23 | )} 24 | onChange={onChange} 25 | sx={{ width: 200 }} 26 | size="small" 27 | value={language || null} 28 | /> 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: 'Bug: ' 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /src/app/[[...category]]/ProjectCardListIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Avatar from '@mui/material/Avatar'; 3 | import ListItemAvatar from '@mui/material/ListItemAvatar'; 4 | import { useTheme, styled } from '@mui/material/styles'; 5 | 6 | export const ProjectCartListAvatar = styled(Avatar)((props) => ({ 7 | width: 35, 8 | height: 35, 9 | backgroundColor: 'transparent', 10 | color: props.color, 11 | })); 12 | 13 | export type ProjectCardListIconProps = { 14 | avatar: React.ReactElement; 15 | color?: string; 16 | }; 17 | 18 | export function ProjectCardListIcon(props: ProjectCardListIconProps) { 19 | const { avatar, color } = props; 20 | const theme = useTheme(); 21 | const defaultColor = 22 | theme.palette.mode === 'light' 23 | ? theme.palette.common.black 24 | : theme.palette.common.white; 25 | return ( 26 | 27 | 28 | {avatar} 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/date.tsx: -------------------------------------------------------------------------------- 1 | export function formatLastUpdateAge(lastCommitAgeInDays: number | null) { 2 | if (lastCommitAgeInDays == null) { 3 | return 'never'; 4 | } 5 | 6 | const monthDuration = 30; 7 | const yearDuration = 365; 8 | if (lastCommitAgeInDays < 1) { 9 | return 'today'; 10 | } 11 | 12 | let lastUpdateText = ''; 13 | if (lastCommitAgeInDays < monthDuration) { 14 | lastUpdateText = `${lastCommitAgeInDays} ${ 15 | lastCommitAgeInDays === 1 ? 'day' : 'days' 16 | }`; 17 | } else if (lastCommitAgeInDays < yearDuration) { 18 | const lastCommitAgeInMonths = Math.floor(lastCommitAgeInDays / monthDuration); 19 | lastUpdateText = `${lastCommitAgeInMonths} ${ 20 | lastCommitAgeInMonths === 1 ? 'month' : 'months' 21 | }`; 22 | } else { 23 | const lastCommitAgeInYears = Math.floor(lastCommitAgeInDays / yearDuration); 24 | lastUpdateText = `${lastCommitAgeInYears} ${ 25 | lastCommitAgeInYears === 1 ? 'year' : 'years' 26 | }`; 27 | } 28 | 29 | return lastUpdateText + ' ago'; 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/getHost.test.ts: -------------------------------------------------------------------------------- 1 | import getHost from './getHost'; 2 | describe('getHost', () => { 3 | const ENV_BACKUP = process.env; 4 | 5 | beforeEach(() => { 6 | jest.resetModules(); 7 | process.env = { ...ENV_BACKUP }; 8 | process.env.NEXT_PUBLIC_VERCEL_URL = 'preview-12345.vercel.app'; 9 | }); 10 | afterAll(() => { 11 | process.env = ENV_BACKUP; 12 | }); 13 | 14 | test('returns production host when NEXT_PUBLIC_VERCEL_ENV is not set', () => { 15 | process.env.NEXT_PUBLIC_VERCEL_ENV = undefined; 16 | expect(getHost()).toBe('https://meaningfulcode.org'); 17 | }); 18 | 19 | test('returns production host when NEXT_PUBLIC_VERCEL_ENV is production', () => { 20 | process.env.NEXT_PUBLIC_VERCEL_ENV = 'production'; 21 | expect(getHost()).toBe('https://meaningfulcode.org'); 22 | }); 23 | 24 | test('returns preview host when NEXT_PUBLIC_VERCEL_ENV is not production', () => { 25 | process.env.NEXT_PUBLIC_VERCEL_ENV = 'development'; 26 | expect(getHost()).toBe('https://preview-12345.vercel.app'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: 'Clone API submodule' 13 | env: 14 | REPO_KEY: ${{ secrets.API_REPOSITORY_KEY }} 15 | run: | 16 | echo "$REPO_KEY" > repo.key && chmod 400 repo.key 17 | export GIT_SSH_COMMAND="ssh -i repo.key" 18 | git submodule update --init 19 | rm repo.key 20 | 21 | - name: 'Setup node' 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: '20' 25 | 26 | - name: 'Deploy to Vercel' 27 | env: 28 | VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} 29 | VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} 30 | VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} 31 | run: | 32 | prodDeployArg="" 33 | if [[ ${GITHUB_REF} == "refs/heads/main" ]]; then 34 | prodDeployArg="--prod" 35 | fi 36 | 37 | npx vercel --token ${VERCEL_TOKEN} $prodDeployArg 38 | -------------------------------------------------------------------------------- /src/app/submit-project/submitProject.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | export type ProjectSubmission = { 4 | name: string; 5 | website: string; 6 | repository: string; 7 | category: string; 8 | description: string; 9 | }; 10 | 11 | export default async function submitProject( 12 | host: string, 13 | project: ProjectSubmission, 14 | recaptcha: string 15 | ) { 16 | try { 17 | const submitProjectEndpoint = `${host}/api/submit-project`; 18 | const data = { project, recaptcha }; 19 | const res = await fetch(submitProjectEndpoint, { 20 | method: 'POST', 21 | headers: { 22 | 'Content-Type': 'application/json', 23 | }, 24 | body: JSON.stringify(data), 25 | }); 26 | 27 | if (!res.ok) { 28 | const errData = await res.json(); 29 | const { reason } = errData; 30 | throw new Error(`Failed to submit project: ${reason}`); 31 | } 32 | 33 | const { url } = await res.json(); 34 | return url; 35 | } catch (err: any) { 36 | // eslint-disable-next-line no-console 37 | console.log(`Error submitting project: ${err}`); 38 | throw new Error(`Failed to submit project`); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/[[...category]]/PlaceholderProjectsContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Card from '@mui/material/Card'; 4 | import Grid from '@mui/material/Grid'; 5 | import Skeleton from '@mui/material/Skeleton'; 6 | 7 | function PlaceholderProject() { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | 27 | export default function PlaceholderProjectsContainer() { 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/MediaCard.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Image from 'next/image'; 3 | import Card from '@mui/material/Card'; 4 | import CardActions from '@mui/material/CardActions'; 5 | import CardContent from '@mui/material/CardContent'; 6 | import Button from '@mui/material/Button'; 7 | import Typography from '@mui/material/Typography'; 8 | 9 | export default function MediaCard({ heading, text }: { heading: string; text: string }) { 10 | return ( 11 | 12 | Random image 23 | 24 | 25 | {heading} 26 | 27 | 28 | {text} 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Link from 'next/link'; 3 | import Image from 'next/image'; 4 | import Typography from '@mui/material/Typography'; 5 | import Stack from '@mui/material/Stack'; 6 | import { Box } from '@mui/material'; 7 | 8 | export default function Logo() { 9 | return ( 10 | 11 | 12 | 13 | logo 20 | 21 | 22 | logo 29 | 30 | 31 | 32 | Meaningful Code 33 | 34 | 35 | Find Open Source projects,
36 | contribute, make a difference. 37 |
38 |
39 |
40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://meaningfulcode.org/ 5 | weekly 6 | 7 | 8 | https://meaningfulcode.org/about 9 | monthly 10 | 11 | 12 | https://meaningfulcode.org/get-started 13 | monthly 14 | 15 | 16 | https://meaningfulcode.org/submit-project 17 | monthly 18 | 19 | 20 | https://meaningfulcode.org/health 21 | weekly 22 | 23 | 24 | https://meaningfulcode.org/education 25 | weekly 26 | 27 | 28 | https://meaningfulcode.org/environment 29 | weekly 30 | 31 | 32 | https://meaningfulcode.org/society 33 | weekly 34 | 35 | 36 | https://meaningfulcode.org/humanitarian 37 | weekly 38 | 39 | 40 | https://meaningfulcode.org/accessibility 41 | weekly 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/app/submit-project/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Metadata } from 'next'; 3 | 4 | import Link from '@mui/material/Link'; 5 | import Typography from '@mui/material/Typography'; 6 | 7 | import getHost from '@/utils/getHost'; 8 | import SubmitProjectForm from './SubmitProjectForm'; 9 | 10 | export const metadata: Metadata = { 11 | title: 'Submit a project for good', 12 | }; 13 | 14 | export default function SubmitProject() { 15 | const ProjectsRepository = 'https://github.com/Meaningful-Code/meaningful-projects'; 16 | const NewProjectIssueLink = 17 | 'https://github.com/Meaningful-Code/meaningfulcode-frontend/issues/new?assignees=&labels=meaningful+project&template=meaningful_project.md&title=Meaningful+project%3A+'; 18 | return ( 19 | <> 20 | Submit a project 21 |

22 | If you have an impactful project that you want to share with the community, you 23 | can: please submit it here! You can also manually create an to submit your 24 | project. 25 |

26 |
    27 |
  • 28 | Create a pull request directly on GitHub 29 |
  • 30 |
  • 31 | Create an issue on GitHub 32 |
  • 33 |
  • Or fill the form below
  • 34 |
35 |
36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/components/CategoryButton.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import { lightTheme } from '@/theme'; 4 | import { ThemeProvider } from '@mui/material/styles'; 5 | import '@testing-library/jest-dom'; 6 | 7 | import CategoryButton from './CategoryButton'; 8 | 9 | describe('Component: CategoryButton', () => { 10 | it('links target URL', async () => { 11 | const targetURL = '/mycategory'; 12 | render( 13 | 14 | 15 | 16 | ); 17 | 18 | expect(screen.getByRole('link')).toHaveAttribute('href', targetURL); 19 | }); 20 | 21 | it('is h1 if active', async () => { 22 | const name = 'mycategory'; 23 | render( 24 | 25 | 26 | 27 | ); 28 | 29 | expect(screen.getByRole('heading')).toBeDefined(); 30 | expect(screen.getByRole('heading').nodeName).toBe('H1'); 31 | expect(screen.getByRole('heading')).toHaveTextContent(name); 32 | }); 33 | 34 | it('is not h1 if inactive', async () => { 35 | render( 36 | 37 | 38 | 39 | ); 40 | 41 | expect(screen.queryByRole('heading')).toBeNull(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/app/layout.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --gray: #888; 3 | --white: #fff; 4 | --black: #000; 5 | --cat-all-color: #1f647d; 6 | --cat-health-color: #cc8562; 7 | --cat-education-color: #3c9d76; 8 | --cat-environment-color: #3c9d76; 9 | --cat-society-color: #1f647d; 10 | --cat-humanitarian-color: #cc8562; 11 | --cat-accessibility-color: #1f647d; 12 | } 13 | 14 | body { 15 | /* Always show vertical bar to avoid resizes on empty results*/ 16 | overflow-y: scroll; 17 | } 18 | 19 | #root { 20 | padding: 0 1em 2em; 21 | } 22 | 23 | #root-container { 24 | padding: 0; 25 | } 26 | 27 | header { 28 | margin-bottom: 2em; 29 | } 30 | 31 | h1.header { 32 | margin: 0; 33 | } 34 | 35 | header > div { 36 | margin-top: 1em; 37 | } 38 | 39 | header a { 40 | text-decoration: none; 41 | } 42 | 43 | #title { 44 | font-size: 1.6rem; 45 | } 46 | 47 | main { 48 | margin-top: 3em; 49 | } 50 | 51 | @media only screen and (max-width: 599px) { 52 | #root { 53 | padding: 1em 0.5em; 54 | } 55 | 56 | #title { 57 | padding: 4px 0 0 4px; 58 | } 59 | 60 | #subtitle { 61 | display: none; 62 | } 63 | 64 | main { 65 | margin-top: 2em; 66 | } 67 | } 68 | 69 | footer { 70 | margin: 2em 0; 71 | min-height: 3em; 72 | } 73 | 74 | #social { 75 | text-align: center; 76 | } 77 | 78 | #socialButton { 79 | margin-top: 1em; 80 | } 81 | 82 | /* About */ 83 | #about h2 { 84 | margin-top: 1em; 85 | } 86 | 87 | #about .MuiButton-root { 88 | margin-top: 0.5em; 89 | margin-right: 0.5em; 90 | } 91 | -------------------------------------------------------------------------------- /src/app/[[...category]]/projectUrl.ts: -------------------------------------------------------------------------------- 1 | import { ReadonlyURLSearchParams } from 'next/navigation'; 2 | 3 | export function decodeSearchParams(searchParams: ReadonlyURLSearchParams) { 4 | // Schema: meaningfulcode.org/?lang=&search=&sort= 5 | const language = searchParams.get('lang'); 6 | const search = searchParams.get('search'); 7 | const sorting = searchParams.get('sort'); 8 | return { language, search, sorting }; 9 | } 10 | 11 | export function encodeSearchParams( 12 | language: string | null, 13 | search: string | null, 14 | sorting: string | null 15 | ): URLSearchParams { 16 | const params = new URLSearchParams(); 17 | if (language) { 18 | params.append('lang', language); 19 | } 20 | if (search) { 21 | params.append('search', search); 22 | } 23 | if (sorting) { 24 | params.append('sort', sorting); 25 | } 26 | return params; 27 | } 28 | 29 | export function projectsUrlFromState( 30 | category: string | null, 31 | language: string | null, 32 | search: string | null, 33 | sorting: string | null 34 | ): string { 35 | const searchParams = encodeSearchParams(language, search, sorting).toString(); 36 | return `/${category || ''}${searchParams && `?${searchParams}`}`; 37 | } 38 | 39 | type Params = { 40 | category: string[] | null; 41 | }; 42 | 43 | export function categoryFromParams(params: Params): string | null { 44 | const category = params.category ? params.category[0] : null; 45 | const hasCategory = category && category !== 'all' && category !== 'index'; 46 | return hasCategory ? category : null; 47 | } 48 | -------------------------------------------------------------------------------- /src/app/find-project/page.tsx: -------------------------------------------------------------------------------- 1 | // 'use server'; 2 | 3 | import React, { Suspense } from 'react'; 4 | import { Metadata } from 'next'; 5 | import Link from 'next/link'; 6 | 7 | import Typography from '@mui/material/Typography'; 8 | import Alert from '@mui/material/Alert'; 9 | 10 | import getHost from '@/utils/getHost'; 11 | import Emoji from '@/components/Emoji'; 12 | 13 | import FindProjectForm from './FindProjectForm'; 14 | 15 | export const metadata: Metadata = { 16 | title: 'Ask and find your project!', 17 | }; 18 | 19 | export default async function FindProject() { 20 | return ( 21 | <> 22 | Natural language project search 23 |

24 | This page lets you search for projects using natural language from the same list 25 | of projects. For a more targeted search, please continue using the{' '} 26 | main search page and its category, language, and search 27 | filters. 28 |

29 | 30 | This feature is experimental, and the quality of results may vary. We would love 31 | to hear your feedback if you have time! 32 |
33 | August 10th: I just got updated to be more 34 | helpful! 35 |
36 |
37 |
38 | What are you looking for? 39 |
40 | 41 | 42 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "meaningfulcode-frontend", 3 | "version": "1.0", 4 | "private": true, 5 | "scripts": { 6 | "prepare": "husky install", 7 | "dev": "next dev", 8 | "build": "next build", 9 | "start": "next start", 10 | "test": "jest", 11 | "lint": "next lint", 12 | "deploy": "yarn lint && yarn test && yarn build && npx vercel" 13 | }, 14 | "dependencies": { 15 | "@aws-sdk/client-bedrock-agent-runtime": "^3.629.0", 16 | "@aws-sdk/client-dynamodb": "^3.629.0", 17 | "@aws-sdk/lib-dynamodb": "^3.629.0", 18 | "@emotion/cache": "^11.13.0", 19 | "@emotion/react": "^11.13.0", 20 | "@emotion/styled": "^11.13.0", 21 | "@mui/icons-material": "^5.16.6", 22 | "@mui/material": "^5.16.6", 23 | "@mui/material-nextjs": "^5.16.6", 24 | "@octokit/rest": "^20.0.2", 25 | "@vercel/node": "^3.2.8", 26 | "axios": "^1.7.4", 27 | "next": "^14.2.5", 28 | "react": "^18.3.1", 29 | "react-dom": "^18.3.1", 30 | "react-google-recaptcha": "^2.1.0" 31 | }, 32 | "devDependencies": { 33 | "@testing-library/jest-dom": "^6.2.0", 34 | "@testing-library/react": "^14.1.2", 35 | "@types/express": "4.17.21", 36 | "@types/jest": "^27.0.3", 37 | "@types/node": "^20.10.7", 38 | "@types/react": "^18.3.3", 39 | "@types/react-dom": "^18.3.0", 40 | "@types/react-google-recaptcha": "^2.1.5", 41 | "eslint": "^8.56.0", 42 | "eslint-config-next": "^14.0.4", 43 | "express": "^4.19.2", 44 | "husky": "^8.0.3", 45 | "jest": "^29.7.0", 46 | "jest-environment-jsdom": "^29.7.0", 47 | "typescript": "^5.3.3" 48 | } 49 | } -------------------------------------------------------------------------------- /src/components/Header.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render as rtlRender, screen } from '@testing-library/react'; 3 | import '@testing-library/jest-dom'; 4 | import { ThemeProvider } from '@mui/material/styles'; 5 | import { lightTheme } from '@/theme'; 6 | 7 | import PageHeader from './Header'; 8 | 9 | const render = (ui: JSX.Element, options = {}) => 10 | rtlRender({ui}, options); 11 | 12 | describe('PageHeader Component', () => { 13 | it('should render the header with logo and title', () => { 14 | render(); 15 | 16 | expect(screen.getByRole('banner')).toBeInTheDocument(); 17 | 18 | const logo = screen.getAllByAltText('logo'); 19 | expect(logo[0]).toBeInTheDocument(); 20 | 21 | const title = screen.getByText('Meaningful Code'); 22 | expect(title).toBeInTheDocument(); 23 | }); 24 | 25 | it('should render navigation links', () => { 26 | render(); 27 | 28 | const addProjectButton = screen.getByRole('link', { name: /add a project!/i }); 29 | expect(addProjectButton).toBeInTheDocument(); 30 | expect(addProjectButton).toHaveAttribute('href', '/submit-project'); 31 | 32 | const getStartedButton = screen.getByRole('link', { name: /get started/i }); 33 | expect(getStartedButton).toBeInTheDocument(); 34 | expect(getStartedButton).toHaveAttribute('href', '/get-started'); 35 | 36 | const aboutButton = screen.getByRole('link', { name: /about/i }); 37 | expect(aboutButton).toBeInTheDocument(); 38 | expect(aboutButton).toHaveAttribute('href', '/about'); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/components/CategoryButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import Link from 'next/link'; 5 | 6 | import Button from '@mui/material/Button'; 7 | import Typography from '@mui/material/Typography'; 8 | import { useTheme } from '@mui/material/styles'; 9 | 10 | import CategoryIcon, { getCategoryMuiColor } from './CategoryIcon'; 11 | 12 | type CategoryButtonProps = { 13 | category: string; 14 | active: boolean; 15 | targetUrl: string; 16 | }; 17 | 18 | export default function CategoryButton(props: CategoryButtonProps) { 19 | const { category, active, targetUrl } = props; 20 | const theme = useTheme(); 21 | 22 | const getTextColor = () => { 23 | if (theme.palette.mode === 'light') { 24 | return active ? theme.palette.common.white : theme.palette.common.black; 25 | } else { 26 | return theme.palette.common.white; 27 | } 28 | }; 29 | 30 | return ( 31 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { createTheme } from '@mui/material/styles'; 4 | 5 | function createCustomTheme(lightMode: boolean) { 6 | const theme = createTheme({ 7 | palette: { 8 | mode: lightMode ? 'light' : 'dark', 9 | primary: { 10 | main: lightMode ? '#3c9d76' : '#54a484', 11 | contrastText: lightMode ? '#fff' : '#000', 12 | }, 13 | secondary: { main: lightMode ? '#1f647d' : '#62a7d0', contrastText: '#fff' }, 14 | neutral: { 15 | main: lightMode ? '#0b7c78' : '#d0d0d0', 16 | contrastText: lightMode ? '#000' : '#000', 17 | }, 18 | cat_all: { 19 | main: lightMode ? '#1f647d' : '#287693', 20 | contrastText: lightMode ? '#000' : '#000', 21 | }, 22 | cat_environment: { 23 | main: lightMode ? '#3c9d76' : '#3c9d76', 24 | contrastText: lightMode ? '#000' : '#fff', 25 | }, 26 | cat_humanitarian: { 27 | main: lightMode ? '#cc8562' : '#cc8562', 28 | contrastText: lightMode ? '#000' : '#fff', 29 | }, 30 | cat_accessibility: { 31 | main: lightMode ? '#1f647d' : '#287693', 32 | contrastText: lightMode ? '#000' : '#fff', 33 | }, 34 | cat_society: { 35 | main: lightMode ? '#1f647d' : '#287693', 36 | contrastText: lightMode ? '#000' : '#fff', 37 | }, 38 | cat_health: { 39 | main: lightMode ? '#cc8562' : '#cc8562', 40 | contrastText: lightMode ? '#000' : '#fff', 41 | }, 42 | cat_education: { 43 | main: lightMode ? '#3c9d76' : '#3c9d76', 44 | contrastText: lightMode ? '#000' : '#fff', 45 | }, 46 | }, 47 | typography: { 48 | fontFamily: 'var(--font-base)', 49 | h1: { fontSize: '2em' }, 50 | h2: { fontSize: '1.7em' }, 51 | h3: { fontSize: '1.5em' }, 52 | }, 53 | }); 54 | return theme; 55 | } 56 | 57 | export const lightTheme = createCustomTheme(true); 58 | export const darkTheme = createCustomTheme(false); 59 | -------------------------------------------------------------------------------- /src/PatchCssStyle.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useEffect } from 'react'; 4 | import { useTheme } from '@mui/material/styles'; 5 | 6 | // import useMediaQuery from '@mui/material/useMediaQuery'; 7 | // const serverSide = typeof window === 'undefined'; 8 | // const prefersLightMode = useMediaQuery('(prefers-color-scheme: light)', { 9 | // noSsr: true, 10 | // }); 11 | // const lightMode = serverSide || prefersLightMode; 12 | export default function PatchCssStyle() { 13 | const theme = useTheme(); 14 | useEffect(() => { 15 | const root = document.documentElement; 16 | root.style.setProperty('--gray', '#888'); 17 | root.style.setProperty('--white', '#fff'); 18 | root.style.setProperty('--black', '#000'); 19 | 20 | /* @ts-ignore: color type not properly recognized */ 21 | root.style.setProperty('--cat-all-color', theme.palette.cat_all.main); 22 | /* @ts-ignore: color type not properly recognized */ 23 | root.style.setProperty('--cat-health-color', theme.palette.cat_health.main); 24 | /* @ts-ignore: color type not properly recognized */ 25 | root.style.setProperty('--cat-education-color', theme.palette.cat_education.main); 26 | root.style.setProperty( 27 | '--cat-environment-color', 28 | /* @ts-ignore: color type not properly recognized */ 29 | theme.palette.cat_environment.main 30 | ); 31 | /* @ts-ignore: color type not properly recognized */ 32 | root.style.setProperty('--cat-society-color', theme.palette.cat_society.main); 33 | root.style.setProperty( 34 | '--cat-humanitarian-color', 35 | /* @ts-ignore: color type not properly recognized */ 36 | theme.palette.cat_humanitarian.main 37 | ); 38 | root.style.setProperty( 39 | '--cat-accessibility-color', 40 | /* @ts-ignore: color type not properly recognized */ 41 | theme.palette.cat_accessibility.main 42 | ); 43 | document.body.style.backgroundColor = theme.palette.background.default; 44 | }, [theme.palette]); 45 | return <>; 46 | } 47 | -------------------------------------------------------------------------------- /src/app/[[...category]]/HeaderAndMenus.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Container from '@mui/material/Container'; 4 | 5 | import { categories } from '@/models/Project'; 6 | import CategoryButton from '@/components/CategoryButton'; 7 | import { projectsUrlFromState } from './projectUrl'; 8 | 9 | import HeaderText from './HeaderText'; 10 | import ProjectsSortingMenu, { SortingAndFilteringHandlers } from './ProjectsSortingMenu'; 11 | 12 | const categoryAll = 'all'; 13 | type CategoryButtonsProps = { 14 | categories: string[]; 15 | category?: string; 16 | urlTemplate: string; 17 | }; 18 | 19 | export function CategoryButtons(props: CategoryButtonsProps) { 20 | const { categories, category = categoryAll, urlTemplate } = props; 21 | const allCategories = [categoryAll].concat(categories); 22 | return ( 23 | 24 | {allCategories.map((btnCategory) => ( 25 | 31 | ))} 32 | 33 | ); 34 | } 35 | 36 | type MenuProps = { 37 | category: string | null; 38 | language: string | null; 39 | languages: string[]; 40 | search: string | null; 41 | sorting: string | null; 42 | }; 43 | 44 | export default function HeaderAndMenus(props: MenuProps) { 45 | const { category, language, languages, search, sorting } = props; 46 | return ( 47 | <> 48 | 53 | 54 | 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /src/components/Footer.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import '@testing-library/jest-dom'; 4 | 5 | import Footer from './Footer'; 6 | 7 | describe('Footer Component', () => { 8 | it('should render the footer with social links', () => { 9 | render(