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
	
	 Avraham Sakal
						Avraham Sakal