master
Avraham Sakal 2 months ago
parent b001a5998d
commit 9610ca2673

@ -0,0 +1,9 @@
# S3 Configuration
S3_ACCESS_KEY=your_access_key_here
S3_SECRET_KEY=your_secret_key_here
S3_REGION=your_region_here
S3_BUCKET_NAME=your_bucket_name_here
# Optional: For S3-compatible services (like Backblaze B2, MinIO, etc.)
# S3_ENDPOINT=https://s3.us-west-000.backblazeb2.com
# S3_FORCE_PATH_STYLE=true

@ -1,48 +1,154 @@
# Astro Starter Kit: Basics
# S3 Image Gallery
```sh
npm create astro@latest -- --template basics
A responsive image gallery application built with Astro.js that connects to any S3-compatible storage service to display your images with infinite scrolling. Works with AWS S3, Backblaze B2, MinIO, DigitalOcean Spaces, and other S3-compatible services.
## Features
- 📱 Responsive design that works on all devices
- 🔄 Infinite scrolling for seamless browsing of large image collections
- 🖼️ Optimized image loading with lazy loading
- 🔒 Secure S3 integration using signed URLs
- 🔌 Compatible with any S3-compatible storage service (AWS S3, Backblaze B2, MinIO, etc.)
- ☁️ Ready for deployment on Cloudflare Pages or Cloudflare Workers
## Prerequisites
- Node.js (v16 or later)
- An S3-compatible storage bucket with your images
- Access credentials with permissions to read from the bucket
## Supported S3-Compatible Services
This application works with any S3-compatible storage service, including:
- Amazon S3
- Backblaze B2
- MinIO
- DigitalOcean Spaces
- Wasabi
- Linode Object Storage
- Scaleway Object Storage
- And many others
Each service may have slightly different configuration requirements. The application uses the AWS SDK for JavaScript v3, which supports custom endpoints for S3-compatible services.
## Getting Started
1. Clone this repository
2. Install dependencies:
```bash
npm install
```
3. Create a `.env` file based on the `.env.example` template:
```bash
cp .env.example .env
```
4. Fill in your S3 credentials in the `.env` file:
```
# Required for all S3-compatible services
S3_ACCESS_KEY=your_access_key_here
S3_SECRET_KEY=your_secret_key_here
S3_REGION=your_region_here
S3_BUCKET_NAME=your_bucket_name_here
# Optional: For S3-compatible services like Backblaze B2, MinIO, etc.
# S3_ENDPOINT=https://s3.us-west-000.backblazeb2.com
# S3_FORCE_PATH_STYLE=true
```
### Using with Backblaze B2
For Backblaze B2, you'll need to set the following:
```
S3_ACCESS_KEY=your_application_key_id
S3_SECRET_KEY=your_application_key
S3_REGION=us-west-000 # Replace with your bucket's region
S3_BUCKET_NAME=your_bucket_name
S3_ENDPOINT=https://s3.us-west-000.backblazeb2.com # Replace with your region
S3_FORCE_PATH_STYLE=true
```
5. Start the development server:
```bash
npm run dev
```
6. Open your browser and navigate to `http://localhost:4321`
## Testing Your S3 Connection
Before running the application, you can test your S3 connection to ensure your credentials are working correctly:
```bash
npm run test:s3
```
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics)
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics)
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json)
This will attempt to connect to your S3-compatible storage service and verify that:
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
1. Your credentials are valid
2. The specified bucket exists and is accessible
![just-the-basics](https://github.com/withastro/astro/assets/2244813/a0a5533c-a856-4198-8470-2d67b1d7c554)
If there are any issues, the script will provide helpful error messages to guide you in fixing the configuration.
## 🚀 Project Structure
## Building for Production
Inside of your Astro project, you'll see the following folders and files:
To build the application for production:
```text
/
├── public/
│ └── favicon.svg
├── src/
│ ├── layouts/
│ │ └── Layout.astro
│ └── pages/
│ └── index.astro
└── package.json
```bash
npm run build
```
To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/).
The built files will be in the `dist` directory.
## Deployment to Cloudflare
## 🧞 Commands
This project is configured to be deployed on Cloudflare Pages or Cloudflare Workers.
All commands are run from the root of the project, from a terminal:
### Deploying to Cloudflare Pages
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
1. Push your code to a Git repository (GitHub, GitLab, etc.)
2. In the Cloudflare Pages dashboard, create a new project and connect it to your repository
3. Configure the build settings:
- Build command: `npm run build`
- Build output directory: `dist`
4. Add your environment variables in the Cloudflare Pages dashboard:
- Required: `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_REGION`, `S3_BUCKET_NAME`
- For S3-compatible services: `S3_ENDPOINT`, `S3_FORCE_PATH_STYLE`
5. Deploy your site
### Deploying to Cloudflare Workers
1. Install Wrangler CLI:
```bash
npm install -g wrangler
```
2. Login to Cloudflare:
```bash
wrangler login
```
3. Configure your `wrangler.toml` file with your account ID and other settings
4. Deploy to Cloudflare Workers:
```bash
wrangler deploy
```
## Customization
You can customize the appearance of the gallery by modifying the CSS variables in `src/layouts/Layout.astro`:
```css
:root {
--color-primary: #3498db;
--color-secondary: #2ecc71;
--color-text: #333;
--color-text-light: #666;
--color-background: #f5f7fa;
--color-card: #fff;
--color-border: #e1e4e8;
}
```
## 👀 Want to learn more?
## License
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
MIT

@ -1,5 +1,12 @@
// @ts-check
import { defineConfig } from 'astro/config';
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
// https://astro.build/config
export default defineConfig({});
export default defineConfig({
output: "server",
adapter: cloudflare({
mode: "directory",
functionPerRoute: true,
}),
});

9852
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -6,9 +6,14 @@
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
"astro": "astro",
"test:s3": "node scripts/test-s3-connection.js"
},
"dependencies": {
"astro": "^5.3.1"
"@astrojs/cloudflare": "^12.2.2",
"@aws-sdk/client-s3": "^3.750.0",
"@aws-sdk/s3-request-presigner": "^3.750.0",
"astro": "^5.3.1",
"dotenv": "^16.4.7"
}
}

@ -0,0 +1,115 @@
// Script to test S3 connection
import { S3Client, ListBucketsCommand } from "@aws-sdk/client-s3";
import dotenv from "dotenv";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
// Get the directory name of the current module
const __dirname = dirname(fileURLToPath(import.meta.url));
// Load environment variables from .env file
dotenv.config({ path: resolve(__dirname, "../.env") });
// Check if required environment variables are set
const requiredEnvVars = [
"S3_ACCESS_KEY",
"S3_SECRET_KEY",
"S3_REGION",
"S3_BUCKET_NAME",
];
const missingEnvVars = requiredEnvVars.filter(
(varName) => !process.env[varName]
);
if (missingEnvVars.length > 0) {
console.error("❌ Missing required environment variables:");
for (const varName of missingEnvVars) {
console.error(` - ${varName}`);
}
console.error(
"\nPlease check your .env file and make sure all required variables are set."
);
process.exit(1);
}
// Initialize S3 client with environment variables
const s3ClientOptions = {
region: process.env.S3_REGION,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY,
secretAccessKey: process.env.S3_SECRET_KEY,
},
};
// Add endpoint URL for S3-compatible services like Backblaze B2
if (process.env.S3_ENDPOINT) {
console.log(`🔧 Using custom S3 endpoint: ${process.env.S3_ENDPOINT}`);
s3ClientOptions.endpoint = process.env.S3_ENDPOINT;
// For some S3-compatible services, we need to force path style addressing
if (process.env.S3_FORCE_PATH_STYLE === "true") {
console.log("🔧 Using path style addressing");
s3ClientOptions.forcePathStyle = true;
}
}
const s3Client = new S3Client(s3ClientOptions);
const bucketName = process.env.S3_BUCKET_NAME;
async function testS3Connection() {
console.log("🔍 Testing S3 connection...");
try {
// Test listing buckets to verify credentials
const listBucketsCommand = new ListBucketsCommand({});
const listBucketsResponse = await s3Client.send(listBucketsCommand);
console.log("✅ Successfully connected to S3!");
console.log(
`📋 Found ${
listBucketsResponse.Buckets?.length || 0
} buckets in your account.`
);
// Check if the specified bucket exists
const bucketExists = listBucketsResponse.Buckets?.some(
(bucket) => bucket.Name === bucketName
);
if (bucketExists) {
console.log(`✅ Bucket "${bucketName}" exists and is accessible.`);
} else {
console.error(`❌ Bucket "${bucketName}" was not found in your account.`);
console.error("Please check your S3_BUCKET_NAME environment variable.");
process.exit(1);
}
console.log("\n🎉 S3 connection test completed successfully!");
console.log("You can now run the application with: npm run dev");
} catch (error) {
console.error("❌ Failed to connect to S3:");
console.error(error.message);
if (error.Code === "InvalidAccessKeyId") {
console.error(
"\nThe access key ID you provided does not exist in our records."
);
console.error("Please check your S3_ACCESS_KEY environment variable.");
} else if (error.Code === "SignatureDoesNotMatch") {
console.error(
"\nThe request signature we calculated does not match the signature you provided."
);
console.error("Please check your S3_SECRET_KEY environment variable.");
} else if (error.Code === "AccessDenied") {
console.error(
"\nAccess denied. Your credentials may not have permission to list buckets."
);
console.error("Please check your IAM permissions.");
}
process.exit(1);
}
}
testS3Connection();

@ -0,0 +1,179 @@
---
// ImageCard.astro - Component to display a single image in the gallery
export interface Props {
imageUrl: string;
imageKey: string;
lastModified?: Date;
size?: number;
}
const { imageUrl, imageKey, lastModified, size } = Astro.props;
// Format the file name for display
const fileName = imageKey.split('/').pop() || imageKey;
// Format the file size
function formatFileSize(bytes: number | undefined): string {
if (!bytes) return 'Unknown size';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
// Format the date
function formatDate(date: Date | undefined): string {
if (!date) return 'Unknown date';
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
---
<div class="image-card" data-image-url={imageUrl} data-image-name={fileName}>
<div class="image-container">
<img src={imageUrl} alt={fileName} loading="lazy" />
<div class="overlay">
<span class="zoom-icon">🔍</span>
</div>
</div>
<div class="image-info">
<h3 class="image-title">{fileName}</h3>
<div class="image-metadata">
{lastModified && <span class="date">{formatDate(lastModified)}</span>}
{size && <span class="size">{formatFileSize(size)}</span>}
</div>
</div>
</div>
<script>
// Add click event to all image cards
document.addEventListener('DOMContentLoaded', () => {
const imageCards = document.querySelectorAll('.image-card');
imageCards.forEach(card => {
card.addEventListener('click', () => {
const imageUrl = card.getAttribute('data-image-url');
const imageName = card.getAttribute('data-image-name');
// Get date and size if available
const dateElement = card.querySelector('.date');
const sizeElement = card.querySelector('.size');
const date = dateElement ? dateElement.textContent : '';
const size = sizeElement ? sizeElement.textContent : '';
// Open lightbox if the openLightbox function exists
if (imageUrl && imageName && window.openLightbox) {
// Type assertion to handle null values
const url = imageUrl || '';
const name = imageName || '';
window.openLightbox(url, name, date || undefined, size || undefined);
}
});
});
});
</script>
<style>
.image-card {
border-radius: 8px;
overflow: hidden;
background-color: #fff;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
height: 100%;
display: flex;
flex-direction: column;
}
.image-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
.image-container {
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
position: relative;
cursor: pointer;
}
.image-container img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
opacity: 0;
transition: opacity 0.3s ease;
}
.zoom-icon {
color: white;
font-size: 2rem;
transform: scale(0.8);
transition: transform 0.3s ease;
}
.image-card:hover .overlay {
opacity: 1;
}
.image-card:hover .zoom-icon {
transform: scale(1);
}
.image-card:hover .image-container img {
transform: scale(1.05);
}
.image-info {
padding: 12px;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.image-title {
margin: 0 0 8px 0;
font-size: 0.9rem;
font-weight: 500;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.image-metadata {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: #666;
}
.date, .size {
display: inline-block;
}
</style>

@ -0,0 +1,317 @@
---
import ImageCard from './ImageCard.astro';
import Lightbox from './Lightbox.astro';
// Initial images can be passed as props
export interface Props {
initialImages?: Array<{
key: string;
url: string;
lastModified?: Date;
size?: number;
}>;
initialContinuationToken?: string;
}
const { initialImages = [], initialContinuationToken = null } = Astro.props;
---
<div class="image-gallery-container">
<div class="image-gallery" id="image-gallery">
{initialImages.map(image => (
<div class="gallery-item">
<ImageCard
imageUrl={image.url}
imageKey={image.key}
lastModified={image.lastModified}
size={image.size}
/>
</div>
))}
</div>
<div id="loading-indicator" class="loading-indicator">
<div class="spinner"></div>
<p>Loading more images...</p>
</div>
<div id="end-message" class="end-message" style="display: none;">
<p>You've reached the end of the gallery</p>
</div>
<!-- Add the lightbox component -->
<Lightbox />
</div>
<script define:vars={{ initialContinuationToken }}>
// State variables
let continuationToken = initialContinuationToken;
let isLoading = false;
let hasMoreImages = !!initialContinuationToken;
// Elements
const gallery = document.getElementById('image-gallery');
const loadingIndicator = document.getElementById('loading-indicator');
const endMessage = document.getElementById('end-message');
// Hide loading indicator initially if no more images
if (!hasMoreImages) {
loadingIndicator.style.display = 'none';
endMessage.style.display = 'flex';
}
// Function to fetch more images
async function fetchMoreImages() {
if (isLoading || !hasMoreImages) return;
isLoading = true;
loadingIndicator.style.display = 'flex';
try {
const url = new URL('/api/images', window.location.origin);
if (continuationToken) {
url.searchParams.append('continuationToken', continuationToken);
}
url.searchParams.append('limit', '20');
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch images: ${response.status}`);
}
const data = await response.json();
// Update continuation token
continuationToken = data.nextContinuationToken;
hasMoreImages = !!continuationToken;
// Append new images to the gallery
if (data.images && data.images.length > 0) {
appendImagesToGallery(data.images);
}
// Show end message if no more images
if (!hasMoreImages) {
endMessage.style.display = 'flex';
}
} catch (error) {
console.error('Error fetching more images:', error);
} finally {
isLoading = false;
if (hasMoreImages) {
loadingIndicator.style.display = 'flex';
} else {
loadingIndicator.style.display = 'none';
}
}
}
// Function to append images to the gallery
function appendImagesToGallery(images) {
images.forEach(image => {
// Create gallery item container
const galleryItem = document.createElement('div');
galleryItem.className = 'gallery-item';
// Create image card HTML
const imageCard = document.createElement('div');
imageCard.className = 'image-card';
// Set data attributes for lightbox
const fileName = image.key.split('/').pop() || image.key;
imageCard.setAttribute('data-image-url', image.url);
imageCard.setAttribute('data-image-name', fileName);
// Create image container
const imageContainer = document.createElement('div');
imageContainer.className = 'image-container';
// Create image element
const img = document.createElement('img');
img.src = image.url;
img.alt = fileName;
img.loading = 'lazy';
// Create overlay with zoom icon
const overlay = document.createElement('div');
overlay.className = 'overlay';
const zoomIcon = document.createElement('span');
zoomIcon.className = 'zoom-icon';
zoomIcon.textContent = '🔍';
overlay.appendChild(zoomIcon);
// Create info container
const imageInfo = document.createElement('div');
imageInfo.className = 'image-info';
// Create title
const title = document.createElement('h3');
title.className = 'image-title';
title.textContent = image.key.split('/').pop() || image.key;
// Create metadata container
const metadata = document.createElement('div');
metadata.className = 'image-metadata';
// Add date if available
if (image.lastModified) {
const date = document.createElement('span');
date.className = 'date';
const lastModified = new Date(image.lastModified);
date.textContent = lastModified.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
metadata.appendChild(date);
}
// Add size if available
if (image.size) {
const size = document.createElement('span');
size.className = 'size';
size.textContent = formatFileSize(image.size);
metadata.appendChild(size);
}
// Assemble the components
imageContainer.appendChild(img);
imageContainer.appendChild(overlay);
imageInfo.appendChild(title);
imageInfo.appendChild(metadata);
imageCard.appendChild(imageContainer);
imageCard.appendChild(imageInfo);
// Add click event listener for lightbox
imageCard.addEventListener('click', () => {
const imageUrl = imageCard.getAttribute('data-image-url');
const imageName = imageCard.getAttribute('data-image-name');
// Get date and size if available
const dateElement = imageCard.querySelector('.date');
const sizeElement = imageCard.querySelector('.size');
const date = dateElement ? dateElement.textContent : '';
const size = sizeElement ? sizeElement.textContent : '';
// Open lightbox if the openLightbox function exists
if (imageUrl && imageName && window.openLightbox) {
window.openLightbox(imageUrl, imageName, date, size);
}
});
galleryItem.appendChild(imageCard);
gallery.appendChild(galleryItem);
});
}
// Format file size helper function
function formatFileSize(bytes) {
if (!bytes) return 'Unknown size';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
// Set up Intersection Observer for infinite scrolling
function setupInfiniteScroll() {
const options = {
root: null, // Use the viewport
rootMargin: '0px 0px 200px 0px', // Load more when within 200px of the bottom
threshold: 0.1
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
fetchMoreImages();
}
});
}, options);
// Observe the loading indicator
observer.observe(loadingIndicator);
}
// Initialize infinite scrolling
document.addEventListener('DOMContentLoaded', setupInfiniteScroll);
// If the document is already loaded, set up infinite scrolling now
if (document.readyState === 'complete' || document.readyState === 'interactive') {
setupInfiniteScroll();
}
</script>
<style>
.image-gallery-container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.image-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.gallery-item {
width: 100%;
break-inside: avoid;
}
.loading-indicator {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 0;
color: #666;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top-color: #3498db;
animation: spin 1s ease-in-out infinite;
margin-bottom: 10px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.end-message {
display: flex;
justify-content: center;
padding: 40px 0;
color: #666;
font-style: italic;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.image-gallery {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 15px;
}
}
</style>

@ -0,0 +1,380 @@
---
// Lightbox.astro - Component to display a full-size image in a modal
---
<div id="lightbox" class="lightbox">
<div class="lightbox-content">
<span class="close-button">&times;</span>
<div class="navigation-controls">
<button id="prev-button" class="nav-button">&lt;</button>
<button id="next-button" class="nav-button">&gt;</button>
</div>
<img id="lightbox-image" src="" alt="Lightbox image">
<div class="image-details">
<h3 id="lightbox-title"></h3>
<div class="metadata">
<span id="lightbox-date"></span>
<span id="lightbox-size"></span>
</div>
</div>
</div>
</div>
<script>
// Get lightbox elements
const lightbox = document.getElementById('lightbox');
const lightboxImage = document.getElementById('lightbox-image');
const lightboxTitle = document.getElementById('lightbox-title');
const lightboxDate = document.getElementById('lightbox-date');
const lightboxSize = document.getElementById('lightbox-size');
const closeButton = document.querySelector('.close-button');
const prevButton = document.getElementById('prev-button');
const nextButton = document.getElementById('next-button');
// Gallery state
let currentImageIndex = -1;
let galleryImages: Array<{url: string, title: string, date?: string, size?: string}> = [];
let isZoomed = false;
// Function to collect all gallery images
function collectGalleryImages() {
galleryImages = [];
const imageCards = document.querySelectorAll('.image-card');
imageCards.forEach(card => {
const url = card.getAttribute('data-image-url') || '';
const title = card.getAttribute('data-image-name') || '';
// Get date and size if available
const dateElement = card.querySelector('.date');
const sizeElement = card.querySelector('.size');
const date = dateElement ? dateElement.textContent || undefined : undefined;
const size = sizeElement ? sizeElement.textContent || undefined : undefined;
if (url && title) {
galleryImages.push({ url, title, date, size });
}
});
}
// Function to open the lightbox
function openLightbox(imageUrl: string, title: string, date?: string, size?: string) {
// Collect all gallery images first
collectGalleryImages();
// Find the index of the current image
currentImageIndex = galleryImages.findIndex(img => img.url === imageUrl);
if (lightbox && lightboxImage && lightboxTitle) {
// Reset zoom state
isZoomed = false;
// Type assertion for HTMLImageElement
const imgElement = lightboxImage as HTMLImageElement;
imgElement.src = imageUrl;
imgElement.alt = title;
imgElement.classList.remove('zoomed');
lightboxTitle.textContent = title;
if (lightboxDate && date) {
lightboxDate.textContent = date;
}
if (lightboxSize && size) {
lightboxSize.textContent = size;
}
// Update navigation buttons visibility
updateNavigationButtons();
lightbox.style.display = 'flex';
document.body.style.overflow = 'hidden'; // Prevent scrolling when lightbox is open
}
}
// Function to update navigation buttons visibility
function updateNavigationButtons() {
if (prevButton && nextButton) {
prevButton.style.visibility = currentImageIndex > 0 ? 'visible' : 'hidden';
nextButton.style.visibility = currentImageIndex < galleryImages.length - 1 ? 'visible' : 'hidden';
}
}
// Function to navigate to the previous image
function showPreviousImage() {
if (currentImageIndex > 0) {
currentImageIndex--;
const prevImage = galleryImages[currentImageIndex];
// Reset zoom state
isZoomed = false;
if (lightboxImage) {
const imgElement = lightboxImage as HTMLImageElement;
imgElement.src = prevImage.url;
imgElement.alt = prevImage.title;
imgElement.classList.remove('zoomed');
}
if (lightboxTitle) {
lightboxTitle.textContent = prevImage.title;
}
if (lightboxDate) {
lightboxDate.textContent = prevImage.date || '';
}
if (lightboxSize) {
lightboxSize.textContent = prevImage.size || '';
}
updateNavigationButtons();
}
}
// Function to navigate to the next image
function showNextImage() {
if (currentImageIndex < galleryImages.length - 1) {
currentImageIndex++;
const nextImage = galleryImages[currentImageIndex];
// Reset zoom state
isZoomed = false;
if (lightboxImage) {
const imgElement = lightboxImage as HTMLImageElement;
imgElement.src = nextImage.url;
imgElement.alt = nextImage.title;
imgElement.classList.remove('zoomed');
}
if (lightboxTitle) {
lightboxTitle.textContent = nextImage.title;
}
if (lightboxDate) {
lightboxDate.textContent = nextImage.date || '';
}
if (lightboxSize) {
lightboxSize.textContent = nextImage.size || '';
}
updateNavigationButtons();
}
}
// Function to toggle zoom
function toggleZoom() {
if (lightboxImage) {
isZoomed = !isZoomed;
lightboxImage.classList.toggle('zoomed', isZoomed);
}
}
// Function to close the lightbox
function closeLightbox() {
if (lightbox) {
lightbox.style.display = 'none';
document.body.style.overflow = ''; // Restore scrolling
// Reset zoom state
if (lightboxImage) {
isZoomed = false;
lightboxImage.classList.remove('zoomed');
}
}
}
// Close lightbox when clicking the close button
if (closeButton) {
closeButton.addEventListener('click', closeLightbox);
}
// Close lightbox when clicking outside the image
if (lightbox) {
lightbox.addEventListener('click', (event) => {
if (event.target === lightbox) {
closeLightbox();
}
});
}
// Add click event to the image for zooming
if (lightboxImage) {
lightboxImage.addEventListener('click', (event) => {
event.stopPropagation(); // Prevent closing the lightbox
toggleZoom();
});
}
// Add click events to navigation buttons
if (prevButton) {
prevButton.addEventListener('click', (event) => {
event.stopPropagation(); // Prevent closing the lightbox
showPreviousImage();
});
}
if (nextButton) {
nextButton.addEventListener('click', (event) => {
event.stopPropagation(); // Prevent closing the lightbox
showNextImage();
});
}
// Keyboard navigation
document.addEventListener('keydown', (event) => {
if (lightbox && lightbox.style.display === 'flex') {
switch (event.key) {
case 'Escape':
closeLightbox();
break;
case 'ArrowLeft':
case 'Backspace':
showPreviousImage();
break;
case 'ArrowRight':
case ' ': // Space key
showNextImage();
break;
}
}
});
// Declare the type for the window object with our custom property
declare global {
interface Window {
openLightbox: (imageUrl: string, title: string, date?: string, size?: string) => void;
}
}
// Expose the openLightbox function globally so it can be called from other components
window.openLightbox = openLightbox;
</script>
<style>
.lightbox {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.9);
z-index: 1000;
justify-content: center;
align-items: center;
flex-direction: column;
padding: 0;
margin: 0;
overflow: hidden;
}
.lightbox-content {
position: relative;
max-width: 90%;
max-height: 90%;
display: flex;
flex-direction: column;
align-items: center;
}
.lightbox-content img {
max-width: 100%;
max-height: 80vh;
object-fit: contain;
border: 2px solid white;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
cursor: zoom-in;
transition: all 0.3s ease;
}
.lightbox-content img.zoomed {
max-width: 100%;
max-height: 100vh;
object-fit: contain;
cursor: zoom-out;
border: none;
box-shadow: none;
}
.close-button {
position: fixed;
top: 15px;
right: 15px;
color: white;
font-size: 35px;
font-weight: bold;
cursor: pointer;
z-index: 1001;
background-color: rgba(0, 0, 0, 0.5);
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
line-height: 1;
}
.navigation-controls {
position: fixed;
width: 100%;
display: flex;
justify-content: space-between;
top: 50%;
transform: translateY(-50%);
z-index: 1001;
pointer-events: none; /* Allow clicks to pass through the container */
}
.nav-button {
background-color: rgba(0, 0, 0, 0.5);
color: white;
border: none;
border-radius: 50%;
width: 50px;
height: 50px;
font-size: 24px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
margin: 0 20px;
transition: background-color 0.3s ease;
pointer-events: auto; /* Make buttons clickable */
}
.nav-button:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.image-details {
color: white;
margin-top: 20px;
text-align: center;
width: 100%;
}
.image-details h3 {
margin: 0 0 10px 0;
font-size: 1.2rem;
}
.metadata {
display: flex;
justify-content: center;
gap: 20px;
font-size: 0.9rem;
color: #ccc;
}
/* Hide elements when in zoomed mode */
.lightbox-content img.zoomed ~ .navigation-controls,
.lightbox-content img.zoomed ~ .image-details,
.lightbox-content img.zoomed ~ .close-button {
display: none;
}
</style>

@ -1,3 +1,15 @@
---
export interface Props {
title?: string;
description?: string;
}
const {
title = "S3 Image Gallery",
description = "A gallery app to explore images from your S3 bucket"
} = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
@ -5,18 +17,110 @@
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>Astro Basics</title>
<meta name="description" content={description} />
<title>{title}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<header>
<div class="header-container">
<h1>{title}</h1>
</div>
</header>
<main>
<slot />
</main>
<footer>
<div class="footer-container">
<p>&copy; {new Date().getFullYear()} S3 Image Gallery</p>
</div>
</footer>
</body>
</html>
<style>
html,
<style is:global>
:root {
--color-primary: #3498db;
--color-secondary: #2ecc71;
--color-text: #333;
--color-text-light: #666;
--color-background: #f5f7fa;
--color-card: #fff;
--color-border: #e1e4e8;
}
* {
box-sizing: border-box;
}
html {
font-family: 'Inter', system-ui, sans-serif;
background-color: var(--color-background);
color: var(--color-text);
}
body {
margin: 0;
padding: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
background-color: var(--color-primary);
color: white;
padding: 1rem 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.header-container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
header h1 {
margin: 0;
font-size: 1.8rem;
font-weight: 600;
}
main {
flex: 1;
padding: 2rem 0;
}
footer {
background-color: #f1f1f1;
padding: 1rem 0;
text-align: center;
font-size: 0.9rem;
color: var(--color-text-light);
}
.footer-container {
width: 100%;
height: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
a {
color: var(--color-primary);
text-decoration: none;
transition: color 0.2s ease;
}
a:hover {
color: var(--color-secondary);
}
img {
max-width: 100%;
height: auto;
}
</style>

@ -0,0 +1,29 @@
import { listImages } from "../../utils/s3.js";
export async function GET({ request, url }) {
try {
// Get query parameters
const continuationToken = url.searchParams.get("continuationToken");
const limit = Number.parseInt(url.searchParams.get("limit") || "20", 10);
// Fetch images from S3
const result = await listImages(continuationToken, limit);
// Return the images and continuation token
return new Response(JSON.stringify(result), {
status: 200,
headers: {
"Content-Type": "application/json",
"Cache-Control": "max-age=60, stale-while-revalidate=300",
},
});
} catch (error) {
console.error("Error fetching images:", error);
return new Response(JSON.stringify({ error: "Failed to fetch images" }), {
status: 500,
headers: {
"Content-Type": "application/json",
},
});
}
}

@ -1,11 +1,90 @@
---
import Welcome from '../components/Welcome.astro';
import Layout from '../layouts/Layout.astro';
import ImageGallery from '../components/ImageGallery.astro';
import { listImages } from '../utils/s3.js';
// Welcome to Astro! Wondering what to do next? Check out the Astro documentation at https://docs.astro.build
// Don't want to use any of this? Delete everything in this file, the `assets`, `components`, and `layouts` directories, and start fresh.
// Get initial images for server-side rendering
let initialImages = [];
let initialContinuationToken: string | undefined = undefined;
try {
// Only attempt to fetch images if we're not in development mode without environment variables
if (import.meta.env.S3_ACCESS_KEY && import.meta.env.S3_SECRET_KEY && import.meta.env.S3_BUCKET_NAME) {
const result = await listImages(undefined, 20);
initialImages = result.images;
initialContinuationToken = result.nextContinuationToken;
}
} catch (error) {
console.error('Error fetching initial images:', error);
}
const title = "S3 Image Gallery";
---
<Layout>
<Welcome />
<Layout title={title}>
<div class="container">
<div class="intro">
<p>Browse your S3 bucket images with infinite scrolling</p>
</div>
{initialImages.length > 0 ? (
<ImageGallery
initialImages={initialImages}
initialContinuationToken={initialContinuationToken}
/>
) : (
<div class="no-images">
<div class="message">
<h2>No images found</h2>
<p>
{import.meta.env.DEV && !import.meta.env.S3_ACCESS_KEY ?
"Environment variables not configured. Please set S3_ACCESS_KEY, S3_SECRET_KEY, S3_REGION, and S3_BUCKET_NAME." :
"No images found in your S3 bucket. Upload some images to get started."
}
</p>
</div>
</div>
)}
</div>
</Layout>
<style>
.container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.intro {
text-align: center;
margin-bottom: 2rem;
color: var(--color-text-light);
}
.no-images {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
background-color: var(--color-card);
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.message {
text-align: center;
padding: 2rem;
}
.message h2 {
margin-top: 0;
color: var(--color-text);
}
.message p {
color: var(--color-text-light);
max-width: 500px;
margin: 1rem auto 0;
}
</style>

@ -0,0 +1,84 @@
import {
S3Client,
ListObjectsV2Command,
GetObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
// Initialize S3 client with environment variables
const s3ClientOptions = {
region: import.meta.env.S3_REGION,
credentials: {
accessKeyId: import.meta.env.S3_ACCESS_KEY,
secretAccessKey: import.meta.env.S3_SECRET_KEY,
},
};
// Add endpoint URL for S3-compatible services like Backblaze B2
if (import.meta.env.S3_ENDPOINT) {
s3ClientOptions.endpoint = import.meta.env.S3_ENDPOINT;
// For some S3-compatible services, we need to force path style addressing
if (import.meta.env.S3_FORCE_PATH_STYLE === "true") {
s3ClientOptions.forcePathStyle = true;
}
}
const s3Client = new S3Client(s3ClientOptions);
const bucketName = import.meta.env.S3_BUCKET_NAME;
/**
* List images from S3 bucket with pagination
* @param {string} continuationToken - Token for pagination
* @param {number} maxKeys - Maximum number of keys to return
* @returns {Promise<{images: Array, nextContinuationToken: string}>}
*/
export async function listImages(continuationToken = undefined, maxKeys = 20) {
const command = new ListObjectsV2Command({
Bucket: bucketName,
MaxKeys: maxKeys,
ContinuationToken: continuationToken,
Prefix: "Camera/",
});
try {
const response = await s3Client.send(command);
// Filter for image files only (common image extensions)
const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"];
const images =
response.Contents?.filter((item) =>
imageExtensions.some((ext) => item.Key.toLowerCase().endsWith(ext))
) || [];
// Generate signed URLs for each image
const imagesWithUrls = await Promise.all(
images.map(async (image) => {
const getObjectCommand = new GetObjectCommand({
Bucket: bucketName,
Key: image.Key,
});
// Create a signed URL that expires in 1 hour
const url = await getSignedUrl(s3Client, getObjectCommand, {
expiresIn: 3600,
});
return {
key: image.Key,
url,
lastModified: image.LastModified,
size: image.Size,
};
})
);
return {
images: imagesWithUrls,
nextContinuationToken: response.NextContinuationToken,
};
} catch (error) {
console.error("Error listing images from S3:", error);
throw error;
}
}
Loading…
Cancel
Save