feat(deck): drop-in image gallery with City/Farm/Festival sections

New Gallery slide (after the entity deep-dives) with a tabbed masonry grid
and a full-screen lightbox (prev/next, counter, ESC/arrows/swipe) styled
after the webapp marketplace viewer. Images auto-populate via
import.meta.glob — drop files into src/deck/gallery/{city,farm,festival}/
and they appear, sorted by filename, with a graceful empty state. Seeded
placeholder SVGs (clearly marked) so the gallery is populated in the demo.

Lightbox keys are handled in the capture phase so they don't trigger the
deck's slide navigation. Deck is now 17 slides.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-07-28 19:09:59 +02:00
commit 49b7a2920a
20 changed files with 533 additions and 0 deletions

View file

@ -12,6 +12,7 @@ import EntitiesSlide from './slides/EntitiesSlide.vue'
import ArchipelagoSlide from './slides/ArchipelagoSlide.vue'
import SolunarSlide from './slides/SolunarSlide.vue'
import ChateauSlide from './slides/ChateauSlide.vue'
import GallerySlide from './slides/GallerySlide.vue'
import EcosystemSlide from './slides/EcosystemSlide.vue'
import RevenueSlide from './slides/RevenueSlide.vue'
import InvestmentOverviewSlide from './slides/InvestmentOverviewSlide.vue'
@ -30,6 +31,7 @@ const slides = [
{ id: 'archipelago', label: 'Archipelago', component: ArchipelagoSlide },
{ id: 'solunar', label: 'Solunar Society', component: SolunarSlide },
{ id: 'chateau', label: 'Chateau du Faune', component: ChateauSlide },
{ id: 'gallery', label: 'Gallery', component: GallerySlide },
{ id: 'ecosystem', label: 'One membership', component: EcosystemSlide },
{ id: 'revenue', label: 'Revenue', component: RevenueSlide },
{ id: 'invest-overview', label: 'The raise', component: InvestmentOverviewSlide },

View file

@ -30,6 +30,8 @@ import {
Moon,
MapPin,
ChevronRight,
Images,
Expand,
type LucideProps,
} from '@lucide/vue'
import type { FunctionalComponent } from 'vue'
@ -64,6 +66,8 @@ const registry: Record<string, FunctionalComponent<LucideProps>> = {
Moon,
MapPin,
ChevronRight,
Images,
Expand,
}
const props = defineProps<{ name: string }>()

View file

@ -0,0 +1,125 @@
<script setup lang="ts">
/**
* Full-screen image lightbox, styled after the webapp marketplace viewer.
* Teleported to <body>; arrow keys / swipe navigate, ESC or backdrop closes.
*
* Keyboard is handled in the CAPTURE phase and stopPropagation'd while open,
* so the deck's own arrow-key slide navigation doesn't also fire.
*/
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { X, ChevronLeft, ChevronRight } from '@lucide/vue'
import type { GalleryImage } from '../gallery'
const props = defineProps<{ images: GalleryImage[] }>()
// null = closed; otherwise the active image index
const index = defineModel<number | null>({ required: true })
const current = computed(() =>
index.value != null ? (props.images[index.value] ?? null) : null,
)
const hasPrev = computed(() => index.value != null && index.value > 0)
const hasNext = computed(() => index.value != null && index.value < props.images.length - 1)
function close() {
index.value = null
}
function prev() {
if (hasPrev.value) index.value = (index.value as number) - 1
}
function next() {
if (hasNext.value) index.value = (index.value as number) + 1
}
function onKey(e: KeyboardEvent) {
if (index.value == null) return // closed let the deck handle keys
if (['ArrowRight', 'ArrowLeft', 'Escape', ' '].includes(e.key)) {
e.preventDefault()
e.stopPropagation()
}
if (e.key === 'ArrowRight' || e.key === ' ') next()
else if (e.key === 'ArrowLeft') prev()
else if (e.key === 'Escape') close()
}
// basic swipe support
let touchX = 0
function onTouchStart(e: TouchEvent) {
touchX = e.changedTouches[0].clientX
}
function onTouchEnd(e: TouchEvent) {
const dx = e.changedTouches[0].clientX - touchX
if (dx > 50) prev()
else if (dx < -50) next()
}
onMounted(() => window.addEventListener('keydown', onKey, true)) // capture
onUnmounted(() => window.removeEventListener('keydown', onKey, true))
</script>
<template>
<Teleport to="body">
<Transition name="lb">
<div
v-if="current"
class="fixed inset-0 z-[9999] flex items-center justify-center bg-[hsl(160_28%_4%/0.94)] p-6 backdrop-blur-sm"
@click="close"
@touchstart.passive="onTouchStart"
@touchend.passive="onTouchEnd"
>
<img
:src="current.src"
:alt="current.name"
class="max-h-[90vh] max-w-[92vw] rounded-lg object-contain shadow-2xl"
@click.stop
/>
<!-- close -->
<button
class="absolute right-5 top-5 grid size-11 place-items-center rounded-full border border-white/15 bg-white/10 text-sand backdrop-blur transition hover:bg-white/20"
aria-label="Close"
@click.stop="close"
>
<X class="size-5" />
</button>
<!-- prev / next -->
<button
v-if="hasPrev"
class="absolute left-5 top-1/2 grid size-11 -translate-y-1/2 place-items-center rounded-full border border-white/15 bg-white/10 text-sand backdrop-blur transition hover:bg-white/20"
aria-label="Previous image"
@click.stop="prev"
>
<ChevronLeft class="size-6" />
</button>
<button
v-if="hasNext"
class="absolute right-5 top-1/2 grid size-11 -translate-y-1/2 place-items-center rounded-full border border-white/15 bg-white/10 text-sand backdrop-blur transition hover:bg-white/20"
aria-label="Next image"
@click.stop="next"
>
<ChevronRight class="size-6" />
</button>
<!-- counter + hint -->
<div
v-if="images.length > 1"
class="absolute bottom-5 left-1/2 -translate-x-1/2 rounded-full border border-white/15 bg-white/10 px-4 py-1.5 text-sm text-sand backdrop-blur"
>
{{ (index ?? 0) + 1 }} / {{ images.length }}
<span class="ml-2 hidden text-sand-dim sm:inline"> to browse · Esc to close</span>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.lb-enter-active,
.lb-leave-active {
transition: opacity 0.2s ease;
}
.lb-enter-from,
.lb-leave-to {
opacity: 0;
}
</style>

82
src/deck/gallery.ts Normal file
View file

@ -0,0 +1,82 @@
/**
* Gallery auto-population.
*
* Drop image files into these folders and they appear in the deck gallery
* automatically no code changes, no manifest to edit:
*
* src/deck/gallery/city/ City (Archipelago)
* src/deck/gallery/farm/ Farm (Chateau du Faune)
* src/deck/gallery/festival/ Festival (Solunar Society)
*
* Supported: jpg jpeg png webp avif gif svg. Images show sorted by filename,
* so prefix with 01-, 02-, to control order. The seeded *-placeholder.svg
* files are just demo art delete them once you add real photos.
*
* Under the hood this uses Vite's import.meta.glob, which must take a literal
* pattern (no variables) hence one call per folder.
*/
import { entityById, type Accent } from './data'
type GlobMap = Record<string, string>
// NB: import.meta.glob is statically analysed by Vite — its options must be an
// inline object literal (not a shared const), so the object is repeated below.
const city = import.meta.glob('./gallery/city/*.{jpg,jpeg,png,webp,avif,gif,svg}', {
eager: true,
query: '?url',
import: 'default',
}) as GlobMap
const farm = import.meta.glob('./gallery/farm/*.{jpg,jpeg,png,webp,avif,gif,svg}', {
eager: true,
query: '?url',
import: 'default',
}) as GlobMap
const festival = import.meta.glob('./gallery/festival/*.{jpg,jpeg,png,webp,avif,gif,svg}', {
eager: true,
query: '?url',
import: 'default',
}) as GlobMap
export type GalleryImage = { src: string; name: string }
function toImages(map: GlobMap): GalleryImage[] {
return Object.entries(map)
.sort(([a], [b]) => a.localeCompare(b))
.map(([path, src]) => ({ src, name: path.split('/').pop()!.replace(/\.[^.]+$/, '') }))
}
export type GallerySection = {
id: 'city' | 'farm' | 'festival'
label: string
entityName: string
accent: Accent
folder: string
images: GalleryImage[]
}
export const gallerySections: GallerySection[] = [
{
id: 'city',
label: 'City',
entityName: entityById('archipelago').name,
accent: 'pine',
folder: 'src/deck/gallery/city/',
images: toImages(city),
},
{
id: 'farm',
label: 'Farm',
entityName: entityById('chateau').name,
accent: 'clay',
folder: 'src/deck/gallery/farm/',
images: toImages(farm),
},
{
id: 'festival',
label: 'Festival',
entityName: entityById('solunar').name,
accent: 'indigo',
folder: 'src/deck/gallery/festival/',
images: toImages(festival),
},
]

View file

@ -0,0 +1,18 @@
# Deck gallery — just drop images in
Each subfolder feeds one section of the Gallery slide. **Drop image files in and
they appear automatically** — no code to touch.
| Folder | Section | Entity |
| ----------- | -------- | ----------------- |
| `city/` | City | Archipelago |
| `farm/` | Farm | Chateau du Faune |
| `festival/` | Festival | Solunar Society |
- Supported formats: `jpg` `jpeg` `png` `webp` `avif` `gif` `svg`.
- Images display **sorted by filename** — prefix `01-`, `02-`, … to order them.
- The `*-placeholder.svg` files are demo art. **Delete them** once you add real photos.
- Wiring lives in `src/deck/gallery.ts` (Vite `import.meta.glob`). If you add a
brand-new format, extend the glob patterns there.
Recommended: web-optimized `.webp` under ~500 KB each for fast loading.

View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 1040" width="800" height="1040" font-family="Georgia, serif">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(160 60% 40%)" stop-opacity="0.40"/>
<stop offset="1" stop-color="hsl(160 28% 6%)" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="800" height="1040" fill="hsl(160 28% 6%)"/>
<rect width="800" height="1040" fill="url(#g)"/>
<circle cx="740" cy="60" r="120" fill="hsl(160 60% 40%)" opacity="0.12"/>
<circle cx="70" cy="970" r="90" fill="hsl(160 60% 40%)" opacity="0.10"/>
<text x="400" y="480" text-anchor="middle" fill="hsl(160 60% 40%)" font-size="46" font-weight="bold">City</text>
<text x="400" y="526" text-anchor="middle" fill="hsl(44 30% 90%)" font-size="26" opacity="0.85">Photo 01</text>
<text x="400" y="564" text-anchor="middle" fill="hsl(150 8% 62%)" font-size="18" letter-spacing="3">PLACEHOLDER · 800×1040</text>
</svg>

After

Width:  |  Height:  |  Size: 993 B

View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 620" width="800" height="620" font-family="Georgia, serif">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(160 60% 40%)" stop-opacity="0.40"/>
<stop offset="1" stop-color="hsl(160 28% 6%)" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="800" height="620" fill="hsl(160 28% 6%)"/>
<rect width="800" height="620" fill="url(#g)"/>
<circle cx="740" cy="60" r="120" fill="hsl(160 60% 40%)" opacity="0.12"/>
<circle cx="70" cy="550" r="90" fill="hsl(160 60% 40%)" opacity="0.10"/>
<text x="400" y="270" text-anchor="middle" fill="hsl(160 60% 40%)" font-size="46" font-weight="bold">City</text>
<text x="400" y="316" text-anchor="middle" fill="hsl(44 30% 90%)" font-size="26" opacity="0.85">Photo 02</text>
<text x="400" y="354" text-anchor="middle" fill="hsl(150 8% 62%)" font-size="18" letter-spacing="3">PLACEHOLDER · 800×620</text>
</svg>

After

Width:  |  Height:  |  Size: 988 B

View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" width="800" height="800" font-family="Georgia, serif">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(160 60% 40%)" stop-opacity="0.40"/>
<stop offset="1" stop-color="hsl(160 28% 6%)" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="800" height="800" fill="hsl(160 28% 6%)"/>
<rect width="800" height="800" fill="url(#g)"/>
<circle cx="740" cy="60" r="120" fill="hsl(160 60% 40%)" opacity="0.12"/>
<circle cx="70" cy="730" r="90" fill="hsl(160 60% 40%)" opacity="0.10"/>
<text x="400" y="360" text-anchor="middle" fill="hsl(160 60% 40%)" font-size="46" font-weight="bold">City</text>
<text x="400" y="406" text-anchor="middle" fill="hsl(44 30% 90%)" font-size="26" opacity="0.85">Photo 03</text>
<text x="400" y="444" text-anchor="middle" fill="hsl(150 8% 62%)" font-size="18" letter-spacing="3">PLACEHOLDER · 800×800</text>
</svg>

After

Width:  |  Height:  |  Size: 988 B

View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 560" width="800" height="560" font-family="Georgia, serif">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(160 60% 40%)" stop-opacity="0.40"/>
<stop offset="1" stop-color="hsl(160 28% 6%)" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="800" height="560" fill="hsl(160 28% 6%)"/>
<rect width="800" height="560" fill="url(#g)"/>
<circle cx="740" cy="60" r="120" fill="hsl(160 60% 40%)" opacity="0.12"/>
<circle cx="70" cy="490" r="90" fill="hsl(160 60% 40%)" opacity="0.10"/>
<text x="400" y="240" text-anchor="middle" fill="hsl(160 60% 40%)" font-size="46" font-weight="bold">City</text>
<text x="400" y="286" text-anchor="middle" fill="hsl(44 30% 90%)" font-size="26" opacity="0.85">Photo 04</text>
<text x="400" y="324" text-anchor="middle" fill="hsl(150 8% 62%)" font-size="18" letter-spacing="3">PLACEHOLDER · 800×560</text>
</svg>

After

Width:  |  Height:  |  Size: 988 B

View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 900" width="800" height="900" font-family="Georgia, serif">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(160 60% 40%)" stop-opacity="0.40"/>
<stop offset="1" stop-color="hsl(160 28% 6%)" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="800" height="900" fill="hsl(160 28% 6%)"/>
<rect width="800" height="900" fill="url(#g)"/>
<circle cx="740" cy="60" r="120" fill="hsl(160 60% 40%)" opacity="0.12"/>
<circle cx="70" cy="830" r="90" fill="hsl(160 60% 40%)" opacity="0.10"/>
<text x="400" y="410" text-anchor="middle" fill="hsl(160 60% 40%)" font-size="46" font-weight="bold">City</text>
<text x="400" y="456" text-anchor="middle" fill="hsl(44 30% 90%)" font-size="26" opacity="0.85">Photo 05</text>
<text x="400" y="494" text-anchor="middle" fill="hsl(150 8% 62%)" font-size="18" letter-spacing="3">PLACEHOLDER · 800×900</text>
</svg>

After

Width:  |  Height:  |  Size: 988 B

View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 620" width="800" height="620" font-family="Georgia, serif">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(14 72% 62%)" stop-opacity="0.40"/>
<stop offset="1" stop-color="hsl(160 28% 6%)" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="800" height="620" fill="hsl(160 28% 6%)"/>
<rect width="800" height="620" fill="url(#g)"/>
<circle cx="740" cy="60" r="120" fill="hsl(14 72% 62%)" opacity="0.12"/>
<circle cx="70" cy="550" r="90" fill="hsl(14 72% 62%)" opacity="0.10"/>
<text x="400" y="270" text-anchor="middle" fill="hsl(14 72% 62%)" font-size="46" font-weight="bold">Farm</text>
<text x="400" y="316" text-anchor="middle" fill="hsl(44 30% 90%)" font-size="26" opacity="0.85">Photo 01</text>
<text x="400" y="354" text-anchor="middle" fill="hsl(150 8% 62%)" font-size="18" letter-spacing="3">PLACEHOLDER · 800×620</text>
</svg>

After

Width:  |  Height:  |  Size: 984 B

View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 900" width="800" height="900" font-family="Georgia, serif">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(14 72% 62%)" stop-opacity="0.40"/>
<stop offset="1" stop-color="hsl(160 28% 6%)" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="800" height="900" fill="hsl(160 28% 6%)"/>
<rect width="800" height="900" fill="url(#g)"/>
<circle cx="740" cy="60" r="120" fill="hsl(14 72% 62%)" opacity="0.12"/>
<circle cx="70" cy="830" r="90" fill="hsl(14 72% 62%)" opacity="0.10"/>
<text x="400" y="410" text-anchor="middle" fill="hsl(14 72% 62%)" font-size="46" font-weight="bold">Farm</text>
<text x="400" y="456" text-anchor="middle" fill="hsl(44 30% 90%)" font-size="26" opacity="0.85">Photo 02</text>
<text x="400" y="494" text-anchor="middle" fill="hsl(150 8% 62%)" font-size="18" letter-spacing="3">PLACEHOLDER · 800×900</text>
</svg>

After

Width:  |  Height:  |  Size: 984 B

View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 560" width="800" height="560" font-family="Georgia, serif">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(14 72% 62%)" stop-opacity="0.40"/>
<stop offset="1" stop-color="hsl(160 28% 6%)" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="800" height="560" fill="hsl(160 28% 6%)"/>
<rect width="800" height="560" fill="url(#g)"/>
<circle cx="740" cy="60" r="120" fill="hsl(14 72% 62%)" opacity="0.12"/>
<circle cx="70" cy="490" r="90" fill="hsl(14 72% 62%)" opacity="0.10"/>
<text x="400" y="240" text-anchor="middle" fill="hsl(14 72% 62%)" font-size="46" font-weight="bold">Farm</text>
<text x="400" y="286" text-anchor="middle" fill="hsl(44 30% 90%)" font-size="26" opacity="0.85">Photo 03</text>
<text x="400" y="324" text-anchor="middle" fill="hsl(150 8% 62%)" font-size="18" letter-spacing="3">PLACEHOLDER · 800×560</text>
</svg>

After

Width:  |  Height:  |  Size: 984 B

View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" width="800" height="800" font-family="Georgia, serif">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(14 72% 62%)" stop-opacity="0.40"/>
<stop offset="1" stop-color="hsl(160 28% 6%)" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="800" height="800" fill="hsl(160 28% 6%)"/>
<rect width="800" height="800" fill="url(#g)"/>
<circle cx="740" cy="60" r="120" fill="hsl(14 72% 62%)" opacity="0.12"/>
<circle cx="70" cy="730" r="90" fill="hsl(14 72% 62%)" opacity="0.10"/>
<text x="400" y="360" text-anchor="middle" fill="hsl(14 72% 62%)" font-size="46" font-weight="bold">Farm</text>
<text x="400" y="406" text-anchor="middle" fill="hsl(44 30% 90%)" font-size="26" opacity="0.85">Photo 04</text>
<text x="400" y="444" text-anchor="middle" fill="hsl(150 8% 62%)" font-size="18" letter-spacing="3">PLACEHOLDER · 800×800</text>
</svg>

After

Width:  |  Height:  |  Size: 984 B

View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" width="800" height="800" font-family="Georgia, serif">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(256 78% 68%)" stop-opacity="0.40"/>
<stop offset="1" stop-color="hsl(160 28% 6%)" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="800" height="800" fill="hsl(160 28% 6%)"/>
<rect width="800" height="800" fill="url(#g)"/>
<circle cx="740" cy="60" r="120" fill="hsl(256 78% 68%)" opacity="0.12"/>
<circle cx="70" cy="730" r="90" fill="hsl(256 78% 68%)" opacity="0.10"/>
<text x="400" y="360" text-anchor="middle" fill="hsl(256 78% 68%)" font-size="46" font-weight="bold">Festival</text>
<text x="400" y="406" text-anchor="middle" fill="hsl(44 30% 90%)" font-size="26" opacity="0.85">Photo 01</text>
<text x="400" y="444" text-anchor="middle" fill="hsl(150 8% 62%)" font-size="18" letter-spacing="3">PLACEHOLDER · 800×800</text>
</svg>

After

Width:  |  Height:  |  Size: 992 B

View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 560" width="800" height="560" font-family="Georgia, serif">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(256 78% 68%)" stop-opacity="0.40"/>
<stop offset="1" stop-color="hsl(160 28% 6%)" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="800" height="560" fill="hsl(160 28% 6%)"/>
<rect width="800" height="560" fill="url(#g)"/>
<circle cx="740" cy="60" r="120" fill="hsl(256 78% 68%)" opacity="0.12"/>
<circle cx="70" cy="490" r="90" fill="hsl(256 78% 68%)" opacity="0.10"/>
<text x="400" y="240" text-anchor="middle" fill="hsl(256 78% 68%)" font-size="46" font-weight="bold">Festival</text>
<text x="400" y="286" text-anchor="middle" fill="hsl(44 30% 90%)" font-size="26" opacity="0.85">Photo 02</text>
<text x="400" y="324" text-anchor="middle" fill="hsl(150 8% 62%)" font-size="18" letter-spacing="3">PLACEHOLDER · 800×560</text>
</svg>

After

Width:  |  Height:  |  Size: 992 B

View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 1040" width="800" height="1040" font-family="Georgia, serif">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(256 78% 68%)" stop-opacity="0.40"/>
<stop offset="1" stop-color="hsl(160 28% 6%)" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="800" height="1040" fill="hsl(160 28% 6%)"/>
<rect width="800" height="1040" fill="url(#g)"/>
<circle cx="740" cy="60" r="120" fill="hsl(256 78% 68%)" opacity="0.12"/>
<circle cx="70" cy="970" r="90" fill="hsl(256 78% 68%)" opacity="0.10"/>
<text x="400" y="480" text-anchor="middle" fill="hsl(256 78% 68%)" font-size="46" font-weight="bold">Festival</text>
<text x="400" y="526" text-anchor="middle" fill="hsl(44 30% 90%)" font-size="26" opacity="0.85">Photo 03</text>
<text x="400" y="564" text-anchor="middle" fill="hsl(150 8% 62%)" font-size="18" letter-spacing="3">PLACEHOLDER · 800×1040</text>
</svg>

After

Width:  |  Height:  |  Size: 997 B

View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 700" width="800" height="700" font-family="Georgia, serif">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(256 78% 68%)" stop-opacity="0.40"/>
<stop offset="1" stop-color="hsl(160 28% 6%)" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="800" height="700" fill="hsl(160 28% 6%)"/>
<rect width="800" height="700" fill="url(#g)"/>
<circle cx="740" cy="60" r="120" fill="hsl(256 78% 68%)" opacity="0.12"/>
<circle cx="70" cy="630" r="90" fill="hsl(256 78% 68%)" opacity="0.10"/>
<text x="400" y="310" text-anchor="middle" fill="hsl(256 78% 68%)" font-size="46" font-weight="bold">Festival</text>
<text x="400" y="356" text-anchor="middle" fill="hsl(44 30% 90%)" font-size="26" opacity="0.85">Photo 04</text>
<text x="400" y="394" text-anchor="middle" fill="hsl(150 8% 62%)" font-size="18" letter-spacing="3">PLACEHOLDER · 800×700</text>
</svg>

After

Width:  |  Height:  |  Size: 992 B

View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="800" height="600" font-family="Georgia, serif">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(256 78% 68%)" stop-opacity="0.40"/>
<stop offset="1" stop-color="hsl(160 28% 6%)" stop-opacity="1"/>
</linearGradient>
</defs>
<rect width="800" height="600" fill="hsl(160 28% 6%)"/>
<rect width="800" height="600" fill="url(#g)"/>
<circle cx="740" cy="60" r="120" fill="hsl(256 78% 68%)" opacity="0.12"/>
<circle cx="70" cy="530" r="90" fill="hsl(256 78% 68%)" opacity="0.10"/>
<text x="400" y="260" text-anchor="middle" fill="hsl(256 78% 68%)" font-size="46" font-weight="bold">Festival</text>
<text x="400" y="306" text-anchor="middle" fill="hsl(44 30% 90%)" font-size="26" opacity="0.85">Photo 05</text>
<text x="400" y="344" text-anchor="middle" fill="hsl(150 8% 62%)" font-size="18" letter-spacing="3">PLACEHOLDER · 800×600</text>
</svg>

After

Width:  |  Height:  |  Size: 992 B

View file

@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import SlideFrame from '../components/SlideFrame.vue'
import Icon from '../components/Icon.vue'
import Lightbox from '../components/Lightbox.vue'
import { gallerySections } from '../gallery'
import { accentText, accentRing } from '../data'
const activeId = ref(gallerySections[0].id)
const active = computed(() => gallerySections.find((s) => s.id === activeId.value)!)
// lightbox index into the active section's images (null = closed)
const lightboxIndex = ref<number | null>(null)
function selectTab(id: (typeof gallerySections)[number]['id']) {
activeId.value = id
lightboxIndex.value = null
}
</script>
<template>
<SlideFrame
kicker="See it"
title="Gallery"
subtitle="Photography from each entity. Drop images into the matching folder and they appear here automatically."
>
<!-- section tabs -->
<div class="flex flex-wrap gap-2">
<button
v-for="s in gallerySections"
:key="s.id"
class="flex items-center gap-2 rounded-full border px-4 py-2 text-sm transition"
:class="
activeId === s.id
? [accentRing[s.accent], accentText[s.accent]]
: 'border-white/10 bg-white/[0.03] text-sand-dim hover:border-white/25 hover:text-sand'
"
:aria-pressed="activeId === s.id"
@click="selectTab(s.id)"
>
<span class="font-medium">{{ s.label }}</span>
<span class="text-xs opacity-70">{{ s.entityName }}</span>
<span class="rounded-full bg-white/10 px-1.5 text-[11px] tabular-nums">{{ s.images.length }}</span>
</button>
</div>
<!-- masonry grid (scrolls within the slide if tall) -->
<div class="mt-6 max-h-[56vh] overflow-y-auto pr-1 [scrollbar-width:thin]">
<div v-if="active.images.length" class="gap-3 [column-fill:_balance] columns-2 sm:columns-3 lg:columns-4">
<button
v-for="(img, i) in active.images"
:key="img.src"
class="group relative mb-3 block w-full break-inside-avoid overflow-hidden rounded-xl border border-white/10 bg-white/[0.03]"
:aria-label="`Open ${img.name}`"
@click="lightboxIndex = i"
>
<img
:src="img.src"
:alt="img.name"
loading="lazy"
class="w-full object-cover transition duration-300 group-hover:scale-[1.03] group-hover:brightness-110"
/>
<span
class="pointer-events-none absolute inset-0 flex items-end justify-end bg-gradient-to-t from-black/50 to-transparent p-2 opacity-0 transition group-hover:opacity-100"
>
<span class="grid size-8 place-items-center rounded-full bg-white/15 text-sand backdrop-blur">
<Icon name="Expand" class="size-4" />
</span>
</span>
</button>
</div>
<!-- empty state -->
<div
v-else
class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-white/15 px-6 py-16 text-center"
>
<span class="grid size-12 place-items-center rounded-xl bg-white/5" :class="accentText[active.accent]">
<Icon name="Images" class="size-6" />
</span>
<p class="mt-4 text-sand">No {{ active.label }} photos yet.</p>
<p class="mt-1 max-w-md text-sm text-sand-dim">
Drop images into
<code class="rounded bg-white/10 px-1.5 py-0.5 text-xs text-sand">{{ active.folder }}</code>
and theyll appear here automatically.
</p>
</div>
</div>
<Lightbox v-model="lightboxIndex" :images="active.images" />
</SlideFrame>
</template>