Building Directus Garden - A Passive Collaborative Event Booth Demo
Published February 28th, 2024
For the last ten years I’ve been running and sponsoring events for developers, and as part of that I’ve become rather opinionated about what it takes to build a logistically-sound and engaging booth (often a table with a TV in a networking hall).
The team at Directus are proud sponsors of VueJS Amsterdam, and I had the pleasure of building a booth demo that highlights what makes our Composable CMS great.
What Must It Do?
One of my favorite iOS games — Neko Atsume — does not vie for your attention and is remarkably laid back. As well as being super cute, it’s an app you open at your leisure to tend to your yard and see which kittens have chosen to grace you with their presence.
This is a perfect vibe — passive, laid-back, and incredibly cute. With this in mind, as well as real business needs, here were the requirements:
- Must have attendees touch Directus as a product in some way.
- Must be passive: we don’t want to ‘manage’ the demo live - we want to be having conversations with developers.
- Must be collaborative instead of competitive. This is very much our vibe, and wanted it to extend through our demo.
- Must provide an opt-in way for attendees to hear from us after the event.
Introducing Directus Garden
In this small demo built in a day, attendees become gardeners helping us build a beautiful and lively garden by placing items in the scene.
To do this, they must use Directus Auth to register for and login to their account, and then Directus Connect to see what they can place before doing so.
The client is a Nuxt.js application backed with Directus, and can facilitate multiple ongoing events. Once audience members participate, they are invited back at the final break for a raffle draw via a transactional email sent through Directus Automate.
Understanding the Project
Participants are expected to hit 4 API endpoints to enter the raffle:
- Register, providing a name, email, and an optional contact opt-in.
- Login to receive an auth token.
- List all items that can be placed.
- Place an item at a specific coordinate.
Once an item is placed, the garden display on the TV behind our table will automatically show the new item using Directus Realtime, along with newly-placed item showing in the “gardener’s log”.
At the end of the event, we want to select a winner from the list of users who participated.
Setting Up Collections
There are three user collections in this project:
- Events - events are instances of the demo. They contain all of the event-specific information like name, date, and raffle information.
- Placeables - items that can be placed, like muffin the rabbit or sky the bird. Each has an image, name, and category.
- Place - one entry into the raffle. It stores the event, placeable, coordinates, and the user which created it. It is singular to form a nicer user-facing URL as part of the demo (
POST /items/place
).
The directus_users
system collection is also given a contact_opt_in
boolean field.
Setting Up Roles
A new Gardener role is created for participants. It is given read access on the placeables
collection, and create access on the place
collection.
The Display role is created for the screen showing the garden. It has read access over all user-created collections, and access to the first_name
of directus_users
(so they can be displayed on the screen).
The Public role is given access on the events
and placeables
collection.
Before users register, their API requests will be made with the permissions of the Public role. The role’s create permissions for the directus_users
collection only allow for them to touch five fields, and requires that all required fields are not empty.
Then, using Field Presets, all new users created with the public role are automatically given the Gardener role - a field that the Public role can not set.
Building the Participation Form
In the original design, attendees would effectively be given an API reference and were expected to make the calls in a HTTP client of their choice. However, this unnecessarily increased the barrier to entry not just technically, but requiring attendees to have a device capable of sending 4 requests, some with a JSON body and with headers. It was too much!
Using the simple-code-editor
Vue 3 component, a page is created on a per-event basis that would allow these requests to be made. When a user successfully registers, it automatically populates the next request’s body. When they log in, we populate the Authorization header, and so on. The final request even picks a random placeable and coordinate set as default, to make participation really easy.
Taking part in our raffle shouldn’t be a test of technical skill - it should be a chance to educate users about what Directus is and show it off in it’s best light. You don’t do that if attendees are frustrated.
These code editors make real requests and display real results (and errors) when they are returned.
Sending Confirmation Email
Using Directus Flows, a confirmation email is sent as soon as a new item is placed. In the flow, we retrieve the associated event and user information, and then send an email with dynamic variables that contain raffle information as well as some other interesting links to read.
Building the Garden Display
The display is primarily built using P5.js - a library to make working with the HTML5 Canvas easier. Once the page loads, we preload all of the placeable images into memory before the canvas is rendered. A places
ref
is created to contain all items that should be shown in the canvas:
const placeablesData = await directus.request(
readItems('placeables', {
fields: ['*', {
'image': ['id', 'width', 'height']
}]
})
)
const placeables = ref([])
const places = ref([])
p5.preload = () => {
background.value = p5.loadImage(asset('image-id-from-directus'))
for (const p of placeablesData) {
placeables.value.push({
image: p5.loadImage(asset(p.image.id)),
name: p.name,
aspect: p.image.width / p.image.height
})
}
}
const placeablesData = await directus.request(
readItems('placeables', {
fields: ['*', {
'image': ['id', 'width', 'height']
}]
})
)
const placeables = ref([])
const places = ref([])
p5.preload = () => {
background.value = p5.loadImage(asset('image-id-from-directus'))
for (const p of placeablesData) {
placeables.value.push({
image: p5.loadImage(asset(p.image.id)),
name: p.name,
aspect: p.image.width / p.image.height
})
}
}
Using Directus Realtime
When subscribing to a collection with existing items, a subscription init
message will be sent in response with current items in the collection. We can use this to add the initial items to the places
variable, and then add new items when they are created:
onMounted(() => {
const connection = new WebSocket(wsBase)
connection.addEventListener('open', () => {
connection.send(JSON.stringify({
type: 'auth',
access_token: 'public-role-user-token'
}))
})
connection.addEventListener('message', (message) => {
const data = JSON.parse(message.data)
if (data.type == 'auth' && data.status == 'ok') {
connection.send(JSON.stringify({
type: 'subscribe',
collection: 'place',
query: {
fields: ['*', 'user_created.first_name'],
filter: { event: { _eq: route.params.event } }
}
}))
}
if (data.type == 'subscription' && data.event == 'init') {
places.value = data.data
}
if (data.type == 'subscription' && data.event == 'create') {
places.value.unshift(data.data[0])
}
if (data.type == 'ping') {
connection.send(JSON.stringify({
type: 'pong'
}))
}
})
})
onMounted(() => {
const connection = new WebSocket(wsBase)
connection.addEventListener('open', () => {
connection.send(JSON.stringify({
type: 'auth',
access_token: 'public-role-user-token'
}))
})
connection.addEventListener('message', (message) => {
const data = JSON.parse(message.data)
if (data.type == 'auth' && data.status == 'ok') {
connection.send(JSON.stringify({
type: 'subscribe',
collection: 'place',
query: {
fields: ['*', 'user_created.first_name'],
filter: { event: { _eq: route.params.event } }
}
}))
}
if (data.type == 'subscription' && data.event == 'init') {
places.value = data.data
}
if (data.type == 'subscription' && data.event == 'create') {
places.value.unshift(data.data[0])
}
if (data.type == 'ping') {
connection.send(JSON.stringify({
type: 'pong'
}))
}
})
})
The logic to automatically show existing and new items.
Drawing Placed Items
Back in the P5 sketch, we loop over all items that are in the array and draw them. If the user is hovering over them, we show who the item’s gardener was:
p5.draw = () => {
p5.background(background.value)
for (let place of places.value) {
// Draw item
const placeable = placeables.value.find(p => p.name == place.name)
const x = parseInt(place.x_pos), y = parseInt(place.y_pos)
p5.image(placeable.image, x, y, 100 * placeable.aspect, 100)
// Draw gardener name on hover
const mouseInBoundsX = p5.mouseX > x && p5.mouseX < x + (100 * placeable.aspect)
const mouseInBoundsY = p5.mouseY > y && p5.mouseY < y + 100
if (mouseInBoundsX && mouseInBoundsY) {
p5.stroke('black')
p5.text(`${placeable.name} by ${place.user_created.first_name}`, p5.mouseX, p5.mouseY)
p5.noStroke()
}
}
}
p5.draw = () => {
p5.background(background.value)
for (let place of places.value) {
// Draw item
const placeable = placeables.value.find(p => p.name == place.name)
const x = parseInt(place.x_pos), y = parseInt(place.y_pos)
p5.image(placeable.image, x, y, 100 * placeable.aspect, 100)
// Draw gardener name on hover
const mouseInBoundsX = p5.mouseX > x && p5.mouseX < x + (100 * placeable.aspect)
const mouseInBoundsY = p5.mouseY > y && p5.mouseY < y + 100
if (mouseInBoundsX && mouseInBoundsY) {
p5.stroke('black')
p5.text(`${placeable.name} by ${place.user_created.first_name}`, p5.mouseX, p5.mouseY)
p5.noStroke()
}
}
}
P5 will redraw this sketch about 60 times a second, so the moment there are new items added via Directus Realtime, they are rendered in the subsequent draw.
To help users place their item in the garden, the P5 sketch also places rulers along the length and height of the screen. These can be toggled, along with the information box, to get a clear view of the garden.
Raffle Draw
The raffle isn’t worth writing much about - we require admin authentication and fetch all placed items. We then client-side dedupe if gardeners were extra enthusiastic and placed multiple items, and then pick a winner at random from the deduped array.
We Hope You Enjoy!
This is a small demo which highlights both the APIs generated by Directus, authentication endpoints, and Realtime capabilities. It aims to be a nice, chill, collaborative experience, and we hope you enjoy taking part.
🧑🌾🍃🌻