Skip to content

Commit 9ff8f08

Browse files
committed
Set "last position" cookies on navigation.
Set a cookie on navigation within any gallery, storing the last-seen index in each mode, as well as the last-seen mode. This state will be restored on the next page load, unless overridden by an explicit index set in the URL fragment. Also: - Increment version to 2.1.0. - Clean up URL fragment handling, and incorporate with "last position" cookies. - Add new cookie handling functions to `utils.js`. - Lift common cookie name prefix to `utils.js`. - Update component graph in `README.md`.
1 parent db6af24 commit 9ff8f08

7 files changed

+145
-59
lines changed

README.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ graph TD
2121
G ---> HD(Header)
2222
G ---> DS(Display)
2323
24+
HD ----> nt_b(NewtabButton)
25+
HD ----> dl_b(DownloadButton)
2426
HD ----> mn_b(MenuButton)
27+
2528
HD ---> nv(Navigation)
2629
HD ----> dt(Details)
2730
@@ -41,8 +44,11 @@ graph TD
4144
classDef container color:#fff,fill:#0d1824,stroke:#9ba;
4245
class GC container;
4346
47+
classDef section fill:#2c482c,stroke:#86b086;
48+
class HD,DS section;
49+
4450
classDef helper fill:#382c2c,stroke:#a88686;
45-
class mn_b,nv_b helper;
51+
class mn_b,nv_b,nt_b,dl_b helper;
4652
4753
linkStyle 10,11 stroke:#999,stroke-width:0.75px,stroke-dasharray:3,color:MediumTurquoise;
4854
```

package-lock.json

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tma-react-gallery",
3-
"version": "2.0.0",
3+
"version": "2.1.0",
44
"license": "AGPL-3.0-or-later",
55
"private": true,
66
"dependencies": {

src/App.css

+1-1
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ body {
145145
.header-details-credit {
146146
user-select: none;
147147
margin: 0vw 0.35vw;
148-
font-size: 0.75em;
148+
font-size: 0.8em;
149149
width: 6em;
150150
text-align: right;
151151
}

src/Gallery.js

+106-45
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,20 @@ import * as Utils from './utils.js';
44
import imgDownload from './img/download-white.png';
55
import imgNewtab from './img/newtab-white.png';
66

7-
/* ——————————————————————————————————————————————————————————————————————————————————————————————————————————————
7+
/* ———————————————————————————————————————————————————————————————————————————————————————————————————————————————————
88
* • Layout
99
* - Header with toggle for image/video modes, navigation buttons, and media detail
1010
* - Full-window image/video view
1111
*
1212
* • TODO:
13-
* (•) Show a loading spinner during loading of each image/video
1413
* (•) Add a thumbnail row along the bottom (pre-generate thumbnails for each picture -- using AWS Lambda fns?).
1514
* (•) Add (pop-out?) list view for images/videos in bucket.
1615
* (•) Add sorting options/controls.
1716
* (•) Add an info icon to show tooltip in mouseover (keyboard shortcuts, etc).
18-
* (?) Set cookie to remember last image/video position.
17+
* (•) Show a loading spinner during loading of each image/video
18+
* (?) Create a "demo" version of the app using copies of all AWS components and a demo bucket with stock images.
1919
*
20-
* ——————————————————————————————————————————————————————————————————————————————————————————————————————————————
20+
* ———————————————————————————————————————————————————————————————————————————————————————————————————————————————————
2121
*/
2222

2323
//┣━━━━━━━━━━━━━━━━┓
@@ -62,62 +62,110 @@ export default function Gallery(props) {
6262
// Initialize defaults for each media type
6363
useEffect(() => {
6464
if (imagesList.length > 0 && videosList.length > 0) { // make sure file list has been loaded
65-
// Default to 'images' mode showing the first image, unless a fragment is provided in the URL (format: 'images-0' or 'videos-0')
65+
// Check cookies and URL fragment for starting position. Otherwise default to 'images' mode and show the first image.
66+
67+
// These variables will remain 0 unless a 'last position' cookie is successfully loaded below.
68+
let startingIndexImages = 0;
69+
let startingIndexVideos = 0;
70+
71+
// First, check user's cookies for saved position info for this gallery.
72+
const lastPositionCookieName = Utils.COOKIE_NAME_PREFIX + "last-" + props.galleryBucketParams['id']; // [TODO: Generalize <|1|>]
73+
const lastPositionCookie = Utils.readCookieAsJSON(lastPositionCookieName);
74+
75+
try {
76+
if (lastPositionCookie) {
77+
78+
if (lastPositionCookie.last_mode == 'images' || lastPositionCookie.last_mode == 'videos')
79+
setMode(lastPositionCookie.last_mode);
80+
else throw new Error("Invalid mode in last position cookie.");
81+
82+
const lastIndexImage = parseInt(lastPositionCookie.last_image_index);
83+
const lastIndexVideo = parseInt(lastPositionCookie.last_video_index);
84+
85+
if (lastIndexImage >= 0 && lastIndexImage < imagesList.length && lastIndexVideo >= 0 && lastIndexVideo < videosList.length)
86+
setCurrentIndex({ image: lastIndexImage, video: lastIndexVideo });
87+
else throw new Error("Image or video index out of bounds in last position cookie.");
88+
89+
// Update the URL fragment to match the loaded position (unless it's the start of a gallery). [TODO: Generalize <|2|>]
90+
// If a fragment is already provided in the URL, skip adding one. The index set above will be overwritten for the mode set in the fragment.
91+
if (!window.location.hash.includes('#') || window.location.hash.length <= 1) {
92+
const lastIndexForMode = lastPositionCookie.last_mode == 'images' ? lastIndexImage : lastIndexVideo;
93+
if (lastIndexForMode > 0) {
94+
let fragment = (lastPositionCookie.last_mode == 'images' ? 'image' : 'video') + (lastIndexForMode + 1);
95+
window.location.hash = "#" + fragment;
96+
}
97+
}
98+
99+
// If we update the starting indices from a cookie, set them here for the next step.
100+
startingIndexImages = lastIndexImage;
101+
startingIndexVideos = lastIndexVideo;
102+
}
103+
}
104+
catch (err) {
105+
console.log("[ERROR] " + err);
106+
// Clear the existing (malformed) cookie for this gallery.
107+
Utils.deleteCookie(lastPositionCookieName);
108+
}
109+
110+
// If a fragment is provided in the URL (as 'image123' or 'video123'), switch the current index (and possibly mode) to that position.
111+
// A pre-existing fragment will override the last position cookie. But, if no fragment is provided, one will be set by the last position cookie.
66112
if (window.location.hash.includes('#') && window.location.hash.length > 1) {
67113
try {
68-
let h_mode = window.location.hash.split('#')[1].split('-')[0];
69-
let h_index = window.location.hash.split('#')[1].split('-')[1];
70-
h_index = parseInt(h_index) - 1; // the fragment index is 1-indexed, so shift to get the array index
71-
72-
// Check for valid mode and index, fail if invalid
73-
if (h_mode !== 'images' && h_mode !== 'videos') throw new Error("Invalid mode in URL fragment.");
74-
if (Number.isNaN(Number.parseInt(h_index))) throw new Error("Invalid index in URL fragment.");
75-
if (h_mode === 'images' && (h_index < 0 || h_index >= imagesList.length)) throw new Error("Out-of-bounds index in URL fragment.");
76-
if (h_mode === 'videos' && (h_index < 0 || h_index >= videosList.length)) throw new Error("Out-of-bounds index in URL fragment.");
77-
78-
// For a valid mode and index, set the defaults accordingly
79-
let h_media = (h_mode === 'images' ? imagesList[h_index] : videosList[h_index]);
114+
let frag_mode = window.location.hash.split('#')[1].match(/(image|video)(\d+)/)[1] + "s";
115+
let frag_index = window.location.hash.split('#')[1].match(/(image|video)(\d+)/)[2];
116+
frag_index = parseInt(frag_index) - 1; // the fragment index is 1-indexed, so shift to get the array index
117+
118+
// Check for valid mode and index, fail if invalid.
119+
if (frag_mode !== 'images' && frag_mode !== 'videos') throw new Error("Invalid mode in URL fragment.");
120+
if (isNaN(parseInt(frag_index))) throw new Error("Invalid index in URL fragment.");
121+
if (frag_mode === 'images' && (frag_index < 0 || frag_index >= imagesList.length)) throw new Error("Out-of-bounds index in URL fragment.");
122+
if (frag_mode === 'videos' && (frag_index < 0 || frag_index >= videosList.length)) throw new Error("Out-of-bounds index in URL fragment.");
123+
124+
// For a valid mode and index, set the defaults accordingly.
125+
// The mode not given in the fragment will default to the last position cookie, or to 0 if none exists.
126+
let frag_media = (frag_mode === 'images' ? imagesList[frag_index] : videosList[frag_index]);
80127
setCurrentMedia({
81-
src: h_media.src,
82-
filename: h_media.filename,
83-
datestamp: h_media.datestamp,
84-
timestamp: h_media.timestamp,
85-
credit: h_media.credit
128+
src: frag_media.src,
129+
filename: frag_media.filename,
130+
datestamp: frag_media.datestamp,
131+
timestamp: frag_media.timestamp,
132+
credit: frag_media.credit
86133
});
87-
if (h_mode === 'images') {
134+
if (frag_mode === 'images') {
88135
setMode('images');
89-
setCurrentIndex({ image: h_index, video: 0 });
136+
setCurrentIndex({ image: frag_index, video: startingIndexVideos });
90137
}
91-
if (h_mode === 'videos') {
138+
if (frag_mode === 'videos') {
92139
setMode('videos');
93-
setCurrentIndex({ image: 0, video: h_index });
140+
setCurrentIndex({ image: startingIndexImages, video: frag_index });
94141
}
95142
}
96143
catch (err) {
97144
console.log("[ERROR] " + err);
98-
// Fallback to defaults if URL fragment is malformed // DRY
145+
// Fallback to defaults (or last position cookie) if URL fragment is malformed // DRY
99146
setCurrentMedia({
100-
src: imagesList[0].src,
101-
filename: imagesList[0].filename,
102-
datestamp: imagesList[0].datestamp,
103-
timestamp: imagesList[0].timestamp,
104-
credit: imagesList[0].credit
147+
src: imagesList[startingIndexImages].src,
148+
filename: imagesList[startingIndexImages].filename,
149+
datestamp: imagesList[startingIndexImages].datestamp,
150+
timestamp: imagesList[startingIndexImages].timestamp,
151+
credit: imagesList[startingIndexImages].credit
105152
});
106-
setCurrentIndex({ image: 0, video: 0 });
153+
setCurrentIndex({ image: startingIndexImages, video: startingIndexVideos });
107154
// Clear the URL fragment (leaves the #)
108155
window.location.hash = '';
109156
}
110157
}
158+
159+
// Defaults (if no URL fragment is provided) // DRY
111160
else {
112-
// Defaults (if no URL fragment is provided) // DRY
113161
setCurrentMedia({
114-
src: imagesList[0].src,
115-
filename: imagesList[0].filename,
116-
datestamp: imagesList[0].datestamp,
117-
timestamp: imagesList[0].timestamp,
118-
credit: imagesList[0].credit
162+
src: imagesList[startingIndexImages].src,
163+
filename: imagesList[startingIndexImages].filename,
164+
datestamp: imagesList[startingIndexImages].datestamp,
165+
timestamp: imagesList[startingIndexImages].timestamp,
166+
credit: imagesList[startingIndexImages].credit
119167
});
120-
setCurrentIndex({ image: 0, video: 0 });
168+
setCurrentIndex({ image: startingIndexImages, video: startingIndexVideos });
121169
}
122170
}
123171
}, [imagesList, videosList]); // Only run when 'imagesList' or 'videoList' changes
@@ -264,6 +312,7 @@ export default function Gallery(props) {
264312
setCurrentIndex={setCurrentIndex}
265313
mediaDetails={mediaDetails}
266314
accessKey={props.accessKey}
315+
galleryBucketParams={props.galleryBucketParams}
267316
/>
268317

269318
<Display mode={mode} currentMedia={currentMedia} mediaDetails={mediaDetails} accessKey={props.accessKey} />
@@ -312,6 +361,7 @@ function Header(props) {
312361
videosListLength={props.videosListLength}
313362
currentIndex={props.currentIndex}
314363
setCurrentIndex={props.setCurrentIndex}
364+
galleryBucketParams={props.galleryBucketParams}
315365
/>
316366

317367
<Details mode={props.mode} currentMedia={props.currentMedia} mediaDetails={props.mediaDetails} />
@@ -332,7 +382,7 @@ function Navigation(props) {
332382
const [navButtonCount, setNavButtonCount] = useState(5);
333383

334384
// One-shot effects to set up adjustments to window length based on viewport width.
335-
// [Ref: https://blog.bitsrc.io/using-react-hooks-to-recognize-respond-to-current-viewport-size-c385009005c0]
385+
// [https://blog.bitsrc.io/using-react-hooks-to-recognize-respond-to-current-viewport-size-c385009005c0]
336386
useEffect(() => { // set initial 'navButtonCount'
337387
if (window.innerWidth < 480)
338388
setNavButtonCount(1);
@@ -354,13 +404,24 @@ function Navigation(props) {
354404
return () => { window.removeEventListener('resize', onResize); } // clean up
355405
}, []);
356406

357-
// Change URL fragment (#) on navigation.
358-
// TODO: Change this to use the `useNavigation` hook from react-router-dom instead? [https://reactrouter.com/en/main/hooks/use-navigate]
407+
// Effects to trigger whenever the `currentIndex` or `mode` is changed, indicating a user navigation.
359408
useEffect(() => {
409+
// Change URL fragment (#) to match the new mode/index on navigation. [TODO: Generalize <|2|>]
410+
// TODO: Change this to use the `useNavigation` hook from react-router-dom instead? [https://reactrouter.com/en/main/hooks/use-navigate]
360411
if (props.currentIndex.image >= 0 && props.currentIndex.video >= 0) {
361-
let h_index = (props.mode === 'images' ? props.currentIndex.image : props.currentIndex.video);
362-
window.location.hash = "#" + props.mode + "-" + (Number.parseInt(h_index) + 1);
412+
let frag_index = (props.mode === 'images' ? props.currentIndex.image : props.currentIndex.video);
413+
let fragment = (props.mode == 'images' ? 'image' : 'video') + (parseInt(frag_index) + 1);
414+
window.location.hash = "#" + fragment;
363415
}
416+
417+
// Store the new mode/index to remember the user's last position in the current gallery. [TODO: Generalize <|1|>]
418+
const bucketCookieValue = {
419+
"bucket_name": props.galleryBucketParams['bucketName'],
420+
"last_mode": props.mode,
421+
"last_image_index": props.currentIndex.image,
422+
"last_video_index": props.currentIndex.video
423+
};
424+
Utils.setCookieAsJSON(Utils.COOKIE_NAME_PREFIX + "last-" + props.galleryBucketParams['id'], bucketCookieValue, 30); // set cookie for 30 days
364425
}, [props.currentIndex, props.mode]);
365426

366427

src/GalleryContainer.js

+6-6
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,6 @@ export default function GalleryContainer() {
2929

3030
const { isValid, validatedEndpointDomain, validatedAccessKey, validatePassword } = useValidatePassword();
3131

32-
// Each bucket will have its own cookie, as a JSON object. The bucket ID (see `S3BucketParams`) will follow this prefix.
33-
// Note: These cookie names must match the names used in the CloudFront function.
34-
const bucketCookieNamePrefix = 'gallery-bucket-';
35-
3632
// Callback passed into `Gateway` component. Used to set the state variable holding the password.
3733
const handlePasswordInput = (inputPassword, inputRemember) => {
3834
// Validate password hash with API using custom hook.
@@ -49,9 +45,10 @@ export default function GalleryContainer() {
4945
}
5046
else if (isValid) {
5147
// Password valid. Store CloudFront domain and access key in cookies, if requested.
48+
// Note: These cookie names must match the names used in the CloudFront function.
5249
if (remember) {
5350
const bucketCookieValue = { "bucket_name": galleryBucketParams['bucketName'], "endpoint_domain": validatedEndpointDomain, "access_key": validatedAccessKey };
54-
Utils.setCookieAsJSON(bucketCookieNamePrefix + galleryBucketParams['id'], bucketCookieValue);
51+
Utils.setCookieAsJSON(Utils.COOKIE_NAME_PREFIX + galleryBucketParams['id'], bucketCookieValue, 400); // set cookie expiry to max length (400 days)
5552
}
5653
setEndpointDomain(validatedEndpointDomain); // Store validated endpoint domain to use when loading `Gallery` component.
5754
setAccessKey(validatedAccessKey); // Store validated access key to use when loading `Gallery` component.
@@ -75,11 +72,14 @@ export default function GalleryContainer() {
7572
}
7673
else {
7774
// Check user's cookies for an existing gallery access key, before prompting user for password.
78-
const existingCookie = Utils.readCookieAsJSON(bucketCookieNamePrefix + galleryBucketParams['id'])
75+
const existingCookie = Utils.readCookieAsJSON(Utils.COOKIE_NAME_PREFIX + galleryBucketParams['id'])
7976
if (existingCookie && existingCookie.access_key) {
8077
setEndpointDomain(existingCookie.endpoint_domain); // Store existing endpoint domain to use when loading `Gallery` component.
8178
setAccessKey(existingCookie.access_key); // Store existing access key to use when loading `Gallery` component.
8279
setReadyLoadGallery(true); // Prepare to load the `Gallery` component.
80+
81+
// Refresh cookie expiry date (to max 400 days).
82+
Utils.refreshCookie(Utils.COOKIE_NAME_PREFIX + galleryBucketParams['id'])
8383
}
8484
}
8585
}, [galleryBucketParams]);

src/utils.js

+22-3
Original file line numberDiff line numberDiff line change
@@ -148,13 +148,13 @@ export function parseDetails(datestamp, timestamp, credit) {
148148
return { date: date_fmt, time: time_fmt, credit: credit_fmt };
149149
}
150150

151-
export function setCookieAsJSON(cookieName, jsonObj) {
151+
export function setCookieAsJSON(cookieName, jsonObj, days) {
152152
// Convert JSON object into an encoded string.
153153
const jsonString = JSON.stringify(jsonObj);
154154
const encodedJSONString = encodeURIComponent(jsonString);
155155

156156
// Set a cookie with the encoded JSON string.
157-
document.cookie = `${cookieName}=${encodedJSONString};max-age=${86400*365*5}`; // 5 years (86400 seconds = 1 day)
157+
document.cookie = `${cookieName}=${encodedJSONString};max-age=${86400*days}`; // 86400 seconds = 1 day
158158
}
159159

160160
export function readCookieAsJSON(cookieName) {
@@ -168,6 +168,16 @@ export function readCookieAsJSON(cookieName) {
168168
return JSON.parse(decodedJSONString);
169169
}
170170

171+
export function refreshCookie(cookieName) {
172+
// Refresh the expiry date of a cookie by setting its max-age to the maximum 400 days.
173+
setCookieAsJSON(cookieName, readCookieAsJSON(cookieName), 400);
174+
}
175+
176+
export function deleteCookie(cookieName) {
177+
// Override the cookie with a max-age of 0 to mark it for deletion.
178+
document.cookie = `${cookieName}=;max-age=0`;
179+
}
180+
171181
// Prepares an image/video file for download-on-click by fetching the file as a blob, creating an object URL, and clicking a hidden anchor tag to trigger downloading.
172182
// This is needed because file downloads cannot be triggered cross-origin. Instead, we make a new URL on this origin pointing to a blob of the file data.
173183
export function downloadMediaViaBlob(mediaURL, mediaFilename) {
@@ -190,4 +200,13 @@ export function downloadMediaViaBlob(mediaURL, mediaFilename) {
190200
document.body.removeChild(blobLink);
191201
})
192202
.catch(() => console.error('Could not download the image at ' + mediaURL));
193-
}
203+
}
204+
205+
206+
207+
//┣━━━━━━━━━━━━━━━━━━━━┓
208+
//┃ Global Constants ┃
209+
//┣━━━━━━━━━━━━━━━━━━━━┛
210+
211+
// Each bucket will have its own cookie, as a JSON object. The bucket ID (see `S3BucketParams`) should immediately follow this prefix.
212+
export const COOKIE_NAME_PREFIX = 'gallery-bucket-';

0 commit comments

Comments
 (0)