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
|
A responsive image gallery application built with Astro.js that connects to any S3-compatible storage service to display your images with infinite scrolling. Works with AWS S3, Backblaze B2, MinIO, DigitalOcean Spaces, and other S3-compatible services.
|
||||||
npm create astro@latest -- --template basics
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 📱 Responsive design that works on all devices
|
||||||
|
- 🔄 Infinite scrolling for seamless browsing of large image collections
|
||||||
|
- 🖼️ Optimized image loading with lazy loading
|
||||||
|
- 🔒 Secure S3 integration using signed URLs
|
||||||
|
- 🔌 Compatible with any S3-compatible storage service (AWS S3, Backblaze B2, MinIO, etc.)
|
||||||
|
- ☁️ Ready for deployment on Cloudflare Pages or Cloudflare Workers
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js (v16 or later)
|
||||||
|
- An S3-compatible storage bucket with your images
|
||||||
|
- Access credentials with permissions to read from the bucket
|
||||||
|
|
||||||
|
## Supported S3-Compatible Services
|
||||||
|
|
||||||
|
This application works with any S3-compatible storage service, including:
|
||||||
|
|
||||||
|
- Amazon S3
|
||||||
|
- Backblaze B2
|
||||||
|
- MinIO
|
||||||
|
- DigitalOcean Spaces
|
||||||
|
- Wasabi
|
||||||
|
- Linode Object Storage
|
||||||
|
- Scaleway Object Storage
|
||||||
|
- And many others
|
||||||
|
|
||||||
|
Each service may have slightly different configuration requirements. The application uses the AWS SDK for JavaScript v3, which supports custom endpoints for S3-compatible services.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Clone this repository
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
3. Create a `.env` file based on the `.env.example` template:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
4. Fill in your S3 credentials in the `.env` file:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Required for all S3-compatible services
|
||||||
|
S3_ACCESS_KEY=your_access_key_here
|
||||||
|
S3_SECRET_KEY=your_secret_key_here
|
||||||
|
S3_REGION=your_region_here
|
||||||
|
S3_BUCKET_NAME=your_bucket_name_here
|
||||||
|
|
||||||
|
# Optional: For S3-compatible services like Backblaze B2, MinIO, etc.
|
||||||
|
# S3_ENDPOINT=https://s3.us-west-000.backblazeb2.com
|
||||||
|
# S3_FORCE_PATH_STYLE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
```
|
```
|
||||||
|
|
||||||
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics)
|
5. Start the development server:
|
||||||
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics)
|
```bash
|
||||||
[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json)
|
npm run dev
|
||||||
|
```
|
||||||
|
6. Open your browser and navigate to `http://localhost:4321`
|
||||||
|
|
||||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
## 🚀 Project Structure
|
This will attempt to connect to your S3-compatible storage service and verify that:
|
||||||
|
|
||||||
Inside of your Astro project, you'll see the following folders and files:
|
1. Your credentials are valid
|
||||||
|
2. The specified bucket exists and is accessible
|
||||||
|
|
||||||
```text
|
If there are any issues, the script will provide helpful error messages to guide you in fixing the configuration.
|
||||||
/
|
|
||||||
├── public/
|
## Building for Production
|
||||||
│ └── favicon.svg
|
|
||||||
├── src/
|
To build the application for production:
|
||||||
│ ├── layouts/
|
|
||||||
│ │ └── Layout.astro
|
```bash
|
||||||
│ └── pages/
|
npm run build
|
||||||
│ └── index.astro
|
|
||||||
└── package.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/).
|
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 |
|
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
|
||||||
| `npm install` | Installs dependencies |
|
3. Configure the build settings:
|
||||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
- Build command: `npm run build`
|
||||||
| `npm run build` | Build your production site to `./dist/` |
|
- Build output directory: `dist`
|
||||||
| `npm run preview` | Preview your build locally, before deploying |
|
4. Add your environment variables in the Cloudflare Pages dashboard:
|
||||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
- Required: `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_REGION`, `S3_BUCKET_NAME`
|
||||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
- 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
|
// @ts-check
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from "astro/config";
|
||||||
|
import cloudflare from "@astrojs/cloudflare";
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({});
|
export default defineConfig({
|
||||||
|
output: "server",
|
||||||
|
adapter: cloudflare({
|
||||||
|
mode: "directory",
|
||||||
|
functionPerRoute: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
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 Layout from '../layouts/Layout.astro';
|
||||||
|
import ImageGallery from '../components/ImageGallery.astro';
|
||||||
|
import { listImages } from '../utils/s3.js';
|
||||||
|
|
||||||
// Welcome to Astro! Wondering what to do next? Check out the Astro documentation at https://docs.astro.build
|
// Get initial images for server-side rendering
|
||||||
// Don't want to use any of this? Delete everything in this file, the `assets`, `components`, and `layouts` directories, and start fresh.
|
let initialImages = [];
|
||||||
|
let initialContinuationToken: string | undefined = undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Only attempt to fetch images if we're not in development mode without environment variables
|
||||||
|
if (import.meta.env.S3_ACCESS_KEY && import.meta.env.S3_SECRET_KEY && import.meta.env.S3_BUCKET_NAME) {
|
||||||
|
const result = await listImages(undefined, 20);
|
||||||
|
initialImages = result.images;
|
||||||
|
initialContinuationToken = result.nextContinuationToken;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching initial images:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = "S3 Image Gallery";
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout title={title}>
|
||||||
<Welcome />
|
<div class="container">
|
||||||
|
<div class="intro">
|
||||||
|
<p>Browse your S3 bucket images with infinite scrolling</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{initialImages.length > 0 ? (
|
||||||
|
<ImageGallery
|
||||||
|
initialImages={initialImages}
|
||||||
|
initialContinuationToken={initialContinuationToken}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div class="no-images">
|
||||||
|
<div class="message">
|
||||||
|
<h2>No images found</h2>
|
||||||
|
<p>
|
||||||
|
{import.meta.env.DEV && !import.meta.env.S3_ACCESS_KEY ?
|
||||||
|
"Environment variables not configured. Please set S3_ACCESS_KEY, S3_SECRET_KEY, S3_REGION, and S3_BUCKET_NAME." :
|
||||||
|
"No images found in your S3 bucket. Upload some images to get started."
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-images {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 400px;
|
||||||
|
background-color: var(--color-card);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message p {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 1rem auto 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@ -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