---
title: "📸 Galerie Photos"
subtitle: "Collection de moments de vol en images"
format:
html:
css: gallery.css
---
```{r setup, include=FALSE}
# Installation automatique des packages si nécessaires
required_packages <- c("here", "dplyr", "glue", "purrr", "stringr")
new_packages <- required_packages[!(required_packages %in% installed.packages()[,"Package"])]
if(length(new_packages)) install.packages(new_packages, repos = "https://cran.rstudio.com/")
library(here)
library(dplyr)
library(glue)
library(purrr)
library(stringr)
# Scanner récursivement toutes les images dans le dossier img/
img_dir <- here("img")
image_files <- list.files(img_dir,
pattern = "\\.(jpg|jpeg|png|webp)$",
recursive = TRUE,
full.names = FALSE)
# Exclure favicon.ico et autres non-photos
image_files <- image_files[!grepl("favicon", image_files)]
# Créer la structure des données des images
images_data <- data.frame(
path = image_files,
alt = tools::file_path_sans_ext(basename(image_files)),
stringsAsFactors = FALSE
) %>%
mutate(
# Extraire le vol depuis le chemin
flight_id = dirname(path),
# Créer un titre propre sans la date (sera affichée séparément)
alt = case_when(
str_detect(flight_id, "^\\d{4}-\\d{2}-\\d{2}") ~ {
# Extraire tout ce qui vient après la date
remaining <- str_replace(flight_id, "^\\d{4}-\\d{2}-\\d{2}-", "")
str_replace_all(remaining, "-", " ")
},
TRUE ~ str_replace_all(flight_id, "-", " ")
),
# Extraire et formater la date depuis flight_id (YYYY-MM-DD -> DD/MM/YYYY)
flight_date = case_when(
str_detect(flight_id, "^\\d{4}-\\d{2}-\\d{2}") ~ {
date_part <- str_extract(flight_id, "^\\d{4}-\\d{2}-\\d{2}")
paste0(str_sub(date_part, 9, 10), "/", str_sub(date_part, 6, 7), "/", str_sub(date_part, 1, 4))
},
TRUE ~ flight_id
)
)
# Générer les cartes HTML
generate_image_cards <- function(images_df) {
cards <- map_chr(1:nrow(images_df), function(i) {
img <- images_df[i, ]
glue('
<div class="gallery-item" data-flight="{img$flight_id}">
<div class="gallery-card">
<img src="img/{img$path}"
alt="{img$alt}"
loading="lazy"
onclick="openLightbox(this)">
<div class="gallery-overlay">
<div class="gallery-info">
<h6>{img$flight_date}</h6>
</div>
<div class="gallery-click-hint">
<i class="fas fa-expand"></i> Cliquer pour agrandir
</div>
</div>
</div>
</div>
')
})
return(paste(cards, collapse = "\n"))
}
```
:::: {.hero-section}
::: {.container}
# 📸 Galerie Photos
Un **pot-pourri visuel** de tous mes vols de parapente - souvenirs en images mélangés de façon aléatoire pour redécouvrir des moments oubliés.
::: {.gallery-stats}
**`r nrow(images_data)` photos** • **`r length(unique(images_data$flight_id))` vols**
:::
:::
::::
::: {.gallery-controls}
<button onclick="shuffleGallery()" class="btn btn-primary">🔀 Mélanger</button>
<button onclick="resetGallery()" class="btn btn-outline-secondary">↺ Reset</button>
:::
::: {.gallery-grid}
```{r}
#| echo: false
#| output: asis
knitr::raw_html(generate_image_cards(images_data))
```
:::
<!-- Lightbox Modal -->
<div id="lightbox-modal" class="lightbox-modal" onclick="closeLightbox()">
<div class="lightbox-content">
<img id="lightbox-image" src="" alt="">
<div class="lightbox-info">
<h5 id="lightbox-title"></h5>
<p id="lightbox-flight"></p>
</div>
<button class="lightbox-close" onclick="closeLightbox()">×</button>
</div>
</div>
<script>
// Variables globales
let originalOrder = [];
let currentOrder = [];
document.addEventListener('DOMContentLoaded', function() {
// Sauvegarder l'ordre initial
const items = document.querySelectorAll('.gallery-item');
originalOrder = Array.from(items);
currentOrder = [...originalOrder];
// Mélanger au chargement pour l'effet "pot-pourri"
shuffleGallery();
});
// Fonction pour mélanger la galerie
function shuffleGallery() {
const container = document.querySelector('.gallery-grid');
const items = Array.from(container.querySelectorAll('.gallery-item'));
// Algorithme de mélange Fisher-Yates
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[items[i], items[j]] = [items[j], items[i]];
}
// Réorganiser les éléments dans le DOM
items.forEach(item => container.appendChild(item));
currentOrder = items;
// Animation de fade
container.style.opacity = '0.5';
setTimeout(() => {
container.style.opacity = '1';
}, 150);
}
// Fonction pour remettre l'ordre initial
function resetGallery() {
const container = document.querySelector('.gallery-grid');
originalOrder.forEach(item => container.appendChild(item));
currentOrder = [...originalOrder];
// Animation
container.style.opacity = '0.5';
setTimeout(() => {
container.style.opacity = '1';
}, 150);
}
// Fonctions Lightbox
function openLightbox(img) {
const modal = document.getElementById('lightbox-modal');
const modalImg = document.getElementById('lightbox-image');
const title = document.getElementById('lightbox-title');
const flight = document.getElementById('lightbox-flight');
modal.style.display = 'flex';
modalImg.src = img.src;
modalImg.alt = img.alt;
const flightItem = img.closest('.gallery-item');
const flightDate = flightItem.querySelector('.gallery-info h6').textContent;
title.textContent = flightDate;
flight.textContent = '';
// Empêcher le scroll du body
document.body.style.overflow = 'hidden';
}
function closeLightbox() {
const modal = document.getElementById('lightbox-modal');
modal.style.display = 'none';
document.body.style.overflow = 'auto';
}
// Fermer avec Escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeLightbox();
}
});
</script>
<style>
/* ===============================================
GALERIE - STYLES PERSONNALISÉS
=============================================== */
.hero-section {
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
color: white;
padding: 4rem 0;
margin-bottom: 3rem;
text-align: center;
}
.hero-section h1 {
font-size: 3rem;
margin-bottom: 1rem;
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.gallery-stats {
background: rgba(255,255,255,0.1);
padding: 0.75rem 1.5rem;
border-radius: 2rem;
display: inline-block;
margin-top: 1rem;
backdrop-filter: blur(10px);
}
.gallery-controls {
text-align: center;
margin-bottom: 2rem;
gap: 1rem;
display: flex;
justify-content: center;
}
.gallery-controls .btn {
margin: 0 0.5rem;
border-radius: 2rem;
padding: 0.5rem 1.5rem;
font-weight: 500;
transition: all 0.3s ease;
}
/* Grid principal */
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
padding: 0 1rem;
transition: opacity 0.3s ease;
}
/* Items de la galerie */
.gallery-item {
position: relative;
overflow: hidden;
border-radius: 0.5rem;
background: white;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
}
.gallery-item:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.gallery-card {
position: relative;
overflow: hidden;
}
.gallery-card img {
width: 100%;
height: 250px;
object-fit: cover;
cursor: pointer;
transition: transform 0.3s ease;
}
.gallery-card:hover img {
transform: scale(1.05);
}
/* Overlay pour les infos */
.gallery-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0,0,0,0.8));
color: white;
padding: 2rem 1rem 1rem;
transform: translateY(100%);
transition: transform 0.3s ease;
}
.gallery-item:hover .gallery-overlay {
transform: translateY(0);
}
.gallery-info h6 {
margin: 0 0 0.25rem 0;
font-weight: 600;
text-transform: capitalize;
}
.gallery-info small {
opacity: 0.8;
font-size: 0.75rem;
}
.gallery-click-hint {
margin-top: 0.5rem;
font-size: 0.7rem;
opacity: 0.9;
display: flex;
align-items: center;
gap: 0.3rem;
justify-content: center;
}
/* ===============================================
LIGHTBOX
=============================================== */
.lightbox-modal {
display: none;
position: fixed;
z-index: 9999;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.9);
align-items: center;
justify-content: center;
animation: fadeIn 0.3s ease;
}
.lightbox-content {
position: relative;
max-width: 90%;
max-height: 90%;
text-align: center;
}
.lightbox-content img {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 0.5rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
}
.lightbox-info {
color: white;
margin-top: 1rem;
}
.lightbox-info h5 {
margin: 0 0 0.5rem 0;
text-transform: capitalize;
}
.lightbox-close {
position: absolute;
top: -40px;
right: 0;
background: none;
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
transition: background 0.2s ease;
}
.lightbox-close:hover {
background: rgba(255, 255, 255, 0.2);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* ===============================================
RESPONSIVE
=============================================== */
@media (max-width: 768px) {
.hero-section {
padding: 2rem 0;
}
.hero-section h1 {
font-size: 2rem;
}
.gallery-grid {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.gallery-card img {
height: 200px;
}
.gallery-controls {
flex-direction: column;
align-items: center;
}
}
@media (max-width: 480px) {
.gallery-grid {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.lightbox-content {
max-width: 95%;
max-height: 95%;
}
}
</style>