Skip to content

Commit d03f218

Browse files
authored
Merge pull request #1380 from loic-brtd/1319-hash-based-challenge-parts-navigation
Hash-based challenge parts navigation
2 parents c9fc98a + 9e88428 commit d03f218

File tree

6 files changed

+89
-52
lines changed

6 files changed

+89
-52
lines changed

src/components/challenges/PartsTimeline.js

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
1-
import React, { memo, useState } from 'react';
1+
import React, { memo } from 'react';
22
import cn from 'classnames';
33

44
import * as css from './PartsTimeline.module.css';
55
import { Link } from 'gatsby';
6+
import { buildPartHash } from '../../utils';
67

7-
const PartsTimeline = ({ className, parts, onPartChange }) => {
8-
const [currentPartIndex, setCurrentPartIndex] = useState(0);
9-
10-
const updatePartIndex = (index) => {
11-
onPartChange(parts[index]);
12-
setCurrentPartIndex(index);
13-
};
14-
8+
const PartsTimeline = ({
9+
className,
10+
parts,
11+
currentPartIndex,
12+
onSelection = () => {}
13+
}) => {
1514
return (
1615
<div className={cn(css.root, className)}>
1716
<div className={css.partsTimeline}>
@@ -23,12 +22,7 @@ const PartsTimeline = ({ className, parts, onPartChange }) => {
2322
[css.seen]: index <= currentPartIndex,
2423
[css.last]: index === currentPartIndex
2524
})}>
26-
<Link
27-
to="#"
28-
onClick={(event) => {
29-
event.preventDefault();
30-
updatePartIndex(index);
31-
}}>
25+
<Link to={buildPartHash(index)} onClick={onSelection}>
3226
{part.title}
3327
</Link>
3428
</li>
@@ -38,23 +32,17 @@ const PartsTimeline = ({ className, parts, onPartChange }) => {
3832
<div className={css.navigation}>
3933
{currentPartIndex > 0 && (
4034
<Link
41-
to="#"
35+
to={buildPartHash(currentPartIndex - 1)}
4236
className={css.navButton}
43-
onClick={(event) => {
44-
event.preventDefault();
45-
updatePartIndex(currentPartIndex - 1);
46-
}}>
37+
onClick={onSelection}>
4738
Previous
4839
</Link>
4940
)}
5041
{currentPartIndex < parts.length - 1 && (
5142
<Link
52-
to="#"
43+
to={buildPartHash(currentPartIndex + 1)}
5344
className={css.navButton}
54-
onClick={(event) => {
55-
event.preventDefault();
56-
updatePartIndex(currentPartIndex + 1);
57-
}}>
45+
onClick={onSelection}>
5846
Next
5947
</Link>
6048
)}

src/components/challenges/VideoSection.js

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import PartsTimeline from './PartsTimeline';
99
import NebulaVideoRow from '../NebulaVideoRow';
1010

1111
import { filteredPath } from '../../utils';
12+
import { useChallengePartIndex } from '../../hooks';
1213

1314
import * as css from './VideoSection.module.css';
1415

@@ -23,9 +24,8 @@ const VideoSection = ({ challenge }) => {
2324
const hasMultiParts = challenge.parts?.length > 0;
2425
const [showTimestamps, setShowTimestamps] = useState(!hasMultiParts);
2526

26-
const [activePart, setActivePart] = useState(
27-
challenge.parts?.[0] ?? challenge
28-
);
27+
const activePartIndex = useChallengePartIndex(challenge.parts?.length || 1);
28+
const activePart = challenge.parts?.[activePartIndex] ?? challenge;
2929
const { videoId, nebulaSlug, timestamps } = activePart;
3030
const hasTimestamps = timestamps?.length > 0;
3131
const hasTimeline = hasMultiParts || hasTimestamps;
@@ -147,10 +147,8 @@ const VideoSection = ({ challenge }) => {
147147
[css.hide]: showTimestamps
148148
})}
149149
parts={challenge.parts}
150-
onPartChange={(part) => {
151-
setActivePart(part);
152-
setShowTimeline(false);
153-
}}
150+
currentPartIndex={activePartIndex}
151+
onSelection={() => setShowTimeline(false)}
154152
/>
155153
)}
156154
{hasTimestamps && (

src/components/tracks/OverviewTimeline.js

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import React, { memo, useState } from 'react';
22
import cn from 'classnames';
33
import { Link } from 'gatsby';
44

5-
import { useChallengePartIndex, usePersistScrollPosition } from '../../hooks';
5+
import { usePersistScrollPosition } from '../../hooks';
6+
import { buildPartHash } from '../../utils';
67

78
import * as css from './OverviewTimeline.module.css';
89

@@ -14,20 +15,35 @@ const usePaths = (chapters, track, trackPosition) => {
1415
? video.parts.map((_, partIndex) => ({ slug: video.slug, partIndex }))
1516
: [{ slug: video.slug, partIndex: 0 }]
1617
);
17-
const partIndex = useChallengePartIndex();
1818
const currentVideo =
1919
chapters[trackPosition.chapterIndex].videos[trackPosition.videoIndex];
2020
const currentIndex = flatTrack.findIndex(
21-
(video) => video.slug === currentVideo.slug && video.partIndex === partIndex
21+
(video) =>
22+
video.slug === currentVideo.slug &&
23+
video.partIndex === trackPosition.partIndex
2224
);
2325
const prevVideo = flatTrack[currentIndex - 1];
2426
const nextVideo = flatTrack[currentIndex + 1];
25-
const computePath = (video) =>
26-
video ? { ...video, path: `/tracks/${track.slug}/${video.slug}` } : null;
27+
const computePath = (video) => {
28+
if (video) {
29+
const hash = buildPartHash(video.partIndex);
30+
return {
31+
...video,
32+
path: `/tracks/${track.slug}/${video.slug}${hash}`
33+
};
34+
}
35+
return null;
36+
};
2737
return [computePath(prevVideo), computePath(nextVideo)];
2838
};
2939

30-
const OverviewTimeline = ({ className, chapters, track, trackPosition }) => {
40+
const OverviewTimeline = ({
41+
className,
42+
chapters,
43+
track,
44+
trackPosition,
45+
onSelection = () => {}
46+
}) => {
3147
const [previousVideo, nextVideo] = usePaths(chapters, track, trackPosition);
3248

3349
const timelineRef = usePersistScrollPosition(track.slug, 'tracks');
@@ -42,6 +58,7 @@ const OverviewTimeline = ({ className, chapters, track, trackPosition }) => {
4258
chapters={chapters}
4359
track={track}
4460
trackPosition={trackPosition}
61+
onSelection={onSelection}
4562
/>
4663
))}
4764
</div>
@@ -50,15 +67,15 @@ const OverviewTimeline = ({ className, chapters, track, trackPosition }) => {
5067
<Link
5168
className={css.navButton}
5269
to={previousVideo.path}
53-
state={{ challengePartIndex: previousVideo.partIndex }}>
70+
onClick={onSelection}>
5471
Previous
5572
</Link>
5673
)}
5774
{nextVideo !== null && (
5875
<Link
5976
className={css.navButton}
6077
to={nextVideo.path}
61-
state={{ challengePartIndex: nextVideo.partIndex }}>
78+
onClick={onSelection}>
6279
Next
6380
</Link>
6481
)}
@@ -68,14 +85,13 @@ const OverviewTimeline = ({ className, chapters, track, trackPosition }) => {
6885
};
6986

7087
const ChapterSection = memo(
71-
({ chapter, chapterIndex, chapters, track, trackPosition }) => {
88+
({ chapter, chapterIndex, chapters, track, trackPosition, onSelection }) => {
7289
const hasSeenChapter = chapterIndex < trackPosition.chapterIndex;
7390
const isThisChapter = chapterIndex === trackPosition.chapterIndex;
7491
const trackPath = `/tracks/${track.slug}`;
7592
const [collapsed, setCollapsed] = useState(false);
7693

7794
const { videoIndex: currentVideoIndex } = trackPosition;
78-
const currentPartIndex = useChallengePartIndex();
7995

8096
return (
8197
<ul className={css.chapterList}>
@@ -105,6 +121,7 @@ const ChapterSection = memo(
105121

106122
return isMultiPart ? (
107123
video.parts.map((part, partIndex) => {
124+
const currentPartIndex = trackPosition.partIndex;
108125
const hasSeenPart =
109126
hasSeenVideo &&
110127
(videoIndex < currentVideoIndex ||
@@ -117,8 +134,10 @@ const ChapterSection = memo(
117134
[css.last]: isLastVideo && partIndex === currentPartIndex
118135
})}>
119136
<Link
120-
to={`${trackPath}/${video.slug}`}
121-
state={{ challengePartIndex: partIndex }}>
137+
to={`${trackPath}/${video.slug}${buildPartHash(
138+
partIndex
139+
)}`}
140+
onClick={onSelection}>
122141
{video.title} - {part.title}
123142
</Link>
124143
</li>
@@ -131,7 +150,9 @@ const ChapterSection = memo(
131150
[css.seen]: hasSeenVideo,
132151
[css.last]: isLastVideo
133152
})}>
134-
<Link to={`${trackPath}/${video.slug}`}>{video.title}</Link>
153+
<Link to={`${trackPath}/${video.slug}`} onClick={onSelection}>
154+
{video.title}
155+
</Link>
135156
</li>
136157
);
137158
})}

src/components/tracks/VideoSection.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ const VideoSection = ({ track, video, trackPosition, mainTitle }) => {
3535

3636
const { title, topics, languages } = video;
3737

38-
const partIndex = useChallengePartIndex();
38+
const partIndex = useChallengePartIndex(video.parts?.length || 1);
39+
trackPosition = { ...trackPosition, partIndex };
3940
const part = video.parts?.[partIndex];
4041
const videoId = part?.videoId ?? video.videoId;
4142
const nebulaSlug = part?.nebulaSlug ?? video.nebulaSlug;
@@ -179,6 +180,7 @@ const VideoSection = ({ track, video, trackPosition, mainTitle }) => {
179180
chapters={chapters}
180181
track={track}
181182
trackPosition={trackPosition}
183+
onSelection={() => setShowTimeline(false)}
182184
/>
183185
)}
184186
</div>

src/hooks/index.js

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,13 +139,27 @@ export const useIsFirstRender = () => {
139139
};
140140

141141
/**
142-
* Returns the challenge part index (0 if the challenge is not multi-part)
143-
* which has been stored in the `location.state` object using the `Link.state`
144-
* property.
142+
* If the current URL hash value (fragment) matches a part number (from a
143+
* multi-part coding challenge), this hook returns the zero-based index of this
144+
* part.
145+
*
146+
* If the hash value doesn't match the format `#part-{partNumber}` where
147+
* `1 <= partNumber <= partsCount`, this hook returns 0.
148+
*
149+
* @param partsCount {number} total number of parts of the challenge (1 if
150+
* the challenge is not multi-part)
145151
*
146152
* @returns {number} challenge part index
153+
*
154+
* @example
155+
* with hash "#part-1", useChallengePartIndex(3) === 0
156+
* with hash "#part-3", useChallengePartIndex(3) === 2
157+
* with hash "#part-8", useChallengePartIndex(2) === 0;
158+
* with hash "#part-abc", useChallengePartIndex(3) === 0
147159
*/
148-
export const useChallengePartIndex = () => {
149-
const { state } = useLocation();
150-
return state?.challengePartIndex ?? 0;
160+
export const useChallengePartIndex = (partsCount) => {
161+
const { hash } = useLocation();
162+
const [match, partNumberStr] = hash.match(/#part-([1-9][0-9]*)/) || [false];
163+
const partIndex = match ? parseInt(partNumberStr) - 1 : 0;
164+
return partIndex < partsCount ? partIndex : 0;
151165
};

src/utils/index.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,17 @@ export const randomElement = (array) => {
7777
const index = Math.floor(Math.random() * array.length);
7878
return array[index];
7979
};
80+
81+
/**
82+
* Creates a URL hash value (fragment) refering to a specific part of a
83+
* multi-part coding challenge. Part 1 of a challenge has no hash value.
84+
*
85+
* @param partIndex {number} Zero-based part index
86+
*
87+
* @example
88+
* buildPartHash(0) === ""
89+
* buildPartHash(1) === "#part-2"
90+
*/
91+
export const buildPartHash = (partIndex) => {
92+
return partIndex > 0 ? `#part-${partIndex + 1}` : '';
93+
};

0 commit comments

Comments
 (0)