This commit is contained in:
Avraham Sakal
2025-02-26 21:32:48 -05:00
parent b001a5998d
commit 9610ca2673
13 changed files with 4109 additions and 51 deletions
+9
View File
@@ -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
+142 -36
View File
@@ -1,48 +1,154 @@
# Astro Starter Kit: Basics # S3 Image Gallery
```sh 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.
npm create astro@latest -- --template basics
## 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
``` ```
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics) ### Using with Backblaze B2
[![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)
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! For Backblaze B2, you'll need to set the following:
![just-the-basics](https://github.com/withastro/astro/assets/2244813/a0a5533c-a856-4198-8470-2d67b1d7c554) ```
S3_ACCESS_KEY=your_application_key_id
## 🚀 Project Structure S3_SECRET_KEY=your_application_key
S3_REGION=us-west-000 # Replace with your bucket's region
Inside of your Astro project, you'll see the following folders and files: S3_BUCKET_NAME=your_bucket_name
S3_ENDPOINT=https://s3.us-west-000.backblazeb2.com # Replace with your region
```text S3_FORCE_PATH_STYLE=true
/
├── public/
│ └── favicon.svg
├── src/
│ ├── layouts/
│ │ └── Layout.astro
│ └── pages/
│ └── index.astro
└── package.json
``` ```
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/). 5. Start the development server:
```bash
npm run dev
```
6. Open your browser and navigate to `http://localhost:4321`
## 🧞 Commands ## Testing Your S3 Connection
All commands are run from the root of the project, from a terminal: Before running the application, you can test your S3 connection to ensure your credentials are working correctly:
| Command | Action | ```bash
| :------------------------ | :----------------------------------------------- | npm run test:s3
| `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 |
## 👀 Want to learn more? This will attempt to connect to your S3-compatible storage service and verify that:
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). 1. Your credentials are valid
2. The specified bucket exists and is accessible
If there are any issues, the script will provide helpful error messages to guide you in fixing the configuration.
## Building for Production
To build the application for production:
```bash
npm run build
```
The built files will be in the `dist` directory.
## Deployment to Cloudflare
This project is configured to be deployed on Cloudflare Pages or Cloudflare Workers.
### Deploying to Cloudflare Pages
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;
}
```
## License
MIT
+9 -2
View File
@@ -1,5 +1,12 @@
// @ts-check // @ts-check
import { defineConfig } from 'astro/config'; import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
// https://astro.build/config // https://astro.build/config
export default defineConfig({}); export default defineConfig({
output: "server",
adapter: cloudflare({
mode: "directory",
functionPerRoute: true,
}),
});
+2647 -3
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -6,9 +6,14 @@
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro" "astro": "astro",
"test:s3": "node scripts/test-s3-connection.js"
}, },
"dependencies": { "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"
} }
} }
+115
View File
@@ -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();
+179
View File
@@ -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>
+317
View File
@@ -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>
+380
View File
@@ -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>
+108 -4
View File
@@ -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> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -5,18 +17,110 @@
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} /> <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> </head>
<body> <body>
<header>
<div class="header-container">
<h1>{title}</h1>
</div>
</header>
<main>
<slot /> <slot />
</main>
<footer>
<div class="footer-container">
<p>&copy; {new Date().getFullYear()} S3 Image Gallery</p>
</div>
</footer>
</body> </body>
</html> </html>
<style> <style is:global>
html, :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 { body {
margin: 0; 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%; width: 100%;
height: 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%;
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> </style>
+29
View File
@@ -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",
},
});
}
}
+84 -5
View File
@@ -1,11 +1,90 @@
--- ---
import Welcome from '../components/Welcome.astro';
import Layout from '../layouts/Layout.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 // Get initial images for server-side rendering
// Don't want to use any of this? Delete everything in this file, the `assets`, `components`, and `layouts` directories, and start fresh. 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> <Layout title={title}>
<Welcome /> <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> </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>
+84
View File
@@ -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;
}
}