+
+
+ {intl.formatMessage(messages.scanHeader)}
+
+ {
+ setShowLockedLinks(!showLockedLinks);
+ }}
+ label={intl.formatMessage(messages.lockedCheckboxLabel)}
+ />
+
+
+
+
+
+ {sections?.map((section, index) => (
+
+ {section.subsections.map((subsection) => (
+ <>
+
+ {subsection.displayName}
+
+ {subsection.units.map((unit) => (
+
+
+
+ ))}
+ >
+ ))}
+
+ ))}
+
+ );
+};
+
+export default ScanResults;
diff --git a/src/optimizer-page/scan-results/ScanResults.scss b/src/optimizer-page/scan-results/ScanResults.scss
new file mode 100644
index 0000000000..6a3e947657
--- /dev/null
+++ b/src/optimizer-page/scan-results/ScanResults.scss
@@ -0,0 +1,107 @@
+.scan-results {
+ thead {
+ display: none;
+ }
+
+ .red-italics {
+ color: $brand-500;
+ margin-left: 2rem;
+ font-weight: 400;
+ font-size: 80%;
+ font-style: italic;
+ }
+
+ .section {
+ &.is-open {
+ &:not(:first-child) {
+ margin-top: 1rem;
+ }
+ margin-bottom: 1rem;
+ }
+ }
+
+ .open-arrow {
+ transform: translate(-10px, 5px);
+ display: inline-block;
+ }
+
+ /* Section Header */
+ .subsection-header {
+ font-size: 16px; /* Slightly smaller */
+ font-weight: 600; /* Reduced boldness */
+ background-color: rgb(248, 247, 246); /* Subtle gray background */
+ padding: 10px;
+ margin-bottom: 10px;
+ }
+
+ /* Subsection Header */
+ .unit-header {
+ font-size: 16px; /* Slightly smaller than Section */
+ font-weight: 500;
+ margin-left: .5rem;
+ margin-top: 10px;
+ color: #555;
+ }
+
+ /* Unit Header */
+ .unit-header {
+ font-size: 14px;
+ font-weight: 700;
+ margin-bottom: 5px;
+ }
+
+ /* Block Links */
+ .broken-link-list li {
+ margin-bottom: 8px; /* Add breathing room */
+ }
+
+ .broken-link-list a {
+ text-decoration: none;
+ margin-left: 2rem;
+ }
+
+ /* Broken Links Highlight */
+ .broken-links-count {
+ color: red;
+ font-weight: bold;
+ }
+
+ .unit {
+ padding: 0 3rem;
+ }
+
+ .broken-link {
+ color: $brand-500;
+ text-decoration: none;
+ }
+
+ .broken-link-container {
+ max-width: 18rem;
+ text-wrap: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .locked-links-checkbox {
+ margin-top: 0.45rem;
+ }
+
+ .locked-links-checkbox-wrapper {
+ display: flex;
+ gap: 1rem;
+ }
+
+ .link-status-text {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
+ .broken-link-icon {
+ color: $brand-500;
+ }
+
+ .lock-icon {
+ color: $warning-300;
+ }
+}
diff --git a/src/optimizer-page/scan-results/index.js b/src/optimizer-page/scan-results/index.js
new file mode 100644
index 0000000000..6edc85d9ea
--- /dev/null
+++ b/src/optimizer-page/scan-results/index.js
@@ -0,0 +1,4 @@
+import ScanResults from './ScanResults';
+
+// eslint-disable-next-line import/prefer-default-export
+export { ScanResults };
diff --git a/src/optimizer-page/scan-results/messages.js b/src/optimizer-page/scan-results/messages.js
new file mode 100644
index 0000000000..9e29a83cf2
--- /dev/null
+++ b/src/optimizer-page/scan-results/messages.js
@@ -0,0 +1,42 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ pageTitle: {
+ id: 'course-authoring.course-optimizer.page.title',
+ defaultMessage: '{headingTitle} | {courseName} | {siteName}',
+ },
+ noDataCard: {
+ id: 'course-authoring.course-optimizer.noDataCard',
+ defaultMessage: 'No Scan data available',
+ },
+ noBrokenLinksCard: {
+ id: 'course-authoring.course-optimizer.emptyResultsCard',
+ defaultMessage: 'No broken links found',
+ },
+ scanHeader: {
+ id: 'course-authoring.course-optimizer.scanHeader',
+ defaultMessage: 'Broken Links Scan',
+ },
+ lockedCheckboxLabel: {
+ id: 'course-authoring.course-optimizer.lockedCheckboxLabel',
+ defaultMessage: 'Show Locked Course Files',
+ },
+ brokenLinksNumber: {
+ id: 'course-authoring.course-optimizer.brokenLinksNumber',
+ defaultMessage: '{count} broken links',
+ },
+ lockedInfoTooltip: {
+ id: 'course-authoring.course-optimizer.lockedInfoTooltip',
+ defaultMessage: 'These course files are "locked", so we cannot test whether they work or not.',
+ },
+ brokenLinkStatus: {
+ id: 'course-authoring.course-optimizer.brokenLinkStatus',
+ defaultMessage: 'Status: Broken',
+ },
+ lockedLinkStatus: {
+ id: 'course-authoring.course-optimizer.lockedLinkStatus',
+ defaultMessage: 'Status: Locked',
+ },
+});
+
+export default messages;
diff --git a/src/optimizer-page/types.ts b/src/optimizer-page/types.ts
new file mode 100644
index 0000000000..f6e2a1a456
--- /dev/null
+++ b/src/optimizer-page/types.ts
@@ -0,0 +1,26 @@
+interface Unit {
+ id: string;
+ displayName: string;
+ blocks: {
+ id: string;
+ url: string;
+ brokenLinks: string[];
+ lockedLinks: string[];
+ }[];
+}
+
+interface SubSection {
+ id: string;
+ displayName: string;
+ units: Unit[];
+}
+
+interface Section {
+ id: string;
+ displayName: string;
+ subsections: SubSection[];
+}
+
+export interface LinkCheckResult {
+ sections: Section[];
+}
diff --git a/src/store.js b/src/store.js
index bf761aadf7..e979d8591d 100644
--- a/src/store.js
+++ b/src/store.js
@@ -18,6 +18,7 @@ import { reducer as CourseUpdatesReducer } from './course-updates/data/slice';
import { reducer as processingNotificationReducer } from './generic/processing-notification/data/slice';
import { reducer as helpUrlsReducer } from './help-urls/data/slice';
import { reducer as courseExportReducer } from './export-page/data/slice';
+import { reducer as courseOptimizerReducer } from './optimizer-page/data/slice';
import { reducer as genericReducer } from './generic/data/slice';
import { reducer as courseImportReducer } from './import-page/data/slice';
import { reducer as videosReducer } from './files-and-videos/videos-page/data/slice';
@@ -47,6 +48,7 @@ export default function initializeStore(preloadedState = undefined) {
processingNotification: processingNotificationReducer,
helpUrls: helpUrlsReducer,
courseExport: courseExportReducer,
+ courseOptimizer: courseOptimizerReducer,
generic: genericReducer,
courseImport: courseImportReducer,
videos: videosReducer,