mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(client): exam environment download page (#57325)
Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
@@ -38,6 +38,7 @@
|
||||
"news": "News",
|
||||
"donate": "Donate",
|
||||
"supporters": "Supporters",
|
||||
"exam-app": "Exam App",
|
||||
"go-to-supporters": "Go to Supporters Page",
|
||||
"update-settings": "Update my account settings",
|
||||
"sign-me-out": "Sign me out of freeCodeCamp",
|
||||
@@ -114,7 +115,9 @@
|
||||
"closed-caption": "Closed caption",
|
||||
"share-on-x": "Share on X",
|
||||
"share-on-bluesky": "Share on BlueSky",
|
||||
"share-on-threads": "Share on Threads"
|
||||
"share-on-threads": "Share on Threads",
|
||||
"play-scene": "Press Play",
|
||||
"download-latest-version": "Download the Latest Version"
|
||||
},
|
||||
"landing": {
|
||||
"big-heading-1": "Learn to code — for free.",
|
||||
@@ -345,6 +348,14 @@
|
||||
"p7": "As an academic institution that grants achievement-based certifications, we take academic honesty very seriously. If you have any questions about this policy, or suspect that someone has violated it, you can email <0>{{email}}</0> and we will investigate."
|
||||
}
|
||||
},
|
||||
"exam": {
|
||||
"download-header": "Download the freeCodeCamp Exam Environment App",
|
||||
"explanation": "To earn a certification, you must take an exam to test your understanding of the material you have learned. Taking the exam is absolutely free of charge.",
|
||||
"version": "The latest version of our app is: {{version}}.",
|
||||
"download-details": "Manually download the app",
|
||||
"unable-to-detect-os": "We were unable to detect your operating system. Please manually download the app below.",
|
||||
"download-trouble": "If you have trouble downloading the correct version, do not hesitate to contact support:"
|
||||
},
|
||||
"profile": {
|
||||
"you-change-privacy": "You need to change your privacy setting in order for your portfolio to be seen by others. This is a preview of how your portfolio will look when made public.",
|
||||
"username-change-privacy": "{{username}} needs to change their privacy setting in order for you to view their portfolio.",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Spacer } from '@freecodecamp/ui';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FullWidthRow } from '../../../components/helpers';
|
||||
import useDetectOS from '../utils/use-detect-os';
|
||||
|
||||
interface GitProps {
|
||||
tag_name: string;
|
||||
assets: {
|
||||
browser_download_url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
function ShowExamDownload(): JSX.Element {
|
||||
const [latestVersion, setLatestVersion] = useState<string | null>(null);
|
||||
|
||||
const [downloadLink, setDownloadLink] = useState<string | undefined>('');
|
||||
const [downloadLinks, setDownloadLinks] = useState<string[]>([]);
|
||||
|
||||
const os = useDetectOS();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleDownloadLink = (downloadLinks: string[]) => {
|
||||
const win = downloadLinks.find(link => link.match(/\.msi/));
|
||||
const macARM = downloadLinks.find(
|
||||
link => link.match(/aarch64/) && link.match(/\.dmg/)
|
||||
);
|
||||
const macX64 = downloadLinks.find(
|
||||
link => link.match(/x64/) && link.match(/\.dmg/)
|
||||
);
|
||||
|
||||
const linuxARM = downloadLinks.find(
|
||||
link => link.match(/aarch64/) && link.match(/tar\.gz/)
|
||||
);
|
||||
|
||||
const linuxX64 = downloadLinks.find(
|
||||
link => link.match(/amd64/) && link.match(/AppImage/)
|
||||
);
|
||||
|
||||
if (os.os === 'WIN') {
|
||||
if (isEmpty(win)) return '';
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
if (os.os === 'MAC') {
|
||||
if (os.architecture.toLowerCase() === 'arm') {
|
||||
if (isEmpty(macARM)) return '';
|
||||
|
||||
return macARM;
|
||||
} else {
|
||||
if (isEmpty(macX64)) return '';
|
||||
|
||||
return macX64;
|
||||
}
|
||||
}
|
||||
|
||||
if (os.os === 'LINUX') {
|
||||
if (os.architecture.toLowerCase() === 'arm') {
|
||||
if (isEmpty(linuxARM)) return '';
|
||||
|
||||
return linuxARM;
|
||||
} else {
|
||||
if (isEmpty(linuxX64)) return '';
|
||||
|
||||
return linuxX64;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkLatestVersion = async () => {
|
||||
return await fetch(
|
||||
'https://api.github.com/repos/freeCodeCamp/exam-env/releases/latest'
|
||||
);
|
||||
};
|
||||
checkLatestVersion()
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
void response.json().then(data => {
|
||||
const { tag_name, assets } = data as GitProps;
|
||||
setLatestVersion(tag_name);
|
||||
|
||||
setDownloadLink(
|
||||
handleDownloadLink(
|
||||
assets.map(links => links.browser_download_url)
|
||||
)
|
||||
);
|
||||
|
||||
setDownloadLinks(assets.map(links => links.browser_download_url));
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setLatestVersion('...');
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<FullWidthRow>
|
||||
<Spacer size='l' />
|
||||
<h2>{t('exam.download-header')}</h2>
|
||||
<p>{t('exam.explanation')}</p>
|
||||
<p>
|
||||
{t('exam.version', {
|
||||
version: latestVersion || '...'
|
||||
})}
|
||||
</p>
|
||||
<Button
|
||||
disabled={!downloadLink}
|
||||
aria-disabled={!downloadLink}
|
||||
href={downloadLink}
|
||||
download={downloadLink}
|
||||
>
|
||||
{t('buttons.download-latest-version')}
|
||||
</Button>
|
||||
{!downloadLink && <strong>{t('exam.unable-to-detect-os')}</strong>}
|
||||
<Spacer size='l' />
|
||||
<details>
|
||||
<summary>{t('exam.download-details')}</summary>
|
||||
<ul>
|
||||
{downloadLinks
|
||||
.filter(link => !link.match(/\.sig|\.json/))
|
||||
.map((link, index) => {
|
||||
return (
|
||||
<li key={index} style={{ listStyle: 'none' }}>
|
||||
<a href={link} download={link}>
|
||||
{link}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</details>
|
||||
<Spacer size='l' />
|
||||
<strong>{t('exam.download-trouble')}</strong>
|
||||
<a href='mailto: support@freecodecamp.org'>support@freecodecamp.org</a>
|
||||
</FullWidthRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShowExamDownload;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type UserOS = 'MAC' | 'WIN' | 'LINUX' | 'AIX' | 'OTHER' | 'LOADING';
|
||||
|
||||
type UserOSState = {
|
||||
os: UserOS;
|
||||
architecture: string;
|
||||
};
|
||||
|
||||
interface NavigatorUAData {
|
||||
getHighEntropyValues(hints: string[]): Promise<Record<string, string>>;
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
userAgentData?: NavigatorUAData;
|
||||
}
|
||||
|
||||
const getArchitecture = async (): Promise<string> => {
|
||||
if ((navigator as Navigator).userAgentData?.getHighEntropyValues) {
|
||||
try {
|
||||
const { architecture } =
|
||||
(await (navigator as Navigator).userAgentData?.getHighEntropyValues([
|
||||
'architecture'
|
||||
])) ?? {};
|
||||
if (architecture) {
|
||||
return architecture;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error fetching architecture via User-Agent Client Hints:',
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userAgent = navigator.userAgent;
|
||||
|
||||
if (/x86_64|Win64|WOW64|amd64/.test(userAgent)) {
|
||||
return 'x86_64';
|
||||
} else if (/i686|x86|Win32/.test(userAgent)) {
|
||||
return 'x86';
|
||||
} else if (/arm|aarch64/.test(userAgent)) {
|
||||
return /aarch64/.test(userAgent) ? 'ARM64' : 'ARM';
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
};
|
||||
|
||||
export const detectOsInUserAgent = (userAgent: string | undefined): UserOS => {
|
||||
const osMatch = userAgent?.match(/(Win|Mac|Linux)/);
|
||||
return osMatch ? (osMatch[1].toUpperCase() as UserOS) : 'OTHER';
|
||||
};
|
||||
|
||||
export const detectOS = (): UserOS => detectOsInUserAgent(navigator?.userAgent);
|
||||
|
||||
const useDetectOS = () => {
|
||||
const [userOS, setUserOS] = useState<UserOSState>({
|
||||
os: 'LOADING',
|
||||
architecture: 'LOADING'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getArchitecture()])
|
||||
.then(([arch]) => {
|
||||
setUserOS({
|
||||
os: detectOS(),
|
||||
architecture: arch ? arch : ''
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
return userOS;
|
||||
};
|
||||
|
||||
export default useDetectOS;
|
||||
@@ -50,6 +50,11 @@ const generic = path.resolve(
|
||||
'../../src/templates/Challenges/generic/show.tsx'
|
||||
);
|
||||
|
||||
const examDownload = path.resolve(
|
||||
__dirname,
|
||||
'../../src/templates/Challenges/exam-download/show.tsx'
|
||||
);
|
||||
|
||||
const views = {
|
||||
backend,
|
||||
classic,
|
||||
@@ -60,7 +65,8 @@ const views = {
|
||||
exam,
|
||||
msTrophy,
|
||||
fillInTheBlank,
|
||||
generic
|
||||
generic,
|
||||
examDownload
|
||||
};
|
||||
|
||||
function getIsFirstStepInBlock(id, edges) {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: 645147516c245de4d11eb7ba
|
||||
title: Certified Full Stack Developer Exam
|
||||
challengeType: 24
|
||||
challengeType: 30
|
||||
dashedName: exam-certified-full-stack-developer
|
||||
---
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ const schema = Joi.object()
|
||||
otherwise: Joi.optional()
|
||||
}),
|
||||
certification: Joi.string().regex(slugWithSlashRE),
|
||||
challengeType: Joi.number().min(0).max(29).required(),
|
||||
challengeType: Joi.number().min(0).max(30).required(),
|
||||
checksum: Joi.number(),
|
||||
// TODO: require this only for normal challenges, not certs
|
||||
dashedName: Joi.string().regex(slugRE),
|
||||
|
||||
@@ -29,6 +29,7 @@ const jsLab = 26;
|
||||
const pyLab = 27;
|
||||
const dailyChallengeJs = 28;
|
||||
const dailyChallengePy = 29;
|
||||
const examDownload = 30;
|
||||
|
||||
export const challengeTypes = {
|
||||
html,
|
||||
@@ -61,7 +62,8 @@ export const challengeTypes = {
|
||||
jsLab,
|
||||
pyLab,
|
||||
dailyChallengeJs,
|
||||
dailyChallengePy
|
||||
dailyChallengePy,
|
||||
examDownload
|
||||
};
|
||||
|
||||
export const hasNoSolution = (challengeType: number): boolean => {
|
||||
@@ -84,7 +86,8 @@ export const hasNoSolution = (challengeType: number): boolean => {
|
||||
multipleChoice,
|
||||
dialogue,
|
||||
fillInTheBlank,
|
||||
generic
|
||||
generic,
|
||||
examDownload
|
||||
];
|
||||
|
||||
return noSolutions.includes(challengeType);
|
||||
@@ -120,7 +123,8 @@ export const viewTypes = {
|
||||
[jsLab]: 'classic',
|
||||
[pyLab]: 'classic',
|
||||
[dailyChallengeJs]: 'classic',
|
||||
[dailyChallengePy]: 'classic'
|
||||
[dailyChallengePy]: 'classic',
|
||||
[examDownload]: 'examDownload'
|
||||
};
|
||||
|
||||
// determine the type of submit function to use for the challenge on completion
|
||||
@@ -157,7 +161,8 @@ export const submitTypes = {
|
||||
[jsLab]: 'tests',
|
||||
[pyLab]: 'tests',
|
||||
[dailyChallengeJs]: 'tests',
|
||||
[dailyChallengePy]: 'tests'
|
||||
[dailyChallengePy]: 'tests',
|
||||
[examDownload]: 'examDownload'
|
||||
};
|
||||
|
||||
export const canSaveToDB = (challengeType: number): boolean =>
|
||||
|
||||
Reference in New Issue
Block a user