The RikVerse rebuild: Watch, hear and read the poems

tl;dr: Rik discovers the joy of presenting his poems to the world in textual, audio and video formats

This is the last in a series of blog posts detailing my journey to rebuild my poetry website - The RikVerse - using Svelte.js, Page.js and Tailwind CSS. Other posts in this series:

  1. Introduction to the project
  2. Setting up the shiny
  3. The client is not the server - 404 errors
  4. Building the blog reader
  5. Fun with Cookies!
  6. The book store bits
  7. Sharing is Caring - social media share buttons
  8. … And finally: the poems!

The code developed in this blog post can be found on GitHub.

Where were we?

In the previous post I developed the functionality required to share my site’s pages onto Facebook and Twitter.

Here’s a quick reminder of my goals for this site rebuild:

  • Index of all the poems available to read
  • Tag filtering functionality on the poems index page
  • Easy for the user to access a poem’s associated media files
  • The landing page should display a randomly selected poem
  • [Done] Simpler, more minimal design; better fonts
  • [Done] Navigation needs to be massively simplified
  • [Done] Avoid 404 Page Not Found errors at all costs!
  • [Done] Add in poetry-and-writing-related blog posts
  • [Done] Get rid of the database!
  • [Done] Keep the donate button; make it More Fun To Use
  • [Done] Add in cookie consent (meeting UK ICO guidance for explicit opt-in)
  • [Done] Make each publication page that book’s keystone page
  • [Done] Let people read the books in-site
  • [Done] Add social media share buttons to each page
  • [Done] Fix the metadata

Building the poem index and poem display components

The main purpose of the RikVerse is to share my poems with the world. To do this, I need to display each poem on its own page. I also need to list all the poems in an index page with links to each poem.

… Which is pretty much the same as the work I’ve already done when I built a blog reader for the site. So I won’t be spending much time here detailing that work.

Instead, I’ll concentrate on the differences between the blog reader and the poem display functionality, as this is much more interesting stuff:

  • I’ve written many more poems than I have blog posts, so I need a way for the user to filter the poems index page using tags.

  • All of my completed poems are released into the wild with a Creative Commons licence (see this RikVerse blog post for more information) - the practical implication is that I need to display the correct Creative Commons licence alongside each poem, including its index page listing.

  • Some of the poems have audio clips of me reading them; others have video clips of me performing them. A few poems have both audio and video clips. So I need to build a mechanism for playing audio and/or video clips when a user requests it, but hide away the media players when not in use.

Finally, I have a requirement to display a randomly selected poem on various pages - specifically:

  • The landing page - as a welcome gift;
  • The error page - as an apology; and
  • The privacy and security page - to congratulate the user on finding, and reading, it.

Just as for the blog posts, I’ve divided each poem’s data into two parts:

  • The bit you read - the “copy” - each poem has its own .html file containing the copy, which I save to the ./public/poemCopy/ folder for easy fetching; and

  • The various bits of data surrounding the poem, such as title, publication date, tags, etc - the “meta” - all of which gets stored as an object in an array in the ./src/data/poemData.mjs Javascript module file.

The objects themselves have the following attributes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
id: // poem's slug, matching its file name in /public/poemCopy/
title: // poem title
tabTitle: 'RikVerse poem',
description: // poem's first line
publishdate: // date poem last updated
imageUrl: // image used for social media sharing
imageText: // text to accompany social media image
statusText: // text describing poem eg "(draft)"
tags: [], // an array of tag strings to help sort the poems into groups
audiofile: // path to audio files, if there are any
videofile: // path to video file, if there is one
imagefile: // path to image file, if the poem has one (eg ekphrastics)
imageCaption: // caption to accompany poem image
imagePosition: // position of image on poem page ("top" or "bottom")
showcase: // boolean - can the poem appear on the home page?
complete: // boolean - is the poem complete, or still in draft?
},

A poem’s “copy” is a normal html fragment which gets fetched and inserted when the poem displays. Because it’s just html, I can include Tailwind CSS classes in the markup.

This is where Tailwind really comes into its own! Formatting a poem to display correctly on a web page can be one of the most frustrating jobs known to developers - Tailwind classes solve a lot of the problems!

Markup like this …

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<p>His fortune lies in heaps<br />
before her front door.</p>

<p>They sit like old lovers<br />
to watch the sun paint clouds.</p>

<p class='ml-6 italic'>"When we burn the offerings,<br />
do You consume the smoke?"</p>

<p>She pours them wine from the jar,<br />
drinks her portion un-watered.</p>

<p class='ml-6 italic'>"I married You when I was nineteen;<br />
I was a virgin, once."</p>

<p>His hands that heal choose<br />
not to smooth her wrinkles.</p>

<p>He sips her libation, watches<br />
her eyes recycle the world.</p>

… Displays like this:

Filtering the Poems Index page using tags

Code for the PoemsIndex component can be found in the ./src/pages/PoemsIndex.svelte file. All the functionality for splitting poems into “completed” and “draft” sections, and for filtering poems based on tags associated with each poem, happens here:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<script>
[... other imports ...]

import poemData from '../data/poemData.mjs';
import { poemIndexHash } from '../handleMetadata.js';

// Get the full list of tags from poemData objects
let taglist = poemData.reduce((arr, item) => arr = [...arr, ...item.tags], []);
taglist = [...new Set(taglist)];
taglist.sort();

// Function to filter poems by tag
let filteredPoems, completedPoems, draftPoems;

const filterPoems = (tag) => {

if (tag && tag.indexOf('#') == 0) tag = tag.substring(1);

if (tag) filteredPoems = poemData.filter(item => item.tags.indexOf(tag) >= 0);
else filteredPoems = poemData;

completedPoems = filteredPoems.filter(item => item.complete);
draftPoems = filteredPoems.filter(item => !item.complete);

poemIndexHash.set(tag);
}

// Filter for the initial display
filterPoems($poemIndexHash);

// Function to handle tag button clicks
const buttonAction = (e) => filterPoems(e.target.getAttribute('tag'));
</script>

<style> [... styling code ...] </style>

[... start of the display/template code ...]

{#if !filteredPoems.length}
<p>There are no poems tagged with <b>#{$poemIndexHash}</b> - please search again</p>

{:else}

{#if $poemIndexHash}
<p>... Listing poems tagged with <b>#{$poemIndexHash}</b>:</p>

{:else}
<p>... Listing <b>all</b> poems:</p>
{/if}
{/if}

<div>
<button
type="button"
on:click={buttonAction}
class="{!$poemIndexHash && 'current-filter'}">
All poems
</button>

{#each taglist as tag}
<button
type="button"
{tag}
on:click={buttonAction}
class="{$poemIndexHash === tag && 'current-filter'}">
#{tag}
</button>
{/each}
</div>

[... rest of the display/template code ...]

Unlike other page components, the PoemsIndex component needs to keep track of its state - whether the user is currently filtering the listing on one of the #tag values.

A Svelte store is perfect for this work: I create and export the store in ./src/handleMetadata.js. When a user filters the list, then leaves the page to read a poem, the page remembers the selected filter tag for whenever the user returns to the list.

Display the correct Creative Commons licence alongside each poem

The screenshots above both show Creative Commons licence icons displayed at the end of the poem, and as part of each poem’s listing in the index. This is two distinctive uses, driven by the same underlying function.

I use two CC licences across the RikVerse. The first, quite restrictive licence is for poems that I consider to be completed less than 15 years ago. For completed poems older than 15 years I apply a much less stringent licence.

The function for determining a poem’s licence is nothing special. I keep it in the ./src/utilities.js file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Creative Commons licence checker
// - all completed poems published > 15 years are "cc-by" licence
// - all other completed poems are "cc-by_nc_nd" licence
// - poems not marked as completed excluded from CC licences
const checkCopyright = (updated, complete) => {

if (updated && complete) {

let [y, m, d] = [...updated.split('-')];

let myDate = new Date(y, m, d);

let now = new Date(),
cy = now.getFullYear() - 15,
cm = now.getMonth(),
cd = now.getDate();

let testDate = new Date(cy, cm, cd);

if (testDate > myDate) return '/images/cc-by.png';
else return '/images/cc-by_nc_nd.png';
}

return '';
};

I could have saved myself some coding effort by including the Moment.js library in the build. But the RikVerse site’s requirements for date/time functionality is so trivial that I decided to do it myself, which saves the user a few extra kilobytes of Javascript download time.

… Every microsecond counts - as they say!

Displaying the poems in their full glory

The last few pages I need to build are the pages which display a poem. There’s four of these pages:

  • ./src/pages/Home.svelte - the page used as the site’s landing page
  • ./src/pages/Privacy.svelte - the privacy and security notice page
  • ./src/pages/ErrorPage.svelte - the SPA’s 404 Page Not Found error page
  • ./src/pages/Poem.svelte - the page called by PoemIndex listing links

Home.svelte is the only page which doesn’t keep its metadata in the ./src/data/pageData.mjs file. This is because its path is / - which matches the path of ./public/index.html. The absolute last thing I want to do is accidentally overwrite the RikVerse site’s keystone file when I run my build toolchain functionality!

Other than that, the pages are very similar. They differ only in their mechanisms to discover which poem they should display, and in copy specific to that page.

The code for Poem.svelte is representative of them all:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<script>
import poemData from '../data/poemData.mjs';
import { scrollToTopOnLoad, navigateTo } from '../utilities.js';
import { updateMetadata } from '../handleMetadata.js';
import PoemCard from '../components/PoemCard.svelte';

export let params;

let poem = poemData.filter(item => item.id === params.slug);

if (poem.length) {

poem = poem[0];
updateMetadata(poem);
scrollToTopOnLoad();
}
else navigateTo('/error');
</script>

<style></style>

<svelte:head>
<title>{poem.tabTitle}</title>
</svelte:head>

<PoemCard poemData={poem} />

All four components rely on another component - ./src/components/PoemCard.svelte - to display the selected poem.

The poem display is the most complex display on the RikVerse website. Not only does it need to display the text of the poem, it also needs to include any image, audio and/or video files associated with the poem.

For this reason, PoemCard.svelte relies on a final set of components to do the work for it. The only work it performs itself is to fetch the poem’s html copy:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
<script>
import PoemNavigation from './PoemNavigation.svelte';
import PoemLicence from './PoemLicence.svelte';
import PoemImage from './PoemImage.svelte';

import { prettifyMonthDate } from '../utilities.js';

import {
videoFile,
audioFile,
videoIsPlaying,
audioIsPlaying } from '../handleMedia.js';

export let poemData;

videoFile.set(poemData.videofile);
audioFile.set(poemData.audiofile);
videoIsPlaying.set(0);
audioIsPlaying.set(0);

let copy = '';

fetch(`/poemCopy/${poemData.id}.html`)
.then(res => res.text())
.then(res => copy = res)
.catch(error => console.log(error));
</script>

<style>
time {
@apply block text-xs text-gray-700 italic mb-4;
}
</style>

{#if copy}
<!-- poem title -->
<h1>{poemData.title}</h1>

<!-- top image, if required -->
{#if poemData.imagePosition === 'top'}
<PoemImage position="top" file={poemData.imagefile} caption={poemData.imageCaption} />
{/if}

<!-- the poem copy goes here -->
{@html copy}

<!-- publication / last updated date -->
<time datetime="{poemData.publishdate}">
Published: {prettifyMonthDate(poemData.publishdate)}
</time>

<!-- poem navigation - includes video/audio display buttons -->
<PoemNavigation />

<!-- bottom image, if required -->
{#if poemData.imagePosition === 'bottom'}
<PoemImage position="bottom" file={poemData.imagefile} caption={poemData.imageCaption} />
{/if}

<!-- copyright notice -->
<PoemLicence publishdate={poemData.publishdate} complete={poemData.complete} />

{:else}
<h3>Retrieving poem</h3>
{/if}

The PoemImage and PoemLicence Svelte components are nothing we haven’t seen before - visit the GitHub repository to see their code.

Watch and/or Listen to Rik reading his poems

The PoemNavigation.svelte component is where the last of the RikVerse magic happens. The component’s job is to coordinate the activities of the <Video> and <Audio> HTML5 elements, and to make sure that only one of those elements gets displayed at any one time.

Because much of the functionality relies on Svelte state to operate correctly, I created a ./src/handleMedia.js Javascript module to keep track of the media elements. This module exports ten objects and functions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import { writable } from 'svelte/store';

// Video content
const videoFile = writable('');
const videoIsPlaying = writable(0);

let videoController;

const startVideo = () => {

stopAudio();

if (videoController) {

videoController.currentTime = 0;

videoController.play()
.then(res => videoIsPlaying.set(1))
.catch(error => console.log(error));
}
};

const stopVideo = () => {

if (videoController) videoController.pause();
videoIsPlaying.set(0);
};

const setVideoController = (el) => videoController = el;


// Audio content
const audioFile = writable('');
const audioIsPlaying = writable(0);

let audioController;

const startAudio = () => {

stopVideo();

if (audioController) {

audioController.currentTime = 0;
audioController.play();
audioIsPlaying.set(1);
}
};

const stopAudio = () => {

if (audioController) audioController.pause();
audioIsPlaying.set(0);
};

const setAudioController = (el) => audioController = el;


export {
videoFile,
setVideoController,
videoIsPlaying,
startVideo,
stopVideo,

audioFile,
setAudioController,
audioIsPlaying,
startAudio,
stopAudio,
}

The functionality specific to the <audio> and <video> elements gets handled in dedicated components - AudioPlayer.svelte and VideoPlayer.svelte.

The job of these components is to define their media element, set a handle for the element each time it is created, and (gracefully) show and hide the element when the user clicks a button to play or stop the media.

This is the VideoPlayer code. The AudioPlayer code differs only in small details.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<script>
import {
videoFile,
setVideoController,
videoIsPlaying } from '../handleMedia.js';

const getVideoController = () => {

let v = document.querySelector('video'),
timeout;

if (v) {

if (timeout) window.clearTimeout(timeout);
setVideoController(v);
}
else timeout = window.setTimeout(getVideoController, 400);
};

getVideoController();

</script>

<style>
video {
@apply mx-auto block mb-0 outline-none;
opacity: 0;
height: 0;
transition: opacity 0.6s ease-in-out, margin-bottom 0.6s ease-in-out, height 0.6s ease-in-out;
}

.video-active {
@apply mb-4;
opacity: 1;
height: 240px;
}
</style>

{#if $videoFile}
<video class="{$videoIsPlaying && 'video-active'}" controls>
<source src="{$videoFile}" type="video/mp4" />
<p>Your browser doesn't support HTML5 video</p>
</video>
{/if}

The VideoPlayer component has two jobs:

  • When the component is created, it has to locate the <video> element that gets added to the page, and put a handle to the element into the videoController variable (which is NOT a Svelte store) in handleMedia.js

  • Then, when the Svelte videoIsPlaying store resolves to 1 (true), the VideoPlayer component adds a video-active class to the <video> element, which triggers its reveal through a CSS transition. The element will hide itself when videoIsPlaying resolves to 0 (false).

So we have a VideoPlayer component, and an AudioPlayer component, and some functionality to keep and manipulate the state required for these components in the handleMedia.js Javascript module.

These three parts need to be bought together. That’s the job of our final component - PoemNavigator.svelte:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
<script>
import VideoPlayer from './VideoPlayer.svelte';
import AudioPlayer from './AudioPlayer.svelte';

import { navigateTo } from '../utilities.js';

import {
videoFile,
videoIsPlaying,
startVideo,
stopVideo,

audioFile,
audioIsPlaying,
startAudio,
stopAudio } from '../handleMedia.js';

const backAction = () => navigateTo('/index');

const videoAction = () => {

if ($videoIsPlaying) stopVideo();
else startVideo();
}

const audioAction = () => {

if ($audioIsPlaying) stopAudio();
else startAudio();
}

let videoLabels = ['&#8635; Watch', 'Stop watching'],
audioLabels = ['&#8635; Listen', 'Stop listening'];

</script>

<style>
div {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-gap: 1em 1em;
grid-auto-flow: row;
}
button {
@apply block rounded-full bg-blue-200 text-green-700 border-blue-500 p-1 m-1 text-center cursor-pointer text-sm mb-4 outline-none;
min-width: 6rem;
transition: color 0.5s, background-color 0.5s;
}
button:hover {
@apply bg-blue-700 text-green-200;
}

</style>

<div>
<!-- back button -->
<button on:click={backAction}>&#8592; Back</button>

<!-- video button -->
{#if $videoFile}
<button on:click={videoAction}>{@html videoLabels[$videoIsPlaying]}</button>
{/if}

<!-- audio button -->
{#if $audioFile}
<button on:click={audioAction}>{@html audioLabels[$audioIsPlaying]}</button>
{/if}
</div>

{#if $videoFile}
<VideoPlayer />
{/if}

{#if $audioFile}
<AudioPlayer />
{/if}

The navigation consists of between one and three buttons - depending on whether or not the poem has associated video and/or audio media.

  • The back button is always present; when the user clicks on this button the site navigates itself back to the Poems Index page.

  • The video button and audio button only appear when the poem has a video or audio file to play; when the user clicks on either of these buttons, PoemNavigator will start playing the associated media - first stopping any other media that is playing. Or alternatively it will stop the media playing.

… Yeah. It all sounds a bit complicated. But compared to payment gateways it ain’t complicated at all!

Wrap up

I have very much enjoyed this whole learning experience. The new shiny I’ve learned about - Svelte, Tailwind, Page.js - have solved far more problems than they’ve created for me.

Svelte site builder has been a revelation. This is how building a site with components should be!

  • It’s very easy to learn
  • Coding a component using (mostly) standard HTML, CSS and Javascript makes life so much easier
  • The state mechanisms it introduces make sense, and are (relatively) simple to use
  • I’ve barely scratched the surface of what this framework can do - for instance: mount/destroy; events; binding; tweens and animation; etc. I’ll investigate these things in my next personal project.

Tailwind CSS is a joy to use - mainly because it doesn’t have much in the way of opinions:

  • The utility-first approach suits my programming style
  • Setup was a little tricky, but once done the integration worked perfectly
  • It’s very customizable - if I can manage to customize it for fonts, anybody can!
  • It has a whole plugin ecosystem I’ve not yet investigated - the basics were enough for me
  • (The latest version now supports grids and transforms - yay!)

PostCSS pre-processing - I learned nothing about this shiny; after I set it up (alongside Tailwind) I never had to think about it again.

Page.js routing - what can I say? It works for me!

  • The routing code I borrowed from Jack Whiting was enough for my needs
  • Which means there’s an awful lot more that this library has to offer that I haven’t bothered learning about (yet)

And, to cap it all

… I now have a fantastic new website for my poems and books! Here’s some before-and-after comparisons for new visits to each site’s landing page

The old site:

Comparison of the two sites

Comparison              Old site                    New site
------------------------------------------------------------------------------
Design                  Cluttered, unfocussed       Clean, purposeful
UX navigation           Obtuse, unfriendly          Simple, Accessible
Responsiveness          Fixed width, ugly           Fully responsive

Cookies                 Ignores user wishes         Meets current requirements
Security                No details offered          Explains data/cookie usage

Uses jQuery/UI          Yes                         No
Uses Google Fonts       Yes                         No

Backend                 Apache/PHP/MySql            None beyond serving files

Networking:
Requests sent             34 requests                13 requests
Transfer load            362 KB                     267 KB
Resources sent           613 KB                     465 KB
DOMContentLoaded         412 ms                     288 ms
Load completes in       1090 ms                     511 ms
Finished in             1110 ms                     604 ms

The new site:

If you’ve enjoyed this series of blog posts, or found them useful - good. That makes me happy!

And if you spot any serious errors in my code, or can suggest better ways to code up various functionalities … please let me know. I want the RikVerse to be the best personal poetry website in the world - and I’m always up for new learning opportunities!