This template creates a simple photo album. It includes controls for the site owner to add, edit, and remove photos.

Source

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <link rel="stylesheet" href="/index.css">
    <link rel="icon" type="image/png" sizes="32x32" href="/thumb.png">
  </head>
  <body>
    <photo-album-app></photo-album-app>
    <script type="module" src="/index.js"></script>
  </body>
</html>
function h (tag, attrs, ...children) {
  var el = document.createElement(tag)
  for (let k in attrs) {
    if (typeof attrs[k] === 'function') {
      el.addEventListener(k, attrs[k])
    } else {
      el.setAttribute(k, attrs[k])
    }
  }
  for (let child of children) el.append(child)
  return el
}

customElements.define('photo-album-app', class extends HTMLElement {
  constructor () {
    super()
    this.siteInfo = undefined
    this.photos = []
  }

  connectedCallback () {
    this.load()
  }

  async load () {
    this.siteInfo = await beaker.hyperdrive.getInfo()
    this.photos = await beaker.hyperdrive.readdir('/photos').catch(e => ([]))

    this.append(h('header', {}, 
      h('h1', {},
        this.siteInfo.title || 'Untitled Photo Album',
        ' ',
        this.siteInfo.writable
          ? h('small', {}, h('a', {href: '#', click: this.onEditInfo.bind(this)}, 'edit'))
          : ''
      ),
      (this.siteInfo.description)
        ? h('p', {}, this.siteInfo.description)
        : '',
      this.siteInfo.writable
        ? h('button', {click: this.onAdd.bind(this)}, '+ Add Photo')
        : '',
      h('input', {type: 'file', accept: '.jpg,.jpeg,.png', change: this.onSelectAdded.bind(this)})
    ))
    this.append(h('div', {class: 'photos'}))
    this.renderPhotos()
  }

  renderPhotos () {
    var container = this.querySelector('.photos')
    container.innerHTML = ''
    for (let photo of this.photos) {
      container.append(
        h('div', {class: 'photo', click: e => this.doViewModal(e, photo)},
          h('img', {src: `/photos/${photo}`, alt: photo})
        )
      )
    }
    if (this.photos.length === 0) {
      container.append(h('div', {class: 'empty'}, 'This album has no photos'))
    }
  }

  onAdd () {
    this.querySelector('input[type="file"]').click()
  }

  onSelectAdded (e) {
    var file = e.currentTarget.files[0]
    if (!file) return
    var fr = new FileReader()
    fr.onload = async () => {
      var ext = file.name.split('.').pop()
      var name = `${Date.now()}.${ext}`
      await beaker.hyperdrive.mkdir('/photos').catch(e => undefined)
      await beaker.hyperdrive.writeFile(`/photos/${name}`, fr.result, 'binary')
      this.photos.push(name)
      this.renderPhotos()

    }
    fr.readAsArrayBuffer(file)
  }
  
  async onEditInfo (e) {
    e.preventDefault()
    await beaker.shell.drivePropertiesDialog(location.toString())
    location.reload()
  }

  async doViewModal (e, photo) {
    e.stopPropagation()

    var existingDialog = this.querySelector('dialog')
    if (existingDialog) existingDialog.remove()

    var description = (await beaker.hyperdrive.stat(`/photos/${photo}`).catch(e => {}))?.metadata?.description

    var dialog = h('dialog', {},
      h('div', {},
        h('img', {src: `/photos/${photo}`}),
        h('div', {},
          this.siteInfo.writable
            ? h('div', {class: 'ctrls'},
              h('button', {class: 'red', click: onDelete}, 'Delete Photo')
            )
            : '',
          h('div', {class: 'description'},
            description ? description : h('em', {}, 'No description'),
          ),
          this.siteInfo.writable
            ? h('div', {class: 'description'}, h('a', {href: '#', click: onShowEditDescription}, 'Edit'))
            : '',
          h('form', {class: 'edit-description'},
            h('textarea', {}, description || ''),
            h('div', {class: 'form-actions'},
              h('button', {class: 'noborder', click: onHideEditDescription}, 'Cancel'),
              h('button', {click: onSaveEditDescription}, 'Save')
            )
          )
        )
      )
    )

    function onShowEditDescription (e) {
      e.preventDefault()
      dialog.classList.add('editing-description')
      dialog.querySelector('.edit-description textarea').focus()
    }

    function onHideEditDescription (e) {
      e.preventDefault()
      dialog.classList.remove('editing-description')
    }

    async function onSaveEditDescription (e) {
      e.preventDefault()
      dialog.classList.remove('editing-description')

      description = dialog.querySelector('.edit-description textarea').value
      dialog.querySelector('.description').textContent = description
      await beaker.hyperdrive.updateMetadata(`/photos/${photo}`, {description})
    }

    async function onDelete (e) {
      if (!confirm('Delete this photo?')) {
        return
      }
      await beaker.hyperdrive.unlink(`/photos/${photo}`)
      location.reload()
    }

    this.append(dialog)
    dialog.showModal()
  }
})

document.body.addEventListener('click', e => {
  var existingDialog = document.querySelector('dialog')
  if (existingDialog && e.path[0] === existingDialog) {
    existingDialog.remove()
  }
})
body {
  margin: 0 10px;
  font-family: 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}

button {
  border: 1px solid #6899fb;
  border-radius: 4px;
  padding: 5px 10px;
  color: #2864dc;
  outline: 0;
  background: #fff;
}

button:hover {
  background: #fafafd;
  cursor: pointer;
}

button.red {
  color: red;
  border-color: red;
}

button.noborder {
  border: 0;
}

photo-album-app {
  display: block;
  max-width: 770px;
  margin: 0 auto;
}

photo-album-app header {
  position: relative;
  padding: 16px 0 20px;
}

photo-album-app header h1,
photo-album-app header p {
  line-height: 1;
  margin: 0;
  letter-spacing: 0.5px
}

photo-album-app header h1 {
  font-weight: 500;
}

photo-album-app header h1 small a {
  font-size: 15px;
  text-decoration: none;
}

photo-album-app header h1 small a:hover {
  text-decoration: underline;
}

photo-album-app header p {
  margin-top: 5px;
}

photo-album-app header button {
  position: absolute;
  bottom: 20px;
  right: 0;
}

photo-album-app input[type="file"] {
  display: none;
}

photo-album-app .photos {
  display: grid;
  grid-template-columns: repeat(auto-fill, 380px);
  grid-gap: 10px;
}

photo-album-app .photos img {
  width: 100%;
  height: 200px;
  object-fit: cover;
  border-radius: 4px;
  cursor: pointer;
}

photo-album-app .photos .empty {
  grid-column-end: 3;
  grid-column-start: 1;
  padding: 130px 0;
  text-align: center;
  background: #f3f3f8;
  color: #667;
}

photo-album-app dialog[open] {
  border: 0;
  padding: 0;
}

photo-album-app dialog > div {
  display: grid;
  grid-template-columns: auto 300px;
}

photo-album-app dialog img {
  max-width: 60vw;
  height: 80vh;
  object-fit: contain;
  background: #f3f3f8;
}

photo-album-app dialog .ctrls {
  padding: 15px;
  background: #fafafd;
  text-align: right;
  position: absolute;
  bottom: 0;
  width: 270px;
}

photo-album-app dialog .description {
  margin: 15px;
  letter-spacing: 0.5px;
  max-height: 70vh;
  overflow-y: auto;
  font-size: 13px;
  white-space: pre-line;
}

photo-album-app dialog .edit-description {
  padding: 15px;
  display: none;
}

photo-album-app dialog .edit-description textarea {
  width: 100%;
  height: 50vh;
  margin-bottom: 10px;
  font-size: 13px;
  letter-spacing: 0.5px;
  resize: none;
  outline: 0;
}

photo-album-app dialog .edit-description .form-actions {
  display: flex;
  justify-content: space-between;
}

photo-album-app dialog.editing-description .description {
  display: none;
}

photo-album-app dialog.editing-description .edit-description {
  display: block;
}