Build a Tag-Filtered Image Gallery

If you've got a lot of art to show off but don't want to hand-write a new <img> tag every time you finish a piece, this is the tutorial for you.
We'll do this with a script that scans your folders, tags your images, and lets visitors filter what they see, no rebuilding by hand, ever again.

01

Requirements

Two things, both free, both quick to install:

  • An IDE (Integrated Developer Environment) — something like Codium or VSCode — for editing your code.
  • Pythondownload it here — for generating a file manifest.
02

Making the Skeleton

Start by creating four files. I like to keep them together in their own sub-folder, but they can live anywhere in your Neocities site as long as the links between them are correct:

-> gallery.html
-> gallery-style.css
-> gallery-script.js
-> generate-manifest.py
03

HTML Hypertext Markup Language

The general idea here is that we want:

  1. A way to select which folder we want to display (dropdown, buttons, etc.)
  2. A gallery element we can add the images to
  3. A tag filter bar so visitors can narrow down what they see
  4. A lightbox element that appears when we click an image in the grid

gallery.html

<html>
	<header>
		<!-- This limits the character type to UTF-8 (See: https://en.wikipedia.org/wiki/UTF-8) -->
		<meta charset="UTF-8">
		<!-- This is a more advanced concept called the 'viewport' -->
		<meta name="viewport" content="width=device-width, initial-scale=1.0">

		<!-- This is how you tell the html to include your CSS and JS files -->
		<link href="/gallery-style.css" rel="stylesheet" type="text/css" media="all">
		<script src="/gallery-script.js"></script>
	</header>

	<body>
		<!--
		This block is for selecting which folder / tag we want to display,
		you could also use buttons or any element with an onclick / onchange property
		-->
		<div class="char-selection">
		    <div class="select-wrapper">
			    <!--
			    'select' is just a dropdown menu with an onchange event we can use to call a JS function.
			    The 'gallery' here is just the id of the element we want to insert the img tags in
			    -->
			    <select name="character" class="char-option" onchange="fetchImages(this.value, 'gallery')">
		            <option class="char-option" id="defaultOption" value="bull">Xerion (Bull)</option>
		            <option class="char-option" value="dog">Angus (Golden Retriever)</option>
		        </select>
		    </div>
		</div>

		<!-- Filter tags as a bar above the gallery -->
		<div class="tag-filters" id="tagFilters"></div>

		<!--
		This is the element we will dynamically add our images to each time the page loads
		or we select a new character (folder) to view
		-->
		<div class="gallery-grid" id="gallery"></div>

		<!--
		A "lightbox" is a common name for a web element that shows an image in isolation
		when you click on it. We set the image and show it each time an image is clicked,
		and unset the image when we close the lightbox
		-->
		<div class="lightbox" id="lightbox">
	        <span class="lightbox-close" id="lightbox-close">&times;</span>
	        <img class="lightbox-img" id="lightbox-img" src="" alt="">
	    </div>

	</body>
</html>
04

CSS Cascading Style Sheet

Now we take those skeleton elements and style them so we get:

  • A grid layout of images that scales with however many we render
  • A lightbox element that's hidden by default but scales with the image once it's set
  • Optional: some styling for the selection dropdown, just to make it look nice

gallery-style.css

/* This is mostly just styling sugar for my dropdown (select)
You could take this out if you wanted buttons or something else */
/* --------- Character selection ---------- */
.char-selection {
    display: flex;
    justify-content: center;
}

.select-wrapper {
    position: relative;
    display: inline-block;
}

.select-wrapper::after {
    content: "▾";
    position: absolute;
    right: 1rem;
    top: 50%;
    transform: translateY(-50%);
    pointer-events: none;
    font-size: 0.9rem;
    color: #222;
}

.char-option {
    appearance: none;
    -webkit-appearance: none;
    -moz-appearance: none;

    font-size: 1rem;
    color: #222;

    padding: 0.6rem 2.5rem 0.6rem 1.2rem;
    border: 2px solid black;
    border-radius: 999px;
    background-color: #fff;

    cursor: pointer;
    transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.char-option:hover {
    border-color: #555;
}

.char-option:focus {
    outline: none;
    border-color: #000;
    box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.1);
}

/* ------- Tag Filters ------- */
.tag-filters {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-items: center;
    gap: 8px;
    max-width: 900px;
    margin: 0 auto 1.5rem;
    padding: 0.9rem 1.2rem;
    background-color: rgba(255, 255, 255, 0.85);
    border: 2px solid black;
    border-radius: 12px;
}

.tag-filter-btn {
    font-family: "Nerko One", cursive;
    font-size: 1rem;
    padding: 5px 14px;
    border-radius: 999px;
    border: 2px solid black;
    background-color: #fff;
    color: #111;
    cursor: pointer;
    transition: background-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}

.tag-filter-btn:hover {
    box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.1);
}

.tag-filter-btn.active {
    background-color: #b8f2ba;
    /* light green */
    color: #111;
    border-color: #2f7a33;
}

.tag-filter-clear {
    font-style: italic;
    background-color: #eee;
}

.tag-filter-divider {
    width: 2px;
    height: 22px;
    background-color: black;
    opacity: 0.25;
    margin: 0 6px;
}

/* --------- Gallery grid ---------- */
/* This is important, it controls the styling of the gallery grid and
the image width constraints */
.gallery-grid {
    display: grid;
	/* Limits our grid column to be minimum 200px, fr is a fractional unit of the grid size, see more here:
	https://www.geeksforgeeks.org/css/css-grid-layout-the-fr-unit */
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
    gap: 1.5rem;
    margin: 1%;
    align-items: start;
}

.gallery-grid a {
    display: block;
    border: 5px double black;
    transition: background-color 0.15s ease;
}

.gallery-grid a:hover {
    background-color: #000;
}

/* Allows the aspect ratio to be preserved */
.gallery-grid img {
    display: block;
    width: 100%;
    height: auto;
}

/* --------- Lightbox overlay --------- */
/* By default this will be hidden so the user can't see it unless they
click on one of the gallery images */
.lightbox {
    display: none;
    position: fixed;
    z-index: 1000;
    inset: 0;
    background-color: rgba(0, 0, 0, 0.85);
    align-items: center;
    justify-content: center;
    padding: 2rem;
}

.lightbox.open {
    display: flex;
}

.lightbox-img {
	/* The vw / vh here are viewport width / height,
	the number in front is the percentage (takes up 90% of viewable screen) */
    max-width: 90vw;
    max-height: 90vh;
    width: auto;
    height: auto;
    border: 5px double black;
    background-color: white;
}

.lightbox-close {
    position: fixed;
    top: 1.5rem;
    right: 2rem;
    font-size: 2.5rem;
    line-height: 1;
    color: white;
    cursor: pointer;
    font-family: Georgia, serif;
    user-select: none;
    transition: color 0.15s ease;
}

.lightbox-close:hover {
    color: #ccc;
}
05

JS JavaScript

Now that we have all the elements, let's add scripting to find our images in each sub-folder and add them to the gallery.

gallery-script.js

// Holds the manifest data after the first fetch, so we don't fetch it again.
var manifestCache = null;

// The folder currently being shown, e.g. "bull" or "dog".
var activeFolder = null;

// The tags currently selected as filters.
var explicitTag = "sfw";
var activeTags = new Set();

// Loads a folder's images: uses the cached manifest if we have it, otherwise fetches it first.
function fetchImages(folder, parentElementId, defaultTags) {
    activeFolder = folder;
    activeTags = new Set(defaultTags || []);
    explicitTag = "sfw";

    if (manifestCache !== null) {
        buildTagFilters(folder);
        renderImages(folder, parentElementId);
        return;
    }

    fetch('/gallery-manifest.json')
        .then(function (response) { return response.json(); })
        .then(function (manifest) {
            manifestCache = manifest;
            buildTagFilters(folder);
            renderImages(folder, parentElementId);
        })
        .catch(function (error) { console.error(error); });
}

// Draws every image in the folder that matches the active tag filters.
function renderImages(folder, parentElementId) {
    var container = document.getElementById(parentElementId);
    container.innerHTML = '';

    var files = manifestCache[folder] || [];

    files.forEach(function (entry) {
        var entryTags = entry.tags || [];
        var matches = false;

        // Early return for explicit tag match
        if (entryTags.indexOf(explicitTag) === -1) {
            return;
        }

        if (activeTags.size !== 0) {
            // The image must have every currently active tag (OR logic).
            activeTags.forEach(function (tag) {
                if (entryTags.indexOf(tag) !== -1) {
                    matches = true;
                }
            });

            if (!matches) {
                return;
            }
        }

        var fullSrc = '/' + folder + '/' + entry.file;
        var link = document.createElement('a');
        var img = document.createElement('img');

        link.href = fullSrc; // backup link in case JS fails
        link.addEventListener('click', function (event) {
            event.preventDefault();
            openLightbox(fullSrc);
        });

        img.src = fullSrc;
        link.appendChild(img);
        container.appendChild(link);
    });
}

// Rebuilds the filter bar: Clear, SFW, NSFW, then every other tag found in this folder.
function buildTagFilters(folder) {
    var filterContainer = document.getElementById('tagFilters');
    if (filterContainer === null) {
        return;
    }
    filterContainer.innerHTML = '';

    var clearBtn = document.createElement('button');
    clearBtn.type = 'button';
    clearBtn.className = 'tag-filter-btn tag-filter-clear';
    clearBtn.textContent = 'Clear Filter';
    clearBtn.addEventListener('click', function () {
        activeTags.clear();
        explicitTag = "sfw";
        updateFilterButtonStates();
        renderImages(activeFolder, 'gallery');
    });
    filterContainer.appendChild(clearBtn);

    filterContainer.appendChild(createTagButton('sfw', true));
    filterContainer.appendChild(createTagButton('nsfw', true));

    var otherTags = new Set();
    (manifestCache[folder] || []).forEach(function (entry) {
        (entry.tags || []).forEach(function (tag) {
            var lower = tag.toLowerCase();
            if (lower !== 'sfw' && lower !== 'nsfw') {
                otherTags.add(tag);
            }
        });
    });

    if (otherTags.size > 0) {
        var divider = document.createElement('span');
        divider.className = 'tag-filter-divider';
        filterContainer.appendChild(divider);

        Array.from(otherTags).sort().forEach(function (tag) {
            filterContainer.appendChild(createTagButton(tag, false));
        });
    }

    updateFilterButtonStates();
}

// Builds a single filter button. isRating buttons (sfw/nsfw) exclude each other when clicked.
function createTagButton(tag, isRating) {
    var button = document.createElement('button');
    button.type = 'button';
    button.className = 'tag-filter-btn';
    button.setAttribute('data-tag', tag);
    button.textContent = isRating ? tag.toUpperCase() : tag;

    button.addEventListener('click', function () {
        if (activeTags.has(tag)) {
            activeTags.delete(tag);
        }
        else {
            if (isRating) {
                explicitTag = tag;
            }
            else {
                activeTags.add(tag);
            }
        }
        updateFilterButtonStates();
        renderImages(activeFolder, 'gallery');
    });

    return button;
}

// Highlights whichever filter buttons match the currently active tags.
function updateFilterButtonStates() {
    var filterContainer = document.getElementById('tagFilters');
    if (filterContainer === null) {
        return;
    }

    filterContainer.querySelectorAll('.tag-filter-btn[data-tag]').forEach(function (button) {
        var tag = button.getAttribute('data-tag');
        button.classList.toggle('active', activeTags.has(tag) || (explicitTag == tag));
    });
}

// Shows the lightbox overlay with the given image.
function openLightbox(src) {
    document.getElementById('lightbox-img').src = src;
    document.getElementById('lightbox').classList.add('open');
    document.body.style.overflow = 'hidden';
}

// Hides the lightbox overlay and clears its image.
function closeLightbox() {
    document.getElementById('lightbox').classList.remove('open');
    document.getElementById('lightbox-img').src = '';
    document.body.style.overflow = '';
}

// Runs once the page loads: shows the default character and wires up the lightbox controls.
window.addEventListener('DOMContentLoaded', function () {
    var select = document.getElementById('defaultOption');
    fetchImages(select.value, 'gallery');

    var lightbox = document.getElementById('lightbox');
    document.getElementById('lightbox-close').addEventListener('click', closeLightbox);

    lightbox.addEventListener('click', function (event) {
        if (event.target === lightbox) {
            closeLightbox();
        }
    });

    document.addEventListener('keydown', function (event) {
        if (event.key === 'Escape') {
            closeLightbox();
        }
    });
});
06

Python The Manifest Generator

This whole concept hinges on a file called gallery-manifest.json, which we generate with a Python script that does the heavy lifting for us.

generate_manifest.py

#!/usr/bin/env python3
"""
generate_manifest.py

Run this from your Neocities site's root folder. It scans specific
subfolders for image files and writes out gallery-manifest.json,
mapping each folder name to a list of {file, tags} entries.

The gallery-manifest.json file is the store of your image paths and tags.

By default, any newly discovered image triggers an interactive prompt
asking you to enter tags right then and there. Use --non-interactive
(or -n) to skip prompting entirely and give new images an empty tag list
instead.

Tag input format: space-separated, each tag wrapped in double quotes.
    Example: "sfw" "bull" "fullbody"

Usage:
    python generate_manifest.py
    python generate_manifest.py --non-interactive
"""

import argparse
import json
import os

# Folders to scan, relative to the site root. Add/remove as needed.
FOLDERS = ["bull", "dog"]

# File extensions considered "images". Add more if needed (e.g. ".webp").
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}

OUTPUT_FILE = "gallery-manifest.json"


def load_tags():
    """
    Load existing tags from gallery-manifest.json, or start fresh if absent.
    Returns a dict mapping "folder/filename" -> list of tags.
    """
    if not os.path.isfile(OUTPUT_FILE):
        return {}

    with open(OUTPUT_FILE, "r", encoding="utf-8") as f:
        manifest = json.load(f)

    tags_lookup = {}
    for folder, entries in manifest.items():
        for entry in entries:
            key = f"{folder}/{entry['file']}"
            tags_lookup[key] = entry.get("tags", [])
    return tags_lookup


def get_images_in_folder(folder_path):
    """Return a sorted list of image filenames in the given folder."""
    if not os.path.isdir(folder_path):
        print(f"  Warning: folder '{folder_path}' not found, skipping.")
        return []

    images = [
        name
        for name in os.listdir(folder_path)
        if os.path.isfile(os.path.join(folder_path, name))
        and os.path.splitext(name)[1].lower() in IMAGE_EXTENSIONS
    ]
    images.sort()
    return images


def parse_tag_line(raw):
    """
    Parse a line like:  "sfw" "bull" "fullbody"

    Returns (tags, error_message). On success, error_message is None.
    On failure, tags is None and error_message explains what was wrong.

    Rules:
      - The whole line must consist of double-quoted tokens separated
        by whitespace, and nothing else.
      - Each token must be non-empty.
      - No unquoted text is allowed outside of whitespace.
    """
    tags = []
    i = 0
    length = len(raw)

    while i < length:
        # Skip whitespace between tokens
        while i < length and raw[i].isspace():
            i += 1

        if i >= length:
            break

        if raw[i] != '"':
            return None, (
                f"Unexpected character '{raw[i]}' at position {i} — "
                'every tag must be wrapped in double quotes, e.g. "sfw".'
            )

        # Find the closing quote
        start = i + 1
        end = start
        while end < length and raw[end] != '"':
            end += 1

        if end >= length:
            return None, "Missing closing quote — every tag needs an opening and closing \"."

        tag = raw[start:end]
        if tag == "":
            return None, "Found empty quotes \"\" — tags can't be blank."

        tags.append(tag)
        i = end + 1  # move past the closing quote

    if not tags:
        return None, "No tags found."

    # De-duplicate while preserving order
    seen = set()
    deduped = []
    for t in tags:
        if t not in seen:
            seen.add(t)
            deduped.append(t)

    return deduped, None


def prompt_for_tags(key):
    """
    Interactively prompt the user for tags on a single image.
    Loops until valid input (or a blank line for "no tags") is given.
    """
    print(f"\nNew image: {key}")
    while True:
        raw = input(
            '  Enter tags, e.g. "sfw" "bull" "fullbody" (or press Enter for none): '
        ).strip()

        if raw == "":
            return []

        tags, error = parse_tag_line(raw)
        if error:
            print(f"  Invalid format: {error} Try again.")
            continue

        return tags


def main():
    parser = argparse.ArgumentParser(description="Generate gallery-manifest.json")
    parser.add_argument(
        "-n", "--non-interactive",
        action="store_true",
        help="Skip tag prompts; new images get an empty tag list instead.",
    )
    args = parser.parse_args()

    tags_lookup = load_tags()
    new_count = 0
    tagged_count = 0
    manifest = {}

    for folder in FOLDERS:
        print(f"Scanning '{folder}'...")
        images = get_images_in_folder(folder)

        entries = []
        for name in images:
            key = f"{folder}/{name}"

            if key not in tags_lookup:
                new_count += 1
                if args.non_interactive:
                    tags_lookup[key] = []
                else:
                    tags_lookup[key] = prompt_for_tags(key)
                    if tags_lookup[key]:
                        tagged_count += 1

            entries.append({"file": name, "tags": tags_lookup[key]})

        manifest[folder] = entries
        print(f"  Found {len(images)} image(s).")

    with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
        json.dump(manifest, f, indent=2)

    if new_count:
        if args.non_interactive:
            print(f"\n{new_count} new image(s) written with empty tags.")
        else:
            print(
                f"\n{new_count} new image(s) found, {tagged_count} tagged interactively."
            )
    else:
        print("\nNo new images found.")

    total = sum(len(v) for v in manifest.values())
    print(f"Wrote {OUTPUT_FILE} with {total} total images.")


if __name__ == "__main__":
    main()
07

Using It

This script runs in two modes:

Standard

Prompts you to add tags for each new image it discovers, in the format "sfw" "fullbody" "panel".

python generate_manifest.py

Non-Interactive

Skips the prompts entirely and adds your images with no tags, so you can go back and fill them in later.

python generate_manifest.py -n

Either way, the script creates gallery-manifest.json. If you chose the non-interactive mode, you can always open that file later and add tags by hand.