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>
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 }>()
|
||||
|
|
|
|||
125
src/deck/components/Lightbox.vue
Normal 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
|
|
@ -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),
|
||||
},
|
||||
]
|
||||
18
src/deck/gallery/README.md
Normal 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.
|
||||
15
src/deck/gallery/city/01-placeholder.svg
Normal 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 |
15
src/deck/gallery/city/02-placeholder.svg
Normal 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 |
15
src/deck/gallery/city/03-placeholder.svg
Normal 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 |
15
src/deck/gallery/city/04-placeholder.svg
Normal 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 |
15
src/deck/gallery/city/05-placeholder.svg
Normal 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 |
15
src/deck/gallery/farm/01-placeholder.svg
Normal 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 |
15
src/deck/gallery/farm/02-placeholder.svg
Normal 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 |
15
src/deck/gallery/farm/03-placeholder.svg
Normal 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 |
15
src/deck/gallery/farm/04-placeholder.svg
Normal 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 |
15
src/deck/gallery/festival/01-placeholder.svg
Normal 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 |
15
src/deck/gallery/festival/02-placeholder.svg
Normal 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 |
15
src/deck/gallery/festival/03-placeholder.svg
Normal 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 |
15
src/deck/gallery/festival/04-placeholder.svg
Normal 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 |
15
src/deck/gallery/festival/05-placeholder.svg
Normal 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 |
92
src/deck/slides/GallerySlide.vue
Normal 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 they’ll appear here automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Lightbox v-model="lightboxIndex" :images="active.images" />
|
||||
</SlideFrame>
|
||||
</template>
|
||||