NanoAlbum 2.0

Da das NanoAlbum https://github.com/oliworx/NanoAlbum seit längerem nicht mehr modifiziert wurde, habe ich mich mal hingesetzt und dieses erweitert.

Was ist NanoAlbum

Es ist ein PHP Script welches autom. alle Bilder im Verzeichnis als Gallerie einließt und anzeigt. Es ist extra simple gehalten und hat nur diese eine Datei. Ein paar Einstellungen kann man am Anfang des Scripts machen.

Es bietet jetzt zusätzlich:

  • responsive Design
  • Anzeige der Metadaten des Bildes
  • swipeing / wischen auf Handy und Tablet
  • modernerer Aufbau des PHP Scripts
  • Download Funktion des Bildes

Ein paar Screenshots

Demo

folgt

Installation

Entpacke den Download und kopiere mit z.B. einem FTP Programm die index.php in einen Ordner (z.b. /images) auf deinem Webspace. Erstelle in diesem für jede Bildersammlung (Kategorie) einen Ordner /images/birthday oder images/hochzeit und kopiere deine Bilder hinein.

Das was schon. Rufe deine Url gefolgt mit deinem Ordner auf:

z.B. https://www.meineurl.de/images

Download

📥

NanoAlbum 2

📊 35 Downloads 🏷️ v2.0
Mit dem Download wird akzeptiert, dass die Software ist wie sie ist und keine Haftung in irgendwelcher Art auch immer übernommen wird!

Code

<?php
/*
* NanoAlbum 2.0
* is a simple and small PHP photo album/gallery
* modified by Conny Henn - www.blog.hennweb.de
* last modified: 4/2025

Goals:
* KISS - keep it small and simple
* no database required
* zero configuration / little configuration
* whith download button, reconstruced code, swipe by tablet or mobile phone, responsive
* small footprint: basic functionality in just one single file
* no wasting of display area, use whole screen
* design for modern browsers, html5, css3
* responsive design for mobile devices
* save bandwidth, using Client cache where possible
* provide original photos for download and viewing
* Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
* original https://github.com/oliworx/NanoAlbum
*
* Install:
* make a subdir and copy this php file inside
* for a category make a subfolder and copy your images inside
* thats all
*/

// Configuration with defaults
@include('config.php');
$defaults = [
    'TITLE' => 'Album',
    'THUMBNAIL_SIZE' => 160,
    'MEDIUM_SIZE' => 600,
    'MOBILE_SIZE' => 600,
    'PRELOAD_IMAGES' => true,
    'CSS_INLINE' => false,
    'FOOTER' => 'powered by <a href="https://github.com/oliworx/NanoAlbum">NanoAlbum</a> | ',
    'SHOW_METADATA' => true,  // Display image metadata (EXIF)
    'SHOW_GPS_DATA' => false  // Display GPS location from EXIF (privacy consideration)
];

// Apply defaults if constants not defined
foreach ($defaults as $const => $value) {
    if (!defined($const)) define($const, $value);
}

define('SELF', $_SERVER['SCRIPT_NAME']);
define('BATSU', base64_decode('R0lGODlhEgARALMAAMwzZvzy9eWZstVZgt9/n/XZ4v////LM2c8/b9lljAAAAAAAAAAAAAAAAAAAAAAAACH5BAAHAP8ALAAAAAASABEAAAQ30MhJq7046ynC7VYwDB5FIMUlktOZYqvnbuu8GQECHLd0jiWNKyZESYgX2xEYSrwoAWdvSqVGAAA7'));

/**
 * Send content with caching headers if it has changed
 * 
 * @param string $sContent Content to send
 * @param int $iMaxAge Max age in seconds for cache
 */
function sendIfChanged($sContent, $iMaxAge = 60) {
    // Set cache headers
    $cacheHeaders = [
        "Pragma: public",
        "Cache-Control: max-age=" . $iMaxAge,
        'Expires: ' . gmdate('D, d M Y H:i:s', time() + $iMaxAge) . ' GMT'
    ];
    
    foreach ($cacheHeaders as $header) {
        header($header);
    }
    
    // Generate Etag
    $sEtag = md5($sContent);
    
    // Check if client already has this version
    if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && $_SERVER['HTTP_IF_NONE_MATCH'] === $sEtag) {
        header('HTTP/1.1 304 Not Modified');
        return;
    }
    
    // Send the content with etag
    header('Etag: "' . $sEtag . '"');
    echo $sContent;
}
/**
 * Generate CSS for the gallery
 * 
 * @param bool $detached Whether to send as separate file
 * @return string CSS content if $detached is false
 */
function getCss($detached = false) {
    $thumbSize = THUMBNAIL_SIZE;
    $mobileSize = MOBILE_SIZE;
    
    $sCss = '
body { margin: 0px; padding: 10px; font: 12px Arial, Helvetica, sans-serif; color: #222; }
img { border: 0px;}
a {text-decoration:none;}
h1, h2 {margin: 3px; display: inline;}
ul {margin: 0; padding: 0;}
li {list-style:none;display:block;float:left;overflow: hidden;}
ul.albums li {background-color: #ddd; margin: 2px; border: 2px solid #999; border-radius: 7px; font-weight:bold; vertical-align:middle; width:' . ($thumbSize + 20) . 'px; height: ' . $thumbSize . 'px; text-align:center;}
img.thumb {margin: 5px; border: 1px; border-radius: 5px; height:' . round($thumbSize * 0.75) . 'px;vertical-align:middle;box-shadow: 3px 2px 5px #aaa;}
ul.albums li img.thumb {width:' . $thumbSize . 'px;height:auto;}
div.details {text-align:center;}
.preload {max-width: 50px; max-height:50px; display:none;}
div.details img {vertical-align:middle;box-shadow: 3px 2px 5px #aaa;border-radius: 5px; max-height: 70vh;}
div.descr {font-weight:bold; margin:10px;}
a.prevnext {padding:5px 10px; font-size: 60px;color: #999; border: 1px solid #999;border-radius: 5px;box-shadow: 3px 2px 5px #aaa; background-size:118px; background-position:center; background-repeat:no-repeat;margin: 5px;}
#footer { padding: 20px; font-size: 10px; color: #999; }
#footer a {color: #999;}
div.clr {clear:both}

/* Download and Close Buttons */
.image-actions {
    margin: 10px 0;
    display: flex;
    justify-content: center;
    gap: 10px;
}
.btn {
    display: inline-block;
    padding: 8px 15px;
    background-color: #f0f0f0;
    color: #333;
    border: 1px solid #ccc;
    border-radius: 4px;
    cursor: pointer;
    font-weight: bold;
    text-decoration: none;
}
.btn:hover {
    background-color: #e0e0e0;
}

/* Lightbox */
.lightbox {
    display: none;
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.9);
    z-index: 1000;
    text-align: center;
    padding: 20px;
    box-sizing: border-box;
}
.lightbox img {
    max-width: 100%;
    max-height: 90vh;
    margin: 0 auto;
    display: block;
}
.close-btn {
    position: absolute;
    top: 15px;
    right: 25px;
    color: white;
    font-size: 35px;
    font-weight: bold;
    cursor: pointer;
}

/* Metadata styling */
.metadata {
    margin: 20px auto;
    padding: 10px;
    background-color: #f5f5f5;
    border-radius: 5px;
    text-align: left;
    max-width: 600px;
    display: inline-block;
}
.meta-item {
    margin: 5px 0;
    padding: 3px 0;
    border-bottom: 1px dotted #ddd;
}
.meta-label {
    font-weight: bold;
    display: inline-block;
    min-width: 100px;
}
.meta-value {
    color: #444;
}

/* Touch swipe support */
#photoview {
    touch-action: pan-y;
    position: relative;
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
}
.image-container {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    position: relative;
}
#photoview.swiping .image-container {
    transition: transform 0.2s ease-out;
}

@media screen and (max-width: ' . $mobileSize . 'px) {
body {padding: 0px;}
ul.albums li {margin: 1px; border: 1px solid #999; border-radius: 4px; font-weight:normal; width:' . ($thumbSize - 10) . 'px; height: ' . ($thumbSize - 20) . 'px;}
img.thumb { margin: 0 auto; border: none; border-radius: 0; box-shadow:none; height:auto; max-width:' . $thumbSize . 'px; max-height:' . $thumbSize . 'px;}
ul li {margin: 1px 0px 0px 1px; width: ' . round($thumbSize * 0.62) . 'px; height: ' . round($thumbSize * 0.62) . 'px;}
ul li img.thumb {margin: 0 -5px; }
a.album, #footer {font-size: 9px; }
div.details img {vertical-align:middle;box-shadow: 2px 1px 3px #aaa; max-width: 90%; max-height: 60vh;}
a.prevnext {padding:15px 10px; font-size: 20px; color: #999; border-radius: 4px; box-shadow: 2px 1px 3px #aaa;}
.image-container {
    gap: 5px;
}
.metadata {
    padding: 5px;
    font-size: 11px;
    margin: 10px auto;
}
.meta-label {
    min-width: 80px;
}
.btn {
    padding: 6px 10px;
    font-size: 11px;
}
}

@media screen and (max-width: 330px) {
h1, h2 {font-size:15px}
div.details img {vertical-align:middle;box-shadow: none; max-width: 80%; max-height: 50vh;}
div.descr {font-size:10px; font-weight:bold; margin:5px;}
a.prevnext {padding:10px 5px;}
}
';

    if ($detached) {
        header('Content-Type: text/css; charset=UTF-8', true);
        sendIfChanged($sCss, 5000);
        exit();
    }

    return $sCss;
}

/**
 * Create full HTML page with content
 * 
 * @param string $sContent The main content HTML
 * @param string $sHeadline Additional headline text
 * @param string $sTitle Optional page title
 */
function getPage($sContent, $sHeadline = '', $sTitle = '') {
    header('Content-Type: text/html; charset=UTF-8', true);
    
    // Decide on CSS inclusion method
    $sCssTag = CSS_INLINE 
        ? '<style type="text/css">' . getCss() . '</style>'
        : '<link type="text/css" rel="stylesheet" href="' . SELF . '?css">';
    
    // Use passed title or default
    $pageTitle = $sTitle ?: TITLE;
    
    // JavaScript for swipe functionality on touch devices and lightbox
    $script = <<<'EOT'
<script>
document.addEventListener('DOMContentLoaded', function() {
    // Lightbox functionality
    var lightbox = document.getElementById('lightbox');
    var mainImage = document.getElementById('main-image');
    var lightboxImg = document.getElementById('lightbox-img');
    var closeBtn = document.getElementById('close-btn');
    
    if (mainImage) {
        mainImage.addEventListener('click', function(e) {
            e.preventDefault();
            var fullsizeSrc = this.parentNode.getAttribute('href');
            lightboxImg.src = fullsizeSrc;
            lightbox.style.display = 'block';
            document.body.style.overflow = 'hidden'; // Prevent scrolling
        });
    }
    
    if (closeBtn) {
        closeBtn.addEventListener('click', function() {
            lightbox.style.display = 'none';
            document.body.style.overflow = 'auto'; // Enable scrolling
        });
    }
    
    // Click outside image to close lightbox
    if (lightbox) {
        lightbox.addEventListener('click', function(e) {
            if (e.target === lightbox) {
                lightbox.style.display = 'none';
                document.body.style.overflow = 'auto';
            }
        });
    }
    
    // Touch swipe support for photo view
    var photoview = document.getElementById('photoview');
    if (!photoview) return;
    
    var imageContainer = document.querySelector('.image-container');
    var startX = 0;
    var currentX = 0;
    var touchInProgress = false;
    var threshold = 80; // Minimum distance to consider as a swipe
    var prevUrl = photoview.getAttribute('data-prev');
    var nextUrl = photoview.getAttribute('data-next');
    
    // Touch event handlers
    imageContainer.addEventListener('touchstart', function(e) {
        startX = e.touches[0].clientX;
        currentX = startX;
        touchInProgress = true;
        imageContainer.style.transition = '';
    });
    
    imageContainer.addEventListener('touchmove', function(e) {
        if (!touchInProgress) return;
        currentX = e.touches[0].clientX;
        var diff = currentX - startX;
        
        // If we have a previous or next image based on swipe direction
        if ((diff > 0 && prevUrl) || (diff < 0 && nextUrl)) {
            e.preventDefault(); // Prevent scrolling when swiping
            // Move the container based on swipe
            var translate = Math.min(Math.abs(diff), 100) * Math.sign(diff);
            imageContainer.style.transform = 'translateX(' + translate + 'px)';
        }
    });
    
    function handleTouchEnd(e) {
        if (!touchInProgress) return;
        touchInProgress = false;
        
        var diff = currentX - startX;
        
        // Add transition for smooth reset
        imageContainer.style.transition = 'transform 0.3s ease-out';
        
        // Reset transform
        imageContainer.style.transform = '';
        
        // Check if the swipe was long enough
        if (Math.abs(diff) >= threshold) {
            if (diff > 0 && prevUrl) {
                window.location.href = prevUrl;
            } else if (diff < 0 && nextUrl) {
                window.location.href = nextUrl;
            }
        }
    }
    
    imageContainer.addEventListener('touchend', handleTouchEnd);
    imageContainer.addEventListener('touchcancel', handleTouchEnd);
    
    // Add keyboard navigation
    document.addEventListener('keydown', function(e) {
        // Don't handle keyboard events when lightbox is open
        if (lightbox && lightbox.style.display === 'block') {
            if (e.key === 'Escape') {
                lightbox.style.display = 'none';
                document.body.style.overflow = 'auto';
            }
            return;
        }
        
        if (e.key === 'ArrowLeft' && prevUrl) {
            window.location.href = prevUrl;
        } else if (e.key === 'ArrowRight' && nextUrl) {
            window.location.href = nextUrl;
        }
    });
});
</script>
EOT;
    
    $sHtml = '<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>' . $pageTitle . '</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
' . $sCssTag . '
</head>
<body>
<header>
    <div id="header">
        <h1><a accesskey="1" href="./">' . TITLE . '</a>' . $sHeadline . '</h1>
    </div>
</header>
' . $sContent . '
<footer>
    <div id="footer">
    ' . FOOTER . '
    <a href="http://blog.hennweb.de">modified by Conny Henn</a> | 
    </div>
</footer>
' . $script . '
</body>
</html>';
    
    sendIfChanged($sHtml);
}
/**
 * Faster version of imagecopyresampled for better performance
 */
function fastimagecopyresampled(&$dst_image, $src_image, $dst_x, $dst_y, $src_x, $src_y, $dst_w, $dst_h, $src_w, $src_h, $quality = 3) {
    if (empty($src_image) || empty($dst_image) || $quality <= 0) {
        return false;
    }
    
    if ($quality < 5 && (($dst_w * $quality) < $src_w || ($dst_h * $quality) < $src_h)) {
        $temp = imagecreatetruecolor($dst_w * $quality + 1, $dst_h * $quality + 1);
        imagecopyresized($temp, $src_image, 0, 0, $src_x, $src_y, $dst_w * $quality + 1, $dst_h * $quality + 1, $src_w, $src_h);
        imagecopyresampled($dst_image, $temp, $dst_x, $dst_y, 0, 0, $dst_w, $dst_h, $dst_w * $quality, $dst_h * $quality);
        imagedestroy($temp);
    } else {
        imagecopyresampled($dst_image, $src_image, $dst_x, $dst_y, $src_x, $src_y, $dst_w, $dst_h, $src_w, $src_h);
    }
    
    return true;
}

/**
 * Create and serve a thumbnail image
 * 
 * @param string $filepath Path to the original image
 * @param int $size Desired size of the thumbnail
 */
function getThumbImage($filepath, $size = THUMBNAIL_SIZE) {
    $path = dirname($filepath);
    $file = basename($filepath);
    $thumbspath = $path . '/.thumbs/' . $size;
    $thumbfilename = $thumbspath . '/' . $file;

    // Create thumbnail if it doesn't exist
    if (!is_file($thumbfilename)) {
        // Check image type
        if (stristr($file, ".jpg") || stristr($file, ".jpeg")) {
            $src = @imagecreatefromjpeg($filepath);
            if (!$src) {
                header("Content-Type: image/gif", false);
                die(BATSU);
            }
        } else {
            die('Format not supported');
        }
        
        // Get image dimensions and calculate new dimensions
        list($width, $height) = getimagesize($filepath);
        
        if ($width > $height) {
            // Landscape
            $newwidth = $size;
            $newheight = round(($height / $width) * $size);
        } else {
            // Portrait
            $newheight = $size;
            $newwidth = round(($width / $height) * $size);
        }

        // Create resized image
        $tmp = imagecreatetruecolor($newwidth, $newheight);
        fastimagecopyresampled($tmp, $src, 0, 0, 0, 0, $newwidth, $newheight, $width, $height);
        
        // Ensure thumbnail directory exists
        if (!is_dir($thumbspath)) {
            if (!@mkdir($thumbspath, 0755, true)) {
                die('Unable to create thumbnail directory, please check permissions');
            }
        }
        
        // Save thumbnail with interlacing for better loading
        imageinterlace($tmp, true);
        if (!imagejpeg($tmp, $thumbfilename, 85)) {
            header("Content-Type: image/gif", false);
            die(BATSU);
        }
        
        // Clean up
        imagedestroy($src);
        imagedestroy($tmp);
    }

    // Serve the thumbnail with cache headers
    $expires = 60 * 60 * 24 * 14; // 2 weeks
    header("Pragma: public");
    header("Cache-Control: maxage=" . $expires);
    header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $expires) . ' GMT');
    header("Content-Type: image/jpeg", false);
    
    $filesize = @filesize($thumbfilename);
    if ($filesize) {
        header("Content-Length: " . $filesize);
    }
    
    if (!@readfile($thumbfilename)) {
        header("Content-Type: text/plain", true);
        die('Could not read file: ' . $thumbfilename);
    }
    
    exit();
}

/**
 * Serve a file for download with proper headers
 * 
 * @param string $filepath Path to the file
 */
function serveDownload($filepath) {
    if (!file_exists($filepath)) {
        header('HTTP/1.0 404 Not Found');
        exit('File not found');
    }

    $filename = basename($filepath);
    
    // Set headers for forced download
    header('Content-Description: File Transfer');
    header('Content-Type: application/octet-stream');
    header('Content-Disposition: attachment; filename="' . $filename . '"');
    header('Content-Transfer-Encoding: binary');
    header('Expires: 0');
    header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
    header('Pragma: public');
    header('Content-Length: ' . filesize($filepath));
    
    // Clean output buffer
    ob_clean();
    flush();
    
    // Read file and exit
    readfile($filepath);
    exit();
}
/**
 * Read image metadata using exif functions
 * 
 * @param string $filepath Path to the image
 * @return array Metadata array
 */
function getImageMetadata($filepath) {
    $metadata = [];
    
    // Make sure exif functions are available
    if (!function_exists('exif_read_data')) {
        return $metadata;
    }
    
    // Try to read EXIF data
    $exif = @exif_read_data($filepath, 'ANY_TAG', true);
    if (!$exif) {
        return $metadata;
    }
    
    // Extract useful metadata
    if (isset($exif['EXIF'])) {
        // Camera information
        if (isset($exif['IFD0']['Make'])) {
            $metadata['Camera'] = trim($exif['IFD0']['Make']);
            if (isset($exif['IFD0']['Model'])) {
                $metadata['Camera'] .= ' ' . trim($exif['IFD0']['Model']);
            }
        }
        
        // Date taken
        if (isset($exif['EXIF']['DateTimeOriginal'])) {
            $metadata['Date Taken'] = $exif['EXIF']['DateTimeOriginal'];
        }
        
        // Exposure info
        if (isset($exif['EXIF']['ExposureTime'])) {
            $metadata['Exposure'] = $exif['EXIF']['ExposureTime'] . 's';
        }
        
        // Aperture
        if (isset($exif['EXIF']['FNumber'])) {
            if (is_array($exif['EXIF']['FNumber'])) {
                $fNumber = $exif['EXIF']['FNumber'][0] / $exif['EXIF']['FNumber'][1];
                $metadata['Aperture'] = 'f/' . round($fNumber, 1);
            } else {
                $metadata['Aperture'] = 'f/' . $exif['EXIF']['FNumber'];
            }
        }
        
        // ISO
        if (isset($exif['EXIF']['ISOSpeedRatings'])) {
            $metadata['ISO'] = 'ISO ' . $exif['EXIF']['ISOSpeedRatings'];
        }
        
        // Focal Length
        if (isset($exif['EXIF']['FocalLength'])) {
            if (is_array($exif['EXIF']['FocalLength'])) {
                $focalLength = $exif['EXIF']['FocalLength'][0] / $exif['EXIF']['FocalLength'][1];
                $metadata['Focal Length'] = round($focalLength) . 'mm';
            } else {
                $metadata['Focal Length'] = $exif['EXIF']['FocalLength'] . 'mm';
            }
        }
    }
    
    // GPS data if available and permitted
    if (isset($exif['GPS']) && defined('SHOW_GPS_DATA') && SHOW_GPS_DATA) {
        $gps = $exif['GPS'];
        if (isset($gps['GPSLatitude']) && isset($gps['GPSLongitude']) && 
            isset($gps['GPSLatitudeRef']) && isset($gps['GPSLongitudeRef'])) {
            
            // Convert GPS coordinates to decimal format
            $latDec = gpsToDecimal($gps['GPSLatitude'], $gps['GPSLatitudeRef']);
            $lonDec = gpsToDecimal($gps['GPSLongitude'], $gps['GPSLongitudeRef']);
            
            if ($latDec && $lonDec) {
                $metadata['GPS'] = "($latDec, $lonDec)";
                $metadata['_map_link'] = "https://www.openstreetmap.org/?mlat=$latDec&mlon=$lonDec&zoom=15";
            }
        }
    }
    
    return $metadata;
}

/**
 * Convert GPS coordinates from EXIF format to decimal
 */
function gpsToDecimal($coordParts, $hemi) {
    $degrees = count($coordParts) > 0 ? convertGpsFraction($coordParts[0]) : 0;
    $minutes = count($coordParts) > 1 ? convertGpsFraction($coordParts[1]) : 0;
    $seconds = count($coordParts) > 2 ? convertGpsFraction($coordParts[2]) : 0;
    
    $decimal = $degrees + $minutes / 60 + $seconds / 3600;
    
    // If this is South or West, make the coordinate negative
    $flipHemi = strtoupper($hemi) == 'S' || strtoupper($hemi) == 'W';
    return $flipHemi ? -$decimal : $decimal;
}

/**
 * Helper to convert GPS fractions
 */
function convertGpsFraction($fraction) {
    if (is_string($fraction)) {
        $parts = explode('/', $fraction);
        if (count($parts) == 2 && $parts[1] != 0) {
            return $parts[0] / $parts[1];
        }
        return floatval($fraction);
    }
    if (is_array($fraction) && count($fraction) == 2) {
        if ($fraction[1] != 0) {
            return $fraction[0] / $fraction[1];
        }
    }
    return 0;
}
/**
 * Get image detail view HTML
 * 
 * @param string $filepath Path to the image
 * @param string &$sHeadline Reference to headline variable
 * @param string &$sTitle Reference to title variable
 * @return string HTML for detail view
 */
function getDetails($filepath, &$sHeadline, &$sTitle = null) {
    $path = dirname($filepath);
    $file = basename($filepath);
    $sTitle = $file;
    
    list($aDirs, $aImages) = getDirectory($path);

    // Find previous and next images
    $pref = $next = false;
    $currentIndex = 0;
    foreach ($aImages as $i => $sFile) {
        if ($sFile == $file) {
            $currentIndex = $i;
            if ($i > 0) $pref = $aImages[$i - 1];
            if (isset($aImages[$i + 1])) $next = $aImages[$i + 1];
            break;
        }
    }
    
    // Get image metadata
    $metadata = getImageMetadata($filepath);
    
    // Build HTML
    $sHtml = '<div class="details" id="photoview" data-current="' . $currentIndex . '"';
    
    // Add navigation data for touch swipe
    if ($pref) {
        $sHtml .= ' data-prev="' . getDetailsUrl($path . '/' . $pref) . '"';
    }
    if ($next) {
        $sHtml .= ' data-next="' . getDetailsUrl($path . '/' . $next) . '"';
    }
    $sHtml .= '>';
    
    // Show breadcrumb navigation
    if ($path != '.') {
        $sHeadline = ' &gt;&nbsp;<a href="' . getAlbumUrl($path) . '" title="Go to album ' . $path . '">' . $path . '</a>';
    }
    
    // Image container with navigation
    $sHtml .= '<div class="image-container">';
    
    // Previous image link
    if ($pref) {
        $background = PRELOAD_IMAGES ? ' style="background-image: url(\'' . getThumbUrl($path . '/' . $pref, MEDIUM_SIZE) . '\')"' : '';
        $sHtml .= '<a class="prevnext prev"' . $background . ' href="' . getDetailsUrl($path . '/' . $pref) . '"><</a>';
    }
    
    // Current image
    $sHtml .= '<a id="-img" href="' . url_encode($filepath) . '" title="' . $file . ' (click to view fullsize)">
        <img id="main-image" alt="' . $file . '" src="' . getThumbUrl($filepath, MEDIUM_SIZE) . '">
    </a>';
    
    // Next image link
    if ($next) {
        $background = PRELOAD_IMAGES ? ' style="background-image: url(\'' . getThumbUrl($path . '/' . $next, MEDIUM_SIZE) . '\')"' : '';
        $sHtml .= '<a class="prevnext next"' . $background . ' href="' . getDetailsUrl($path . '/' . $next) . '">&gt;</a>';
    }
    
    $sHtml .= '</div>'; // Close image-container
    
    // Image description with filename
    $sHtml .= '<div class="descr">' . $file . '</div>';
    
    // Download button and fullsize view info
    $sHtml .= '<div class="image-actions">
        <a href="' . url_encode($filepath) . '" download="' . $file . '" class="btn">Download</a>
        <span>Click image to view fullsize<br>or swipe left/right</span>
    </div>';
    
    // Add metadata section
    if (!empty($metadata)) {
        $sHtml .= '<div class="metadata">';
        
        foreach ($metadata as $key => $value) {
            // Skip internal keys (starting with underscore)
            if ($key[0] === '_') continue;
            
            // Handle special case for GPS with map link
            if ($key === 'GPS' && isset($metadata['_map_link'])) {
                $sHtml .= '<div class="meta-item"><span class="meta-label">' . $key . ':</span> 
                           <a href="' . $metadata['_map_link'] . '" target="_blank" rel="noopener">' . $value . '</a></div>';
            } else {
                $sHtml .= '<div class="meta-item"><span class="meta-label">' . $key . ':</span> <span class="meta-value">' . $value . '</span></div>';
            }
        }
        
        $sHtml .= '</div>';
    }
    
    // Add lightbox for fullsize view
    $sHtml .= '<div id="lightbox" class="lightbox">
        <span id="close-btn" class="close-btn">&times;</span>
        <img id="lightbox-img" src="" alt="Fullsize image">
    </div>';
    
    $sHtml .= '</div>'; // Close photoview
    
    return $sHtml;
}

/**
 * Get directory entries sorted
 * 
 * @param string $directory Directory path
 * @return array Sorted directory entries
 */
function getDir($directory) {
    $aDir = [];
    
    if (empty($directory)) {
        $directory = './';
    }
    
    $handle = @opendir($directory);
    if (!$handle) {
        return $aDir;
    }
    
    while (false !== ($sFile = readdir($handle))) {
        if ($sFile[0] != '.') {
            $aDir[] = $sFile;
        }
    }
    
    closedir($handle);
    sort($aDir);
    
    return $aDir;
}

/**
 * Get directories and images in a directory
 * 
 * @param string $directory Directory path
 * @return array Array containing arrays of directories and images
 */
function getDirectory($directory) {
    $aDirs = [];
    $aImages = [];
    
    if (empty($directory)) {
        $directory = '.';
    }
    
    $directory = rtrim($directory, '/') . '/';
    $handle = @opendir($directory);
    
    if (!$handle) {
        return [$aDirs, $aImages];
    }
    
    while (false !== ($sFile = readdir($handle))) {
        if ($sFile[0] != '.') {
            $fullPath = $directory . $sFile;
            
            if (is_dir($fullPath)) {
                $aDirs[] = $sFile;
            } elseif (preg_match('/\.(jpe?g)$/i', $sFile)) {
                $aImages[] = $sFile;
            }
        }
    }
    
    closedir($handle);
    sort($aDirs);
    sort($aImages);
    
    return [$aDirs, $aImages];
}

/**
 * URL encode a filepath preserving slashes
 * 
 * @param string $filepath Path to encode
 * @return string Encoded path
 */
function url_encode($filepath) {
    return str_replace("%2F", "/", rawurlencode($filepath));
}

/**
 * Get URL for a thumbnail
 * 
 * @param string $filepath Path to the original image
 * @param int $size Thumbnail size
 * @return string URL to the thumbnail
 */
function getThumbUrl($filepath, $size = THUMBNAIL_SIZE) {
    $path = dirname($filepath);
    $file = basename($filepath);
    $thumbspath = $path . '/.thumbs/' . $size;
    $thumbfilename = $thumbspath . '/' . $file;
    
    // Return direct URL if thumbnail exists
    if (is_file($thumbfilename)) {
        return url_encode($thumbfilename);
    }
    
    // Return dynamic URL if thumbnail doesn't exist
    $param = ($size == MEDIUM_SIZE) ? 'm' : 't';
    return SELF . "?{$param}=" . urlencode($filepath);
}

/**
 * Get URL for an album
 * 
 * @param string $sPath Album path
 * @return string URL to the album
 */
function getAlbumUrl($sPath) {
    return SELF . '?a=' . urlencode($sPath);
}

/**
 * Get URL for image details
 * 
 * @param string $filepath Path to the image
 * @return string URL to the image details
 */
function getDetailsUrl($filepath) {
    return SELF . '?d=' . urlencode($filepath) . '#img';
}

/**
 * Get a representative thumbnail for an album
 * 
 * @param string $directory Album directory
 * @return string HTML for the album thumbnail
 */
function getAlbumThumbnail($directory) {
    list(, $aImages) = getDirectory($directory);
    
    if (count($aImages) > 0) {
        $iMiddle = floor(count($aImages) / 2);
        return '<img class="thumb" alt="' . htmlspecialchars($aImages[$iMiddle]) . '" src="' . 
               getThumbUrl($directory . '/' . $aImages[$iMiddle]) . '">';
    }
    
    return '';
}

/**
 * Get album view HTML
 * 
 * @param string $directory Album directory
 * @param string &$sHeadline Reference to headline variable
 * @param string &$sTitle Reference to title variable
 * @return string HTML for album view
 */
function getAlbum($directory, &$sHeadline, &$sTitle = null) {
    $sAlbums = '';
    $sThumbs = '';
    
    list($aDirs, $aImages) = getDirectory($directory);
    
    // Set title and headline
    if ($directory) {
        $sTitle = $directory;
        $sHeadline = ' &gt;' . $directory;
        $directory .= '/';
    }
    
    // Build directory listing
    foreach ($aDirs as $sFile) {
        $sAlbums .= '<li><a class="album" href="' . getAlbumUrl($directory . $sFile) . '">' . 
                   htmlspecialchars($sFile) . '<br>' . getAlbumThumbnail($directory . $sFile) . '</a></li>';
    }
    
    // Build image thumbnails
    foreach ($aImages as $sFile) {
        $sThumbs .= '<li><a href="' . getDetailsUrl($directory . $sFile) . '"><img class="thumb" alt="' . 
                   htmlspecialchars($sFile) . '" title="' . htmlspecialchars($sFile) . '" src="' . 
                   getThumbUrl($directory . $sFile) . '"></a></li>';
    }
    
    // Finalize HTML
    if ($sAlbums) {
        $sAlbums = '<ul class="albums">' . $sAlbums . '</ul><div class="clr"></div>';
    }
    
    if ($sThumbs) {
        $sThumbs = '<ul>' . $sThumbs . '</ul><div class="clr"></div>';
    }
    
    return $sAlbums . $sThumbs;
}
// MAIN EXECUTION FLOW
$sHeadline = $sTitle = '';

// Route request to appropriate handler
if (isset($_REQUEST['t'])) {
    // Generate thumbnail
    getThumbImage($_REQUEST['t']);
} elseif (isset($_REQUEST['m'])) {
    // Generate medium image
    getThumbImage($_REQUEST['m'], MEDIUM_SIZE);
} elseif (isset($_GET['css'])) {
    // Serve CSS
    getCss(true);
} elseif (isset($_REQUEST['download'])) {
    // Serve file for download
    serveDownload($_REQUEST['download']);
} elseif (isset($_REQUEST['d'])) {
    // Show image details
    $sHtml = getDetails($_REQUEST['d'], $sHeadline, $sTitle);
} else {
    // Show album
    $sHtml = getAlbum($_REQUEST['a'] ?? '', $sHeadline, $sTitle);
}

// Render page
getPage($sHtml, $sHeadline, $sTitle);
?>