Skip to content

Virtual pages from image gallery

Intro

If we want to show single files on a page together with some additional information, we can create a parent page with subpages, and upload a single image to each subpage. However, in the Panel those pages would be pretty empty when the information about the image is actually stored in the image meta data. Wouldn't it be cool and less time consuming if we could just upload a bunch of images to the parent page, fill in the meta data—maybe even automatically add some EXIF data at upload (no, that's not part of this recipe)—,and the child pages would be automagically created from these images?

Thanks to virtual pages in Kirby, we can, and that with little more code than what we would need for the parent/subpage approach. Let's work out how.

Prerequisites

  • A working Kirby installation. In this example, we use a Starterkit to make use of the existing styles, but you might as well use a Plainkit or your own setup.
  • A code editor of your choice
  • Optionally, some music that gets you into coding mood

Files we will create in this recipe

Blueprints

  • /site/blueprint/pages/gallery.yml
  • /site/blueprint/files/gallery-image.yml

Templates

  • /site/templates/gallery.php
  • /site/templates/gallery-image.php

Models

  • /site/models/gallery.php
  • /site/models/gallery-image.php

Plugin (optional)

  • /site/plugins/virtual-gallery/index.php

At the end of this recipe we will wrap all this code into a plugin. So if you prefer to start with that structure right from the beginning, you will find the folder structure below.

Let's start with creating a blueprint for our new gallery overview page with a files section to which we can upload the images and assign a files blueprint.

/site/blueprints/pages/gallery.yml
title: Gallery

sections:
  files:
    headline: Gallery
    type: files
    layout: cards
    size: medium
    template: gallery-image

In our site.yml, we have to adapt the pages section and add the gallery template as a new template option. We also change the create option, so that this part of the blueprint now looks like this:

/site/blueprints/site.yml
#...
pages:
  type: pages
  create: gallery # we want to create a new page with this template
  templates:
    - about
    - home
    - default
    - gallery

Now, log in to Panel and create a new gallery page using the gallery.yml blueprint. Upload some files (if you use the Starterkit, you can borrow somes images from the subpages of the photography page). Then publish the page.

Before we visit the image gallery on the frontend, we need a new template—gallery.php—with the following code:

/site/template/gallery.php
<?php snippet('header') ?>
<?php snippet('intro') ?>

<ul class="grid" style="--gutter: 1.5rem">
  <?php foreach ($page->images() as $image): ?>
  <li class="column" style="--columns: 4">
    <a href="<?= $image->url() ?>">
      <figure>
        <span class="img" style="--w:4;--h:5">
          <?= $image->crop(400, 500) ?>
        </span>
      </figure>
    </a>
  </li>
  <?php endforeach ?>
</ul>

<?php snippet('footer') ?>

We are ready to view our spectacular little gallery on the frontend.

If you look closely, you will notice that we currently output the images of the page and the href attribute points to the image URL itself. If you click on any of the links, the browser will open the single file in a new tab or window, but not in the context of a new page. That's not really what we want, and we'll of course fix that later.

So next, let's go tell Kirby that we want these images to be children of the gallery page.

We can fetch child pages of a given page in Kirby with the children() method, and by default this method returns the subfolders of the given page.

In a Page Model, we can however overwrite this method and thus change what Kirby regards as children. Let's create such a model in the site/models folder.

/site/models/gallery.php
<?php

class GalleryPage extends Page
{
  public function children()
  {
    $images = [];

    foreach ($this->images()->template('gallery-image') as $image) {
      $images[] = [
        'slug'     => $image->name(),
        'num'      => $image->sort()->value(),
        'template' => 'gallery-image',
        'model'    => 'gallery-image',
      ];
    }

    return Pages::factory($images, $this);
  }
}

We loop through the images, store the page properties for each image in the $images array and pass it to the Pages::factory().

Note that we use the file's name instead of the filename (i.e. without the extension) for the page slug. This can have certain downsides if you happen to have multiple files with the same filename but a different extension, which also use the same file template. On the other hand, we probably don't want to use the extension in the URL, particularly if we want to provide different file formats, e.g. support for webp images.

It's up to you to adapt the slug as fits your use case. If you change the slug, you will have to adapt the image method in the gallery-image.php page model, though.

If you want to use the unsluggified filename with extension (e.g. attentions-sharks.jpg), you will need an additional route to prevent Kirby redirecting the URL to the media folder.

With this model in place, we can now modify our gallery.php template and output the child pages instead of the images. Let's do it!

/site/template/gallery.php
<?php snippet('header') ?>
<?php snippet('intro') ?>

<ul class="grid" style="--gutter: 1.5rem">
  <?php foreach ($page->children() as $child): ?>
  <li class="column" style="--columns: 4">
    <a href="<?= $child->url() ?>">
      <figure>
        <span class="img" style="--w:4;--h:5">
          <?= $child->url() ?>
        </span>
      </figure>
    </a>
  </li>
  <?php endforeach ?>
</ul>

<?php snippet('footer') ?>

Instead of looping through the images as before, we now loop through the children (and just output the URL for the moment).

But if we open this page in the browser, all we see is some url strings (or black rectangles if you used the Starterkit) instead of the images we had before. But if we click on one of the links, the new virtual subpage opens with the file's name as title (and otherwise almost empty). We are getting there…

Fetch image

How do we get the images back? We need another page model for the child pages that fetches the correct image from the images of the parent page based on the page slug.

/site/models/gallery-image.php
<?php

class GalleryImagePage extends Page
{
  public function image(?string $filename = null)
  {
    if (!$filename) {
      return $this->parent()->images()->template('gallery-image')->findBy('name', $this->slug());
    }

    return parent::image($filename);
  }
}

Here we redefine the image() method and return the image that matches the page slug if no file name is passed to the method. Otherwise, we fall back to the parent method. If necessary, you can modify this code to also filter by file type or extension (e.g. if you use different versions of the same file).

We can now adjust the gallery.php template again:

/site/template/gallery.php
<?php snippet('header') ?>
<?php snippet('intro') ?>

<ul class="grid" style="--gutter: 1.5rem">
  <?php foreach ($page->children() as $child): ?>
  <li class="column" style="--columns: 4">
    <a href="<?= $child->url() ?>">
      <?php if ($image = $child->image()) : ?>
      <figure>
        <span class="img" style="--w:4;--h:5">
          <?= $image->crop(500,600) ?>
        </span>
      </figure>
      <?php endif; ?>
    </a>
  </li>
  <?php endforeach ?>
</ul>

<?php snippet('footer') ?>

And hurray! The images show up on the frontend again.

File blueprint

Let's quickly set up the file blueprint with some fields so that we can add some meta data to the images.

/site/blueprints/files/gallery-image.yml
title: Gallery Image

columns:
  main:
    width: 2/3
    fields:
      title:
        type: text
      subheading:
        type: text
      story:
        type: blocks
        fieldsets:
          - text
          - heading
          - quote

  sidebar:
    width: 1/3
    fields:
      date:
        label: Date taken
        type: date
      photographer:
        type: text
      place:
        type: text

Since we want to access the file meta data as if it was page data, we have to map each image's meta data to the page content object, which we do inside the children method of the gallery.php model from above.

/site/models/gallery.php
<?php

class GalleryPage extends Page
{
  public function children()
  {
    $images = [];

    foreach ($this->images()->template('gallery-image') as $image) {
      $images[] = [
        'slug'     => $image->name(),
        'num'      => $image->sort()->value(),
        'template' => 'gallery-image',
        'model'    => 'gallery-image',
        'content'  => $image->content()->toArray(),
      ];
    }

    return Pages::factory($images, $this);
  }
}

Child page template

With the page model complete, let's set out to create a template for the children pages.

/site/templates/gallery-image.php
<?php snippet('header') ?>

<?php if ($image = $page->image()) : ?>
  <a href="<?= $image->crop(1200, 600)->url() ?>" data-lightbox class="img margin-s" style="--w:2; --h:1">
    <?= $image ?>
  </a>
<?php endif ?>

<article>
  <div class="grid">
    <div class="column" style="--columns:11; --gutter:1.5rem">
      <h1 class="h1 margin-m"><?= $page->title()->html() ?></h1>
      <?php if ($page->subheading()->isNotEmpty()) : ?>
        <p class="h2 color-grey"><?= $page->subheading()->html() ?></p>
      <?php endif ?>
      <time datetime="<?= $page->date('c') ?>">Taken on <?= $page->date() ?></time>
      <p class="margin-m">by <?= $page->photographer()->html() ?></p>
      <?= $page->story()->toBlocks() ?>
    </div>

    <div class="column" style="--columns:1">
      <nav class="blog-prevnext">
        <div class="grid" style="--gutter: 0.5rem;">
          <?php if ($prev = $page->prev()) : ?>
            <a href="<?= $prev->url() ?>" class="column" style="--columns:6;text-align: right">&larr;</a>
          <?php else : ?>
            <span class="column color-grey" style="--columns:6; text-align: right">&larr;</span>
          <?php endif ?>
          <?php if ($next = $page->next()) : ?>
            <a href="<?= $next->url() ?>" class="column" style="--columns:6; text-align: right">&rarr;</a>
          <?php else : ?>
            <span class="column color-grey" style="--columns:6; text-align: right;">&rarr;</span>
          <?php endif ?>
        </div>
      </nav>
    </div>
  </div>
</article>

<?php snippet('footer') ?>

Provided you have filled in some data for each image, you can now lean back and admire the result. Feel free to adjust the styles or the content fields to your liking.

There's only one thing left to do…

File component

At this point, when we update a file's metadata, we cannot preview the resulting page like a normal page. If we click on the Open button in the file view, we are just redirected to the file. We could now add a pages section to our gallery.yml together with a blueprint for the subpages and extend the model, but that would only complicate matters without really adding any advantages.

Instead, we can use a file::url component that changes the file URL depending on the request header. In this case, we want to modify the URL only if the visitor wants a JSON response.

This component might actually interfere with JSON requests from the frontend, so only add this component if it works for you.

Inside the /plugins folder, create a folder virtual-gallery with the following index.php

/site/plugins/file-component/index.php
<?php
Kirby::plugin('cookbook/virtual-gallery', [
  'components' => [
    'file::url' => function (Kirby $kirby, $file) {
      if ($kirby->visitor()->prefersJson() && $file->parent()->slug() === 'gallery') {
          return $kirby->url() . '/' . $file->parent()->slug() . '/' . $file->name();
      }

      return $file->mediaUrl();
    }
  ]
]);

Final result

Done! The virtual pages from files in the parent page are now working exactly like we wanted.

Here is the final code again, this time all nicely tucked up into a plugin.

Folder structure

  • plugins
    • virtual-gallery
      • blueprints
        • files
          • gallery-image.yml
        • pages
          • gallery.yml
      • models
        • Gallery.php
        • GalleryImage.php
      • templates
        • gallery.php
        • gallery-image.php
      • index.php

index.php

/site/plugins/virtual-gallery/index.php
<?php
use Kirby\Cms\App as Kirby;

require __DIR__ . '/models/Gallery.php';
require __DIR__ . '/models/GalleryImage.php';

Kirby::plugin('cookbook/virtual-gallery', [
  'blueprints' => [
    'pages/gallery'       => __DIR__ . '/blueprints/pages/gallery.yml',
    'pages/gallery-image' => __DIR__ . '/blueprints/pages/gallery-image.yml',
    'files/gallery-image' => __DIR__ . '/blueprints/files/gallery-image.yml',
  ],
  'pageModels' => [
    'gallery'       => 'GalleryPage',
    'gallery-image' => 'GalleryImagePage',
  ],
  'templates' => [
    'gallery'       => __DIR__ . '/templates/gallery.php',
    'gallery-image' => __DIR__ . '/templates/gallery-image.php',
  ],
  'components' => [
    'file::url' => function (Kirby $kirby, $file) {
      if ($kirby->visitor()->prefersJson() && $file->parent()->slug() === 'gallery') {
          return $kirby->url() . '/' . $file->parent()->slug() . '/' . $file->name();
      }

      return $file->mediaUrl();
    }
  ]
]);
/site/plugins/virtual-gallery/blueprints/pages/gallery.yml
title: Gallery

sections:
  files:
    headline: Gallery images
    type: files
    layout: cards
    size: medium
    template: gallery-image
    text: "{{ file.title }}"
    info: "{{ file.photographer }}"
    image:
      cover: true
/site/plugins/virtual-gallery/blueprints/files/gallery.yml
title: Gallery Image

columns:
  main:
    width: 2/3
    fields:
      title:
        type: text
      subheading:
        type: text
      story:
        type: blocks
        fieldsets:
          - text
          - heading
          - quote

  sidebar:
    width: 1/3
    fields:
      date:
        label: Date taken
        type: date
      photographer:
        type: text
      place:
        type: text
/site/plugins/virtual-gallery/models/Gallery.php
<?php

use Kirby\Cms\Page;
use Kirby\Cms\Pages;

class GalleryPage extends Page
{
  public function children()
  {
    $images = [];

    foreach ($this->images()->template('gallery-image') as $image) {
      $images[] = [
        'slug'     => $image->name(),
        'num'      => $image->sort()->value(), // or use the image sort number here if available
        'template' => 'gallery-image',
        'model'    => 'gallery-image',
        'content'  => $image->content()->toArray(),
      ];
    }

    return Pages::factory($images, $this);
  }
}

GalleryImage model

/site/plugins/virtual-gallery/models/GalleryImage.php
<?php

use Kirby\Cms\Page;

class GalleryImagePage extends Page
{
  public function image(?string $filename = null)
  {
    if (!$filename) {
      return $this->parent()->images()->template('gallery-image')->findBy('name', $this->slug());
    }

    return parent::filename($filename);
  }
}
/site/plugins/virtual-gallery/templates/gallery.php
<?php snippet('header') ?>
<?php snippet('intro') ?>

<ul class="grid" style="--gutter: 1.5rem">
  <?php foreach ($page->children() as $child): ?>
  <li class="column" style="--columns: 4">
    <a href="<?= $child->url() ?>">
      <?php if ($image = $child->image()) : ?>
      <figure>
        <span class="img" style="--w:4;--h:5">
          <?= $image->crop(500,600) ?>
        </span>
      </figure>
      <?php endif; ?>
    </a>
  </li>
  <?php endforeach ?>
</ul>

<?php snippet('footer') ?>
/site/plugins/virtual-gallery/templates/gallery-image.php
<?php snippet('header') ?>

<?php if ($image = $page->image()) : ?>
  <a href="<?= $image->crop(1200, 600)->url() ?>" data-lightbox class="img margin-s" style="--w:2; --h:1">
    <?= $image ?>
  </a>
<?php endif ?>

<article>
  <div class="grid">
    <div class="column" style="--columns:11; --gutter:1.5rem">
      <h1 class="h1 margin-m"><?= $page->title()->html() ?></h1>
      <?php if ($page->subheading()->isNotEmpty()) : ?>
        <p class="h2 color-grey"><?= $page->subheading()->html() ?></p>
      <?php endif ?>
      <time datetime="<?= $page->date('c') ?>">Taken on <?= $page->date() ?></time>
      <p class="margin-m">by <?= $page->photographer()->html() ?></p>
      <?= $page->story()->toBlocks() ?>
    </div>

    <div class="column" style="--columns:1">
      <nav class="blog-prevnext">
        <div class="grid" style="--gutter: 0.5rem;">
          <?php if ($prev = $page->prev()) : ?>
            <a href="<?= $prev->url() ?>" class="column" style="--columns:6;text-align: right">&larr;</a>
          <?php else : ?>
            <span class="column color-grey" style="--columns:6; text-align: right">&larr;</span>
          <?php endif ?>
          <?php if ($next = $page->next()) : ?>
            <a href="<?= $next->url() ?>" class="column" style="--columns:6; text-align: right">&rarr;</a>
          <?php else : ?>
            <span class="column color-grey" style="--columns:6; text-align: right;">&rarr;</span>
          <?php endif ?>
        </div>
      </nav>
    </div>
  </div>
</article>

<?php snippet('footer') ?>

Further reading

Author