├── frontend
├── .npmrc
├── public
│ ├── _redirects
│ ├── robots.txt
│ ├── favicon.ico
│ ├── logo192.png
│ ├── logo512.png
│ ├── manifest.json
│ └── index.html
├── .dockerignore
├── Dockerfile
├── src
│ ├── components
│ │ ├── Card
│ │ │ └── Card.js
│ │ ├── Chip
│ │ │ └── Chip.js
│ │ ├── ResourceSkeleton
│ │ │ └── ResourceSkeleton.js
│ │ ├── ResourceList
│ │ │ └── ResourceList.js
│ │ ├── ResourceSkeletonList
│ │ │ └── ResourceSkeletonList.js
│ │ ├── BaseModal
│ │ │ └── BaseModal.js
│ │ ├── Buttons
│ │ │ ├── ClearBookmarksButton.js
│ │ │ ├── GoToTopButton.js
│ │ │ ├── BookmarkButton.js
│ │ │ ├── RemoveBookmarkButton.js
│ │ │ ├── LoadMoreResourcesButton.js
│ │ │ └── BookmarkButtonsGroup.js
│ │ ├── BookmarkGroupCard
│ │ │ └── BookmarkGroupCard.js
│ │ ├── Nav
│ │ │ └── Nav.js
│ │ ├── ConfirmModal
│ │ │ └── ConfirmModal.js
│ │ ├── NewBookmarkGroupModal
│ │ │ └── NewBookmarkGroupModal.js
│ │ ├── Resource
│ │ │ └── Resource.js
│ │ ├── EditBookmarkGroupModal
│ │ │ └── EditBookmarkGroupModal.js
│ │ ├── BookmarkGroupListModal
│ │ │ └── BookmarkGroupListModal.js
│ │ ├── Header
│ │ │ └── Header.js
│ │ ├── BookmarkGroupDetailsHeader
│ │ │ └── BookmarkGroupDetailsHeader.js
│ │ └── SearchForm
│ │ │ └── SearchForm.js
│ ├── stories
│ │ ├── NotFound.stories.js
│ │ ├── SearchForm.stories.js
│ │ ├── NewBookmarkGroupModal.stories.js
│ │ ├── BookmarkButtonsGroup.stories.js
│ │ ├── EditBookmarkGroupModal.stories.js
│ │ ├── Modals.stories.js
│ │ ├── ConfirmModal.stories.js
│ │ ├── ErrorFetchingResources.stories.js
│ │ ├── BookmarkGroupCard.stories.js
│ │ ├── Card.stories.js
│ │ ├── BookmarkGroupListModal.stories.js
│ │ └── BookmarkGroupDetailsHeader.stories.js
│ ├── assets
│ │ └── checkmark.svg
│ ├── constants.js
│ ├── index.js
│ ├── App.js
│ ├── pages
│ │ ├── NotFound
│ │ │ └── NotFound.js
│ │ ├── ErrorFetchingResources
│ │ │ └── ErrorFetchingResources.js
│ │ ├── Bookmarks
│ │ │ └── Bookmarks.js
│ │ └── Resources
│ │ │ └── Resources.js
│ ├── AppContext.js
│ ├── svgs.js
│ └── App.css
├── .storybook
│ ├── main.js
│ └── preview.js
├── .gitignore
├── package.json
├── README2.md
└── STYLEGUIDE.md
├── .dockerignore
├── backend
├── Procfile
├── .dockerignore
├── Dockerfile
├── src
│ ├── docs
│ │ ├── servers.js
│ │ ├── tags.js
│ │ ├── index.js
│ │ ├── paths
│ │ │ ├── index.js
│ │ │ ├── topic.js
│ │ │ ├── resource.js
│ │ │ └── project.js
│ │ └── swagger.js
│ ├── constants.js
│ ├── get-resources-from-database.js
│ ├── utils.js
│ ├── format-resources.js
│ ├── firebase.js
│ ├── database-config.js
│ ├── server.js
│ ├── routes
│ │ ├── all.js
│ │ ├── topics.js
│ │ └── projects.js
│ ├── update-resources-database.js
│ ├── web-scrape-resources.js
│ └── __tests__
│ │ └── getPageData.test.js
├── README.md
├── package.json
└── .gitignore
├── .prettierignore
├── .vscode
├── extensions.json
├── settings.json
└── coding-resource-finder.code-workspace
├── docs
├── screenshots
│ ├── Screenshot (141).png
│ ├── Screenshot (143).png
│ ├── Screenshot (146).png
│ ├── Screenshot (148).png
│ ├── Screenshot (150).png
│ ├── Screenshot (152).png
│ ├── Screenshot (154).png
│ └── Screenshot (156).png
└── SCREENSHOTS.md
├── .deepsource.toml
├── docker-compose.yml
├── .prettierrc.js
├── .github
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
├── pull_request_template.md
└── workflows
│ └── update-database.yml
├── LICENSE
├── CONTRIBUTING.md
├── CODE_OF_CONDUCT.md
└── README.md
/frontend/.npmrc:
--------------------------------------------------------------------------------
1 | legacy-peer-deps=true
2 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # node_modules
2 | **/node_modules
--------------------------------------------------------------------------------
/backend/Procfile:
--------------------------------------------------------------------------------
1 | web: node ./src/server.js
2 |
--------------------------------------------------------------------------------
/frontend/public/_redirects:
--------------------------------------------------------------------------------
1 | /* /index.html 200
--------------------------------------------------------------------------------
/backend/.dockerignore:
--------------------------------------------------------------------------------
1 | # node_modules
2 | node_modules
--------------------------------------------------------------------------------
/frontend/.dockerignore:
--------------------------------------------------------------------------------
1 | # node_modules
2 | node_modules
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Add any files Prettier should not format
2 |
3 | backend
4 | docs
5 | .github
--------------------------------------------------------------------------------
/frontend/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
3 | }
4 |
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ngoakor12/coding-resource-finder/HEAD/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ngoakor12/coding-resource-finder/HEAD/frontend/public/logo192.png
--------------------------------------------------------------------------------
/frontend/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ngoakor12/coding-resource-finder/HEAD/frontend/public/logo512.png
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "eslint.workingDirectories": ["./backend", "./frontend"],
3 | "editor.formatOnSave": true
4 | }
5 |
--------------------------------------------------------------------------------
/docs/screenshots/Screenshot (141).png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ngoakor12/coding-resource-finder/HEAD/docs/screenshots/Screenshot (141).png
--------------------------------------------------------------------------------
/docs/screenshots/Screenshot (143).png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ngoakor12/coding-resource-finder/HEAD/docs/screenshots/Screenshot (143).png
--------------------------------------------------------------------------------
/docs/screenshots/Screenshot (146).png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ngoakor12/coding-resource-finder/HEAD/docs/screenshots/Screenshot (146).png
--------------------------------------------------------------------------------
/docs/screenshots/Screenshot (148).png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ngoakor12/coding-resource-finder/HEAD/docs/screenshots/Screenshot (148).png
--------------------------------------------------------------------------------
/docs/screenshots/Screenshot (150).png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ngoakor12/coding-resource-finder/HEAD/docs/screenshots/Screenshot (150).png
--------------------------------------------------------------------------------
/docs/screenshots/Screenshot (152).png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ngoakor12/coding-resource-finder/HEAD/docs/screenshots/Screenshot (152).png
--------------------------------------------------------------------------------
/docs/screenshots/Screenshot (154).png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ngoakor12/coding-resource-finder/HEAD/docs/screenshots/Screenshot (154).png
--------------------------------------------------------------------------------
/docs/screenshots/Screenshot (156).png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ngoakor12/coding-resource-finder/HEAD/docs/screenshots/Screenshot (156).png
--------------------------------------------------------------------------------
/.deepsource.toml:
--------------------------------------------------------------------------------
1 | version = 1
2 |
3 | [[analyzers]]
4 | name = "javascript"
5 | enabled = true
6 |
7 | [analyzers.meta]
8 | plugins = ["react"]
--------------------------------------------------------------------------------
/.vscode/coding-resource-finder.code-workspace:
--------------------------------------------------------------------------------
1 | {
2 | "folders": [
3 | {
4 | "path": ".."
5 | }
6 | ],
7 | "settings": {}
8 | }
9 |
--------------------------------------------------------------------------------
/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16-alpine
2 |
3 | WORKDIR /app/backend
4 |
5 | COPY . .
6 |
7 | RUN npm install
8 |
9 | EXPOSE 2856
10 |
11 | CMD ["npm", "run", "dev"]
12 |
--------------------------------------------------------------------------------
/frontend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16-alpine
2 |
3 | WORKDIR /app/frontend
4 |
5 | COPY . .
6 |
7 | RUN npm install
8 |
9 | RUN npm run lint
10 |
11 | EXPOSE 3000
12 |
13 | CMD ["npm", "run", "start"]
14 |
--------------------------------------------------------------------------------
/frontend/src/components/Card/Card.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function Card({ children, fullWidth }) {
4 | return (
5 |
{children}
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/backend/src/docs/servers.js:
--------------------------------------------------------------------------------
1 | const { PORT } = require('./../constants');
2 |
3 | module.exports = {
4 | servers: [
5 | {
6 | url: `http://localhost:${PORT}`,
7 | description: 'Local server',
8 | },
9 | ],
10 | };
11 |
--------------------------------------------------------------------------------
/backend/src/docs/tags.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | tags: [
3 | {
4 | name: 'Projects',
5 | },
6 | {
7 | name: 'Resources',
8 | },
9 | {
10 | name: 'Topics',
11 | },
12 | ],
13 | };
14 |
--------------------------------------------------------------------------------
/frontend/src/components/Chip/Chip.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function Chip({ title, isActive, onClick }) {
4 | return (
5 |
6 | {title}
7 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/backend/src/constants.js:
--------------------------------------------------------------------------------
1 | require("dotenv").config();
2 |
3 | const ACN_URL = "https://syllabus.africacode.net/";
4 | const PORT = process.env.PORT || 2856;
5 | const API_BASE_URL = process.env.PROD_BASE_URL || `http://localhost:${PORT}`;
6 |
7 | module.exports = { ACN_URL, PORT, API_BASE_URL };
8 |
--------------------------------------------------------------------------------
/backend/src/docs/index.js:
--------------------------------------------------------------------------------
1 | const servers = require('./servers');
2 | const tags = require('./tags');
3 | const swagger = require('./swagger');
4 | const apis = require('./paths');
5 |
6 | module.exports = {
7 | ...swagger,
8 | ...servers,
9 | ...tags,
10 | ...apis,
11 | };
12 |
--------------------------------------------------------------------------------
/backend/src/docs/paths/index.js:
--------------------------------------------------------------------------------
1 | const project = require('./project');
2 | const resource = require('./resource');
3 | const topic = require('./topic');
4 |
5 | module.exports = {
6 | paths: {
7 | ...project,
8 | ...resource,
9 | ...topic,
10 | },
11 | };
12 |
--------------------------------------------------------------------------------
/frontend/src/stories/NotFound.stories.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import NotFound from "../pages/NotFound/NotFound";
4 |
5 | export default {
6 | title: "Pages/NotFound",
7 | component: NotFound,
8 | };
9 | const Template = (args) => ;
10 |
11 | export const NotFoundPage = Template.bind({});
12 |
--------------------------------------------------------------------------------
/frontend/src/assets/checkmark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/stories/SearchForm.stories.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import SearchForm from "../components/SearchForm/SearchForm";
4 |
5 | export default {
6 | title: "Components/SearchForm",
7 | component: SearchForm,
8 | };
9 | const Template = (args) => ;
10 |
11 | export const Search = Template.bind({});
12 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 | services:
3 | backend:
4 | build: ./backend
5 | ports:
6 | - "2856:2856"
7 | container_name: coding-resource-finder-backend
8 | frontend:
9 | depends_on:
10 | - backend
11 | build: ./frontend
12 | ports:
13 | - "3000:3000"
14 | container_name: coding-resource-finder-frontend
--------------------------------------------------------------------------------
/frontend/src/components/ResourceSkeleton/ResourceSkeleton.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function SkeletonResource() {
4 | return (
5 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/frontend/src/stories/NewBookmarkGroupModal.stories.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import NewBookmarkGroupModal from "../components/NewBookmarkGroupModal/NewBookmarkGroupModal";
3 |
4 | export default {
5 | title: "components/Modals/NewBookmarkGroupModal",
6 | component: NewBookmarkGroupModal,
7 | };
8 | const Template = (args) => ;
9 |
10 | export const Primary = Template.bind({});
11 |
--------------------------------------------------------------------------------
/frontend/.storybook/main.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
3 | addons: [
4 | "@storybook/addon-links",
5 | "@storybook/addon-essentials",
6 | "@storybook/addon-interactions",
7 | "@storybook/preset-create-react-app",
8 | ],
9 | framework: "@storybook/react",
10 | core: {
11 | builder: "@storybook/builder-webpack5",
12 | },
13 | };
14 |
--------------------------------------------------------------------------------
/frontend/src/components/ResourceList/ResourceList.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { nanoid } from "nanoid";
3 |
4 | import Resource from "../Resource/Resource";
5 |
6 | export default function ResourceList({ resources }) {
7 | return (
8 |
9 | {resources.map((resource) => (
10 |
11 | ))}
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/frontend/src/stories/BookmarkButtonsGroup.stories.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import BookmarkButtonsGroup from "../components/Buttons/BookmarkButtonsGroup";
3 |
4 | export default {
5 | title: "components/BookmarkButtonsGroup",
6 | component: BookmarkButtonsGroup,
7 | };
8 |
9 | const Template = (args) => ;
10 |
11 | export const Primary = Template.bind({});
12 |
13 | Primary.args = {};
14 |
--------------------------------------------------------------------------------
/frontend/src/stories/EditBookmarkGroupModal.stories.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import EditBookmarkGroupModal from "../components/EditBookmarkGroupModal/EditBookmarkGroupModal";
3 |
4 | export default {
5 | title: "components/Modals/EditBookmarkGroupModal",
6 | component: EditBookmarkGroupModal,
7 | };
8 | const Template = (args) => ;
9 |
10 | export const Primary = Template.bind({});
11 |
--------------------------------------------------------------------------------
/frontend/src/stories/Modals.stories.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import BaseModal from "../components/BaseModal/BaseModal";
3 |
4 | export default {
5 | title: "components/Modals/BaseModal",
6 | component: BaseModal,
7 | };
8 | const Template = (args) => ;
9 |
10 | export const Default = Template.bind({});
11 |
12 | Default.args = {
13 | children: "Some text inside the body of the modal",
14 | };
15 |
--------------------------------------------------------------------------------
/frontend/.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 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | .env
--------------------------------------------------------------------------------
/frontend/src/stories/ConfirmModal.stories.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ConfirmModal from "../components/ConfirmModal/ConfirmModal";
3 |
4 | export default {
5 | title: "components/Modals/ConfirmModal",
6 | component: ConfirmModal,
7 | };
8 | const Template = (args) => ;
9 |
10 | export const Primary = Template.bind({});
11 |
12 | Primary.args = {
13 | prompt: "Are you ready to rumble?",
14 | };
15 |
--------------------------------------------------------------------------------
/frontend/src/stories/ErrorFetchingResources.stories.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import ErrorFetchingResources from "../pages/ErrorFetchingResources/ErrorFetchingResources";
4 |
5 | export default {
6 | title: "Pages/ErrorFetchingResources",
7 | component: ErrorFetchingResources,
8 | };
9 | const Template = (args) => ;
10 |
11 | export const PageErrorFetchingResources = Template.bind({});
12 |
--------------------------------------------------------------------------------
/frontend/src/components/ResourceSkeletonList/ResourceSkeletonList.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { nanoid } from "nanoid";
3 |
4 | import ResourceSkeleton from "../ResourceSkeleton/ResourceSkeleton";
5 |
6 | export default function ResourceSkeletonList() {
7 | return (
8 |
9 | {[...Array(20)].map(() => (
10 |
11 | ))}
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/frontend/src/stories/BookmarkGroupCard.stories.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import BookmarkGroupCard from "../components/BookmarkGroupCard/BookmarkGroupCard";
3 |
4 | export default {
5 | title: "components/BookmarkGroupCard",
6 | component: BookmarkGroupCard,
7 | };
8 |
9 | export const Default = (args) => ;
10 |
11 | Default.args = {
12 | title: "A Visual History of Nobel Prize Winners",
13 | count: "10",
14 | bookmarkLink: "",
15 | };
16 |
--------------------------------------------------------------------------------
/frontend/src/components/BaseModal/BaseModal.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function BaseModal({ heading, children }) {
4 | return (
5 |
6 |
7 |
8 | {heading ? heading : "Heading"}
9 |
10 |
{children}
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/docs/SCREENSHOTS.md:
--------------------------------------------------------------------------------
1 | .png)
2 |
3 | .png)
4 |
5 | .png)
6 |
7 | .png)
8 |
9 | .png)
10 |
11 | .png)
12 |
13 | .png)
14 |
15 | .png)
--------------------------------------------------------------------------------
/backend/src/docs/swagger.js:
--------------------------------------------------------------------------------
1 | const paths = require('./paths');
2 |
3 | module.exports = {
4 | openapi: '3.0.3',
5 | info: {
6 | title: `Coding Resource Finder API Documentation ( count: ${
7 | Object.keys(paths.paths).length
8 | } )`,
9 | description: 'API Documentation for Coding Resource Finder',
10 | version: '1.0.0',
11 | contact: {
12 | name: 'Ngoako Ramokgopa',
13 | email: 'ngoakor12@gmail.com',
14 | },
15 | },
16 | };
17 |
--------------------------------------------------------------------------------
/frontend/src/components/Buttons/ClearBookmarksButton.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useContext } from "react";
3 |
4 | import { Context } from "../../AppContext";
5 |
6 | export default function ClearBookmarksButton() {
7 | const { setBookmarks } = useContext(Context);
8 |
9 | function handleClick() {
10 | setBookmarks([]);
11 | }
12 |
13 | return (
14 |
15 | Clear bookmarks
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/frontend/src/constants.js:
--------------------------------------------------------------------------------
1 | export const PORT = process.env.PORT || 2856;
2 | export const API_BASE_URL =
3 | process.env.REACT_APP_API_PROD_BASE_URL || `http://localhost:${PORT}`;
4 | export const CLIENT_BASE_URL =
5 | process.env.REACT_APP_CLIENT_PROD_BASE_URL || "http://localhost:3000";
6 |
7 | export const ALL_RESOURCES_URL = `${API_BASE_URL}/all`;
8 | export const FIRST_PAGE_RESOURCES_URL = `${API_BASE_URL}/all/1`;
9 |
10 | export const ERROR = {
11 | FETCH: "Something went wrong. Please try again.",
12 | };
13 |
--------------------------------------------------------------------------------
/frontend/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { createRoot } from "react-dom/client";
3 | import { BrowserRouter as Router } from "react-router-dom";
4 |
5 | import App from "./App";
6 | import { ContextProvider } from "./AppContext";
7 |
8 | const container = document.getElementById("root");
9 | const root = createRoot(container);
10 | root.render(
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | );
19 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | arrowParens: "always",
3 | bracketSameLine: false,
4 | bracketSpacing: true,
5 | embeddedLanguageFormatting: "auto",
6 | endOfLine: "lf",
7 | filepath: undefined,
8 | htmlWhitespaceSensitivity: "css",
9 | insertPragma: false,
10 | jsxSingleQuote: false,
11 | printWidth: 80,
12 | proseWrap: "preserve",
13 | quoteProps: "as-needed",
14 | rangeEnd: Infinity,
15 | requirePragma: false,
16 | semi: true,
17 | singleQuote: false,
18 | tabWidth: 2,
19 | trailingComma: "es5",
20 | useTabs: false,
21 | };
22 |
--------------------------------------------------------------------------------
/frontend/src/components/Buttons/GoToTopButton.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { goToTopIcon } from "../../svgs";
3 |
4 | export default function GoToTopButton() {
5 | function handleClick() {
6 | // can be set to auto if you want it to snap to top
7 | window.scrollTo({ top: 0, left: 0, behavior: "smooth" });
8 | }
9 |
10 | return (
11 |
17 | {goToTopIcon}
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/.storybook/preview.js:
--------------------------------------------------------------------------------
1 | import { ContextProvider } from "../src/AppContext";
2 | import "../src/App.css";
3 | import { BrowserRouter as Router } from "react-router-dom";
4 |
5 | export const parameters = {
6 | actions: { argTypesRegex: "^on[A-Z].*" },
7 | controls: {
8 | matchers: {
9 | color: /(background|color)$/i,
10 | date: /Date$/,
11 | },
12 | },
13 | };
14 |
15 | export const decorators = [
16 | (Story) => (
17 |
18 |
19 |
20 |
21 |
22 | ),
23 | ];
24 |
--------------------------------------------------------------------------------
/frontend/src/components/BookmarkGroupCard/BookmarkGroupCard.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Link } from "react-router-dom";
3 |
4 | import Card from "../Card/Card";
5 |
6 | export default function BookmarkGroupCard(bookmarkGroup) {
7 | const { title, count, bookmarkLink } = bookmarkGroup;
8 | return (
9 |
10 |
11 | {title}
12 | {count}
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/frontend/src/stories/Card.stories.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Card from "../components/Card/Card";
3 |
4 | export default {
5 | title: "components/Card",
6 | component: Card,
7 | };
8 |
9 | export const Default = (args) => ;
10 |
11 | Default.args = {
12 | children: "Dear God, it's me again...",
13 | fullWidth: false,
14 | };
15 |
16 | export const FullWidthCard = (args) => ;
17 |
18 | FullWidthCard.args = {
19 | children: "Heal the world, make it a better place...",
20 | fullWidth: true,
21 | };
22 |
23 | console.log(FullWidthCard.args);
24 |
--------------------------------------------------------------------------------
/frontend/src/stories/BookmarkGroupListModal.stories.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import BookmarkGroupListModal from "../components/BookmarkGroupListModal/BookmarkGroupListModal";
3 |
4 | export default {
5 | title: "components/Modals/BookmarkGroupListModal",
6 | component: BookmarkGroupListModal,
7 | };
8 |
9 | const Template = (args) => ;
10 |
11 | export const Primary = Template.bind({});
12 |
13 | Primary.args = {
14 | bookmarkGroupList: [
15 | { id: "1", title: "Bookmarks" },
16 | { id: "2", title: "Frontend revision and other things" },
17 | ],
18 | };
19 |
--------------------------------------------------------------------------------
/frontend/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/src/components/Buttons/BookmarkButton.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useContext } from "react";
3 |
4 | import { Context } from "../../AppContext";
5 | import { bookmarkIcon } from "../../svgs";
6 |
7 | export default function RemoveBookmarkButton({ resource }) {
8 | const { addBookmark } = useContext(Context);
9 |
10 | function handleClick() {
11 | addBookmark(resource.url);
12 | }
13 |
14 | return (
15 |
21 | {bookmarkIcon}
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/backend/src/get-resources-from-database.js:
--------------------------------------------------------------------------------
1 | const { getCurrentCollectionName } = require("./utils");
2 |
3 | async function getResourcesFromDB(database) {
4 | const collectionName = getCurrentCollectionName();
5 | const resources = [];
6 | await database
7 | .collection(collectionName)
8 | .find({}, { projection: { _id: 0, type: 1, url: 1, title: 1 } })
9 | .sort({ title: 1 })
10 | .forEach((resource) => {
11 | resources.push(resource);
12 | });
13 | const allResourcesData = {
14 | num_of_resources: resources.length,
15 | data: resources,
16 | };
17 | return allResourcesData;
18 | }
19 |
20 | module.exports = { getResourcesFromDB };
21 |
--------------------------------------------------------------------------------
/frontend/src/components/Buttons/RemoveBookmarkButton.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useContext } from "react";
3 |
4 | import { Context } from "../../AppContext";
5 | import { removeBookmarkIcon } from "../../svgs";
6 |
7 | export default function RemoveBookmarkButton({ resource }) {
8 | const { removeBookmark } = useContext(Context);
9 |
10 | function handleClick() {
11 | removeBookmark(resource.url);
12 | }
13 |
14 | return (
15 |
21 | {removeBookmarkIcon}
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/backend/README.md:
--------------------------------------------------------------------------------
1 | # Coding resource finder api
2 | Express API for [coding-resource-finder](https://github.com/Ngoakor12/coding-resource-finder)
3 |
4 | ## Swagger api documentation
5 | http://localhost:2856/api/docs
6 |
7 | ## Api Details
8 |
9 | method | endpoint | params | description | return type
10 | --- | --- | --- | --- | ---
11 | GET | /all/projects | - | Get all projects | Object
12 | GET | /all/projects/{page} | Page number | Get a page of projects | Object
13 | GET | /all | - | Get all resources | Object
14 | GET | /all/{page} | Page number | Get a page of resources | Object
15 | GET | /all/topics | - | Get all topics | Object
16 | GET | /all/topics/{page} | Page number | Get a page of topics | Object
--------------------------------------------------------------------------------
/frontend/src/App.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Routes, Route, Navigate } from "react-router-dom";
3 |
4 | import "./App.css";
5 | import Bookmarks from "./pages/Bookmarks/Bookmarks";
6 | import Resources from "./pages/Resources/Resources";
7 | import NotFound from "./pages/NotFound/NotFound";
8 |
9 | export default function App() {
10 | return (
11 |
12 | }
16 | />
17 | } />
18 | } />
19 | } />
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ""
5 | labels: ""
6 | assignees: ""
7 | ---
8 |
9 | **Is your feature request related to a problem? Please describe.**
10 |
11 |
12 |
13 | **Describe the solution you'd like**
14 |
15 |
16 |
17 | **Describe alternatives you've considered**
18 |
19 |
20 |
21 | **Additional context**
22 |
23 |
24 |
--------------------------------------------------------------------------------
/frontend/src/pages/NotFound/NotFound.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useNavigate } from "react-router-dom";
3 |
4 | import { alertIcon } from "../../svgs";
5 |
6 | export default function NotFound() {
7 | const navigate = useNavigate();
8 |
9 | function navigateToHomepage() {
10 | navigate("/");
11 | }
12 | return (
13 |
14 |
15 | {alertIcon}
16 |
Page not found
17 |
18 | Check if the address you entered is correct and try again
19 |
20 |
21 | Go back to homepage
22 |
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/src/stories/BookmarkGroupDetailsHeader.stories.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import BookmarkGroupDetailsHeader from "../components/BookmarkGroupDetailsHeader/BookmarkGroupDetailsHeader";
3 |
4 | export default {
5 | title: "components/BookmarkGroupDetailsHeader",
6 | component: BookmarkGroupDetailsHeader,
7 | };
8 |
9 | export const Default = (args) => ;
10 |
11 | Default.args = {
12 | heading: "All bookmarks",
13 | bookmarkGroups: [
14 | {
15 | title: "All bookmarks",
16 | count: 10,
17 | link: "",
18 | },
19 | {
20 | title: "Frontend revision",
21 | count: 4,
22 | link: "",
23 | },
24 | {
25 | title: "React native",
26 | count: 7,
27 | link: "",
28 | },
29 | ],
30 | };
31 |
--------------------------------------------------------------------------------
/frontend/src/components/Nav/Nav.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useContext } from "react";
3 | import { NavLink } from "react-router-dom";
4 |
5 | import { Context } from "../../AppContext";
6 |
7 | export default function Nav() {
8 | const { renderedResources, bookmarks } = useContext(Context);
9 |
10 | function className({ isActive }) {
11 | return isActive ? "nav-item active-nav" : "nav-item";
12 | }
13 |
14 | return (
15 |
16 |
17 | {`Resources ${renderedResources && `(${renderedResources.length})`}`}
18 |
19 |
20 | {`Bookmarks ${bookmarks && `(${bookmarks.length})`}`}
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/src/components/ConfirmModal/ConfirmModal.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function ConfirmModal({ prompt }) {
4 | return (
5 |
6 |
7 |
8 |
9 | {prompt ? prompt : "Are you sure?"}
10 |
11 |
12 |
13 | Cancel
14 |
15 |
16 | Confirm
17 |
18 |
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/frontend/src/components/Buttons/LoadMoreResourcesButton.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useContext } from "react";
3 |
4 | import { Context } from "../../AppContext";
5 |
6 | export default function LoadMoreResourcesButton() {
7 | const { allResources, renderedResources, setRenderedResources } =
8 | useContext(Context);
9 |
10 | function handleClick() {
11 | setRenderedResources(allResources);
12 | }
13 |
14 | function handleDisableClick() {
15 | return renderedResources.length === allResources.length;
16 | }
17 |
18 | return (
19 |
25 | {`Load all resources (${allResources.length - renderedResources.length})`}
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/backend/src/utils.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Function to get the current collection name
3 | * @returns {String} A string including the current year and month (resources_year_month)
4 | */
5 |
6 | function getCurrentCollectionName() {
7 | // return `resources_${new Date().getFullYear()}_${new Date().getMonth() + 1}`;
8 | return "resources_2024_4";
9 | }
10 |
11 | /**
12 | * Function to check if an enter page is a number before or after trying to convert it into a number
13 | * @param page a number representing representing a page of resources
14 | * @returns {Boolean} true if page is a number and false if page can be converted into a number
15 | */
16 |
17 | function isPageNumber(page) {
18 | const pageNumber = Number(page);
19 | if (pageNumber && typeof pageNumber === "number") return true;
20 | return false;
21 | }
22 |
23 | module.exports = { getCurrentCollectionName, isPageNumber };
24 |
--------------------------------------------------------------------------------
/backend/src/format-resources.js:
--------------------------------------------------------------------------------
1 | function getPages(resources) {
2 | const numOfResources = resources.length;
3 | const numOfResourcesPerPage = 20;
4 | const pages = [];
5 | const numOfPages = Math.ceil(numOfResources / numOfResourcesPerPage);
6 | let start = 0,
7 | end = numOfResourcesPerPage;
8 |
9 | for (let i = 1; i <= numOfPages; i++) {
10 | pages.push({
11 | current_page: i,
12 | num_of_pages: numOfPages,
13 | num_of_resources: resources.slice(start, end).length,
14 | data: resources.slice(start, end),
15 | });
16 | start += numOfResourcesPerPage;
17 | end += numOfResourcesPerPage;
18 | }
19 | return pages;
20 | }
21 |
22 | function getPageData(resources, page) {
23 | const pages = getPages(resources);
24 | const pageData = pages[Number(page) - 1];
25 | return pageData;
26 | }
27 |
28 | module.exports = {
29 | getPageData,
30 | };
31 |
--------------------------------------------------------------------------------
/frontend/src/components/Buttons/BookmarkButtonsGroup.js:
--------------------------------------------------------------------------------
1 | import React, { useContext } from "react";
2 | import { expandIcon } from "../../svgs";
3 | import { Context } from "../../AppContext";
4 | import { bookmarkIcon } from "../../svgs";
5 |
6 | export default function BookmarkButtonsGroup() {
7 | const { addBookmark } = useContext(Context);
8 |
9 | function handleClick() {
10 | addBookmark(resource.url);
11 | }
12 | return (
13 |
14 |
20 | {bookmarkIcon}
21 |
22 |
27 | {expandIcon}
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/backend/src/firebase.js:
--------------------------------------------------------------------------------
1 | const { initializeApp } = require("firebase/app");
2 | const {
3 | getFirestore,
4 | collection,
5 | getDocs,
6 | addDoc,
7 | query,
8 | orderBy,
9 | } = require("firebase/firestore");
10 |
11 | const { getCurrentCollectionName } = require("./utils");
12 |
13 | const firebaseConfig = {
14 | apiKey: "AIzaSyC8suQigoEopXl19pXRGrGSUWAAprv9-mg",
15 | authDomain: "coding-resource-finder.firebaseapp.com",
16 | projectId: "coding-resource-finder",
17 | storageBucket: "coding-resource-finder.appspot.com",
18 | messagingSenderId: "186484380448",
19 | appId: "1:186484380448:web:0d8db73fca5005ffea7d45",
20 | };
21 |
22 | // init firebase
23 | const app = initializeApp(firebaseConfig);
24 |
25 | // init db
26 | const db = getFirestore(app);
27 |
28 | const resourcesRef = collection(db, getCurrentCollectionName());
29 |
30 | const resourcesQuery = query(resourcesRef, orderBy("title", "asc"));
31 |
32 | module.exports = { db, resourcesRef, getDocs, addDoc, resourcesQuery };
33 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | ## 🛠️ Fixes issue(s)
4 |
5 |
6 |
7 |
8 |
9 | ## 👨💻 Change(s) proposed
10 |
11 |
12 |
13 | ## ✔️ Checklist (Check all the applicable boxes)
14 |
15 |
16 |
20 |
21 | - [ ] My code follows the code style of this project.
22 | - [ ] This PR does not contain plagiarized content.
23 | - [ ] The title of my pull request is a short description of the requested changes.
24 |
25 | ## 📄 Note to reviewers
26 |
27 |
28 |
29 | ## 📷 Screenshots
30 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 |
12 |
13 | **To Reproduce**
14 |
19 |
20 | **Expected behavior**
21 |
22 |
23 | **Screenshots**
24 |
25 |
26 | **Desktop (please complete the following information):**
27 |
30 |
31 | **Smartphone (please complete the following information):**
32 |
36 |
37 | **Additional context**
38 |
39 |
--------------------------------------------------------------------------------
/frontend/src/components/NewBookmarkGroupModal/NewBookmarkGroupModal.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import BaseModal from "../BaseModal/BaseModal";
3 |
4 | export default function NewBookmarkGroupModal() {
5 | function handleSubmit(e) {
6 | e.preventDefault();
7 | }
8 | return (
9 |
10 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/frontend/src/components/Resource/Resource.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useContext } from "react";
3 |
4 | import { Context } from "../../AppContext";
5 | import RemoveBookmarkButton from "../Buttons/RemoveBookmarkButton";
6 | import BookmarkButton from "../Buttons/BookmarkButton";
7 |
8 | export default function Resource({ resource }) {
9 | const { bookmarks } = useContext(Context);
10 | const isBookmarked = bookmarks.find((bookmark) => {
11 | return bookmark.url === resource.url;
12 | });
13 | const icon = isBookmarked ? (
14 |
15 | ) : (
16 |
17 | );
18 |
19 | return (
20 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/backend/src/database-config.js:
--------------------------------------------------------------------------------
1 | require("dotenv").config();
2 | const { MongoClient, ServerApiVersion } = require("mongodb");
3 |
4 | // for local development
5 | const localUri =
6 | "mongodb+srv://local:local@cluster0.7khoml9.mongodb.net/coding-resource-finder?retryWrites=true&w=majority";
7 |
8 | const uri = process.env.MONGO_URI || localUri;
9 |
10 | const client = new MongoClient(uri, {
11 | serverApi: {
12 | version: ServerApiVersion.v1,
13 | strict: true,
14 | deprecationErrors: true,
15 | },
16 | });
17 |
18 | const connectToDb = async () => {
19 | console.log("---In connectToDb---");
20 | try {
21 | const database = client.db();
22 | return database;
23 | } catch (err) {
24 | console.error("Error connecting to the database:", err);
25 | throw err; // Rethrow the error to handle it in the caller.
26 | }
27 | };
28 |
29 | const closeDb = (client) => {
30 | console.log("---In closeDb---");
31 | if (client) {
32 | client.close();
33 | console.log("Database connection closed.");
34 | }
35 | };
36 |
37 | module.exports = { connectToDb, closeDb };
38 |
--------------------------------------------------------------------------------
/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "coding-resource-finder",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "nodemon ./src/server.js",
8 | "start": "node ./src/server.js",
9 | "update": "node ./src/update-resources-database.js",
10 | "deploy": "git push heroku master",
11 | "test": "jest"
12 | },
13 | "repository": {
14 | "type": "git",
15 | "url": "git+https://github.com/Ngoakor12/coding-resource-finder.git"
16 | },
17 | "keywords": [],
18 | "author": "",
19 | "license": "ISC",
20 | "bugs": {
21 | "url": "https://github.com/Ngoakor12/coding-resource-finder/issues"
22 | },
23 | "homepage": "https://github.com/Ngoakor12/coding-resource-finder#readme",
24 | "dependencies": {
25 | "cheerio": "^1.0.0-rc.12",
26 | "dotenv": "^16.0.0",
27 | "express": "^4.17.1",
28 | "firebase": "^9.6.10",
29 | "mongodb": "^4.12.1",
30 | "swagger-ui-express": "^4.5.0"
31 | },
32 | "devDependencies": {
33 | "jest": "^29.2.0",
34 | "cors": "^2.8.5",
35 | "nodemon": "^2.0.15"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/frontend/src/components/EditBookmarkGroupModal/EditBookmarkGroupModal.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import BaseModal from "../BaseModal/BaseModal";
3 |
4 | export default function EditBookmarkGroupModal() {
5 | function handleSubmit(e) {
6 | e.preventDefault();
7 | }
8 |
9 | return (
10 |
11 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/frontend/src/pages/ErrorFetchingResources/ErrorFetchingResources.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { alertIcon } from "../../svgs";
4 |
5 | export default function ErrorFetchingResources() {
6 |
7 | function navigateToHomepage() {
8 |
9 | window.location.href = "/";
10 | }
11 |
12 | return (
13 |
14 | {alertIcon}
15 |
16 | Something went wrong while fetching resources
17 |
18 |
19 |
20 |
If in development mode:
21 |
22 |
23 | Make sure the development server is running
24 | {"Check that the address you're trying to fetch from exists"}
25 |
26 |
27 |
Otherwise:
28 |
31 |
32 |
33 | Reload
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Ngoako Ramokgopa
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/backend/src/server.js:
--------------------------------------------------------------------------------
1 | const app = require("express")();
2 | const cors = require("cors");
3 |
4 | const { API_BASE_URL, PORT } = require("./constants");
5 | const allRoutes = require("./routes/all");
6 | const topicsRoutes = require("./routes/topics");
7 | const projectsRoutes = require("./routes/projects");
8 |
9 | app.use(
10 | cors({
11 | origin: "*",
12 | })
13 | );
14 |
15 | app.get("/", (_, res) => {
16 | res.status(200).json({
17 | resources: `${API_BASE_URL}/all`,
18 | topics: `${API_BASE_URL}/all/topics`,
19 | projects: `${API_BASE_URL}/all/projects`,
20 | resources_page: `${API_BASE_URL}/all/{page}`,
21 | topics_page: `${API_BASE_URL}/all/topics/{page}`,
22 | projects_page: `${API_BASE_URL}/all/projects/{page}`,
23 | });
24 | });
25 |
26 | app.use("/all/topics", topicsRoutes);
27 | app.use("/all/projects", projectsRoutes);
28 | app.use("/all", allRoutes);
29 |
30 | // swagger
31 | const swaggerUi = require("swagger-ui-express");
32 | const docs = require("./docs");
33 | app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(docs));
34 |
35 | app.listen(PORT, () => {
36 | console.log(`App running at ${API_BASE_URL}`);
37 | });
38 |
--------------------------------------------------------------------------------
/.github/workflows/update-database.yml:
--------------------------------------------------------------------------------
1 | name: update-database
2 | on:
3 | schedule:
4 | - cron: "0 00 1 * *"
5 | workflow_dispatch:
6 |
7 | jobs:
8 | update-database-local-dev:
9 | runs-on: ubuntu-latest
10 |
11 | defaults:
12 | run:
13 | working-directory: ./backend
14 |
15 | steps:
16 | - name: Check out repo
17 | uses: actions/checkout@v3
18 |
19 | - name: Set up Node
20 | uses: actions/setup-node@v3
21 | with:
22 | node-version: "latest"
23 |
24 | - name: Clean install dependencies
25 | run: npm ci
26 |
27 | - name: Run update script
28 | run: npm run update
29 |
30 | update-database-production:
31 | runs-on: ubuntu-latest
32 | env:
33 | MONGO_URI: ${{secrets.MONGO_URI}}
34 |
35 | defaults:
36 | run:
37 | working-directory: ./backend
38 |
39 | steps:
40 | - name: Check out repo
41 | uses: actions/checkout@v3
42 |
43 | - name: Set up Node
44 | uses: actions/setup-node@v3
45 | with:
46 | node-version: "latest"
47 |
48 | - name: Clean install dependencies
49 | run: npm ci
50 |
51 | - name: Run update script
52 | if: env.MONGO_URI != ''
53 | run: npm run update
54 |
55 |
--------------------------------------------------------------------------------
/frontend/src/pages/Bookmarks/Bookmarks.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useContext } from "react";
2 |
3 | import { Context } from "../../AppContext";
4 | import ClearBookmarksButton from "../../components/Buttons/ClearBookmarksButton";
5 | import ResourceList from "../../components/ResourceList/ResourceList";
6 | import GoToTopButton from "../../components/Buttons/GoToTopButton";
7 | import Nav from "../../components/Nav/Nav";
8 | import Header from "../../components/Header/Header";
9 |
10 | export default function Bookmarks() {
11 | const { bookmarks, setPageTitle } = useContext(Context);
12 |
13 | useEffect(() => {
14 | setPageTitle("Bookmarks | Coding Resource Finder");
15 | // eslint-disable-next-line
16 | }, []);
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
26 |
27 |
28 | {bookmarks.length ? (
29 |
30 |
31 |
32 |
33 |
34 |
35 | ) : (
36 | No bookmarks yet...
37 | )}
38 |
39 |
40 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/backend/src/routes/all.js:
--------------------------------------------------------------------------------
1 | const allRouter = require("express").Router();
2 | const { getPageData } = require("../format-resources");
3 | const { getResourcesFromDB } = require("../get-resources-from-database");
4 | const { connectToDb, closeDb } = require("../database-config");
5 |
6 | let dbClient; // Global variable to store the MongoDB client.
7 |
8 | // Connect to the database when application/router starts.
9 | (async () => {
10 | try {
11 | dbClient = await connectToDb();
12 | } catch (error) {
13 | console.error("Error connecting to the database:", error);
14 | }
15 | })();
16 |
17 | // Middleware to ensure the database client is available in route handlers.
18 | const withDb = (req, res, next) => {
19 | req.dbClient = dbClient;
20 | next();
21 | };
22 |
23 | allRouter.get("/", withDb, async (req, res) => {
24 | try {
25 | const db = req.dbClient;
26 | const resources = await getResourcesFromDB(db);
27 | res.status(200).json(resources);
28 | } catch (error) {
29 | res.json(error);
30 | }
31 | });
32 |
33 | allRouter.get("/:page", withDb, async (req, res) => {
34 | try {
35 | const db = req.dbClient;
36 | const resources = await getResourcesFromDB(db);
37 | const data = getPageData(resources.data, req.params.page);
38 | res.status(200).json(data);
39 | } catch (error) {
40 | res.json(error);
41 | }
42 | });
43 |
44 | // Close the database connection when application exits.
45 | process.on("exit", () => {
46 | if (dbClient) {
47 | closeDb(dbClient);
48 | }
49 | });
50 |
51 | module.exports = allRouter;
52 |
--------------------------------------------------------------------------------
/frontend/src/components/BookmarkGroupListModal/BookmarkGroupListModal.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function BookmarkGroupListModal({ bookmarkGroupList }) {
4 | function handleSubmit(e) {
5 | e.preventDefault();
6 | }
7 |
8 | return (
9 |
10 |
Bookmark groups
11 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/frontend/src/components/Header/Header.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { CLIENT_BASE_URL } from "../../constants";
4 |
5 | export default function Header() {
6 | return (
7 |
8 |
13 |
14 |
15 | An easier way to find coding related topics and projects on the{" "}
16 |
22 | ACN syllabus
23 |
24 |
25 |
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/frontend/src/components/BookmarkGroupDetailsHeader/BookmarkGroupDetailsHeader.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { backArrowIcon } from "../../svgs";
3 | import { Link } from "react-router-dom";
4 |
5 | export default function BookmarkGroupDetailsHeader({
6 | heading,
7 | bookmarkGroups,
8 | }) {
9 | return (
10 |
11 |
12 |
13 |
14 | {backArrowIcon}
15 |
16 |
{heading}
17 |
18 |
19 | Edit
20 | Clear
21 |
22 | Delete
23 |
24 |
25 |
26 |
27 | {bookmarkGroups.map((group) => {
28 | return (
29 |
36 | {`${group.title}(${group.count})`}
37 |
38 | );
39 | })}
40 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | Coding Resource Finder
28 |
29 |
30 | You need to enable JavaScript to run this app.
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/backend/src/routes/topics.js:
--------------------------------------------------------------------------------
1 | const topicsRouter = require("express").Router();
2 | const { getPageData } = require("../format-resources");
3 | const { getResourcesFromDB } = require("../get-resources-from-database");
4 | const { connectToDb, closeDb } = require("../database-config");
5 |
6 | let dbClient; // Global variable to store the MongoDB client.
7 |
8 | // Connect to the database when application/router starts.
9 | (async () => {
10 | try {
11 | dbClient = await connectToDb();
12 | } catch (error) {
13 | console.error("Error connecting to the database:", error);
14 | }
15 | })();
16 |
17 | // Middleware to ensure the database client is available in route handlers.
18 | const withDb = (req, res, next) => {
19 | req.dbClient = dbClient;
20 | next();
21 | };
22 |
23 | topicsRouter.get("/", withDb, async (req, res) => {
24 | try {
25 | const db = req.dbClient;
26 | const resources = await getResourcesFromDB(db);
27 | const topics = resources.data.filter(
28 | (resource) => resource.type === "topic"
29 | );
30 | const topicsData = { num_of_topics: topics.length, data: topics };
31 | res.status(200).json(topicsData);
32 | } catch (error) {
33 | res.json(error);
34 | }
35 | });
36 |
37 | topicsRouter.get("/:page", withDb, async (req, res) => {
38 | try {
39 | const db = req.dbClient;
40 | const resources = await getResourcesFromDB(db);
41 | const topics = resources.data.filter(
42 | (resource) => resource.type === "topic"
43 | );
44 | const topicsData = { num_of_topics: topics.length, data: topics };
45 | const data = getPageData(topicsData.data, req.params.page);
46 | res.status(200).json(data);
47 | } catch (error) {
48 | res.json(error);
49 | }
50 | });
51 |
52 | // Close the database connection when application exits.
53 | process.on("exit", () => {
54 | if (dbClient) {
55 | closeDb(dbClient);
56 | }
57 | });
58 |
59 | module.exports = topicsRouter;
60 |
--------------------------------------------------------------------------------
/backend/src/routes/projects.js:
--------------------------------------------------------------------------------
1 | const projectsRouter = require("express").Router();
2 | const { getPageData } = require("../format-resources");
3 | const { getResourcesFromDB } = require("../get-resources-from-database");
4 | const { connectToDb, closeDb } = require("../database-config");
5 |
6 | let dbClient; // Global variable to store the MongoDB client.
7 |
8 | // Connect to the database when application/router starts.
9 | (async () => {
10 | try {
11 | dbClient = await connectToDb();
12 | } catch (error) {
13 | console.error("Error connecting to the database:", error);
14 | }
15 | })();
16 |
17 | // Middleware to ensure the database client is available in route handlers.
18 | const withDb = (req, res, next) => {
19 | req.dbClient = dbClient;
20 | next();
21 | };
22 |
23 | projectsRouter.get("/", withDb, async (req, res) => {
24 | try {
25 | const db = req.dbClient;
26 | const resources = await getResourcesFromDB(db);
27 | const projects = resources.data.filter(
28 | (resource) => resource.type === "project"
29 | );
30 | const projectsData = { num_of_projects: projects.length, data: projects };
31 | res.status(200).json(projectsData);
32 | } catch (error) {
33 | res.json(error);
34 | }
35 | });
36 |
37 | projectsRouter.get("/:page", withDb, async (req, res) => {
38 | try {
39 | const db = req.dbClient;
40 | const resources = await getResourcesFromDB(db);
41 | const projects = resources.data.filter(
42 | (resource) => resource.type === "project"
43 | );
44 | const projectsData = { num_of_projects: projects.length, data: projects };
45 | const data = getPageData(projectsData.data, req.params.page);
46 | res.status(200).json(data);
47 | } catch (error) {
48 | res.json(error);
49 | }
50 | });
51 |
52 | // Close the database connection when application exits.
53 | process.on("exit", () => {
54 | if (dbClient) {
55 | closeDb(dbClient);
56 | }
57 | });
58 |
59 | module.exports = projectsRouter;
60 |
--------------------------------------------------------------------------------
/backend/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # TypeScript v1 declaration files
45 | typings/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Microbundle cache
57 | .rpt2_cache/
58 | .rts2_cache_cjs/
59 | .rts2_cache_es/
60 | .rts2_cache_umd/
61 |
62 | # Optional REPL history
63 | .node_repl_history
64 |
65 | # Output of 'npm pack'
66 | *.tgz
67 |
68 | # Yarn Integrity file
69 | .yarn-integrity
70 |
71 | # dotenv environment variables file
72 | .env
73 | .env.test
74 |
75 | # parcel-bundler cache (https://parceljs.org/)
76 | .cache
77 |
78 | # Next.js build output
79 | .next
80 |
81 | # Nuxt.js build / generate output
82 | .nuxt
83 | dist
84 |
85 | # Gatsby files
86 | .cache/
87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
88 | # https://nextjs.org/blog/next-9-1#public-directory-support
89 | # public
90 |
91 | # vuepress build output
92 | .vuepress/dist
93 |
94 | # Serverless directories
95 | .serverless/
96 |
97 | # FuseBox cache
98 | .fusebox/
99 |
100 | # DynamoDB Local files
101 | .dynamodb/
102 |
103 | # TernJS port file
104 | .tern-port
105 |
--------------------------------------------------------------------------------
/backend/src/update-resources-database.js:
--------------------------------------------------------------------------------
1 | const { connectToDb, closeDb } = require("./database-config");
2 | const { getCurrentCollectionName } = require("./utils");
3 | const { getAllResources } = require("./web-scrape-resources");
4 |
5 | /**
6 | * A function that adds resources to the database
7 | * @param {Object} database representing the database connection/instance
8 | * @param {Array} resources
9 | * @returns {void}
10 | */
11 |
12 | async function updateResources(database, resources = []) {
13 | console.log("---In updateResources---");
14 | console.log(`Format all resources. Number of resources: ${resources.length}`);
15 | const formattedResources = resources.map((resource) => ({
16 | title: resource.title,
17 | url: resource.url,
18 | type: resource.type,
19 | groups: [],
20 | }));
21 | console.log(
22 | `Successfully formatted all resources. Number of resources: ${formattedResources.length}`
23 | );
24 | console.log("Run getCurrentCollectionName");
25 | const collectionName = getCurrentCollectionName();
26 | console.log(`Successfully got collection name: ${collectionName}`);
27 |
28 | try {
29 | console.log("Run insertMany on collection");
30 | await database.collection(collectionName).insertMany(formattedResources);
31 | console.log("Successfully ran insertMany");
32 | } catch (error) {
33 | console.log(`Error running insertMany: ${error}`);
34 | }
35 | }
36 |
37 | async function main() {
38 | console.log("---In main---");
39 |
40 | let client;
41 |
42 | try {
43 | console.log("Connect to db");
44 | client = await connectToDb();
45 | const db = client.db();
46 | console.log("Successfully connected to db");
47 |
48 | console.log("Run getAllResources");
49 | const resources = await getAllResources();
50 | console.log("Successfully ran getAllResources");
51 | console.log(
52 | `Run updateResources. Number of resources: ${resources.length}`
53 | );
54 | await updateResources(db, resources);
55 | console.log("Successfully ran updateResources");
56 | } catch (error) {
57 | console.log(`Error: ${error}`);
58 | } finally {
59 | closeDb(client);
60 | // prevent node from hanging
61 | process.exit(0);
62 | }
63 | }
64 |
65 | main();
66 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "coding-resource-finder",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@babel/preset-env": "^7.19.4",
7 | "esprima": "^4.0.1",
8 | "nanoid": "^4.0.0",
9 | "react": "^18.2.0",
10 | "react-dom": "^18.2.0",
11 | "react-router-dom": "^6.8.0",
12 | "react-scripts": "^5.0.0"
13 | },
14 | "scripts": {
15 | "start": "react-scripts start",
16 | "build": "react-scripts build",
17 | "test": "react-scripts test",
18 | "eject": "react-scripts eject",
19 | "lint": "eslint '**/*.js' --fix & prettier --write .",
20 | "storybook": "start-storybook -p 6006 -s public",
21 | "build-storybook": "build-storybook -s public"
22 | },
23 | "eslintConfig": {
24 | "extends": [
25 | "react-app",
26 | "react-app/jest"
27 | ],
28 | "overrides": [
29 | {
30 | "files": [
31 | "**/*.stories.*"
32 | ],
33 | "rules": {
34 | "import/no-anonymous-default-export": "off"
35 | }
36 | }
37 | ]
38 | },
39 | "browserslist": {
40 | "production": [
41 | ">0.2%",
42 | "not dead",
43 | "not op_mini all"
44 | ],
45 | "development": [
46 | "last 1 chrome version",
47 | "last 1 firefox version",
48 | "last 1 safari version"
49 | ]
50 | },
51 | "devDependencies": {
52 | "@babel/core": "^7.19.3",
53 | "@babel/eslint-parser": "^7.19.1",
54 | "@babel/preset-react": "^7.18.6",
55 | "@storybook/addon-actions": "^6.5.12",
56 | "@storybook/addon-essentials": "^6.5.12",
57 | "@storybook/addon-interactions": "^6.5.12",
58 | "@storybook/addon-links": "^6.5.12",
59 | "@storybook/builder-webpack5": "^6.5.12",
60 | "@storybook/manager-webpack5": "^6.5.12",
61 | "@storybook/node-logger": "^6.5.12",
62 | "@storybook/preset-create-react-app": "^4.1.2",
63 | "@storybook/react": "^6.5.12",
64 | "@storybook/testing-library": "^0.0.13",
65 | "babel-eslint": "^10.1.0",
66 | "babel-plugin-named-exports-order": "^0.0.2",
67 | "eslint": "^8.25.0",
68 | "eslint-config-prettier": "^8.5.0",
69 | "eslint-plugin-html": "^7.1.0",
70 | "eslint-plugin-react": "^7.31.10",
71 | "prettier": "2.7.1",
72 | "prop-types": "^15.8.1",
73 | "webpack": "^5.74.0"
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/backend/src/web-scrape-resources.js:
--------------------------------------------------------------------------------
1 | const http = require("http");
2 | const cheerio = require("cheerio");
3 |
4 | const { ACN_URL } = require("./constants");
5 |
6 | // use node's core http API to reduce overhead
7 | /**
8 | * Fetches HTML document from {ACN_URL} - http://syllabus.africacode.net/
9 | *
10 | * @returns {Promise}
11 | */
12 | function getHTML() {
13 | return new Promise((resolve, reject) => {
14 | try {
15 | http.get(ACN_URL, (res) => {
16 | const { statusCode } = res;
17 |
18 | if (statusCode => 400) {
19 | throw new Error(
20 | `Request failed\nReceived status code ${statusCode}\nExpected status code 200`
21 | );
22 | }
23 |
24 | let html = "";
25 | res.on("data", (chunk) => (html += chunk));
26 | res.on("end", () => resolve(html));
27 | });
28 | } catch (e) {
29 | console.error(e.message);
30 | reject(new Error(`Failed to fetch HTML document from ${ACN_URL}`));
31 | }
32 | });
33 | }
34 |
35 | /**
36 | * @typedef {Object} Resource
37 | * @property {String} title - Resource title
38 | * @property {String} url - Resource URL
39 | * @property {String} type - Resource type
40 | */
41 |
42 | /**
43 | * Returns all resources categorized by "topic" or "project"
44 | *
45 | * @returns {Resource[]} - Array of resources
46 | */
47 | async function getAllResources() {
48 | let html = null;
49 | try {
50 | html = await getHTML();
51 | } catch (e) {
52 | console.error(e.message);
53 | return [];
54 | }
55 |
56 | const $ = cheerio.load(html);
57 |
58 | const resources = [];
59 | const resourceTypes = ["Topics", "Projects"];
60 |
61 | resourceTypes.forEach((resourceType) => {
62 | const resourceElements = $("#sidebar").find(
63 | `li[title=${resourceType}] ul li a`
64 | );
65 | resourceElements.each(function () {
66 | resources.push({
67 | title: $(this).text().replace(/\n/g, "").trim(),
68 | // remove the preceding `/` from `href`
69 | url: `${ACN_URL}${$(this).attr("href").slice(1)}`,
70 | // Assuming the `resourceType` is a singular lowercased version of itself
71 | type: resourceType.toLowerCase().slice(0, -1),
72 | });
73 | });
74 | });
75 |
76 | return resources;
77 | }
78 |
79 | module.exports = {
80 | getAllResources,
81 | };
82 |
--------------------------------------------------------------------------------
/frontend/src/pages/Resources/Resources.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useEffect } from "react";
2 |
3 | import { Context } from "../../AppContext";
4 | import SearchForm from "../../components/SearchForm/SearchForm";
5 | import ResourceList from "../../components/ResourceList/ResourceList";
6 | import ResourceSkeletonList from "../../components/ResourceSkeletonList/ResourceSkeletonList";
7 | import LoadMoreResourcesButton from "../../components/Buttons/LoadMoreResourcesButton";
8 | import GoToTopButton from "../../components/Buttons/GoToTopButton";
9 | import Nav from "../../components/Nav/Nav";
10 | import Header from "../../components/Header/Header";
11 | import ErrorFetchingResources from "../ErrorFetchingResources/ErrorFetchingResources";
12 |
13 | export default function Resources() {
14 | const {
15 | setPageTitle,
16 | renderedResources,
17 | searchTerm,
18 | hasFetchError,
19 | resourceFilter,
20 | } = useContext(Context);
21 |
22 | const filteredResources =
23 | resourceFilter === "all" ? (
24 |
25 | ) : (
26 | resource.type === resourceFilter
29 | )}
30 | />
31 | );
32 |
33 | useEffect(() => {
34 | setPageTitle("Resources | Coding Resource Finder");
35 | // eslint-disable-next-line
36 | }, []);
37 |
38 | return (
39 |
40 | {hasFetchError ? (
41 |
42 | ) : (
43 |
44 |
45 |
46 |
49 |
50 |
51 |
52 | {renderedResources && renderedResources.length ? (
53 |
54 | {filteredResources}
55 |
56 |
57 | ) : searchTerm ? (
58 |
59 | Resource(s) not found...
60 |
61 | ) : (
62 |
63 | )}
64 |
65 |
66 |
67 |
68 |
69 | )}
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/frontend/README2.md:
--------------------------------------------------------------------------------
1 | # Getting Started with Create React App
2 |
3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `npm start`
10 |
11 | Runs the app in the development mode.\
12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
13 |
14 | The page will reload if you make edits.\
15 | You will also see any lint errors in the console.
16 |
17 | ### `npm test`
18 |
19 | Launches the test runner in the interactive watch mode.\
20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
21 |
22 | ### `npm run build`
23 |
24 | Builds the app for production to the `build` folder.\
25 | It correctly bundles React in production mode and optimizes the build for the best performance.
26 |
27 | The build is minified and the filenames include the hashes.\
28 | Your app is ready to be deployed!
29 |
30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
31 |
32 | ### `npm run eject`
33 |
34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
35 |
36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
37 |
38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
39 |
40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
41 |
42 | ## Learn More
43 |
44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
45 |
46 | To learn React, check out the [React documentation](https://reactjs.org/).
47 |
48 | ### Code Splitting
49 |
50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
51 |
52 | ### Analyzing the Bundle Size
53 |
54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
55 |
56 | ### Making a Progressive Web App
57 |
58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
59 |
60 | ### Advanced Configuration
61 |
62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
63 |
64 | ### Deployment
65 |
66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
67 |
68 | ### `npm run build` fails to minify
69 |
70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
71 |
--------------------------------------------------------------------------------
/frontend/src/AppContext.js:
--------------------------------------------------------------------------------
1 | import React, { createContext, useEffect, useState } from "react";
2 |
3 | import { ALL_RESOURCES_URL, FIRST_PAGE_RESOURCES_URL } from "./constants";
4 |
5 | export const Context = createContext();
6 |
7 | export function ContextProvider({ children }) {
8 | const [allResources, setAllResources] = useState([]);
9 | // eslint-disable-next-line
10 | const [bookmarks, setBookmarks] = useState(() => {
11 | const saved = localStorage.getItem("bookmarks");
12 | const initialValue = JSON.parse(saved);
13 | return initialValue || [];
14 | });
15 | const [searchTerm, setSearchTerm] = useState("");
16 | const [pageTitle, setPageTitle] = useState("Coding Resource Finder");
17 | const [renderedResources, setRenderedResources] = useState([]);
18 | const [hasFetchError, setHasFetchError] = useState(false);
19 | const [resourceFilter, setResourceFilter] = useState("all");
20 |
21 | useEffect(() => {
22 | localStorage.setItem("bookmarks", JSON.stringify(bookmarks));
23 | }, [bookmarks]);
24 |
25 | useEffect(() => {
26 | document.title = pageTitle;
27 | }, [pageTitle]);
28 |
29 | useEffect(() => {
30 | getAndSetInitialResources();
31 | // eslint-disable-next-line
32 | }, []);
33 |
34 | async function getAndSetInitialResources() {
35 | const responseData = await Promise.all([
36 | getFirstPageOfResources(FIRST_PAGE_RESOURCES_URL),
37 | getAllResources(ALL_RESOURCES_URL),
38 | ]);
39 | const [firstPageResourcesResponse, allResourcesResponse] = responseData;
40 | setRenderedResources(firstPageResourcesResponse);
41 | setAllResources(allResourcesResponse);
42 | }
43 |
44 | async function getAllResources(url) {
45 | try {
46 | const response = await fetch(url);
47 | const responseData = await response.json();
48 | const allResources = await responseData.data;
49 | return allResources;
50 | } catch (error) {
51 | setHasFetchError(true);
52 | console.log(error);
53 | }
54 | }
55 |
56 | async function getFirstPageOfResources(url) {
57 | try {
58 | const response = await fetch(url);
59 | const responseData = await response.json();
60 | const firstPageResources = await responseData.data;
61 | return firstPageResources;
62 | } catch (error) {
63 | setHasFetchError(true);
64 | console.log(error);
65 | }
66 | }
67 |
68 | function addBookmark(resourceUrl) {
69 | const newBookmark = allResources.find(
70 | (resource) => resource.url === resourceUrl
71 | );
72 | setBookmarks((prevBookmarks) => [...prevBookmarks, newBookmark]);
73 | localStorage.setItem("bookmarks", JSON.stringify(bookmarks));
74 | }
75 |
76 | function removeBookmark(bookmarkUrl) {
77 | const newBookmarks = bookmarks.filter(
78 | (bookmark) => bookmark.url !== bookmarkUrl
79 | );
80 | setBookmarks(newBookmarks);
81 | localStorage.setItem("bookmarks", JSON.stringify(bookmarks));
82 | }
83 |
84 | return (
85 |
104 | {children}
105 |
106 | );
107 | }
108 |
--------------------------------------------------------------------------------
/frontend/src/svgs.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export const removeBookmarkIcon = (
4 |
12 |
13 |
14 |
15 |
16 | );
17 | export const bookmarkIcon = (
18 |
26 |
27 |
28 |
29 |
30 | );
31 |
32 | export const expandIcon = (
33 |
44 |
48 |
49 | );
50 | export const clearSearchIcon = (
51 |
59 |
63 |
64 | );
65 |
66 | export const goToTopIcon = (
67 |
73 |
77 |
78 | );
79 |
80 | export const alertIcon = (
81 |
88 |
92 |
93 | );
94 |
95 | export const backArrowIcon = (
96 |
102 |
106 |
107 | );
108 |
--------------------------------------------------------------------------------
/frontend/STYLEGUIDE.md:
--------------------------------------------------------------------------------
1 | # Style Guide
2 |
3 | As an open-source codebase grows, different developers with differing approaches to problem-solving, software design and opinions will converge and attempt to push changes. When this happens, a universal guide for programming style will be needed to ensure a pattern is followed for easier-to-understand code. That is the purpose of this style guide covering the front-end code.
4 |
5 | ### Contents
6 |
7 | - [React/JavaScript](#1)
8 | - [Basic Rules](#1.1)
9 | - [Naming](#1.2)
10 | - [Tags](#1.3)
11 | - [Spacing](#1.4)
12 | - [Quotes](#1.5)
13 | - [Editor Setup](#2)
14 | - [Install Prettier & ESLint](#2.1)
15 | - [Set up Prettier](#2.2)
16 | - [Set up ESLint](#2.3)
17 |
18 | React/JavaScript
19 | Basic Rules
20 |
21 | - Follow the best practices contained in [Thinking in React](https://beta.reactjs.org/learn/thinking-in-react).
22 | - Always use JSX syntax.
23 | - Avoid classes and use Hooks to the best possible extent.
24 | - All components should be inside a **descriptive** folder. If a component falls under an **existing** category, e.g Buttons, place its .js file in there. If an existing category doesn't exist, but you feel you can create one, create a descriptive category and use it in naming the folder. If it is impossible to create a category, name the folder the same as the component, e.g if you need to build a Tooltip.js component, you can place it in a folder named Tooltips.
25 | - All styles should be defined in `App.css`.
26 | - SVGs are treated as reusable components. All SVGs are & should be placed in `src/svgs.js.`
27 | - Abstract component **logic** into Context as much as possible.
28 | - Use ` ` syntax instead of `<>` when creating fragments. Doing otherwise might result in an error depending on how your workspace is set up if you use VSCode. We're working on getting rid of this restriction.
29 | - Use ES6 syntax to the best of your ability.
30 | - State the export type when defining the function and not after.
31 |
32 | ```javascript
33 | // don't do this
34 | function LiftWeights(){
35 | // do stuff
36 | }
37 | export default LiftWeights
38 |
39 |
40 | // do this instead
41 | export default function SleepAllDay(){
42 | // do stuff
43 | }
44 | ```
45 |
46 | - Always use camelCase for prop names.
47 | - Omit the value of the prop when it is explicitly true. There's no need for `primary={true}`, simply use `primary`
48 |
49 | Naming
50 |
51 | - **File extensions**: Use the .js extension for React components and any utility functions.
52 | - **Component file names**: Component file/folder names should follow the same PascalCase format as React components. Component/folder names should be as descriptive as possible. Avoid loose names like "AsyncComponent" that could encompass a variety of components. Instead, use specific names like ResourceDetailsMenu.js. Don't be afraid to use relatively long names within a reasonable limit (~22 chars).
53 |
54 | Quotes
55 |
56 | Always use double quotes (").
57 |
58 | ```javascript
59 | // bad 👎🏾
60 |
61 |
62 | // good 👍🏾
63 |
64 | ```
65 |
66 | Spacing
67 |
68 | - Always include a **single** space in your self-closing tag.
69 |
70 | ```javascript
71 | // bad 👎🏾
72 |
73 |
74 | // also bad 👎🏾
75 |
76 |
77 | // please don't do this 😑
78 |
80 |
81 | // good 👍🏾
82 |
83 | ```
84 |
85 | - Do not pad JSX curly braces with spaces.
86 |
87 | ```javascript
88 | // bad 👎🏾
89 |
90 |
91 | // good 👍🏾
92 |
93 | ```
94 |
95 | Tags
96 |
97 | Always self-close tags that have no children.
98 |
99 | ```javascript
100 | // bad 👎🏾
101 |
102 |
103 | // good 👍🏾
104 |
105 | ```
106 |
107 | Happy coding!
108 |
--------------------------------------------------------------------------------
/frontend/src/components/SearchForm/SearchForm.js:
--------------------------------------------------------------------------------
1 | import React, { useContext, useEffect, useState } from "react";
2 | import { nanoid } from "nanoid";
3 |
4 | import { Context } from "../../AppContext";
5 | import { clearSearchIcon } from "../../svgs";
6 | import Chip from "../Chip/Chip";
7 |
8 | const initialSuggestions = [
9 | { text: "JavaScript", isSelected: false },
10 | { text: "React", isSelected: false },
11 | { text: "Python", isSelected: false },
12 | { text: "Java", isSelected: false },
13 | { text: "Node", isSelected: false },
14 | { text: "SQL", isSelected: false },
15 | ];
16 |
17 | export default function SearchForm() {
18 | const {
19 | allResources,
20 | searchTerm,
21 | setSearchTerm,
22 | setRenderedResources,
23 | resourceFilter,
24 | setResourceFilter,
25 | } = useContext(Context);
26 | const [suggestions, setSuggestions] = useState(initialSuggestions || "");
27 |
28 | const filters = ["project", "topic"];
29 |
30 | useEffect(() => {
31 | handleSearch();
32 | //eslint-disable-next-line
33 | }, [searchTerm]);
34 |
35 | function searchWithSuggestion(text) {
36 | setSuggestions(
37 | suggestions.map((suggestion) => {
38 | if (text === suggestion.text) {
39 | if (suggestion.isSelected) {
40 | setSearchTerm("");
41 | return { text: suggestion.text, isSelected: false };
42 | }
43 | setSearchTerm(suggestion.text.toLocaleLowerCase());
44 | return { text: suggestion.text, isSelected: true };
45 | }
46 | return { text: suggestion.text, isSelected: false };
47 | })
48 | );
49 | }
50 |
51 | function handleSearch() {
52 | let result = [];
53 | result = allResources.filter(({ title }) => {
54 | return title
55 | .toLowerCase()
56 | .includes(searchTerm.toLocaleLowerCase().trim());
57 | });
58 |
59 | if (result.length > 0 && searchTerm.trim()) {
60 | setRenderedResources(result);
61 | } else if (searchTerm.trim() && !result.length) {
62 | setRenderedResources([]);
63 | } else {
64 | setRenderedResources(allResources);
65 | setSuggestions(initialSuggestions);
66 | }
67 | }
68 |
69 | function handleInputSearchTerm(e) {
70 | setSearchTerm(e.target.value);
71 | }
72 |
73 | function handleClickSearchTerm() {
74 | setSearchTerm("");
75 | }
76 |
77 | function handleClickSearchWithSuggestion(suggestion) {
78 | return () => {
79 | searchWithSuggestion(suggestion.text);
80 | };
81 | }
82 |
83 | function handleClickFilterChip(filter) {
84 | setResourceFilter(resourceFilter === filter ? "all" : filter);
85 | }
86 |
87 | return (
88 |
89 |
90 |
97 |
102 | {clearSearchIcon}
103 |
104 |
105 |
106 | {suggestions.map((suggestion) => {
107 | return (
108 |
114 | );
115 | })}
116 |
117 |
118 |
Filters:
119 | {filters.map((filter) => {
120 | return (
121 | handleClickFilterChip(filter)}
126 | />
127 | );
128 | })}
129 |
130 |
131 | );
132 | }
133 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing Guide
2 |
3 | Everyone is welcome to raise issues and/or make pull requests.
4 |
5 | In this guide, you will be guided through the usage/contribution flow, which includes:
6 |
7 | - Running the project locally
8 | - Opening an issue
9 | - Creating a PR (pull-request)
10 | - Contributing code
11 |
12 | ## Running the project
13 |
14 | ### Prerequisites
15 |
16 | - Have [`git`](https://git-scm.com/downloads) installed
17 | - Have [`Node.JS`](https://nodejs.org/en/download/) installed
18 |
19 | ### Cloning the Repository
20 |
21 | - Open `terminal` in the `directory` you want to clone this repository to
22 | - In the terminal, type `git clone https://github.com/Ngoakor12/coding-resource-finder.git`
23 | - Type `cd ./coding-resource-finder` to navigate to the root of the project directory
24 |
25 | ### Running the Backend
26 |
27 | - While in the project root directory (`"coding-resource-finder"`), type `cd ./backend` in the terminal, to navigate to the `backend` directory
28 | - Type `npm install` to install project dependencies
29 | - Type `npm run dev` to run the backend server/API
30 |
31 | ### Running the Frontend
32 |
33 | - While in the project root directory (`"coding-resource-finder"`), type `cd ./frontend` in the terminal, to navigate to the `frontend` directory
34 | - Type `npm install` to install project dependencies
35 | - Type `npm run start` to run the frontend web application
36 |
37 | [Optionally: Run `npm run lint` to detect (and even auto-fix) code-styling issues]
38 |
39 | ## Opening an Issue
40 |
41 | ### Guidelines
42 |
43 | Before opening any issue, make sure that:
44 |
45 | - No similar issue already exists, by navigating to the `Issues` tab on the repository page and going through the `open` issues
46 | - Add relevant labels to your issues, such as `enhancement`, `bug`, `feature-request`, `backend` etc.
47 | - If you're submitting a PR for an already `closed` issue, `reopen` that issue and state what your PR addresses that the prior one had left out
48 |
49 | ## Creating a PR
50 |
51 | ### Guidelines
52 |
53 | Before creating an PR, make sure that:
54 |
55 | - You navigate through `open` issues, and if one interests you, you ask to work on it. That way, you can be assigned the PR, and others will know that you're already working on it
56 | - Avoid opening PRs that might not have an accompanying issue. If you want to add something, open an issue beforehand, and discuss your changes/additions with the maintainers
57 | - Reference related issues, that your PR resolves, via the provided PR template
58 |
59 | ## Contributing Code
60 |
61 | To contribute code to the project, follow these steps:
62 |
63 | - `clone` the repositroy
64 | - `fork` the repository
65 | - Add your forked repo as a remote-url locally using `git remote add `
66 | - Whenever you work on something, make sure you branch out to another branch using `git checkout -b `
67 | - Before making any changes, ensure you're strictly following the `style-guide`, `coding-conventions` and `practices` as requested by the maintainers
68 | - When you're done with your changes, use:
69 | - `git add .` to add all changed files
70 | - `git commit -m ""` to mention what changes you've introduced
71 | - `git push -u ` to push your changes to the remote repository
72 | - Once pushed, go over to the repository page, and you'll see a `Compare & pull request` button, click it
73 | - Make sure the PR is being made to the `master` branch of the original repository (this one)
74 | - You'll then be redirected to a page with the PR template, fill it out accordingly, and then `Create pull request`
75 | - You're done! Happy Coding!
76 |
77 | ## Editor Setup
78 |
79 | This project uses Prettier & ESLint to ensure consistency in code style. To ensure you are all set and to reduce friction during code review, it is _recommended_ that you install the ['Prettier - Code formatter'](https://https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) and the ['ESLint' extensions](https://https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) from Visual Studio Code if you haven't already done so to have the full benefit of automatic code linting and formatting.
80 |
81 | For other editors, take a look at the [official Prettier](https://prettier.io/docs/en/editors.html) and [ESLint docs](https://eslint.org/docs/latest/user-guide/integrations#editors).
82 |
--------------------------------------------------------------------------------
/backend/src/docs/paths/topic.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | '/all/topics': {
3 | get: {
4 | tags: ['Topics'],
5 | summary: 'Get all topics',
6 | operationId: 'topics',
7 | responses: {
8 | 200: {
9 | content: {
10 | 'application/json': {
11 | schema: {
12 | type: 'object',
13 | properties: {
14 | num_of_resources: {
15 | type: 'number',
16 | example: 2,
17 | },
18 | data: {
19 | type: 'array',
20 | items: { type: 'string' },
21 | example: [
22 | {
23 | title: 'API basics',
24 | url: 'http://syllabus.africacode.net/topics/apis/basics/',
25 | type: 'topic',
26 | },
27 | {
28 | title: 'APIs and Node',
29 | url: 'http://syllabus.africacode.net/topics/js-and-node-specific/apis-with-node/',
30 | type: 'topic',
31 | },
32 | ],
33 | },
34 | },
35 | },
36 | },
37 | },
38 | },
39 | },
40 | },
41 | },
42 | '/all/topics/{page}': {
43 | get: {
44 | tags: ['Topics'],
45 | summary: 'Get a page of topics',
46 | operationId: 'topic_page',
47 | parameters: [
48 | {
49 | in: 'path',
50 | name: 'page',
51 | required: true,
52 | description: 'Page number',
53 | schema: {
54 | type: 'integer',
55 | example: 1,
56 | },
57 | },
58 | ],
59 | responses: {
60 | 200: {
61 | content: {
62 | 'application/json': {
63 | schema: {
64 | type: 'object',
65 | properties: {
66 | current_page: {
67 | type: 'number',
68 | example: 1,
69 | },
70 | num_of_pages: {
71 | type: 'number',
72 | example: 18,
73 | },
74 | num_of_resources: {
75 | type: 'number',
76 | example: 20,
77 | },
78 | data: {
79 | type: 'array',
80 | items: { type: 'string' },
81 | example: [
82 | {
83 | title: 'API basics',
84 | url: 'http://syllabus.africacode.net/topics/apis/basics/',
85 | type: 'topic',
86 | },
87 | {
88 | title: 'APIs and Node',
89 | url: 'http://syllabus.africacode.net/topics/js-and-node-specific/apis-with-node/',
90 | type: 'topic',
91 | },
92 | ],
93 | },
94 | },
95 | },
96 | },
97 | },
98 | },
99 | },
100 | },
101 | },
102 | };
103 |
--------------------------------------------------------------------------------
/backend/src/docs/paths/resource.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | '/all': {
3 | get: {
4 | tags: ['Resources'],
5 | summary: 'Get all resources',
6 | operationId: 'resource',
7 | responses: {
8 | 200: {
9 | content: {
10 | 'application/json': {
11 | schema: {
12 | type: 'object',
13 | properties: {
14 | num_of_resources: {
15 | type: 'number',
16 | example: 2,
17 | },
18 | data: {
19 | type: 'array',
20 | items: { type: 'string' },
21 | example: [
22 | {
23 | title: '1. semitone difference - Make a simple GUI',
24 | url: 'http://syllabus.africacode.net/projects/semitone-challenge/gui-part-1/',
25 | type: 'project',
26 | },
27 | {
28 | title: 'API basics',
29 | url: 'http://syllabus.africacode.net/topics/apis/basics/',
30 | type: 'topic',
31 | },
32 | ],
33 | },
34 | },
35 | },
36 | },
37 | },
38 | },
39 | },
40 | },
41 | },
42 | '/all/{page}': {
43 | get: {
44 | tags: ['Resources'],
45 | summary: 'Get a page of resources',
46 | operationId: 'resource_page',
47 | parameters: [
48 | {
49 | in: 'path',
50 | name: 'page',
51 | required: true,
52 | description: 'Page number',
53 | schema: {
54 | type: 'integer',
55 | example: 1,
56 | },
57 | },
58 | ],
59 | responses: {
60 | 200: {
61 | content: {
62 | 'application/json': {
63 | schema: {
64 | type: 'object',
65 | properties: {
66 | current_page: {
67 | type: 'number',
68 | example: 1,
69 | },
70 | num_of_pages: {
71 | type: 'number',
72 | example: 29,
73 | },
74 | num_of_resources: {
75 | type: 'number',
76 | example: 20,
77 | },
78 | data: {
79 | type: 'array',
80 | items: { type: 'string' },
81 | example: [
82 | {
83 | title: '1. semitone difference - Make a simple GUI',
84 | url: 'http://syllabus.africacode.net/projects/semitone-challenge/gui-part-1/',
85 | type: 'project',
86 | },
87 | {
88 | title: 'API basics',
89 | url: 'http://syllabus.africacode.net/topics/apis/basics/',
90 | type: 'topic',
91 | },
92 | ],
93 | },
94 | },
95 | },
96 | },
97 | },
98 | },
99 | },
100 | },
101 | },
102 | };
103 |
--------------------------------------------------------------------------------
/backend/src/docs/paths/project.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | '/all/projects': {
3 | get: {
4 | tags: ['Projects'],
5 | summary: 'Get all projects',
6 | operationId: 'projects',
7 | responses: {
8 | 200: {
9 | content: {
10 | 'application/json': {
11 | schema: {
12 | type: 'object',
13 | properties: {
14 | num_of_resources: {
15 | type: 'number',
16 | example: 2,
17 | },
18 | data: {
19 | type: 'array',
20 | items: { type: 'string' },
21 | example: [
22 | {
23 | title: '1. semitone difference - Make a simple GUI',
24 | url: 'http://syllabus.africacode.net/projects/semitone-challenge/gui-part-1/',
25 | type: 'project',
26 | },
27 | {
28 | title: '1. semitone difference - basic algorithm',
29 | url: 'http://syllabus.africacode.net/projects/semitone-challenge/basic-algorithm/',
30 | type: 'project',
31 | },
32 | ],
33 | },
34 | },
35 | },
36 | },
37 | },
38 | },
39 | },
40 | },
41 | },
42 | '/all/projects/{page}': {
43 | get: {
44 | tags: ['Projects'],
45 | summary: 'Get a page of projects',
46 | operationId: 'project_page',
47 | parameters: [
48 | {
49 | in: 'path',
50 | name: 'page',
51 | required: true,
52 | description: 'Page number',
53 | schema: {
54 | type: 'integer',
55 | example: 1,
56 | },
57 | },
58 | ],
59 | responses: {
60 | 200: {
61 | content: {
62 | 'application/json': {
63 | schema: {
64 | type: 'object',
65 | properties: {
66 | current_page: {
67 | type: 'number',
68 | example: 1,
69 | },
70 | num_of_pages: {
71 | type: 'number',
72 | example: 11,
73 | },
74 | num_of_resources: {
75 | type: 'number',
76 | example: 20,
77 | },
78 | data: {
79 | type: 'array',
80 | items: { type: 'string' },
81 | example: [
82 | {
83 | url: 'http://syllabus.africacode.net/projects/semitone-challenge/gui-part-1/',
84 | title: '1. semitone difference - Make a simple GUI',
85 | type: 'project',
86 | },
87 | {
88 | url: 'http://syllabus.africacode.net/projects/semitone-challenge/basic-algorithm/',
89 | title: '1. semitone difference - basic algorithm',
90 | type: 'project',
91 | },
92 | ],
93 | },
94 | },
95 | },
96 | },
97 | },
98 | },
99 | },
100 | },
101 | },
102 | };
103 |
--------------------------------------------------------------------------------
/backend/src/__tests__/getPageData.test.js:
--------------------------------------------------------------------------------
1 | const { getPageData } = require("../format-resources");
2 |
3 | const resources = {
4 | current_page: 2,
5 | num_of_pages: 1,
6 | num_of_resources: 20,
7 | data: [
8 | {
9 | title: "Android user interface resources",
10 | type: "topic",
11 | url: "http://syllabus.africacode.net/topics/android/ui-resources/",
12 | },
13 | {
14 | url: "http://syllabus.africacode.net/projects/kotlin/",
15 | type: "project",
16 | title: "Android with Kotlin Projects",
17 | },
18 | {
19 | title: "Android-Kotlin",
20 | url: "http://syllabus.africacode.net/topics/kotlin/",
21 | type: "topic",
22 | },
23 | {
24 | title: "Androids",
25 | type: "topic",
26 | url: "http://syllabus.africacode.net/topics/android/",
27 | },
28 | {
29 | type: "project",
30 | title: "Androids",
31 | url: "http://syllabus.africacode.net/projects/android/",
32 | },
33 | {
34 | type: "topic",
35 | url: "http://syllabus.africacode.net/topics/angular-elements/",
36 | title: "Angular Elements",
37 | },
38 | {
39 | type: "project",
40 | url: "http://syllabus.africacode.net/projects/angular-testing-with-cucumber/",
41 | title: "Angular Testing with Cucumber",
42 | },
43 | {
44 | type: "project",
45 | title: "Angular Tutorial",
46 | url: "http://syllabus.africacode.net/projects/angular-tutorial/",
47 | },
48 | {
49 | url: "http://syllabus.africacode.net/topics/angular-material/",
50 | type: "topic",
51 | title: "Angular material",
52 | },
53 | {
54 | url: "http://syllabus.africacode.net/topics/angular-testing-cucumber/",
55 | title: "Angular testing with Cucumber and Protractor",
56 | type: "topic",
57 | },
58 | {
59 | type: "topic",
60 | url: "http://syllabus.africacode.net/topics/angular-testing/",
61 | title: "Angular unit tests",
62 | },
63 | {
64 | title: "Animals",
65 | url: "http://syllabus.africacode.net/projects/oop/animals/",
66 | type: "project",
67 | },
68 | {
69 | url: "http://syllabus.africacode.net/projects/oop/animals/part1/",
70 | title: "Animals Part 1. OOP basics",
71 | type: "project",
72 | },
73 | {
74 | title: "Animals Part 2. Adding Tests",
75 | type: "project",
76 | url: "http://syllabus.africacode.net/projects/oop/animals/part2/",
77 | },
78 | {
79 | type: "topic",
80 | url: "http://syllabus.africacode.net/topics/kotlin/annotations/",
81 | title: "Annotations",
82 | },
83 | {
84 | title: "Apis",
85 | url: "http://syllabus.africacode.net/topics/apis/",
86 | type: "topic",
87 | },
88 | {
89 | title: "Assertive programming and Pandas",
90 | type: "topic",
91 | url: "http://syllabus.africacode.net/topics/assertive-programming-tricks-for-pandas/",
92 | },
93 | {
94 | url: "http://syllabus.africacode.net/topics/python-specific/automate-the-boring-stuff-book/chapter-0-introduction/",
95 | title: "Automate the boring stuff: Chapter 0 – Introduction",
96 | type: "topic",
97 | },
98 | {
99 | title: "Automate the boring stuff: Chapter 1 – Python Basics",
100 | type: "topic",
101 | url: "http://syllabus.africacode.net/topics/python-specific/automate-the-boring-stuff-book/chapter-1-basics/",
102 | },
103 | {
104 | title: "Automate the boring stuff: Chapter 10 – Organizing Files",
105 | url: "http://syllabus.africacode.net/topics/python-specific/automate-the-boring-stuff-book/chapter-10-organising-files/",
106 | type: "topic",
107 | },
108 | ],
109 | };
110 |
111 | describe("Get page data tests", () => {
112 | test("Should return requested page data", () => {
113 | const pageDataTest = getPageData(resources.data, 1);
114 | expect(pageDataTest.data).toEqual(resources.data);
115 | });
116 | test("Should return an error (page set to negative number)", () => {
117 | expect(() => {
118 | getPageData(resources.data, -1).toThrow(error);
119 | });
120 | });
121 | test("Should return an error (page set to zero)", () => {
122 | expect(() => {
123 | getPageData(resources.data, 0).toThrow(error);
124 | });
125 | });
126 | test("Should return an error (wrong resource)", () => {
127 | expect(() => {
128 | getPageData(454545, 2).toThrow(error);
129 | });
130 | });
131 | test("Number of pages test", () => {
132 | const pageDataTest = getPageData(resources.data, 1);
133 | expect(pageDataTest.num_of_pages).toBe(1);
134 | });
135 | test("Current page test", () => {
136 | const pageDataTest = getPageData(resources.data, 1);
137 | expect(pageDataTest.current_page).toBe(1);
138 | });
139 | test("Number of resources test", () => {
140 | const pageDataTest = getPageData(resources.data, 1);
141 | expect(pageDataTest.num_of_resources).toBe(20);
142 | });
143 | });
144 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | - Demonstrating empathy and kindness toward other people
21 | - Being respectful of differing opinions, viewpoints, and experiences
22 | - Giving and gracefully accepting constructive feedback
23 | - Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | - Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | - The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | - Trolling, insulting or derogatory comments, and personal or political attacks
33 | - Public or private harassment
34 | - Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | - Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | ngoakor12@gmail.com.
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | [homepage]: https://www.contributor-covenant.org
125 |
126 | For answers to common questions about this code of conduct, see the FAQ at
127 | https://www.contributor-covenant.org/faq. Translations are available at
128 | https://www.contributor-covenant.org/translations.
129 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Coding Resource Finder
2 |
3 | An easier way to find coding related topics and projects on the [ACN syllabus](http://syllabus.africacode.net/).
4 |
5 | Built with **ReactJS** and **NodeJS**.
6 |
7 | [**Live project**](https://coding-resource-finder.netlify.app/) 🌐
8 |
9 | [**Setup locally**](#local-setup) 🔧
10 |
11 | ## Table of Contents
12 |
13 | - [Problem](#problem)
14 | - [Tech Stack](#tech-stack)
15 | - [Features](#challenges)
16 | - [Running the Project Locally](#running-the-project-locally)
17 | - [Running the Project Locally (Using Docker)](#running-the-project-locally-using-docker)
18 | - [Editor Setup](#editor-setup)
19 |
20 | ## Problem
21 |
22 | I like [ACN syllabus](http://syllabus.africacode.net/) and the people behind it. I go there whenever I want to learn something new to see if there are any relevant resources I can use.
23 |
24 | The "problem" started when I noticed how searching resources required more clicks and I also had to use the default browser find feature(Ctrl+F). And, there is currently no way to save what resources I'm going through or planning to check out in the future.
25 | To solve these problems that only someone who visits the site frequently notices. I decided to clone the website, not the entire website but the data(links to the resources) to be precise. Then add features that I know would make my life smoother using the original website.
26 |
27 | ## Tech Stack
28 |
29 | - **ReactJS** - for the frontend
30 | - **Context** - global state management
31 | - **React Router** - Internal routing
32 | - **Cheerio** - web scraping
33 | - **ExpressJS** - API development
34 |
35 | ## Features
36 |
37 | - Search for resources
38 | - Bookmark resources you are busy with or want to work on next
39 | - The website is keyboard navigate-able\*
40 | - Fully responsive\*
41 |
42 | These are almost always expected, but hardly correctly implemented.
43 |
44 | ## Challenges
45 |
46 | - Getting the resources data without an API - Overcame this by learning web scraping, getting the data and creating an API to query the data in a less painful way.
47 |
48 | ## Lessons learned
49 |
50 | - Finding undocumented APIs. I learned about it too late in this case.
51 | - Using React Context for global state management.
52 | - Web scraping using JSDOM.
53 | - Deploying React frontends in a subfolder with GitHub Pages.
54 | - Deploying NodeJS API in a subfolder using Heroku.
55 | - Persisting data in local storage in React.
56 | - Server-side and client-side pagination.
57 |
58 | ## Screenshots
59 |
60 | .png>)
61 | .png>)
62 |
63 | [More screenshots](./docs/SCREENSHOTS.md)
64 |
65 | ## Running the Project Locally
66 |
67 | ### Prerequisites
68 |
69 | - Have [`git`](https://git-scm.com/downloads) installed
70 | - Have [`Node.JS`](https://nodejs.org/en/download/) installed
71 | - **[OPTIONAL]** Have [`Docker`](https://www.docker.com/) installed
72 |
73 | ### Cloning the Repository
74 |
75 | - Open `terminal` in the `directory` you want to clone this repository to
76 | - In the terminal, type `git clone https://github.com/Ngoakor12/coding-resource-finder.git`
77 | - Type `cd ./coding-resource-finder` to navigate to the root of the project directory
78 |
79 | ### Running the Backend
80 |
81 | - While in the project root directory (`"coding-resource-finder"`), type `cd ./backend` in the terminal, to navigate to the `backend` directory
82 | - Type `npm install` to install project dependencies
83 | - Type `npm run dev` to run the backend server/API
84 |
85 | ### Running the Frontend
86 |
87 | - While in the project root directory (`"coding-resource-finder"`), type `cd ./frontend` in the terminal, to navigate to the `frontend` directory
88 | - Type `npm install` to install project dependencies
89 | - Type `npm run start` to run the frontend web application
90 |
91 | [Optionally: Run `npm run lint` to detect (and even auto-fix) code-styling issues]
92 |
93 | ## Running the Project Locally (Using Docker)
94 |
95 | ### Prerequisites
96 |
97 | - Have [`Docker`](https://www.docker.com/) installed
98 |
99 | ### Running the Entire Application
100 |
101 | - While in the project root directory (`"coding-resource-finder"`), and with the [`docker daemon`](https://docs.docker.com/get-started/overview/#the-docker-daemon) running, type `docker compose up -d`
102 | - The web application will be available at `localhost:3000`
103 |
104 | (Note: Do **NOT** follow the [Running only the Backend](#running-only-the-backend) and [Running only the Frontend](#running-only-the-frontend) steps, if you have deployed the entire application)
105 |
106 | ### Running only the Backend
107 |
108 | - While in the project root directory (`"coding-resource-finder"`), type `docker build -t crf-backend ./backend`, to build the backend container image
109 | - Type `docker run --name crf-backend -p 2856:2856 -d crf-backend` to run the docker container in detached mode
110 | - The backend API is now available at `localhost:2856`
111 |
112 | ### Running only the Frontend
113 |
114 | - While in the project root directory (`"coding-resource-finder"`), type `docker build -t crf-frontend ./frontend`, to build the frontend container image
115 | - Type `docker run --name crf-frontend -p 3000:3000 -d crf-frontend` to run the docker container in detached mode
116 | - The frontend web application is now available at `localhost:3000`
117 |
118 | ## Editor Setup
119 |
120 | This project uses Prettier & ESLint to ensure consistency in code style.
121 |
122 | To reduce friction during code review, please follow [the steps in CONTRIBUTING.md](CONTRIBUTING.md#editor_setup) to get set up.
123 |
--------------------------------------------------------------------------------
/frontend/src/App.css:
--------------------------------------------------------------------------------
1 | @import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap");
2 |
3 | * {
4 | padding: 0;
5 | margin: 0;
6 | box-sizing: border-box;
7 | }
8 |
9 | *:focus-visible {
10 | outline: #8fffab 3px solid;
11 | outline-offset: 0;
12 | z-index: 1;
13 | }
14 |
15 | body {
16 | font-family: "Roboto Mono", monospace;
17 | color: #f1f1f1;
18 | background-color: rgb(41, 41, 41);
19 | }
20 |
21 | ::-webkit-scrollbar {
22 | width: 6px;
23 | }
24 |
25 | /* Track */
26 | ::-webkit-scrollbar-track {
27 | box-shadow: inset 0 0 5px rgb(110, 110, 110);
28 | border-radius: 20px;
29 | }
30 |
31 | /* Handle */
32 | ::-webkit-scrollbar-thumb {
33 | background: #8fffaa;
34 | border-radius: 20px;
35 | }
36 |
37 | /* Handle on hover */
38 | ::-webkit-scrollbar-thumb:hover {
39 | background: #79e192;
40 | }
41 |
42 | :root {
43 | --modal-main-background: #393939;
44 | --modal-header-background: #5c5c5c;
45 | --modal-border: 1px solid #5c5c5c;
46 | --modal-border-primary-color: 1px solid #8fffab;
47 | --modal-border-primary-color-focus: 3px solid #8fffab;
48 | --modal-border-radius: 8px;
49 | --modal-padding: 16px;
50 | --modal-big-gap: 30px;
51 | --modal-primary-color: #8fffab;
52 | --modal-white: #f1f1f1;
53 | --modal-font-weight: 700;
54 | }
55 |
56 | #root {
57 | display: flex;
58 | flex-direction: column;
59 | max-width: 900px;
60 | min-width: 280px;
61 | margin: 0 2em 2em 2.5em;
62 | position: relative;
63 | }
64 |
65 | .home {
66 | color: #f1f1f1;
67 | text-decoration: none;
68 | }
69 |
70 | header {
71 | background-color: rgb(31, 31, 31);
72 | padding: 1.8em;
73 | border-radius: 8px;
74 | /* SEARCH_BOTTOM - commented out the previos margin */
75 | margin-top: 6.5em;
76 | /* margin-top: 3.5em; */
77 | }
78 |
79 | .header-details {
80 | display: flex;
81 | flex-direction: column;
82 | }
83 |
84 | .header-title {
85 | font-size: 25px;
86 | line-height: 100%;
87 | padding-bottom: 0.2em;
88 | }
89 |
90 | .header-description {
91 | font-size: 16px;
92 | }
93 |
94 | .header-links {
95 | display: flex;
96 | flex-direction: column;
97 | gap: 0.5em;
98 | }
99 |
100 | .go-to-top-button {
101 | position: fixed;
102 | bottom: 30px;
103 | right: 30px;
104 | padding: 6px 8px;
105 | color: #f1f1f1;
106 | background-color: #292929;
107 | border-radius: 5px;
108 | border-style: none;
109 | box-shadow: rgba(0, 0, 0, 0.25) 2px 4px 8px;
110 | transition: background-color 300ms ease, color 300ms ease;
111 | z-index: 1;
112 | }
113 |
114 | .go-to-top-button:hover {
115 | background-color: rgb(31, 31, 31);
116 | color: #8fffab;
117 | transition: background-color 300ms ease, color 300ms ease;
118 | cursor: pointer;
119 | }
120 |
121 | .go-to-top-button:active {
122 | color: #8fffab;
123 | background-color: rgb(21, 21, 21);
124 | transition: background-color 300ms ease, color 300ms ease;
125 | }
126 |
127 | .go-to-home-button {
128 | background: #1f1f1f;
129 | border: 1px solid #5c5c5c;
130 | border-radius: 5px;
131 | color: #ffffff;
132 | padding: 10px 20px;
133 | font-family: "Roboto Mono";
134 | font-style: normal;
135 | font-weight: 700;
136 | font-size: 14px;
137 | line-height: 18px;
138 | }
139 |
140 | .go-to-home-button:hover,
141 | .reload-button:hover {
142 | background-color: rgb(31, 31, 31);
143 | color: #8fffab;
144 | transition: background-color 300ms ease, color 300ms ease;
145 | cursor: pointer;
146 | }
147 |
148 | .go-to-home-button:active,
149 | .reload-button:active {
150 | color: #8fffab;
151 | background-color: rgb(21, 21, 21);
152 | transition: background-color 300ms ease, color 300ms ease;
153 | }
154 |
155 | .resource-list {
156 | display: flex;
157 | flex-direction: column;
158 | gap: 15px;
159 | flex-wrap: wrap;
160 | }
161 |
162 | .bookmark-list {
163 | display: flex;
164 | flex-direction: column;
165 | gap: 15px;
166 | }
167 |
168 | .resources-list {
169 | display: flex;
170 | flex-direction: column;
171 | gap: 15px;
172 | padding-bottom: 5em;
173 | }
174 |
175 | .resource {
176 | display: flex;
177 | justify-content: space-between;
178 | flex-direction: column;
179 | align-items: flex-start;
180 | gap: 10px;
181 | color: #f1f1f1;
182 | text-decoration: none;
183 | background-color: transparent;
184 | padding: 20px;
185 | border: 1px solid rgb(92, 92, 92);
186 | border-radius: 10px;
187 | font-weight: 700;
188 | flex-wrap: wrap;
189 | width: 100%;
190 | transition: background-color 300ms ease;
191 | }
192 |
193 | .resource-title {
194 | font-weight: 400;
195 | font-size: 18px;
196 | padding-right: 35px;
197 | }
198 |
199 | .resource:hover {
200 | background-color: rgb(31, 31, 31);
201 | }
202 |
203 | .resource:focus-visible {
204 | color: #8fffab;
205 | border: 1px solid #8fffab;
206 | outline: #8fffab 3px solid;
207 | outline-offset: 0;
208 | }
209 |
210 | .resource-type {
211 | font-size: x-small;
212 | top: 20px;
213 | right: 20px;
214 | color: rgb(51, 51, 51);
215 | background-color: #f1f1f1;
216 | padding: 5px 10px;
217 | border-radius: 10px;
218 | }
219 |
220 | .search-input {
221 | font-family: "Roboto Mono", monospace;
222 | background-color: #f1f1f1;
223 | max-width: 800px;
224 | min-width: 100%;
225 | padding: 15px 25px;
226 | font-size: 18px;
227 | border: 0;
228 | border: 1px solid #8fffab;
229 | position: fixed;
230 | /* SEARCH_BOTTOM - remove top and uncomment bottom */
231 | top: 65px;
232 | /* bottom: 0; */
233 | left: 0;
234 | z-index: 1;
235 | padding-right: 55px;
236 | }
237 |
238 | .search-input:focus-visible {
239 | background-color: #f1f1f1;
240 | outline: #8fffab 3px solid;
241 | }
242 |
243 | .search-input-wrapper {
244 | display: flex;
245 | flex-direction: column;
246 | }
247 |
248 | .filter-tabs-wrapper {
249 | width: 100%;
250 | display: flex;
251 | max-width: 800px;
252 | min-width: 100%;
253 | }
254 |
255 | .filter-tab {
256 | font-family: "Roboto Mono", monospace;
257 | font-size: 18px;
258 | flex-grow: 1;
259 | padding: 15px 0;
260 | margin-bottom: 20px;
261 | border: none;
262 | background: rgb(125, 125, 125);
263 | cursor: pointer;
264 | text-align: center;
265 | text-decoration: none;
266 | color: rgb(0, 0, 0);
267 | }
268 |
269 | .filter-tab.active-tab {
270 | background: rgba(196, 196, 196);
271 | }
272 |
273 | .filter-tab:first-child {
274 | border-top-left-radius: 10px;
275 | border-bottom-left-radius: 10px;
276 | border-right: 1px solid rgb(41, 41, 41);
277 | }
278 |
279 | .filter-tab:last-child {
280 | border-top-right-radius: 10px;
281 | border-bottom-right-radius: 10px;
282 | border-left: 1px solid rgb(41, 41, 41);
283 | }
284 |
285 | .search-input-inner-wrapper {
286 | position: relative;
287 | }
288 |
289 | .chips-wrapper {
290 | display: flex;
291 | max-width: 600px;
292 | flex-wrap: wrap;
293 | gap: 10px;
294 | margin-bottom: 15px;
295 | }
296 |
297 | .chip {
298 | border: 1px solid rgb(92, 92, 92);
299 | border-radius: 20px;
300 | padding: 4px 15px;
301 | font-size: 14px;
302 | cursor: pointer;
303 | transition: all 200ms ease;
304 | display: flex;
305 | align-items: center;
306 | }
307 |
308 | .chip:hover {
309 | color: #8fffab !important;
310 | background-color: rgb(31, 31, 31);
311 | }
312 |
313 | .active {
314 | color: #8fffab !important;
315 | background-color: rgb(31, 31, 31);
316 | }
317 |
318 | .clear-button {
319 | padding: 8px;
320 | position: fixed;
321 | right: 18px;
322 | /* SEARCH_BOTTOM - remove top and uncomment bottom */
323 | top: 75px;
324 | /* bottom: 4px; */
325 | z-index: 1;
326 | color: rgb(27, 27, 27);
327 | }
328 |
329 | .main {
330 | display: flex;
331 | flex-direction: column;
332 | gap: 20px;
333 | position: relative;
334 | }
335 |
336 | .nav {
337 | display: flex;
338 | justify-content: space-around;
339 | background-color: rgb(31, 31, 31);
340 | position: fixed;
341 | top: 0;
342 | left: 0;
343 | z-index: 1;
344 | width: 100%;
345 | /* SEARCH_BOTTOM - remove this height */
346 | height: 65px;
347 | border: 1px solid rgb(92, 92, 92);
348 | }
349 |
350 | .nav-item {
351 | display: flex;
352 | align-items: center;
353 | justify-content: center;
354 | width: 100%;
355 | font-family: "Roboto Mono", monospace;
356 | font-weight: 400;
357 | background: none;
358 | border: 0;
359 | border-radius: 8px;
360 | padding: 20px;
361 | cursor: pointer;
362 | color: #8f8f8f;
363 | font-size: 18px;
364 | transition: color 300ms ease;
365 | }
366 |
367 | .nav-item:focus-visible {
368 | outline: #8fffab 3px solid;
369 | outline-offset: 0;
370 | }
371 |
372 | .nav-item:hover {
373 | color: #f1f1f1 !important;
374 | }
375 |
376 | .active-nav {
377 | color: #8fffab;
378 | background-color: rgb(27, 27, 27);
379 | }
380 |
381 | .creator,
382 | .source-code {
383 | font-size: 0.9rem;
384 | padding: 1em 0;
385 | opacity: 0.9;
386 | }
387 |
388 | .simple-link {
389 | color: rgb(143, 255, 171);
390 | }
391 |
392 | .content-placeholder {
393 | margin: 40px 0;
394 | place-self: center;
395 | }
396 |
397 | .resource-wrapper {
398 | position: relative;
399 | }
400 |
401 | .bookmark-buttons-group {
402 | display: flex;
403 | width: 88px;
404 | }
405 |
406 | .bookmark-buttons-group-bookmark,
407 | .bookmark-buttons-group-expand {
408 | color: rgb(255, 255, 131);
409 | display: flex;
410 | align-items: center;
411 | justify-content: center;
412 | /* height: 30px; */
413 | width: 44px;
414 | background-color: transparent;
415 | cursor: pointer;
416 | transition: color 300ms ease;
417 | transition: background-color 300ms ease;
418 | /* margin: 2px; */
419 | padding: 8px;
420 | border: 0;
421 | border-radius: 5px;
422 | border: 1px solid rgb(92, 92, 92);
423 | }
424 |
425 | .bookmark-buttons-group-bookmark {
426 | border-top-right-radius: 0;
427 | border-bottom-right-radius: 0;
428 | border-right: 0;
429 | }
430 |
431 | .bookmark-buttons-group-expand {
432 | border-top-left-radius: 0;
433 | border-bottom-left-radius: 0;
434 | }
435 |
436 | .bookmark-buttons-group-bookmark:hover,
437 | .bookmark-buttons-group-expand:hover {
438 | color: hsl(135, 100%, 78%);
439 | background-color: hsla(135, 100%, 78%, 0.192);
440 | }
441 |
442 | .bookmark-buttons-group-bookmark:focus-visible,
443 | .bookmark-buttons-group-expand:focus-visible {
444 | outline: #8fffab 3px solid;
445 | border-color: transparent;
446 | border-radius: 5px;
447 | }
448 |
449 | .bookmark-buttons-group-bookmark:focus-visible + button {
450 | z-index: -1;
451 | }
452 |
453 | .bookmark-buttons-group svg {
454 | height: 28px;
455 | width: 28px;
456 | }
457 |
458 | .bookmark-button {
459 | color: rgb(255, 255, 131);
460 | display: flex;
461 | align-items: center;
462 | justify-content: center;
463 | position: absolute;
464 | top: 20px;
465 | right: 20px;
466 | width: 33px;
467 | height: 33px;
468 | border: 0;
469 | border-radius: 100%;
470 | background-color: transparent;
471 | cursor: pointer;
472 | transition: color 300ms ease;
473 | transition: background-color 300ms ease;
474 | margin: 2px;
475 | padding: 5px;
476 | /* border: 0.1px solid rgb(92, 92, 92);
477 | border-radius: 3px;
478 | border-right: none;
479 | border-top-right-radius: 0px;
480 | border-bottom-right-radius: 0px;
481 | flex-grow: 1;
482 | box-sizing: border-box;
483 | text-align: center; */
484 | }
485 |
486 | .remove-bookmark-button {
487 | display: flex;
488 | align-items: center;
489 | justify-content: center;
490 | font-size: 18px;
491 | position: absolute;
492 | top: 20px;
493 | right: 20px;
494 | width: 33px;
495 | height: 33px;
496 | border: 0;
497 | border-radius: 100%;
498 | color: rgb(255, 255, 131);
499 | background-color: transparent;
500 | cursor: pointer;
501 | transition: background-color 200ms ease, color 200ms ease;
502 | padding: 5px;
503 | margin: 2px;
504 | /* border: 0.1px solid rgb(92, 92, 92);
505 | border-radius: 3px;
506 | border-right: none;
507 | border-top-right-radius: 0px;
508 | border-bottom-right-radius: 0px;
509 | flex-grow: 1;
510 | box-sizing: border-box;
511 | text-align: center; */
512 | }
513 |
514 | .resource-wrapper:hover .bookmark-button {
515 | color: hsl(135, 100%, 78%);
516 | }
517 |
518 | .resource-wrapper:hover .bookmark-button:hover {
519 | background-color: hsla(135, 100%, 78%, 0.192);
520 | }
521 |
522 | .resource-wrapper:hover .remove-bookmark-button {
523 | color: hsl(135, 100%, 78%);
524 | }
525 |
526 | .resource-wrapper:hover .remove-bookmark-button:hover {
527 | color: hsl(0, 100%, 78%);
528 | background-color: hsla(0, 100%, 78%, 0.247);
529 | }
530 |
531 | .resource-wrapper:hover .resource {
532 | font-weight: 700;
533 | color: #8fffab;
534 | background-color: rgb(31, 31, 31);
535 | }
536 |
537 | .bookmark-button:focus-visible,
538 | .remove-bookmark-button:focus-visible {
539 | outline: #8fffab 3px solid;
540 | }
541 |
542 | .bookmark-button:active,
543 | .remove-bookmark-button:active {
544 | transform: scale(95%);
545 | transform-origin: center;
546 | }
547 |
548 | .clear-bookmarks {
549 | font-family: "Roboto Mono", monospace;
550 | font-size: 14px;
551 | padding: 10px;
552 | cursor: pointer;
553 | color: rgb(255, 255, 131);
554 | align-self: flex-end;
555 | transition: color 300ms ease;
556 | border: 0;
557 | background: none;
558 | }
559 |
560 | .clear-bookmarks:hover {
561 | color: hsl(0, 100%, 78%);
562 | }
563 |
564 | .nav > * {
565 | text-decoration: none;
566 | }
567 |
568 | .load-more-btn {
569 | padding: 10px 20px;
570 | color: #8fffab;
571 | font-family: "Roboto Mono", monospace;
572 | font-size: 14px;
573 | /* font-weight: 700; */
574 | border: 1px solid rgb(92, 92, 92);
575 | border-radius: 8px;
576 | background-color: rgb(31, 31, 31);
577 | }
578 |
579 | .load-more-btn:hover {
580 | color: #f1f1f1;
581 | cursor: pointer;
582 | }
583 |
584 | .load-more-btn:disabled {
585 | color: #8fffab;
586 | opacity: 0.5;
587 | cursor: not-allowed;
588 | }
589 |
590 | .load-more-btn:disabled:hover {
591 | opacity: 0.5;
592 | }
593 |
594 | .resource-skeleton {
595 | border: 1px solid #5c5c5c;
596 | border-radius: 10px;
597 | max-width: 635px;
598 | display: flex;
599 | align-items: flex-start;
600 | justify-content: space-between;
601 | padding: 20px;
602 | }
603 |
604 | .resource-skeleton .left {
605 | width: 100%;
606 | }
607 |
608 | .resource-skeleton .larger {
609 | border-radius: 10px;
610 | height: 24px;
611 | max-width: 493px;
612 | margin-bottom: 10px;
613 | margin-right: 35px;
614 | animation: blinkAnimation 1s infinite alternate;
615 | }
616 |
617 | .resource-skeleton .smaller {
618 | border-radius: 10px;
619 | height: 23px;
620 | max-width: 60px;
621 | animation: blinkAnimation 1s infinite alternate;
622 | }
623 |
624 | .resource-skeleton .right {
625 | border-radius: 10px;
626 | min-height: 33px;
627 | min-width: 33px;
628 | animation: blinkAnimation 1s infinite alternate;
629 | }
630 |
631 | .page-title {
632 | font-family: "Roboto Mono";
633 | margin: 10px 0 20px 0;
634 | }
635 |
636 | .error-fetching-container {
637 | min-height: 100vh;
638 | display: flex;
639 | flex-direction: column;
640 | justify-content: center;
641 | align-items: flex-start;
642 | }
643 |
644 | .error-fetching-heading {
645 | font-family: "Roboto Mono";
646 | font-style: normal;
647 | font-weight: 700;
648 | font-size: 18px;
649 | line-height: 24px;
650 | /* identical to box height */
651 | color: #ffffff;
652 | margin-top: 10px;
653 | margin-bottom: 20px;
654 | }
655 |
656 | .page-description {
657 | font-family: "Roboto Mono";
658 | font-style: normal;
659 | font-weight: 400;
660 | font-size: 14px;
661 | line-height: 18px;
662 |
663 | color: #ffffff;
664 | margin-bottom: 20px;
665 | }
666 |
667 | .centered-container {
668 | min-height: 100vh;
669 | display: flex;
670 | flex-direction: column;
671 | justify-content: center;
672 | align-items: flex-start;
673 | }
674 |
675 | .error-fetching-content {
676 | font-family: "Roboto Mono";
677 | font-style: normal;
678 | font-weight: 400;
679 | font-size: 14px;
680 | line-height: 28px;
681 | }
682 |
683 | .error-fetching-list {
684 | margin-left: 20px;
685 | }
686 |
687 | .reload-button {
688 | cursor: pointer;
689 | min-width: 116px;
690 | font-family: "Roboto Mono";
691 | font-style: normal;
692 | font-weight: 700;
693 | font-size: 14px;
694 | line-height: 18px;
695 | background: #1f1f1f;
696 | border: 1px solid #5c5c5c;
697 | border-radius: 5px;
698 | color: #ffffff;
699 | padding: 10px 20px;
700 | margin-top: 20px;
701 | }
702 |
703 | @keyframes blinkAnimation {
704 | from {
705 | background-color: #5c5c5c;
706 | }
707 |
708 | to {
709 | background-color: #949494;
710 | }
711 | }
712 |
713 | .bookmark-group-modal {
714 | background-color: var(--modal-main-background);
715 | border-radius: var(--modal-border-radius);
716 | border: var(--modal-border);
717 | box-shadow: 2px 2px 5px 0px #00000040;
718 | overflow: hidden;
719 | max-width: 300px;
720 | min-width: 200px;
721 | }
722 |
723 | .bookmark-group-modal-body {
724 | display: flex;
725 | padding: var(--modal-padding);
726 | padding-top: var(--modal-big-gap);
727 | background-color: var(--modal-main-background);
728 | }
729 |
730 | .prompt-modal-container,
731 | .base-modal-container {
732 | position: fixed;
733 | z-index: 1;
734 | left: 0;
735 | top: 0;
736 | height: 100vh;
737 | width: 100vw;
738 | display: flex;
739 | align-items: center;
740 | justify-content: center;
741 | background-color: rgb(41, 41, 41, 60%);
742 | }
743 | .prompt-modal {
744 | background-color: var(--modal-main-background);
745 | border-radius: var(--modal-border-radius);
746 | border: var(--modal-border);
747 | box-shadow: 2px 2px 5px 0px #00000040;
748 | overflow: hidden;
749 | display: flex;
750 | flex-direction: column;
751 | align-items: center;
752 | z-index: 2;
753 | max-width: 500px;
754 | min-width: 200px;
755 | }
756 | .prompt-modal-body {
757 | display: flex;
758 | padding: var(--modal-padding);
759 | padding-top: var(--modal-big-gap);
760 | background-color: var(--modal-main-background);
761 | flex-direction: column;
762 | gap: var(--modal-big-gap);
763 | }
764 | .prompt-modal-promt {
765 | text-align: center;
766 | }
767 |
768 | .new-bookmark-group-form {
769 | display: flex;
770 | flex-direction: column;
771 | gap: var(--modal-big-gap);
772 | background-color: var(--modal-main-background);
773 | }
774 |
775 | .bookmark-group-modal-form {
776 | width: 100%;
777 | }
778 |
779 | .bookmark-group-modal-form-fields {
780 | display: flex;
781 | flex-direction: column;
782 | gap: 10px;
783 | padding-bottom: var(--modal-big-gap);
784 | font-size: 14px;
785 | }
786 |
787 | .bookmark-group-modal-form-field-inline.checkmark-container label {
788 | white-space: nowrap;
789 | overflow: hidden;
790 | text-overflow: ellipsis;
791 | max-width: 180px;
792 | }
793 |
794 | .bookmark-group-modal-form-field {
795 | display: flex;
796 | flex-direction: column;
797 | gap: 10px;
798 | padding-bottom: 10px;
799 | }
800 |
801 | .bookmark-group-modal-form-fields input {
802 | color: var(--modal-white);
803 | background-color: var(--modal-header-background);
804 | padding: var(--modal-border-radius);
805 | border: none;
806 | border: var(--modal-border);
807 | border-radius: var(--modal-border-radius);
808 | font-size: 16px;
809 | }
810 |
811 | .bookmark-group-modal-form-fields input[type="text"]:focus,
812 | .bookmark-group-modal-buttons button:focus-visible {
813 | outline: var(--modal-border-primary-color-focus);
814 | }
815 | .bookmark-group-modal-form-fields input:focus-visible {
816 | color: #292929;
817 | background-color: var(--modal-white);
818 | }
819 |
820 | .bookmark-group-modal-header {
821 | display: flex;
822 | justify-content: center;
823 | background-color: var(--modal-header-background);
824 | padding: var(--modal-border-radius);
825 | font-weight: var(--modal-font-weight);
826 | }
827 |
828 | .bookmark-group-modal-buttons {
829 | display: flex;
830 | justify-content: space-between;
831 | }
832 |
833 | .bookmark-group-modal-buttons button {
834 | padding: var(--modal-border-radius) var(--modal-padding);
835 | border-radius: var(--modal-border-radius);
836 | font-weight: var(--modal-font-weight);
837 | cursor: pointer;
838 | }
839 |
840 | .bookmark-group-modal-buttons button:active {
841 | opacity: 0.8;
842 | }
843 |
844 | .new-bookmark-group-form-cancel {
845 | color: var(--modal-white);
846 | border: 0;
847 | background-color: var(--modal-header-background);
848 | transition: background-color 300ms ease;
849 | }
850 |
851 | .new-bookmark-group-form-submit {
852 | color: var(--modal-white);
853 | border: var(--modal-border-primary-color);
854 | background: none;
855 | transition: background 300ms ease;
856 | }
857 |
858 | .new-bookmark-group-form-cancel:hover,
859 | .new-bookmark-group-form-submit:hover {
860 | color: var(--modal-primary-color);
861 | background-color: #292929;
862 | }
863 |
864 | /* custom checkmark */
865 |
866 | .checkmark-container {
867 | display: flex;
868 | align-items: center;
869 | }
870 |
871 | .checkmark-container label {
872 | display: flex;
873 | cursor: pointer;
874 | width: 100%;
875 | }
876 |
877 | /* remove default checkbox */
878 |
879 | .checkmark-container input[type="checkbox"] {
880 | cursor: pointer;
881 | opacity: 0;
882 | position: absolute;
883 | }
884 |
885 | /* create new checkmark */
886 | .checkmark-container label::before {
887 | content: "";
888 | min-height: 18px;
889 | min-width: 18px;
890 | margin-right: 10px;
891 | border-radius: 4px;
892 | border: 1px solid var(--modal-white);
893 | }
894 |
895 | .checkmark-container label:hover::before,
896 | .checkmark-container input[type="checkbox"]:hover + label::before {
897 | background-color: var(--modal-white);
898 | transition: background-color 300ms ease;
899 | }
900 |
901 | .checkmark-container label:focus-visible::before,
902 | .checkmark-container input[type="checkbox"]:focus-visible + label::before {
903 | outline: var(--modal-border-primary-color-focus);
904 | }
905 |
906 | /* when label input is checked */
907 | .checkmark-container input[type="checkbox"]:checked + label::before {
908 | background-color: var(--modal-white);
909 | background: url(./assets/checkmark.svg);
910 | background-position: center;
911 | overflow: hidden;
912 | transition: background 300ms ease;
913 | }
914 |
915 | .card {
916 | color: #f1f1f1;
917 | text-decoration: none;
918 | background-color: transparent;
919 | padding: 20px;
920 | border: 1px solid rgb(92, 92, 92);
921 | border-radius: 8px;
922 | width: auto;
923 | }
924 |
925 | .full-width {
926 | width: 100%;
927 | }
928 |
929 | .bookmark-group-card {
930 | text-decoration: none;
931 | }
932 |
933 | .bookmark-group-card:hover .card {
934 | background-color: #1f1f1f;
935 | border-radius: var(--modal-border-radius);
936 | border: var(--modal-border-primary-color);
937 | }
938 | .bookmark-group-card:hover .bookmark-group-card-title {
939 | color: #8fffaa;
940 | }
941 |
942 | .bookmark-group-card-title {
943 | font-size: 18px;
944 | }
945 |
946 | .bookmark-group-card-count {
947 | font-size: 36px;
948 | font-weight: 700;
949 | color: #949494;
950 | }
951 |
952 | .bookmark-group-details-header {
953 | display: flex;
954 | flex-direction: column;
955 | gap: 20px;
956 | }
957 |
958 | .bookmark-group-details-header-first-row {
959 | display: flex;
960 | align-items: center;
961 | justify-content: space-between;
962 | flex-wrap: wrap;
963 | justify-content: space-between;
964 | gap: 10px;
965 | }
966 | .bookmark-group-details-header-second-row {
967 | display: flex;
968 | align-items: center;
969 | align-content: center;
970 | gap: 10px;
971 | overflow-x: auto;
972 | }
973 |
974 | .bookmark-group-details-header-back {
975 | color: var(--modal-white);
976 | border-radius: 8px;
977 | padding: 4px;
978 | display: flex;
979 | }
980 | .bookmark-group-details-header-back:hover {
981 | color: var(--modal-white);
982 | background-color: #1f1f1f;
983 | }
984 |
985 | .bookmark-group-details-header-left {
986 | display: flex;
987 | gap: 10px;
988 | }
989 |
990 | .bookmark-group-details-header-heading {
991 | display: flex;
992 | align-self: center;
993 | line-height: 24px;
994 | }
995 |
996 | .bookmark-group-details-header-right button {
997 | background-color: transparent;
998 | color: var(--modal-white);
999 | border: none;
1000 | /* border: 1px solid transparent; */
1001 | padding: 10px;
1002 | border-radius: 8px;
1003 | cursor: pointer;
1004 | }
1005 |
1006 | .bookmark-group-details-header-right button:hover {
1007 | background-color: #1f1f1f;
1008 | color: #8fffaa;
1009 | /* border: 1px solid #8fffaa; */
1010 | }
1011 |
1012 | @media (min-width: 800px) {
1013 | body {
1014 | display: flex;
1015 | justify-content: center;
1016 | }
1017 |
1018 | /* #root {
1019 | align-items: center;
1020 | } */
1021 |
1022 | .header {
1023 | align-self: flex-start;
1024 | display: flex;
1025 | /* padding-bottom: 2.5em; */
1026 | margin-top: 0;
1027 | margin-bottom: 3em;
1028 | border-top-left-radius: 0;
1029 | border-top-right-radius: 0;
1030 | border-bottom-left-radius: 8px;
1031 | border-bottom-right-radius: 8px;
1032 | }
1033 |
1034 | .header-details {
1035 | display: flex;
1036 | flex-direction: column;
1037 | align-items: flex-start;
1038 | justify-content: center;
1039 | gap: 0;
1040 | }
1041 |
1042 | .header-title {
1043 | font-size: 35px;
1044 | padding-top: 0;
1045 | padding-bottom: 0;
1046 | }
1047 |
1048 | .header-description {
1049 | font-size: 18px;
1050 | }
1051 |
1052 | .header-links {
1053 | flex-direction: column;
1054 | gap: 0;
1055 | }
1056 |
1057 | .main {
1058 | display: grid;
1059 | grid-template-columns: 25% minmax(0, 80%);
1060 | gap: 40px;
1061 | min-width: 100%;
1062 | }
1063 |
1064 | .nav {
1065 | display: flex;
1066 | flex-direction: column;
1067 | align-items: center;
1068 | /* align-items: flex-start; */
1069 | justify-content: center;
1070 | position: sticky;
1071 | top: 0;
1072 | min-width: 160px;
1073 | height: auto;
1074 | border-radius: 8px;
1075 | /* min-width: 20%; */
1076 | }
1077 |
1078 | a.nav-item {
1079 | display: flex;
1080 | justify-content: center;
1081 | }
1082 |
1083 | .search-input-wrapper {
1084 | position: sticky;
1085 | top: 0;
1086 | z-index: 10;
1087 | background: #292929;
1088 | box-shadow: 0 5px 3px #292929;
1089 | }
1090 |
1091 | .search-input {
1092 | margin-bottom: 20px;
1093 | border-radius: 10px;
1094 | border: none;
1095 | position: static;
1096 | }
1097 |
1098 | .clear-button {
1099 | cursor: pointer;
1100 | position: absolute;
1101 | /* SEARCH_BOTTOM - remove top */
1102 | top: auto;
1103 | right: 18px;
1104 | bottom: 28px;
1105 | z-index: 1;
1106 | color: rgb(27, 27, 27);
1107 | width: 35px;
1108 | height: 35px;
1109 | display: flex;
1110 | place-content: center;
1111 | border-radius: 100%;
1112 | transition: background-color 300ms ease;
1113 | }
1114 |
1115 | .clear-button:hover {
1116 | background-color: #8f8f8f48;
1117 | border-radius: 100%;
1118 | }
1119 | }
1120 | /* .resource-wrapper:hover .story-button {
1121 | color: hsl(135, 100%, 78%);
1122 | }
1123 |
1124 | .resource-wrapper:hover .story-button:hover {
1125 | background-color: hsla(135, 100%, 78%, 0.192);
1126 | }
1127 | .icon-button {
1128 | display: flex;
1129 | }
1130 |
1131 | .story-button {
1132 | color: rgb(255, 255, 131);
1133 | display: flex;
1134 | align-items: center;
1135 | justify-content: center;
1136 | position: absolute;
1137 | top: 20px;
1138 | right: 0px;
1139 | width: 33px;
1140 | height: 33px;
1141 | border: 0;
1142 | border-radius: 100%;
1143 | background-color: transparent;
1144 | cursor: pointer;
1145 | transition: color 300ms ease;
1146 | transition: background-color 300ms ease;
1147 | margin: 2px;
1148 | padding: 5px;
1149 | border: 0.001px solid rgb(92, 92, 92);
1150 | border-radius: 3px;
1151 | border-right: 0.01em solid rgb(92, 92, 92);
1152 | border-left: 0.1% solid rgb(92, 92, 92);
1153 | flex-grow: 1;
1154 | box-sizing: border-box;
1155 | text-align: center;
1156 | border-top-left-radius: 0px;
1157 | border-bottom-left-radius: 0px;
1158 | } */
1159 |
--------------------------------------------------------------------------------