diff --git a/.eslintrc.json b/.eslintrc.json index adb0c21aa..c2c8826d2 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -6,9 +6,11 @@ "airbnb/hooks", "prettier", "plugin:@typescript-eslint/recommended", - "plugin:@typescript-eslint/recommended-requiring-type-checking" + "plugin:@typescript-eslint/recommended-requiring-type-checking", + "eslint-config-airbnb", + "eslint-config-next" ], - "plugins": ["react", "@typescript-eslint", "prettier"], + "plugins": ["react", "@typescript-eslint", "prettier", "react-hooks"], "env": { "browser": true, "es2021": true, @@ -24,6 +26,8 @@ "sourceType": "module" }, "rules": { + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", "@typescript-eslint/no-misused-promises": [ "error", { diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..b58b603fe --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..03d9549ea --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 000000000..8bdf84ccd --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..35eb1ddfb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vets-who-code-app.iml b/.idea/vets-who-code-app.iml new file mode 100644 index 000000000..24643cc37 --- /dev/null +++ b/.idea/vets-who-code-app.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 000000000..29f8f8c1f --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,29 @@ +#-------------------------------------------------------------------------------# +# Qodana analysis is configured by qodana.yaml file # +# https://www.jetbrains.com/help/qodana/qodana-yaml.html # +#-------------------------------------------------------------------------------# +version: "1.0" + +#Specify inspection profile for code analysis +profile: + name: qodana.starter + +#Enable inspections +#include: +# - name: + +#Disable inspections +#exclude: +# - name: +# paths: +# - + +#Execute shell command before Qodana execution (Applied in CI/CD pipeline) +#bootstrap: sh ./prepare-qodana.sh + +#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) +#plugins: +# - id: #(plugin id can be found at https://plugins.jetbrains.com) + +#Specify Qodana linter for analysis (Applied in CI/CD pipeline) +linter: jetbrains/qodana-js:latest diff --git a/src/components/media-card/index.tsx b/src/components/media-card/index.tsx new file mode 100644 index 000000000..a23811e9e --- /dev/null +++ b/src/components/media-card/index.tsx @@ -0,0 +1,58 @@ +import { forwardRef } from "react"; +import clsx from "clsx"; +import Anchor from "@ui/anchor"; +import { IMedia } from "@utils/types"; + +type TProps = Pick< + IMedia, + "image" | "path" | "title" | "description" | "type" | "date" +> & { + className?: string; +}; + +const MediaCard = forwardRef( + ({ className, image, path, title, description, type, date }, ref) => { + return ( +
+
+ {image?.src && ( +
+ {image?.alt +
+ )} + + {title} + +
+ +
+
+ {type} +
+ +

+ {title} +

+ +

{description}

+ +
    +
  • + + {date} +
  • +
+
+
+ ); + } +); + +export default MediaCard; \ No newline at end of file diff --git a/src/components/media-grid/index.tsx b/src/components/media-grid/index.tsx new file mode 100644 index 000000000..24561bd5c --- /dev/null +++ b/src/components/media-grid/index.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import MediaCard from '@components/media-card'; // Adjust the import path as needed + +type MediaGridProps = { + section: string; + data: { + image: { src: string; alt?: string; width?: number; height?: number; loading?: string; }; + title: string; + description: string; + type: string; + date: string; + path: string; + views: number; + slug: string; + }[]; +}; + +const MediaGrid: React.FC = ({ section, data }) => { + if (!Array.isArray(data)) { + return null; // or some fallback UI + } + + return ( +
+

{section}

+
+ {data.map(item => ( + + ))} +
+
+ ); +}; + +MediaGrid.propTypes = { + section: PropTypes.string.isRequired, + data: PropTypes.arrayOf(PropTypes.shape({ + image: PropTypes.shape({ + src: PropTypes.string.isRequired, + alt: PropTypes.string, + width: PropTypes.number, + height: PropTypes.number, + loading: PropTypes.string, + }).isRequired, + title: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + date: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + views: PropTypes.number.isRequired, + slug: PropTypes.string.isRequired, + })).isRequired, +}; + +export default MediaGrid; diff --git a/src/components/ui/button/index.tsx b/src/components/ui/button/index.tsx index b5811ec78..d0d60237b 100644 --- a/src/components/ui/button/index.tsx +++ b/src/components/ui/button/index.tsx @@ -14,11 +14,11 @@ interface ButtonProps { /** * Optional. Default is `contained`. */ - variant?: "contained" | "outlined" | "texted"; + variant?: "contained" | "outlined" | "texted" | "media"; /** * Optional. Default is `primary`. */ - color?: "primary" | "light"; + color?: "primary" | "light" | "media"; /** * Optional. Default is `md`. */ @@ -164,6 +164,24 @@ const Button = ({ lightHoverClass, ]; + // Media Button + const mediaClass = "tw-bg-media tw-border-media tw-text-white"; + const mediaHoverClass = + !disabled && + !active && + hover === "default" && + "hover:tw-bg-media-dark hover:tw-border-media-dark hover:tw-text-white"; + const mediaActiveClass = + !disabled && + active && + "tw-bg-media-dark tw-border-media-dark active:tw-bg-media-dark active:tw-border-media-dark"; + const mediaBtn = color === "media" && [ + mediaClass, + mediaHoverClass, + mediaActiveClass, + lightHoverClass, + ]; + // Buton Sizes const mdBtn = size === "md" && @@ -179,8 +197,8 @@ const Button = ({ const classnames = clsx( variant !== "texted" && baseClass, variant !== "texted" && baseNotFullWidthClass, - variant === "contained" && [containedPrimaryBtn, containedLightBtn], - variant === "outlined" && [outlinedPrimaryBtn, outlinedLightBtn], + variant === "contained" && [containedPrimaryBtn, containedLightBtn, mediaBtn], + variant === "outlined" && [outlinedPrimaryBtn, outlinedLightBtn, mediaBtn], !iconButton && variant !== "texted" && [mdBtn, xsBtn], roundedBtn, ellipseBtn, diff --git a/src/containers/media-full/index.tsx b/src/containers/media-full/index.tsx new file mode 100644 index 000000000..1f79991b6 --- /dev/null +++ b/src/containers/media-full/index.tsx @@ -0,0 +1,57 @@ +import { motion } from "framer-motion"; +import Section from "@ui/section"; +import MediaCard from "@components/media-card/index"; // Assuming there's a media-card component similar to blog-card +import Pagination from "@components/pagination/pagination-01"; +import { IMedia } from "@utils/types"; +import { scrollUpVariants } from "@utils/variants"; + +const AnimatedMediaCard = motion(MediaCard); + +type TProps = { + data: { + media: IMedia[]; + pagiData?: { + currentPage: number; + numberOfPages: number; + }; + }; +}; + +const MediaArea = ({ data: { media, pagiData } }: TProps) => { + return ( +
+

Media Section

+
+ {media.length > 0 && ( +
+ {media.map((item) => ( + + ))} +
+ )} + {pagiData && pagiData.numberOfPages > 1 && ( + + )} +
+
+ ); +}; + +export default MediaArea; \ No newline at end of file diff --git a/src/data/media.json b/src/data/media.json new file mode 100644 index 000000000..bfed3ded7 --- /dev/null +++ b/src/data/media.json @@ -0,0 +1,64 @@ +{ + "media": [ + { + "section": "What we have built", + "items": [ + { + "title": "Project 1", + "description": "Description of Project 1", + "link": "https://example.com/project1" + }, + { + "title": "Project 2", + "description": "Description of Project 2", + "link": "https://example.com/project2" + } + ] + }, + { + "section": "Publications", + "items": [ + { + "title": "Publication 1", + "description": "Description of Publication 1", + "link": "https://example.com/publication1" + }, + { + "title": "Publication 2", + "description": "Description of Publication 2", + "link": "https://example.com/publication2" + } + ] + }, + { + "section": "Podcasts", + "items": [ + { + "title": "Podcast 1", + "description": "Description of Podcast 1", + "link": "https://example.com/podcast1" + }, + { + "title": "Podcast 2", + "description": "Description of Podcast 2", + "link": "https://example.com/podcast2" + } + ] + }, + { + "section": "Courses", + "items": [ + { + "title": "Course 1", + "description": "Description of Course 1", + "link": "https://example.com/course1" + }, + { + "title": "Course 2", + "description": "Description of Course 2", + "link": "https://example.com/course2" + } + ] + } + ] +} diff --git a/src/data/media/media-one.md b/src/data/media/media-one.md new file mode 100644 index 000000000..fffb3ce8c --- /dev/null +++ b/src/data/media/media-one.md @@ -0,0 +1,13 @@ +--- +title: "Media Title 1" +image: "https://res.cloudinary.com/vetswhocode/image/upload/v1716598792/junior-senior-card_sjcshc.jpg" +description: "Description for media 1" +tags: + - "tag1" + - "tag2" +date: "2023-05-01" +sortOrder: 0 +type: "publication" +slug: "media-one" +--- +Content for media item 1. diff --git a/src/data/media/media-three.md b/src/data/media/media-three.md new file mode 100644 index 000000000..f3cd3bf19 --- /dev/null +++ b/src/data/media/media-three.md @@ -0,0 +1,13 @@ +--- +title: "Media Title 5" +image: "https://res.cloudinary.com/vetswhocode/image/upload/v1716598792/junior-senior-card_sjcshc.jpg" +description: "Description for media " +tags: + - "tag1" + - "tag2" +date: "2023-05-01" +sortOrder: 1 +type: "publication" +slug: "media-one" +--- +Content for media item 5. diff --git a/src/data/media/media-two.md b/src/data/media/media-two.md new file mode 100644 index 000000000..d13128b06 --- /dev/null +++ b/src/data/media/media-two.md @@ -0,0 +1,13 @@ +--- +title: "Media Title 2" +image: "https://dummyimage.com/640x4:3/" +description: "Description for media 2" +tags: + - "tag3" + - "tag4" +date: "2023-06-15" +sortOrder: 2 +type: "podcast" +--- + +Content for media item 2. diff --git a/src/data/menu.ts b/src/data/menu.ts index 9c32cb8eb..34d5703ed 100644 --- a/src/data/menu.ts +++ b/src/data/menu.ts @@ -3,98 +3,6 @@ export default [ id: 1, label: "Home", path: "/", - /* megamenu: [ - { - id: 11, - title: "Group 01", - submenu: [ - { - id: 111, - label: "submenu item 01", - path: "/", - status: "hot", - }, - { - id: 112, - label: "submenu item 02", - path: "#", - }, - { - id: 113, - label: "submenu item 03", - path: "#", - status: "hot", - }, - { - id: 114, - label: "submenu item 04", - path: "#", - }, - { - id: 115, - label: "submenu item 05", - path: "#", - }, - { - id: 116, - label: "submenu item 06", - path: "#", - }, - ], - }, - { - id: 12, - title: "Group 02", - submenu: [ - { - id: 121, - label: "submenu item 07", - path: "/", - status: "coming soon", - }, - { - id: 122, - label: "submenu item 08", - path: "/", - status: "coming soon", - }, - { - id: 123, - label: "submenu item 09", - path: "/", - status: "coming soon", - }, - { - id: 124, - label: "submenu item 10", - path: "/", - status: "coming soon", - }, - { - id: 125, - label: "submenu item 11", - path: "/", - status: "coming soon", - }, - { - id: 126, - label: "submenu item 12", - path: "/", - status: "coming soon", - }, - ], - }, - { - id: 13, - title: "Banner", - banner: { - path: "/", - image: { - src: "/images/menu/mega-menu.jpg", - }, - }, - }, - ], */ }, { id: 2, @@ -133,12 +41,6 @@ export default [ label: "Apply to be a Student", path: "/apply", }, - /* { - id: 26, - label: "Join our community", - path: "/join-our-community", - }, -*/ ], }, { @@ -166,4 +68,9 @@ export default [ label: "Donate", path: "/donate", }, + { + id: 9, + label: "Media", + path: "/media", + }, ]; diff --git a/src/lib/media.ts b/src/lib/media.ts new file mode 100644 index 000000000..fda6846c7 --- /dev/null +++ b/src/lib/media.ts @@ -0,0 +1,122 @@ +import fs from "fs"; +import { join } from "path"; +import matter from "gray-matter"; +import { IMedia } from "@utils/types"; +import { slugify, flatDeep } from "@utils/methods"; +import { getSlugs } from "./util"; + +const mediaDirectory = join(process.cwd(), "src/data/media"); + +const makeExcerpt = (str: string, maxLength: number): string => { + if (str.length <= maxLength) { + return str; + } + let excerpt = str.substring(0, maxLength); + excerpt = excerpt.substring(0, excerpt.lastIndexOf(" ")); + return `${excerpt} ...`; +}; + +export function getMediaBySlug( + slug: string, + fields: Array | "all" = [] +): IMedia { + const realSlug = slug.replace(/\.md$/, ""); + const fullPath = join(mediaDirectory, `${realSlug}.md`); + const fileContents = fs.readFileSync(fullPath, "utf8"); + const { data, content } = matter(fileContents); + + const mediaData = data as IMedia; + + let media: IMedia; + + if (fields === "all") { + media = { + ...mediaData, + content, + tags: mediaData.tags.map((tag: string) => ({ + title: tag, + slug: slugify(tag), + path: `/media/tag/${slugify(tag)}`, + })), + slug: realSlug, + excerpt: makeExcerpt(content, 150), + }; + } else { + media = fields.reduce((acc: IMedia, field: keyof IMedia) => { + if (field === "slug") { + return { ...acc, slug: realSlug }; + } + if (field === "content") { + return { ...acc, [field]: content }; + } + if (field === "excerpt") { + return { ...acc, excerpt: makeExcerpt(content, 150) }; + } + if (field === "tags") { + return { + ...acc, + tags: mediaData.tags.map((tag: string) => ({ + title: tag, + slug: slugify(tag), + path: `/media/tag/${slugify(tag)}`, + })), + }; + } + if (typeof data[field] !== "undefined") { + return { ...acc, [field]: mediaData[field] }; + } + return acc; + }, {}); + } + + return { + ...media, + path: `/media/${realSlug}`, + }; +} + +export function getAllMedia( + fields: Array | "all" = [], + skip = 0, + limit?: number +) { + const slugs = getSlugs(mediaDirectory); + let media = slugs + .map((slug) => getMediaBySlug(slug, fields)) + .sort((item1, item2) => + new Date(item1.date).getTime() > new Date(item2.date).getTime() + ? -1 + : 1 + ); + if (limit) media = media.slice(skip, skip + limit); + return { media, count: slugs.length }; +} + +export function getTags() { + const { media } = getAllMedia(["tags"]); + const tags = flatDeep(media.map((item) => item.tags)); + const result: { title: string; slug: string; path: string }[] = []; + + tags.forEach((tag) => { + if (!result.find((t) => t.title === tag.title)) { + result.push(tag); + } + }); + + return result; +} + +export function getMediaByTag( + tag: string, + fields: Array | "all" = [], + skip = 0, + limit?: number +) { + const postFields = + fields === "all" ? "all" : ([...fields, "tags"] as Array); + const { media } = getAllMedia(postFields); + let result = media.filter((item) => item.tags.some((t) => t.slug === tag)); + const totalItems = result.length; + if (limit) result = result.slice(skip, skip + limit); + return { items: result, count: totalItems }; +} diff --git a/src/pages/blogs/blog-grid-sidebar/index.tsx b/src/pages/blogs/blog-grid-sidebar/index.tsx index 057cdeb75..16c4168c3 100644 --- a/src/pages/blogs/blog-grid-sidebar/index.tsx +++ b/src/pages/blogs/blog-grid-sidebar/index.tsx @@ -27,10 +27,10 @@ const BlogGridSidebar: PageProps = ({ }) => { return ( <> - + { return ( <> - + { return ( <> - + & { + Layout: typeof Layout01; +}; + +const POSTS_PER_PAGE = 9; + +const Media: PageProps = ({ data: { media, currentPage, numberOfPages } }) => { + return ( + <> + + + + + ); +}; + +Media.Layout = Layout01; + +export const getStaticProps: GetStaticProps = () => { + const { media, count } = getAllMedia([ + "title", "image", "description", "tags", "date", "content", "slug", "excerpt", "sortOrder", "type" + ], 0, POSTS_PER_PAGE); + + // Remove duplicates and sort data by sortOrder + const uniqueMedia = Array.from(new Set(media.map(item => item.slug))) + .map(slug => media.find(item => item.slug === slug)) + .filter(item => item !== undefined) // Ensure item is not undefined + .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); + + return { + props: { + data: { + media: uniqueMedia, + currentPage: 1, + numberOfPages: Math.ceil(count / POSTS_PER_PAGE), + }, + layout: { + headerShadow: true, + headerFluid: false, + footerMode: "light", + }, + }, + }; +}; + +export default Media; \ No newline at end of file diff --git a/src/utils/types.ts b/src/utils/types.ts index 08d664fd8..6c62592de 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -308,3 +308,17 @@ export type ApiResponse = { error?: string; message?: string; }; + +// utils/types.ts + +export interface IMedia { + image: string; + title: string; + description?: string; + tags?: { title: string; slug: string; path: string }[]; + date: string; + content: string; + slug: string; + excerpt: string; + path: string; +}