init
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
|
||||
```
|
||||
|
||||
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics)
|
||||
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics)
|
||||
[](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
|
||||
|
||||

|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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">×</span>
|
||||
<div class="navigation-controls">
|
||||
<button id="prev-button" class="nav-button"><</button>
|
||||
<button id="next-button" class="nav-button">></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>
|
@ -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…
Reference in New Issue