diff --git a/api/src/app/endpoints.ts b/api/src/app/endpoints.ts index 133ff309..875bd244 100644 --- a/api/src/app/endpoints.ts +++ b/api/src/app/endpoints.ts @@ -1,4 +1,9 @@ -import { GetContributionsResponse } from "src/contribution/types"; +import { + GetContributionResponse, + GetContributionsForSitemapResponse, + GetContributionsResponse, + GetContributionTitleResponse, +} from "src/contribution/types"; import { GetContributorNameResponse, GetContributorResponse, @@ -34,6 +39,17 @@ export interface Endpoints { "api:Contributions": { response: GetContributionsResponse; }; + "api:Contributions/:id": { + response: GetContributionResponse; + params: { id: string }; + }; + "api:contributions/:id/title": { + response: GetContributionTitleResponse; + params: { id: string }; + }; + "api:contributions/for-sitemap": { + response: GetContributionsForSitemapResponse; + }; "api:Contributors": { response: GetContributorsResponse; }; diff --git a/api/src/contribution/controller.ts b/api/src/contribution/controller.ts index 22f65106..1db22f44 100644 --- a/api/src/contribution/controller.ts +++ b/api/src/contribution/controller.ts @@ -1,8 +1,13 @@ -import { Controller, Get } from "routing-controllers"; +import { Controller, Get, NotFoundError, Param } from "routing-controllers"; import { Service } from "typedi"; import { ContributionRepository } from "./repository"; -import { GetContributionsResponse } from "./types"; +import { + GetContributionTitleResponse, + GetContributionResponse, + GetContributionsResponse, + GetContributionsForSitemapResponse, +} from "./types"; @Service() @Controller("/Contributions") @@ -17,4 +22,33 @@ export class ContributionController { contributions, }; } + + @Get("/for-sitemap") + public async getContributionsForSitemap(): Promise { + const contributions = await this.contributionRepository.findForSitemap(); + + return { + contributions, + }; + } + + @Get("/:id") + public async getContribution(@Param("id") id: string): Promise { + const contribution = await this.contributionRepository.findByIdWithStats(id); + + return { + contribution, + }; + } + + @Get("/:id/title") + public async getContributionTitle( + @Param("id") id: string, + ): Promise { + const contribution = await this.contributionRepository.findTitle(id); + + if (!contribution) throw new NotFoundError("Contribution not found"); + + return { contribution }; + } } diff --git a/api/src/contribution/repository.ts b/api/src/contribution/repository.ts index 89afbc0e..598b4931 100644 --- a/api/src/contribution/repository.ts +++ b/api/src/contribution/repository.ts @@ -14,6 +14,28 @@ import { ContributionRow, contributionsTable } from "./table"; export class ContributionRepository { constructor(private readonly postgresService: PostgresService) {} + public async findTitle(contributionId: string) { + // todo-ZM: guard against SQL injections in all sql`` statements + const statement = sql` + SELECT + ${contributionsTable.title} + FROM + ${contributionsTable} + WHERE + ${contributionsTable.id} = ${contributionId} + `; + + const raw = await this.postgresService.db.execute(statement); + const entries = Array.from(raw); + const entry = entries[0]; + + if (!entry) return null; + + const unStringifiedRaw = unStringifyDeep(entry); + const camelCased = camelCaseObject(unStringifiedRaw); + return camelCased; + } + public async findForProject(projectId: string) { const statement = sql` SELECT @@ -58,6 +80,22 @@ export class ContributionRepository { return camelCased; } + public async findForSitemap() { + const statement = sql` + SELECT + ${contributionsTable.id}, + ${contributionsTable.title} + FROM + ${contributionsTable} + `; + + const raw = await this.postgresService.db.execute(statement); + const entries = Array.from(raw); + const unStringifiedRaw = unStringifyDeep(entries); + const camelCased = camelCaseObject(unStringifiedRaw); + return camelCased; + } + public async upsert(contribution: ContributionRow) { return await this.postgresService.db .insert(contributionsTable) @@ -148,4 +186,75 @@ export class ContributionRepository { return sortedUpdatedAt; } + + public async findByIdWithStats(id: string) { + const statement = sql` + SELECT + p.id as id, + p.name as name, + json_agg( + json_build_object('id', r.id, 'name', r.name, 'owner', r.owner, 'contributions', r.contributions) + ) AS repositories + FROM + (SELECT + r.id as id, + r.owner as owner, + r.name as name, + r.project_id as project_id, + json_agg( + json_build_object( + 'id', + c.id, + 'title', + c.title, + 'type', + c.type, + 'url', + c.url, + 'updated_at', + c.updated_at, + 'activity_count', + c.activity_count, + 'contributor', + json_build_object( + 'id', + cr.id, + 'name', + cr.name, + 'username', + cr.username, + 'avatar_url', + cr.avatar_url + ) + ) + ) AS contributions + FROM + ${contributionsTable} c + INNER JOIN + ${repositoriesTable} r ON c.repository_id = r.id + INNER JOIN + ${contributorsTable} cr ON c.contributor_id = cr.id + WHERE + c.id = ${id} + GROUP BY + r.id) AS r + INNER JOIN + ${projectsTable} p ON r.project_id = p.id + GROUP BY + p.id + `; + + const raw = await this.postgresService.db.execute(statement); + const entries = Array.from(raw); + const unStringifiedRaw = unStringifyDeep(entries); + + const reversed = reverseHierarchy(unStringifiedRaw, [ + { from: "repositories", setParentAs: "project" }, + { from: "contributions", setParentAs: "repository" }, + ]); + + const camelCased = camelCaseObject(reversed); + + return camelCased[0]; + } } diff --git a/api/src/contribution/types.ts b/api/src/contribution/types.ts index 8c9f54c2..093b6b96 100644 --- a/api/src/contribution/types.ts +++ b/api/src/contribution/types.ts @@ -14,3 +14,23 @@ export interface GetContributionsResponse extends GeneralResponse { } >; } + +export interface GetContributionResponse extends GeneralResponse { + contribution: Pick< + ContributionEntity, + "id" | "title" | "type" | "url" | "updatedAt" | "activityCount" + > & { + repository: Pick & { + project: Pick; + }; + contributor: Pick; + }; +} + +export interface GetContributionTitleResponse extends GeneralResponse { + contribution: Pick; +} + +export interface GetContributionsForSitemapResponse extends GeneralResponse { + contributions: Array>; +} diff --git a/web/cloudflare/functions/ar/contribute/[slug].ts b/web/cloudflare/functions/ar/contribute/[slug].ts new file mode 100644 index 00000000..b006b233 --- /dev/null +++ b/web/cloudflare/functions/ar/contribute/[slug].ts @@ -0,0 +1,3 @@ +import { Env, handleContributionRequest } from "handler/contribution"; + +export const onRequest: PagesFunction = handleContributionRequest; diff --git a/web/cloudflare/functions/contribute/[slug].ts b/web/cloudflare/functions/contribute/[slug].ts new file mode 100644 index 00000000..b006b233 --- /dev/null +++ b/web/cloudflare/functions/contribute/[slug].ts @@ -0,0 +1,3 @@ +import { Env, handleContributionRequest } from "handler/contribution"; + +export const onRequest: PagesFunction = handleContributionRequest; diff --git a/web/cloudflare/functions/w/contributions-sitemap.xml.ts b/web/cloudflare/functions/w/contributions-sitemap.xml.ts new file mode 100644 index 00000000..0c84078b --- /dev/null +++ b/web/cloudflare/functions/w/contributions-sitemap.xml.ts @@ -0,0 +1,56 @@ +import { Env } from "handler/contribution"; +import { environments } from "@dzcode.io/utils/dist/config/environment"; +import { allLanguages, LanguageEntity } from "@dzcode.io/models/dist/language"; +import { getContributionURL } from "@dzcode.io/web/dist/utils/contribution"; +import { fsConfig } from "@dzcode.io/utils/dist/config"; +import { fetchV2Factory } from "@dzcode.io/utils/dist/fetch/factory"; +import { Endpoints } from "@dzcode.io/api/dist/app/endpoints"; + +function xmlEscape(s: string) { + return s.replace( + /[<>&"']/g, + (c) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[c] as string, + ); +} + +export const onRequest: PagesFunction = async (context) => { + let stage = context.env.STAGE; + if (!environments.includes(stage)) { + console.log(`⚠️ No STAGE provided, falling back to "development"`); + stage = "development"; + } + const fullstackConfig = fsConfig(stage); + const fetchV2 = fetchV2Factory(fullstackConfig); + + const { contributions } = await fetchV2("api:contributions/for-sitemap", {}); + + const hostname = "https://www.dzCode.io"; + const links = contributions.reduce<{ url: string; lang: LanguageEntity["code"] }[]>((pV, cV) => { + return [ + ...pV, + ...allLanguages.map(({ baseUrl, code }) => ({ + url: xmlEscape(`${baseUrl}${getContributionURL(cV)}`), + lang: code, + })), + ]; + }, []); + + const xml = ` + + ${links + .map( + (link) => ` + + ${hostname}${link.url} + + `, + ) + .join("")} +`; + + return new Response(xml, { headers: { "content-type": "application/xml; charset=utf-8" } }); +}; diff --git a/web/cloudflare/handler/contribution.ts b/web/cloudflare/handler/contribution.ts new file mode 100644 index 00000000..f9963ac0 --- /dev/null +++ b/web/cloudflare/handler/contribution.ts @@ -0,0 +1,71 @@ +declare const htmlTemplate: string; // @ts-expect-error cloudflare converts this to a string using esbuild +import htmlTemplate from "../public/template.html"; +declare const notFoundEn: string; // @ts-expect-error cloudflare converts this to a string using esbuild +import notFoundEn from "../public/404.html"; +declare const notFoundAr: string; // @ts-expect-error cloudflare converts this to a string using esbuild +import notFoundAr from "../public/ar/404.html"; + +import { Environment, environments } from "@dzcode.io/utils/dist/config/environment"; +import { fsConfig } from "@dzcode.io/utils/dist/config"; +import { plainLocalize } from "@dzcode.io/web/dist/components/locale/utils"; +import { dictionary, AllDictionaryKeys } from "@dzcode.io/web/dist/components/locale/dictionary"; +import { LanguageEntity } from "@dzcode.io/models/dist/language"; +import { fetchV2Factory } from "@dzcode.io/utils/dist/fetch/factory"; +import { Endpoints } from "@dzcode.io/api/dist/app/endpoints"; + +export interface Env { + STAGE: Environment; +} + +export const handleContributionRequest: PagesFunction = async (context) => { + let stage = context.env.STAGE; + if (!environments.includes(stage)) { + console.log(`⚠️ No STAGE provided, falling back to "development"`); + stage = "development"; + } + + const pathName = new URL(context.request.url).pathname; + + const languageRegex = /^\/(ar|en)\//i; + const language = (pathName?.match(languageRegex)?.[1]?.toLowerCase() || + "en") as LanguageEntity["code"]; + const notFound = language === "ar" ? notFoundAr : notFoundEn; + + const contributionIdRegex = /contribute\/(.*)-(.*)-(.*)/; + const contributionId = + pathName?.match(contributionIdRegex)?.[2] + "-" + pathName?.match(contributionIdRegex)?.[3]; + + if (!contributionId) + return new Response(notFound, { + headers: { "content-type": "text/html; charset=utf-8" }, + status: 404, + }); + + const localize = (key: AllDictionaryKeys) => + plainLocalize(dictionary, language, key, "NO-TRANSLATION"); + + const fullstackConfig = fsConfig(stage); + const fetchV2 = fetchV2Factory(fullstackConfig); + + try { + const { contribution } = await fetchV2("api:contributions/:id/title", { + params: { id: contributionId }, + }); + const pageTitle = `${localize("contribution-title-pre")} ${contribution.title} ${localize("contribution-title-post")}`; + + const newData = htmlTemplate + .replace(/{{template-title}}/g, pageTitle) + .replace(/{{template-description}}/g, localize("contribute-description")) + .replace(/{{template-lang}}/g, language); + + return new Response(newData, { headers: { "content-type": "text/html; charset=utf-8" } }); + } catch (error) { + // @TODO-ZM: log error to sentry + console.error(error); + + return new Response(notFound, { + headers: { "content-type": "text/html; charset=utf-8" }, + status: 404, + }); + } +}; diff --git a/web/src/_entry/app.tsx b/web/src/_entry/app.tsx index 7b39681d..b705d333 100644 --- a/web/src/_entry/app.tsx +++ b/web/src/_entry/app.tsx @@ -35,8 +35,11 @@ let routes: Array< }, { pageName: "contribute", - // @TODO-ZM: change this back once we have contribution page - path: "/contribute/:slug?", + path: "/contribute", + }, + { + pageName: "contribute/contribution", + path: "/contribute/*", }, { pageName: "team", diff --git a/web/src/components/contribution-card.tsx b/web/src/components/contribution-card.tsx index e678f740..03fc4b14 100644 --- a/web/src/components/contribution-card.tsx +++ b/web/src/components/contribution-card.tsx @@ -3,19 +3,28 @@ import React from "react"; import { Link } from "src/components/link"; import { useLocale } from "src/components/locale"; import { Markdown } from "src/components/markdown"; +import { getContributionURL } from "src/utils/contribution"; import { getElapsedTime } from "src/utils/elapsed-time"; export function ContributionCard({ contribution, compact = false, + onClick, }: { contribution: GetContributionsResponse["contributions"][number]; compact?: boolean; + onClick?: () => void; }) { const { localize } = useLocale(); return ( -
+

@@ -59,14 +68,9 @@ export function ContributionCard({ {getElapsedTime(contribution.updatedAt, localize("elapsed-time-suffixes"))}

)} - - {contribution.type === "ISSUE" - ? localize("contribute-read-issue") - : localize("contribute-review-changes")} -
- + ); } diff --git a/web/src/components/locale/dictionary.ts b/web/src/components/locale/dictionary.ts index de763ffd..489c6c22 100644 --- a/web/src/components/locale/dictionary.ts +++ b/web/src/components/locale/dictionary.ts @@ -365,6 +365,18 @@ Besides the open tasks on [/Contribute](/Contribute) page, you can also contribu en: "Review changes", ar: "مراجعة التغييرات", }, + "contribution-title-pre": { + en: "Help with: ", + ar: "ساعد في: ", + }, + "contribution-title-post": { + en: " | DzCode i/o", + ar: " | DzCode i / o", + }, + "contribution-breadcrumbs-1": { + en: "Contributions", + ar: "المساهمات", + }, "elapsed-time-suffixes": { en: "y|mo|d|h|min|Just now", ar: " عام| شهر| يوم| ساعة| دقيقة| الآن", diff --git a/web/src/components/project-card.tsx b/web/src/components/project-card.tsx index 0a2bc67a..41be808c 100644 --- a/web/src/components/project-card.tsx +++ b/web/src/components/project-card.tsx @@ -17,6 +17,7 @@ export function ProjectCard({ href={getProjectURL(project)} dir="ltr" className="bg-base-300 w-full max-w-xs sm:max-w-sm flex flex-col rounded-lg border-base-200 border-2 overflow-hidden" + // TODO-OB: there's a bug here: when passing onClick to Link, the link no longer work as a SPA link, and instead causes a full reload of the page onClick={onClick} >

{project.name}

diff --git a/web/src/components/search.tsx b/web/src/components/search.tsx index 746ed78e..e5a91c4f 100644 --- a/web/src/components/search.tsx +++ b/web/src/components/search.tsx @@ -132,7 +132,12 @@ export function Search(): JSX.Element {
{contributionsList.map((contribution) => ( - + ))}
diff --git a/web/src/pages/contribute/contribution/index.tsx b/web/src/pages/contribute/contribution/index.tsx new file mode 100644 index 00000000..1d2d19e3 --- /dev/null +++ b/web/src/pages/contribute/contribution/index.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useMemo } from "react"; +import { useAppDispatch, useAppSelector } from "src/redux/store"; +import { useParams } from "react-router-dom"; +import { Redirect } from "src/components/redirect"; +import { fetchContributionAction } from "src/redux/actions/contribution"; +import { Helmet } from "react-helmet-async"; +import { Locale, useLocale } from "src/components/locale"; +import { getContributionURL } from "src/utils/contribution"; +import { Link } from "src/components/link"; +import { TryAgain } from "src/components/try-again"; +import { Loading } from "src/components/loading"; +import { Markdown } from "src/components/markdown"; +import { getElapsedTime } from "src/utils/elapsed-time"; + +// ts-prune-ignore-next +export default function Page(): JSX.Element { + const { localize } = useLocale(); + const { contribution } = useAppSelector((state) => state.contributionPage); + const dispatch = useAppDispatch(); + const { "*": contributionSlug } = useParams<{ "*": string }>(); + const contributionId = useMemo(() => { + // slug: [title slug]-[id: [provider]-[number]] + const id = contributionSlug?.split("-").slice(-2).join("-"); + return id; + }, [contributionSlug]); + + useEffect(() => { + dispatch(fetchContributionAction(contributionId)); + }, [dispatch, contributionId]); + + if (contribution === "404") { + return ; + } + + return ( +
+ {contribution !== "ERROR" && contribution !== null ? ( + + + {localize("contribution-title-pre")} {contribution.title}{" "} + {localize("contribution-title-post")} + + + {/* @TODO-ZM: add canonical url on all pages */} + + + ) : null} +
+
    +
  • + + + +
  • + {contribution !== "ERROR" && contribution !== null ?
  • {contribution.title}
  • : null} +
+
+
+ {contribution === "ERROR" ? ( + { + dispatch(fetchContributionAction(contributionId)); + }} + /> + ) : contribution === null ? ( + + ) : ( +
+ {/* TODO-ZM: more tailored design for /contribute/:slug page instead of copy-pasting components from /contribute */} +
+
+
+

+ +

+ + {contribution.repository.project.name} + + {contribution.repository.owner}/{contribution.repository.name} + +
+ +
+ {contribution.activityCount > 0 && ( +
+ + + + {contribution.activityCount} +
+ )} +
+ {getElapsedTime(contribution.updatedAt, localize("elapsed-time-suffixes"))} +
+ + {contribution.type === "ISSUE" + ? localize("contribute-read-issue") + : localize("contribute-review-changes")} + +
+
+
+
+
+ )} +
+
+ ); +} diff --git a/web/src/redux/actions/contribution.ts b/web/src/redux/actions/contribution.ts new file mode 100644 index 00000000..47626ccd --- /dev/null +++ b/web/src/redux/actions/contribution.ts @@ -0,0 +1,22 @@ +import { Action, ThunkAction } from "@reduxjs/toolkit"; +import { captureException } from "@sentry/react"; +import { contributionPageSlice } from "src/redux/slices/contribution-page"; +import { AppState } from "src/redux/store"; +import { fetchV2 } from "src/utils/fetch"; + +export const fetchContributionAction = + (id?: string): ThunkAction => + async (dispatch) => { + if (!id) { + dispatch(contributionPageSlice.actions.set({ contribution: "404" })); + return; + } + try { + dispatch(contributionPageSlice.actions.set({ contribution: null })); + const { contribution } = await fetchV2("api:Contributions/:id", { params: { id } }); + dispatch(contributionPageSlice.actions.set({ contribution })); + } catch (error) { + dispatch(contributionPageSlice.actions.set({ contribution: "ERROR" })); + captureException(error, { tags: { type: "WEB_FETCH" } }); + } + }; diff --git a/web/src/redux/slices/contribution-page.ts b/web/src/redux/slices/contribution-page.ts new file mode 100644 index 00000000..d74ae31c --- /dev/null +++ b/web/src/redux/slices/contribution-page.ts @@ -0,0 +1,21 @@ +import { GetContributionResponse } from "@dzcode.io/api/dist/contribution/types"; +import { createSlice } from "@reduxjs/toolkit"; +import { setReducerFactory } from "src/redux/utils"; +import { Loadable } from "src/utils/loadable"; + +// ts-prune-ignore-next +export interface ContributionPageState { + contribution: Loadable; +} + +const initialState: ContributionPageState = { + contribution: null, +}; + +export const contributionPageSlice = createSlice({ + name: "contribution-page", + initialState, + reducers: { + set: setReducerFactory(), + }, +}); diff --git a/web/src/redux/store.tsx b/web/src/redux/store.tsx index ebb93ae2..0fd35902 100644 --- a/web/src/redux/store.tsx +++ b/web/src/redux/store.tsx @@ -4,6 +4,7 @@ import { PropsWithChildren, useState } from "react"; import { Provider as ReduxProvider, useDispatch, useSelector } from "react-redux"; import { contributionsPageSlice } from "./slices/contributions-page"; +import { contributionPageSlice } from "./slices/contribution-page"; import { contributorsPageSlice } from "./slices/contributors-page"; import { landingPageSlice } from "./slices/landing-page"; import { projectsPageSlice } from "./slices/projects-page"; @@ -20,6 +21,7 @@ const makeAppStore = () => { contributorsPage: contributorsPageSlice.reducer, contributorPage: contributorPageSlice.reducer, contributionsPage: contributionsPageSlice.reducer, + contributionPage: contributionPageSlice.reducer, landingPage: landingPageSlice.reducer, }, });