# Photo gallery

A photo-heavy page — a wedding album, a portfolio, a property's hero
shots, a restaurant's interior. Upload images, render them in a
responsive masonry-style grid, lightbox on click.

This file covers two things:

1. **How to upload images** — two methods: URL (preferred) and presigned
   PUT URL (for local files or any binary).
2. **The HTML pattern** — Alpine.js for the gallery state, lightbox,
   keyboard navigation.

## Uploading images

Two methods, in order of preference.

### 1. URL upload (preferred)

If the image already has a public HTTPS URL, let the server fetch it.
No local curl, no extra steps — just pass the URL:

```python
requests.put(
    f"{API}/projects/{slug}/pages/photos/files/beach.jpg",
    headers=H,
    json={"url": "https://example.com/photos/beach.jpg"},
)
```

### 2. Presigned PUT URL (for local files)

When the user has a photo on their own machine (not hosted anywhere
yet) and can run a curl command, ask uat-beam.page for a short-lived S3
presigned PUT URL. Two hops: you request the URL via the API, then
the user runs a curl command to send the bytes directly to S3.

```python
# Step 1 — ask for the upload URL
resp = requests.post(
    f"{API}/projects/{slug}/pages/photos/files/beach.jpg/upload-url",
    headers=H,
).json()

upload_url = resp["uploadUrl"]
headers_to_send = resp["headers"]
# resp also contains expiresIn (900) and brief

# Step 2 — hand the user a curl command to run locally
header_flags = " ".join(f'-H "{k}: {v}"' for k, v in headers_to_send.items())
print(f'curl -X PUT -T /path/to/beach.jpg {header_flags} "{upload_url}"')
```

The file lands directly in uat-beam.page's S3 bucket and appears in the
page's `files` list within a second or two. The presigned URL expires
in 15 minutes. Every `headers` entry must be echoed exactly —
they're part of the signature, and any mismatch fails with
`SignatureDoesNotMatch`.

This method works for any file type (JPG, PNG, PDF, MP4, ZIP) up to
4MB, without touching the LLM's context or running PIL.

## Recipe: build a gallery page

```python
import requests
import time

slug = "maria-and-carlos"

# Create a gallery sub-page
requests.post(f"{API}/projects/{slug}/pages", headers=H, json={
    "slug": "photos",
    "notes": "Wedding photos",
})
time.sleep(1)

# Upload photos via URL (preferred — server fetches and stores them)
photos_urls = [
    ("beach.jpg",    "https://example.com/photos/beach.jpg"),
    ("party.jpg",    "https://example.com/photos/party.jpg"),
    ("ceremony.jpg", "https://example.com/photos/ceremony.jpg"),
    ("toast.jpg",    "https://example.com/photos/toast.jpg"),
]

for filename, url in photos_urls:
    requests.put(
        f"{API}/projects/{slug}/pages/photos/files/{filename}",
        headers=H,
        json={"url": url},
    )

# Track the filenames in metadata so the gallery page can render them
requests.put(
    f"{API}/projects/{slug}/pages/photos/metadata",
    headers=H,
    json={
        "photos": [
            {"file": "beach.jpg",    "caption": "On the beach the day after"},
            {"file": "party.jpg",    "caption": "Dance floor at midnight"},
            {"file": "ceremony.jpg", "caption": "The first kiss"},
            {"file": "toast.jpg",    "caption": "The best man's speech"},
        ],
    },
)

# 4. Upload the gallery page HTML
gallery_html = """<!DOCTYPE html>
<html lang="en">
<head>
  <base href="/photos/">
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Photos — Maria & Carlos</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <script src="/assets/tailwind-config.js"></script>
  <style>
    .gallery { column-count: 1; column-gap: 1rem; }
    @media (min-width: 640px)  { .gallery { column-count: 2; } }
    @media (min-width: 1024px) { .gallery { column-count: 3; } }
    .gallery img { width: 100%; margin-bottom: 1rem; break-inside: avoid; cursor: zoom-in; transition: transform 0.3s; }
    .gallery img:hover { transform: scale(1.02); }
  </style>
</head>
<body class="bg-stone-50 p-8"
      x-data="{ p: null, lightbox: null }"
      x-init="
        fetch('/cross_page_metadata.json').then(r => r.json())
          .then(d => p = d.pages.find(pg => pg.slug === 'photos'))
      "
      @keydown.escape.window="lightbox = null"
      @keydown.left.window="if (lightbox !== null && lightbox > 0) lightbox--"
      @keydown.right.window="if (lightbox !== null && p && lightbox < p.metadata.photos.length - 1) lightbox++">

  <header class="text-center mb-12">
    <a href="/" class="text-stone-500 text-sm hover:text-stone-900">&larr; Home</a>
    <h1 class="font-serif text-5xl text-stone-900 mt-4">Photos</h1>
  </header>

  <main class="max-w-6xl mx-auto">
    <template x-if="p && p.metadata && p.metadata.photos">
      <div class="gallery">
        <template x-for="(photo, i) in p.metadata.photos" :key="photo.file">
          <img :src="'/photos/' + photo.file" :alt="photo.caption" @click="lightbox = i">
        </template>
      </div>
    </template>
  </main>

  <!-- Lightbox -->
  <template x-if="lightbox !== null && p">
    <div class="fixed inset-0 bg-black bg-opacity-95 flex items-center justify-center z-50 p-8"
         @click="lightbox = null">
      <button @click.stop="if (lightbox > 0) lightbox--"
              class="absolute left-4 top-1/2 -translate-y-1/2 text-white text-4xl hover:text-stone-300">‹</button>

      <div class="text-center">
        <img :src="'/photos/' + p.metadata.photos[lightbox].file"
             class="max-h-[85vh] max-w-full mx-auto" @click.stop>
        <p class="text-white text-sm mt-4" x-text="p.metadata.photos[lightbox].caption"></p>
        <p class="text-stone-400 text-xs mt-1" x-text="(lightbox + 1) + ' / ' + p.metadata.photos.length"></p>
      </div>

      <button @click.stop="if (lightbox < p.metadata.photos.length - 1) lightbox++"
              class="absolute right-4 top-1/2 -translate-y-1/2 text-white text-4xl hover:text-stone-300">›</button>
      <button @click="lightbox = null"
              class="absolute top-4 right-4 text-white text-2xl hover:text-stone-300">×</button>
    </div>
  </template>

  <script src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js" defer></script>
</body>
</html>
"""

requests.put(
    f"{API}/projects/{slug}/pages/photos/files/index.html",
    headers=H,
    json={"content": gallery_html},
)

print(f"Done. Gallery at https://{slug}.uat-beam.page/photos")
```

## How the gallery renders

- **CSS columns**, not flexbox, give the masonry effect for free.
  Browser handles the wrapping; no JS required for layout.
- **Photos come from metadata**, not from the filesystem. So adding a
  new photo is two calls: upload the file, append to the metadata
  array. The page picks it up automatically.
- **Lightbox is just an Alpine state** — `lightbox` is the index of
  the open photo, or `null` if closed. Click → set; Escape → null;
  Left/Right arrows → navigate.
- **Captions live in the metadata** alongside the file references, so
  they're easy to update without touching HTML.

## Where to put photos

| Use case | Where to upload |
|---|---|
| Wedding album, portfolio shots | A dedicated `photos` (or `gallery`) sub-page's files folder |
| Hero photo on the main page | The main page's files folder (`_root`) |
| Hero photo on a property/event/job listing page | That listing's files folder |
| Logo, icons, decorative graphics | `/assets/` (shared across all pages) |
| The same photo used on many pages | `/assets/` |

## Updating the gallery later

Add a photo:

```python
# Upload the file (URL if available, or use presigned PUT URL for local files)
requests.put(
    f"{API}/projects/{slug}/pages/photos/files/honeymoon.jpg",
    headers=H,
    json={"url": "https://example.com/photos/honeymoon.jpg"},
)

# Append to metadata
page = requests.get(f"{API}/projects/{slug}/pages/photos", headers=H).json()
metadata = page["metadata"]
metadata["photos"].append({"file": "honeymoon.jpg", "caption": "Honeymoon in Greece"})
requests.put(f"{API}/projects/{slug}/pages/photos/metadata", headers=H, json=metadata)
```

Remove a photo:

```python
page = requests.get(f"{API}/projects/{slug}/pages/photos", headers=H).json()
metadata = page["metadata"]
metadata["photos"] = [p for p in metadata["photos"] if p["file"] != "party.jpg"]
requests.put(f"{API}/projects/{slug}/pages/photos/metadata", headers=H, json=metadata)

# Optionally also delete the file from S3
requests.delete(f"{API}/projects/{slug}/pages/photos/files/party.jpg", headers=H)
```
