Compare commits
32 Commits
v1.1
...
d3484a4b50
Author | SHA1 | Date | |
---|---|---|---|
d3484a4b50 | |||
9d37b0a60f | |||
080eb2593d | |||
73a0dd0ce6 | |||
97645b06fa | |||
142c042b86 | |||
041db66b3d | |||
1b0b228273 | |||
41450837f2 | |||
4edeb8709a | |||
6fc573c510 | |||
43c007c1fe | |||
dfbd532efd | |||
efe1bbca29 | |||
7e1a5e659f | |||
f5a5aefd09 | |||
f76420b2c3 | |||
3901bf8acf | |||
39b24a05cb | |||
d379fc63d1 | |||
af6b2289e0 | |||
5728ebb649 | |||
7f86f8f522 | |||
080209d202 | |||
e4a9c57b31 | |||
d0fe57fe9c | |||
bf71ac6dde | |||
f069ee1065 | |||
b0c991af58 | |||
224441f629 | |||
b378e9a386 | |||
2749302082 |
8
.gitignore
vendored
@ -1,3 +1,5 @@
|
|||||||
.venv
|
.*
|
||||||
.output
|
!.sh
|
||||||
__pycache__
|
!.gitignore
|
||||||
|
output/
|
||||||
|
__pycache__/
|
19
Dockerfile
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY ./src/ ./src/
|
||||||
|
COPY ./build.py ./build.py
|
||||||
|
COPY ./gallery.py ./gallery.py
|
||||||
|
COPY ./config /app/default
|
||||||
|
COPY ./docker/.sh/entrypoint.sh /app/entrypoint.sh
|
||||||
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
RUN printf '#!/bin/sh\n/app/entrypoint.sh build\n' > /usr/local/bin/build && chmod +x /usr/local/bin/build && \
|
||||||
|
printf '#!/bin/sh\n/app/entrypoint.sh gallery\n' > /usr/local/bin/gallery && chmod +x /usr/local/bin/gallery
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
259
README.MD
@ -1,234 +1,61 @@
|
|||||||
<h1 align="center">Lumeex</h1>
|
<div align="center">
|
||||||
<div align="center" >
|
<img src="https://git.djeex.fr/Djeex/lumeex/raw/branch/main/illustration/logo.svg" alt="Lumeex Screenshot" width="400"/>
|
||||||
<img src="https://git.djeex.fr/Djeex/lumeex/raw/branch/main/illustration/lumeex.png" alt="Lumeex Screenshot">
|
</div>
|
||||||
|
<p/>
|
||||||
|
<div align="center">
|
||||||
|
<p>Yet another minimalist, lightweight photo gallery static site generator.</p>
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
<div align="center">
|
||||||
|
<img src="https://git.djeex.fr/Djeex/lumeex/raw/branch/main/illustration/lumeex.png" alt="Lumeex Screenshot" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Lumeex** - Yet another minimalist photo gallery with a static site generator.
|
Lumeex is a static site generator designed to create minimalist photo galleries that highlight your artworks over the author. It empowers users to organize and explore images using tags, with each page load presenting photos in a random order to encourage discovery of new content.
|
||||||
|
|
||||||
Lumeex is a static site generator that builds a minimalist photo gallery. The project was born from the need to create a gallery focused more on the artworks than the author, while allowing users to organize works using tags and share filtered views. In this spirit, each page load displays the photos in random order, allowing users to discover new content they might not have seen before.
|
The project includes two thoughtfully designed themes—one modern, one minimalistic—both crafted to keep the spotlight on your photos:
|
||||||
|
|
||||||
The project comes with two themes: one modern, the other more minimalistic, both designed to keep the focus on the artworks:
|
- **Modern** — [View Demo](https://modern.djeex.fr)
|
||||||
- Modern 👉 [demo](https://modern.djeex.fr)
|
- **Typewriter** — [View Demo](https://typewriter.djeex.fr)
|
||||||
- Typewriter 👉 [demo](https://typewriter.djeex.fr)
|
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> _This GitHub repository is a mirror of https://git.djeex.fr/Djeex/lumeex. You’ll find the complete package, history, and release notes there. An LLM is used for bug checking._
|
> This GitHub repository is a mirror of the primary source at [git.djeex.fr/Djeex/lumeex](https://git.djeex.fr/Djeex/lumeex). The main repository includes the full history, releases, and bug-checking assisted by an LLM.
|
||||||
|
|
||||||
|
|
||||||
## 📌 Table of Contents
|
## 📌 Table of Contents
|
||||||
|
|
||||||
- [Features](#features)
|
- [✨ Features](#-features)
|
||||||
- [Python Installation](#python-installation)
|
- [🐳 Docker or 🐍 Python Installation](#-docker-or--python-installation)
|
||||||
- [Configuration](#configuration)
|
|
||||||
- [Build the Site](#build-the-site)
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
**Gallery (Static Website)**
|
## ✨ Features
|
||||||
- Photos displayed in a random order on each page load.
|
|
||||||
- Tag-based filtering (with the ability to combine multiple tags).
|
|
||||||
- Shareable URLs with active tag filters.
|
|
||||||
- A photo carousel on the landing page.
|
|
||||||
- A legal notice page.
|
|
||||||
- Two visual themes (easily customizable):
|
|
||||||
- Modern 👉 [demo](https://modern.djeex.fr)
|
|
||||||
- Typewriter 👉 [demo](https://typewriter.djeex.fr)
|
|
||||||
- Supports Google Fonts and local fonts.
|
|
||||||
|
|
||||||
**No-Code Builder Based on YAML Files**
|
### Gallery (Static Website)
|
||||||
- YAML files to configure site information, SEO, colors, fonts, etc.—no code needed
|
|
||||||
- YAML files to reference and tag photos—no code needed.
|
|
||||||
- *(Optional)* Automatically add photos to the reference file.
|
|
||||||
|
|
||||||
**Simple Build Process**
|
- Photos displayed in a new random order with every page load
|
||||||
- Compiles from YAML config files (theme selection, template building, fonts, colors, etc.).
|
- Tag-based filtering with multi-tag support
|
||||||
- Automatically converts the favicon to all required formats.
|
- Shareable URLs that retain active tag filters
|
||||||
- Automatically resize social thumbnail
|
- Photo carousel on the homepage
|
||||||
- *(Optional)* Automatically resizes photos to a max width of 1140px.
|
- Legal notice page included
|
||||||
- *(Optional)* Converts images to WebP for better performance.
|
- Two customizable visual themes:
|
||||||
- Outputs a fully generated static website, ready to be copied to any web server.
|
- Modern — [Demo](https://modern.djeex.fr)
|
||||||
|
- Typewriter — [Demo](https://typewriter.djeex.fr)
|
||||||
|
- Supports Google Fonts and locally hosted fonts
|
||||||
|
|
||||||
## Python Installation
|
### No-Code Builder (YAML Based)
|
||||||
|
|
||||||
Instructions to run the Python scripts directly.
|
- Configure site info, SEO, colors, fonts, and more through simple YAML files
|
||||||
|
- Reference and tag photos without any coding required
|
||||||
|
- *(Optional)* Automatically update photo references via script
|
||||||
|
|
||||||
**Requirements**
|
### Simple Build Process
|
||||||
|
|
||||||
- Python 3.11 or higher
|
- Compiles static site from YAML configuration files (themes, templates, fonts, colors)
|
||||||
- PyYAML
|
- Converts favicon automatically to all required formats
|
||||||
- Pillow
|
- Resizes social sharing thumbnails
|
||||||
|
- *(Optional)* Automatically resizes photos to a maximum width of 1140px
|
||||||
|
- *(Optional)* Converts images to WebP format for optimized performance
|
||||||
|
- Outputs a complete static website ready to deploy on any web server
|
||||||
|
|
||||||
**Installation**
|
## 🐳 Docker or 🐍 Python Installation
|
||||||
|
For comprehensive documentation on installation, configuration options, customization, and demos, please visit:
|
||||||
```sh
|
https://lumeex.djeex.fr
|
||||||
git clone https://git.djeex.fr/Djeex/lumeex.git
|
|
||||||
cd lumeex
|
|
||||||
python3 -m venv .venv
|
|
||||||
source .venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
You're ready to go!
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
All user configuration files are located in the `config` folder.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
Lumeex/
|
|
||||||
└── config/
|
|
||||||
├── photos/
|
|
||||||
│ ├── gallery
|
|
||||||
│ └── hero
|
|
||||||
├── themes/
|
|
||||||
│ ├── modern
|
|
||||||
│ └── typewriter
|
|
||||||
├── gallery.yaml
|
|
||||||
└── site.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
**`photos/`**
|
|
||||||
|
|
||||||
- `gallery/`: place your gallery photos here.
|
|
||||||
- `hero/`: place carousel photos for the homepage here.
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> You can use `gallery.py` to automatically reference all photos in `gallery/` and `hero/` into `gallery.yaml` and `site.yaml` by running `python3 gallery.py` from the `lumeex` directory.
|
|
||||||
> You’ll just need to tag the photos in `gallery.yaml`.
|
|
||||||
|
|
||||||
**`site.yaml`**
|
|
||||||
|
|
||||||
This file contains all your site’s metadata and settings. For example:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
info:
|
|
||||||
title: your title
|
|
||||||
subtitle: your subtitle
|
|
||||||
description: your description
|
|
||||||
canonical: all, your, keywords
|
|
||||||
author: you
|
|
||||||
google_analytics_id: G-XXXXXXX # optional
|
|
||||||
|
|
||||||
social:
|
|
||||||
instagram_url: https://www.instagram.com/yourprofile
|
|
||||||
thumbnail: gallery/anyphoto.png # put the path from your photo folder to your file
|
|
||||||
menu:
|
|
||||||
items:
|
|
||||||
- label: your_home
|
|
||||||
href: /
|
|
||||||
- label: your_second_menu
|
|
||||||
href: /?tag=yourtag1
|
|
||||||
- label: Your_third_menu
|
|
||||||
href: /?tag=yourtag2
|
|
||||||
|
|
||||||
hero:
|
|
||||||
images:
|
|
||||||
- src: hero/your_photo_1.jpg
|
|
||||||
- src: hero/your_photo_2.jpg
|
|
||||||
- src: hero/your_photo_3.jpg
|
|
||||||
|
|
||||||
footer:
|
|
||||||
copyright: Copyright © 2025 – You
|
|
||||||
legal_link: '/legals.html'
|
|
||||||
legal_label: Legal notice
|
|
||||||
|
|
||||||
build:
|
|
||||||
theme: modern
|
|
||||||
convert_images: false
|
|
||||||
resize_images: false
|
|
||||||
|
|
||||||
legals:
|
|
||||||
hoster_name: Your_hoster
|
|
||||||
hoster_adress: Your hoster address
|
|
||||||
hoster_contact: Your hoster contact
|
|
||||||
intellectual_property:
|
|
||||||
- paragraph: "Your text here"
|
|
||||||
- paragraph: "Your second paragraph here"
|
|
||||||
- paragraph: "Etc..."
|
|
||||||
```
|
|
||||||
|
|
||||||
**`gallery.yaml`**
|
|
||||||
|
|
||||||
Use this file to reference the images in `photos/gallery/`. You can do this manually or automatically by running `python3 gallery.py`. You can also assign tags to the photos here.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
images:
|
|
||||||
- src: gallery/your_photo_1.jpg
|
|
||||||
tags: ["portrait"]
|
|
||||||
- src: gallery/your_photo_2.jpg
|
|
||||||
tags: ["portrait", "sunset", "boat"]
|
|
||||||
- src: gallery/your_photo_3.jpg
|
|
||||||
tags: ["landscape", "sea", "beach", "sand"]
|
|
||||||
```
|
|
||||||
|
|
||||||
**`themes/`**
|
|
||||||
|
|
||||||
```sh
|
|
||||||
themes/
|
|
||||||
└── yourtheme/
|
|
||||||
├── fonts (optional)
|
|
||||||
├── theme.yaml
|
|
||||||
├── theme.css (optional)
|
|
||||||
└── favicon.png
|
|
||||||
```
|
|
||||||
|
|
||||||
**Lumeex** is shipped with two prebuilt themes:
|
|
||||||
- Modern 👉 [demo](https://modern.djeex.fr)
|
|
||||||
- Typewriter 👉 [demo](https://typewriter.djeex.fr)
|
|
||||||
|
|
||||||
You can edit existing themes or create your own. Each theme can include:
|
|
||||||
- **Required:** a `theme.yaml` file for visual settings (colors, fonts, etc.)
|
|
||||||
- *(Optional)* a `theme.css` file for additional styling
|
|
||||||
- *(Optional)* a `fonts` folder for local fonts
|
|
||||||
- *(Optional)* a square `favicon.png` (min 196px) that will be automatically converted to all required formats.
|
|
||||||
|
|
||||||
Example `theme.yaml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
colors:
|
|
||||||
primary: '#0065a1'
|
|
||||||
primary_dark: '#005384'
|
|
||||||
secondary: '#00b0f0'
|
|
||||||
accent: '#ffc700'
|
|
||||||
text_dark: '#333'
|
|
||||||
background: '#fff'
|
|
||||||
browser_color: '#fff'
|
|
||||||
favicon:
|
|
||||||
path: favicon.png
|
|
||||||
google_fonts:
|
|
||||||
- family: Lato
|
|
||||||
weights:
|
|
||||||
- '200'
|
|
||||||
- '400'
|
|
||||||
- '700'
|
|
||||||
- family: Montserrat
|
|
||||||
weights:
|
|
||||||
- '200'
|
|
||||||
- '400'
|
|
||||||
- '700'
|
|
||||||
fonts:
|
|
||||||
primary:
|
|
||||||
name: Lato
|
|
||||||
fallback: sans-serif
|
|
||||||
secondary:
|
|
||||||
name: Montserrat
|
|
||||||
fallback: serif
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build the Site
|
|
||||||
|
|
||||||
Once everything is configured, make sure you're in the `lumeex` directory and your Python virtual environment is activated (`source .venv/bin/activate`).
|
|
||||||
|
|
||||||
- *(Optional)* Run `python3 gallery.py` to auto-fill `gallery.yaml` and add carousel photos to `site.yaml`. Don't forget to add tags to your photos in `gallery.yaml`.
|
|
||||||
- Run `python3 build.py` to generate the static site.
|
|
||||||
- *(Optional)* Serve locally with:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
python3 -m http.server 3000 --directory .output
|
|
||||||
```
|
|
||||||
|
|
||||||
Then visit `http://localhost:3000` or, if remote, `http://your-server-ip:3000`.
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> Use this only to test your site. Don't use python server for production !
|
|
||||||
|
|
||||||
- Finally, copy the contents of the `.output/` directory to your favorite web server.
|
|
181
build.py
@ -1,183 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from src.py.builder.site_builder import build
|
||||||
from pathlib import Path
|
|
||||||
from shutil import copyfile
|
|
||||||
from PIL import Image
|
|
||||||
from src.py.utils import ensure_dir, copy_assets, load_yaml, load_theme_config
|
|
||||||
from src.py.css_generator import generate_css_variables, generate_fonts_css, generate_google_fonts_link
|
|
||||||
from src.py.image_processor import process_images, copy_original_images, convert_and_resize_image, generate_favicons_from_logo, generate_favicon_ico
|
|
||||||
from src.py.html_generator import render_template, render_gallery_images, generate_gallery_json_from_images, generate_robots_txt, generate_sitemap_xml
|
|
||||||
|
|
||||||
# Configure logging to display only the messages
|
|
||||||
logging.basicConfig(level=logging.INFO, format='%(message)s')
|
|
||||||
|
|
||||||
# Define key directories used throughout the script
|
|
||||||
SRC_DIR = Path.cwd()
|
|
||||||
BUILD_DIR = SRC_DIR / ".output"
|
|
||||||
TEMPLATE_DIR = SRC_DIR / "src/templates"
|
|
||||||
IMG_DIR = SRC_DIR / "config/photos"
|
|
||||||
JS_DIR = SRC_DIR / "src/public/js"
|
|
||||||
STYLE_DIR = SRC_DIR / "src/public/style"
|
|
||||||
GALLERY_FILE = SRC_DIR / "config/gallery.yaml"
|
|
||||||
SITE_FILE = SRC_DIR / "config/site.yaml"
|
|
||||||
THEMES_DIR = SRC_DIR / "config/themes"
|
|
||||||
|
|
||||||
def build():
|
|
||||||
logging.info("🚀 Starting build...")
|
|
||||||
ensure_dir(BUILD_DIR)
|
|
||||||
copy_assets(JS_DIR, STYLE_DIR, BUILD_DIR)
|
|
||||||
|
|
||||||
# Defining build vars
|
|
||||||
build_date = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
||||||
site_vars = load_yaml(SITE_FILE)
|
|
||||||
gallery_sections = load_yaml(GALLERY_FILE)
|
|
||||||
build_section = site_vars.get("build", {})
|
|
||||||
theme_name = site_vars.get("build", {}).get("theme", "default")
|
|
||||||
theme_vars, theme_dir = load_theme_config(theme_name, THEMES_DIR)
|
|
||||||
fonts_dir = theme_dir / "fonts"
|
|
||||||
theme_css_path = theme_dir / "theme.css"
|
|
||||||
canonical_url = site_vars.get("info", {}).get("canonical", "").rstrip("/")
|
|
||||||
canonical_home = f"{canonical_url}/"
|
|
||||||
canonical_legals = f"{canonical_url}/legals/"
|
|
||||||
|
|
||||||
# Copying theme.css if existing
|
|
||||||
if theme_css_path.exists():
|
|
||||||
dest_theme_css = BUILD_DIR / "style" / "theme.css"
|
|
||||||
dest_theme_css.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
copyfile(theme_css_path, dest_theme_css)
|
|
||||||
theme_css = f'<link rel="stylesheet" href="/style/theme.css?build_date={build_date}">'
|
|
||||||
logging.info(f"[✓] Theme CSS found, copied to build folder: {dest_theme_css}")
|
|
||||||
else:
|
|
||||||
theme_css = ""
|
|
||||||
logging.warning(f"[~] No theme.css found in {theme_css_path}, skipping theme CSS injection.")
|
|
||||||
|
|
||||||
preload_links = generate_fonts_css(fonts_dir, BUILD_DIR / "style" / "fonts.css", fonts_cfg=theme_vars.get("fonts"))
|
|
||||||
generate_css_variables(theme_vars.get("colors", {}), BUILD_DIR / "style" / "colors.css")
|
|
||||||
generate_favicons_from_logo(theme_vars, theme_dir, BUILD_DIR / "img" / "favicon")
|
|
||||||
generate_favicon_ico(theme_vars, theme_dir, BUILD_DIR / "favicon.ico")
|
|
||||||
|
|
||||||
# Converting and resizing images if enabled
|
|
||||||
convert_images = build_section.get("convert_images", False)
|
|
||||||
resize_images = build_section.get("resize_images", False)
|
|
||||||
logging.info(f"[~] convert_images = {convert_images}")
|
|
||||||
logging.info(f"[~] resize_images = {resize_images}")
|
|
||||||
|
|
||||||
hero_images = site_vars.get("hero", {}).get("images", [])
|
|
||||||
gallery_images = [img for section in gallery_sections for img in section["images"]] if isinstance(gallery_sections, list) else gallery_sections.get("images", [])
|
|
||||||
|
|
||||||
if convert_images:
|
|
||||||
process_images(hero_images, resize_images, IMG_DIR, BUILD_DIR)
|
|
||||||
process_images(gallery_images, resize_images, IMG_DIR, BUILD_DIR)
|
|
||||||
else:
|
|
||||||
copy_original_images(hero_images, IMG_DIR, BUILD_DIR)
|
|
||||||
copy_original_images(gallery_images, IMG_DIR, BUILD_DIR)
|
|
||||||
|
|
||||||
# Adding menu
|
|
||||||
menu_html = "\n".join(
|
|
||||||
f'<li class="nav-item appear"><a href="{item["href"]}">{item["label"]}</a></li>'
|
|
||||||
for item in site_vars.get("menu", {}).get("items", [])
|
|
||||||
)
|
|
||||||
site_vars["hero"]["menu_items"] = menu_html
|
|
||||||
if "footer" in site_vars:
|
|
||||||
site_vars["footer"]["menu_items"] = menu_html
|
|
||||||
|
|
||||||
# Adding Google fonts if existing
|
|
||||||
google_fonts_link = generate_google_fonts_link(theme_vars.get("google_fonts", []))
|
|
||||||
logging.info(f"[✓] Google Fonts link generated:\n{google_fonts_link}")
|
|
||||||
|
|
||||||
# Generating thumbnail
|
|
||||||
thumbnail_path = site_vars.get("social", {}).get("thumbnail")
|
|
||||||
if thumbnail_path:
|
|
||||||
src_thumb = IMG_DIR / thumbnail_path
|
|
||||||
dest_thumb_dir = BUILD_DIR / "img" / "social"
|
|
||||||
dest_thumb_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
dest_thumb = dest_thumb_dir / Path(thumbnail_path).name
|
|
||||||
try:
|
|
||||||
img = Image.open(src_thumb)
|
|
||||||
img = img.convert("RGB")
|
|
||||||
img = img.resize((1200, 630), Image.LANCZOS)
|
|
||||||
img.save(dest_thumb, "JPEG", quality=90)
|
|
||||||
logging.info(f"[✓] Thumbnail resized and saved to {dest_thumb}")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"[✗] Failed to process thumbnail: {e}")
|
|
||||||
else:
|
|
||||||
logging.warning("[~] No thumbnail found in social section")
|
|
||||||
|
|
||||||
# Defining head variables
|
|
||||||
head_vars = dict(site_vars.get("info", {}))
|
|
||||||
head_vars.update(theme_vars.get("colors", {}))
|
|
||||||
head_vars.update(site_vars.get("social", {}))
|
|
||||||
head_vars["thumbnail"] = f"/img/social/{Path(thumbnail_path).name}" if thumbnail_path else ""
|
|
||||||
head_vars["google_fonts_link"] = google_fonts_link
|
|
||||||
head_vars["font_preloads"] = "\n".join(preload_links)
|
|
||||||
head_vars["theme_css"] = theme_css
|
|
||||||
head_vars["build_date"] = build_date
|
|
||||||
head_vars["canonical"] = canonical_home
|
|
||||||
|
|
||||||
# Render the home page
|
|
||||||
head = render_template(TEMPLATE_DIR / "head.html", head_vars)
|
|
||||||
hero = render_template(TEMPLATE_DIR / "hero.html", {**site_vars["hero"], **head_vars})
|
|
||||||
footer = render_template(TEMPLATE_DIR / "footer.html", {**site_vars.get("footer", {}), **head_vars})
|
|
||||||
gallery_html = render_gallery_images(gallery_images)
|
|
||||||
gallery = render_template(TEMPLATE_DIR / "gallery.html", {"gallery_images": gallery_html})
|
|
||||||
|
|
||||||
signature = f"<!-- Build with Lumeex v1.0 | https://git.djeex.fr/Djeex/lumeex | {build_date} -->"
|
|
||||||
body = f"""
|
|
||||||
<body>
|
|
||||||
<div class="page-loader"><div class="spinner"></div></div>
|
|
||||||
{hero}
|
|
||||||
{gallery}
|
|
||||||
{footer}
|
|
||||||
</body>
|
|
||||||
"""
|
|
||||||
output_file = BUILD_DIR / "index.html"
|
|
||||||
with open(output_file, "w", encoding="utf-8") as f:
|
|
||||||
f.write(f"<!DOCTYPE html>\n{signature}\n<html lang='en'>\n{head}\n{body}\n</html>")
|
|
||||||
logging.info(f"[✓] HTML generated: {output_file}")
|
|
||||||
|
|
||||||
# Rendering legals page
|
|
||||||
head_vars["canonical"] = canonical_legals
|
|
||||||
|
|
||||||
legals_vars = site_vars.get("legals", {})
|
|
||||||
if legals_vars:
|
|
||||||
head = render_template(TEMPLATE_DIR / "head.html", head_vars)
|
|
||||||
|
|
||||||
ip_paragraphs = legals_vars.get("intellectual_property", [])
|
|
||||||
paragraphs_html = "\n".join(f"<p>{item['paragraph']}</p>" for item in ip_paragraphs)
|
|
||||||
legals_context = {
|
|
||||||
"hoster_name": legals_vars.get("hoster_name", ""),
|
|
||||||
"hoster_adress": legals_vars.get("hoster_adress", ""),
|
|
||||||
"hoster_contact": legals_vars.get("hoster_contact", ""),
|
|
||||||
"intellectual_property": paragraphs_html,
|
|
||||||
}
|
|
||||||
legals_body = render_template(TEMPLATE_DIR / "legals.html", legals_context)
|
|
||||||
legals_html = f"<!DOCTYPE html>\n{signature}\n<html lang='en'>\n{head}\n{legals_body}\n{footer}\n</html>"
|
|
||||||
output_legals = BUILD_DIR / "legals" / "index.html"
|
|
||||||
output_legals.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(output_legals, "w", encoding="utf-8") as f:
|
|
||||||
f.write(legals_html)
|
|
||||||
logging.info(f"[✓] Legals page generated: {output_legals}")
|
|
||||||
else:
|
|
||||||
logging.warning("[~] No legals section found in site.yaml")
|
|
||||||
|
|
||||||
# Hero carrousel generator
|
|
||||||
if hero_images:
|
|
||||||
generate_gallery_json_from_images(hero_images, BUILD_DIR / "data" / "gallery.json")
|
|
||||||
else:
|
|
||||||
logging.warning("[~] No hero images found, skipping JSON generation.")
|
|
||||||
|
|
||||||
# Sitemap and robot.txt generator
|
|
||||||
site_info = site_vars.get("info", {})
|
|
||||||
canonical_url = site_info.get("canonical", "").rstrip("/")
|
|
||||||
if canonical_url:
|
|
||||||
allowed_pages = ["/", "/legals/"]
|
|
||||||
generate_robots_txt(canonical_url, allowed_pages)
|
|
||||||
generate_sitemap_xml(canonical_url, allowed_pages)
|
|
||||||
else:
|
|
||||||
logging.warning("[~] No canonical URL found in site.yaml info section, skipping robots.txt and sitemap.xml generation.")
|
|
||||||
|
|
||||||
logging.info("✅ Build complete.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
build()
|
build()
|
||||||
|
|
@ -1,30 +1,4 @@
|
|||||||
# Source your photos here
|
hero:
|
||||||
# Relative path is set from built img folder
|
images: []
|
||||||
# You can also use gallery.py to automatically add photos stored in your /config/photos/gallery folder
|
gallery:
|
||||||
# Add tags to your photos as shown below
|
images: []
|
||||||
# remove the # before [] if you removed all images to use gallery.py
|
|
||||||
images: #[]
|
|
||||||
- src: gallery/almos-bechtold-3402kvtHhOo-unsplash.jpg
|
|
||||||
tags: ["portrait"]
|
|
||||||
- src: gallery/arthur-savary-nLfAqmZ2hJo-unsplash.jpg
|
|
||||||
tags: ["portrait", "sunset", "boat"]
|
|
||||||
- src: gallery/francesco-ungaro-Zbc9Ka8msdI-unsplash.jpg
|
|
||||||
tags: ["landscape", "sea", "beach", "sand"]
|
|
||||||
- src: gallery/gilley-aguilar-ywGDhTlf93E-unsplash.jpg
|
|
||||||
tags: ["landscape", "sky", "cloud", "mountains"]
|
|
||||||
- src: gallery/jacob-reinikainen-nGG6m3RbjSk-unsplash.jpg
|
|
||||||
tags: ["lanscape", "sunset", "mountains"]
|
|
||||||
- src: gallery/jonas-degener-LueP5EdWGFY-unsplash.jpg
|
|
||||||
tags: ["landscape", "mountains", "fog"]
|
|
||||||
- src: gallery/michiel-annaert-M27pZnHV6M0-unsplash.jpg
|
|
||||||
tags: ["flowers", "nature"]
|
|
||||||
- src: gallery/nir-himi-AjecvkfSHxA-unsplash.jpg
|
|
||||||
tags: ["landscape", "mountains", "sky"]
|
|
||||||
- src: gallery/rachel-mcdermott-0fN7Fxv1eWA-unsplash.jpg
|
|
||||||
tags: ["portrait", "black and white"]
|
|
||||||
- src: gallery/tianlei-wu-g5o6T-PWT3g-unsplash.jpg
|
|
||||||
tags: ["cat", "animals"]
|
|
||||||
- src: gallery/we-care-wild-zLweeVLU9Fo-unsplash.jpg
|
|
||||||
tags: ["bison", "animals"]
|
|
||||||
- src: gallery/y-s-z90w7yStOkk-unsplash.jpg
|
|
||||||
tags: ["frog", "green", "animals"]
|
|
||||||
|
@ -1,59 +1,34 @@
|
|||||||
# This file is filled with the demo info
|
# Please change this by your settings.
|
||||||
# Please change this by your settings
|
|
||||||
|
|
||||||
info:
|
info:
|
||||||
title: Lumeex
|
title:
|
||||||
subtitle: A minimalistic Gallery
|
subtitle:
|
||||||
description: A minimalistic Gallery
|
description:
|
||||||
canonical: https://lumeex.djeex.fr
|
canonical:
|
||||||
keywords: photography, lumen, demo, gallery, minimalistic
|
keywords:
|
||||||
author: Djeex
|
author:
|
||||||
google_analytics_id: G-XXXXXXX # optional
|
|
||||||
|
|
||||||
social:
|
social:
|
||||||
instagram_url: https://www.instagram.com/
|
instagram_url:
|
||||||
thumbnail: hero/jacob-reinikainen-nGG6m3RbjSk-unsplash.jpg
|
thumbnail:
|
||||||
|
|
||||||
menu:
|
menu:
|
||||||
items:
|
items:
|
||||||
- label: Home
|
- label: Home
|
||||||
href: /
|
href: /
|
||||||
- label: Nature
|
|
||||||
href: /?tag=Nature
|
|
||||||
- label: Landscape
|
|
||||||
href: /?tag=landscape
|
|
||||||
- label: Portrait
|
|
||||||
href: /?tag=portrait
|
|
||||||
- label: Animals
|
|
||||||
href: /?tag=animals
|
|
||||||
|
|
||||||
hero:
|
|
||||||
# Source your hero carrousel images here.
|
|
||||||
# Root folder is img.
|
|
||||||
# You can also use gallery.py to automatically add images from config/photos/hero folder
|
|
||||||
# remove the # before [] if you removed all images to use gallery.py
|
|
||||||
images: #[]
|
|
||||||
- src: hero/francesco-ungaro-Zbc9Ka8msdI-unsplash.jpg
|
|
||||||
- src: hero/gilley-aguilar-ywGDhTlf93E-unsplash.jpg
|
|
||||||
- src: hero/jacob-reinikainen-nGG6m3RbjSk-unsplash.jpg
|
|
||||||
|
|
||||||
footer:
|
footer:
|
||||||
copyright: Copyright © 2025 – Lumeex
|
copyright: Copyright © 2025
|
||||||
legal_link: '/legals/'
|
legal_link: '/legals/'
|
||||||
legal_label: Legal notice
|
legal_label: Legal notice
|
||||||
|
|
||||||
# Build parameters
|
# Build parameters
|
||||||
build:
|
build:
|
||||||
theme: modern # choose a theme in config/theme folder.
|
theme: modern # choose a theme in config/theme folder
|
||||||
convert_images: true # use true to automatically convert images to webp small weight images.
|
|
||||||
resize_images: true # use true to automatically resize to width 1140px (maximum width used in the gallery)
|
|
||||||
|
|
||||||
# Change this by your legals
|
# Change this by your legals
|
||||||
legals:
|
legals:
|
||||||
hoster_name: Djeex
|
hoster_name:
|
||||||
hoster_adress: Paris, France
|
hoster_adress:
|
||||||
hoster_contact: contact@djeex.fr
|
hoster_contact:
|
||||||
intellectual_property:
|
intellectual_property:
|
||||||
- paragraph: "Users of this website are required to comply with the provisions of the French Data Protection Act (Loi Informatique et Libertés), the violation of which may result in criminal penalties. In particular, they must refrain from any collection or misuse of personal data accessible through the site, and more generally, from any act likely to infringe upon the privacy or reputation of individuals."
|
- paragraph: ""
|
||||||
- paragraph: "The overall structure, as well as the software, texts, animated or still images, know-how, and all other components of the site, are the exclusive property of Lumeex"
|
|
||||||
- paragraph: "Any total or partial reproduction of this website, by any means whatsoever, without the express authorization of Lumeex, is prohibited and constitutes an infringement punishable under articles L.335-2 and following of the French Intellectual Property Code. The same applies to the databases appearing on the website, which are protected by the provisions of the law of July 1, 1998, implementing into the Intellectual Property Code the European directive of March 11, 1996, on the legal protection of databases."
|
|
||||||
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 16 KiB |
36
demo/config/gallery.yaml
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Use gallery.py to automatically add photos stored in your /config/photos/gallery folder
|
||||||
|
# Add tags to your photos as shown below
|
||||||
|
# remove the # before [] if you removed all images to use gallery.py again
|
||||||
|
|
||||||
|
hero:
|
||||||
|
images: #[]
|
||||||
|
- src: hero/francesco-ungaro-Zbc9Ka8msdI-unsplash.jpg
|
||||||
|
- src: hero/gilley-aguilar-ywGDhTlf93E-unsplash.jpg
|
||||||
|
- src: hero/jacob-reinikainen-nGG6m3RbjSk-unsplash.jpg
|
||||||
|
|
||||||
|
gallery:
|
||||||
|
images: #[]
|
||||||
|
- src: gallery/almos-bechtold-3402kvtHhOo-unsplash.jpg
|
||||||
|
tags: [portrait]
|
||||||
|
- src: gallery/arthur-savary-nLfAqmZ2hJo-unsplash.jpg
|
||||||
|
tags: [portrait, sunset, boat]
|
||||||
|
- src: gallery/francesco-ungaro-Zbc9Ka8msdI-unsplash.jpg
|
||||||
|
tags: [landscape, sea, beach, sand]
|
||||||
|
- src: gallery/gilley-aguilar-ywGDhTlf93E-unsplash.jpg
|
||||||
|
tags: [landscape, sky, cloud, mountains]
|
||||||
|
- src: gallery/jacob-reinikainen-nGG6m3RbjSk-unsplash.jpg
|
||||||
|
tags: [lanscape, sunset, mountains]
|
||||||
|
- src: gallery/jonas-degener-LueP5EdWGFY-unsplash.jpg
|
||||||
|
tags: [landscape, mountains, fog]
|
||||||
|
- src: gallery/michiel-annaert-M27pZnHV6M0-unsplash.jpg
|
||||||
|
tags: [flowers, nature]
|
||||||
|
- src: gallery/nir-himi-AjecvkfSHxA-unsplash.jpg
|
||||||
|
tags: [landscape, mountains, sky]
|
||||||
|
- src: gallery/rachel-mcdermott-0fN7Fxv1eWA-unsplash.jpg
|
||||||
|
tags: [portrait, black and white]
|
||||||
|
- src: gallery/tianlei-wu-g5o6T-PWT3g-unsplash.jpg
|
||||||
|
tags: [cat, animals]
|
||||||
|
- src: gallery/we-care-wild-zLweeVLU9Fo-unsplash.jpg
|
||||||
|
tags: [bison, animals]
|
||||||
|
- src: gallery/y-s-z90w7yStOkk-unsplash.jpg
|
||||||
|
tags: [frog, green, animals]
|
BIN
demo/config/photos/.DS_Store
vendored
Normal file
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.7 MiB |
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.7 MiB |
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.4 MiB |
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
Before Width: | Height: | Size: 5.7 MiB After Width: | Height: | Size: 5.7 MiB |
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.7 MiB |
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 364 KiB |
Before Width: | Height: | Size: 5.6 MiB After Width: | Height: | Size: 5.6 MiB |
Before Width: | Height: | Size: 8.4 MiB After Width: | Height: | Size: 8.4 MiB |
Before Width: | Height: | Size: 706 KiB After Width: | Height: | Size: 706 KiB |
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.7 MiB |
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.4 MiB |
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
49
demo/config/site.yaml
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# This file is filled with the demo info
|
||||||
|
# Please change this by your settings
|
||||||
|
|
||||||
|
info:
|
||||||
|
title: Lumeex
|
||||||
|
subtitle: A minimalistic Gallery
|
||||||
|
description: A minimalistic Gallery
|
||||||
|
canonical: https://lumeex.djeex.fr
|
||||||
|
keywords: photography, lumen, demo, gallery, minimalistic
|
||||||
|
author: Djeex
|
||||||
|
google_analytics_id: G-XXXXXXX # optional
|
||||||
|
|
||||||
|
social:
|
||||||
|
instagram_url: https://www.instagram.com/
|
||||||
|
thumbnail: hero/jacob-reinikainen-nGG6m3RbjSk-unsplash.jpg
|
||||||
|
|
||||||
|
menu:
|
||||||
|
items:
|
||||||
|
- label: Home
|
||||||
|
href: /
|
||||||
|
- label: Nature
|
||||||
|
href: /?tag=Nature
|
||||||
|
- label: Landscape
|
||||||
|
href: /?tag=landscape
|
||||||
|
- label: Portrait
|
||||||
|
href: /?tag=portrait
|
||||||
|
- label: Animals
|
||||||
|
href: /?tag=animals
|
||||||
|
|
||||||
|
footer:
|
||||||
|
copyright: Copyright © 2025 – Lumeex
|
||||||
|
legal_link: '/legals/'
|
||||||
|
legal_label: Legal notice
|
||||||
|
|
||||||
|
# Build parameters
|
||||||
|
build:
|
||||||
|
theme: modern # choose a theme in config/theme folder.
|
||||||
|
convert_images: true # use true to automatically convert images to webp small weight images.
|
||||||
|
resize_images: true # use true to automatically resize to width 1140px (maximum width used in the gallery)
|
||||||
|
|
||||||
|
# Change this by your legals
|
||||||
|
legals:
|
||||||
|
hoster_name: Djeex
|
||||||
|
hoster_adress: Paris, France
|
||||||
|
hoster_contact: contact@djeex.fr
|
||||||
|
intellectual_property:
|
||||||
|
- paragraph: "Users of this website are required to comply with the provisions of the French Data Protection Act (Loi Informatique et Libertés), the violation of which may result in criminal penalties. In particular, they must refrain from any collection or misuse of personal data accessible through the site, and more generally, from any act likely to infringe upon the privacy or reputation of individuals."
|
||||||
|
- paragraph: "The overall structure, as well as the software, texts, animated or still images, know-how, and all other components of the site, are the exclusive property of Lumeex"
|
||||||
|
- paragraph: "Any total or partial reproduction of this website, by any means whatsoever, without the express authorization of Lumeex, is prohibited and constitutes an infringement punishable under articles L.335-2 and following of the French Intellectual Property Code. The same applies to the databases appearing on the website, which are protected by the provisions of the law of July 1, 1998, implementing into the Intellectual Property Code the European directive of March 11, 1996, on the legal protection of databases."
|
BIN
demo/config/themes/modern/favicon.png
Normal file
After Width: | Height: | Size: 16 KiB |
63
demo/config/themes/modern/theme.css
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
/*-----------------------------------*/
|
||||||
|
/* Modern theme for Lumeex */
|
||||||
|
/* https://git.djeex.fr/Djeex/lumeex */
|
||||||
|
/*-----------------------------------*/
|
||||||
|
|
||||||
|
.hero-background {
|
||||||
|
border-radius: 0 0 15px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img, tag {
|
||||||
|
border-radius: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag, .scroll-up, .back-button {
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: rgb(245 245 245);
|
||||||
|
border-radius: 30px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
padding: 8px 15px 10px 15px;
|
||||||
|
margin-left: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag:hover {
|
||||||
|
background: rgb(231, 231, 231);
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag.active, .scroll-up:hover, .back-button:hover {
|
||||||
|
color: var(--color-background);
|
||||||
|
background: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
#footer {
|
||||||
|
box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5);
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
|
||||||
|
#footer {
|
||||||
|
box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 15px 15px 0 0;
|
||||||
|
max-width: 1140px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-background {
|
||||||
|
max-width: 90%;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
margin-left: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
32
demo/config/themes/modern/theme.yaml
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
#-----------------------------------#
|
||||||
|
# Modern theme for Lumeex #
|
||||||
|
# https://git.djeex.fr/Djeex/lumeex #
|
||||||
|
#-----------------------------------#
|
||||||
|
colors:
|
||||||
|
primary: '#0065a1'
|
||||||
|
primary_dark: '#005384'
|
||||||
|
secondary: '#00b0f0'
|
||||||
|
accent: '#ffc700'
|
||||||
|
text_dark: '#616161'
|
||||||
|
background: '#fff'
|
||||||
|
browser_color: '#fff'
|
||||||
|
favicon:
|
||||||
|
path: favicon.png
|
||||||
|
google_fonts:
|
||||||
|
- family: Lato
|
||||||
|
weights:
|
||||||
|
- '200'
|
||||||
|
- '400'
|
||||||
|
- '700'
|
||||||
|
- family: Montserrat
|
||||||
|
weights:
|
||||||
|
- '200'
|
||||||
|
- '400'
|
||||||
|
- '700'
|
||||||
|
fonts:
|
||||||
|
primary:
|
||||||
|
name: Lato
|
||||||
|
fallback: sans-serif
|
||||||
|
secondary:
|
||||||
|
name: Montserrat
|
||||||
|
fallback: serif
|
BIN
demo/config/themes/typewriter/favicon.png
Normal file
After Width: | Height: | Size: 69 KiB |
BIN
demo/config/themes/typewriter/fonts/trixie.woff
Normal file
BIN
demo/config/themes/typewriter/fonts/trixie.woff2
Normal file
13
demo/config/themes/typewriter/theme.css
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
/*-----------------------------------*/
|
||||||
|
/* Typewriter theme for Lumeex */
|
||||||
|
/* https://git.djeex.fr/Djeex/lumeex */
|
||||||
|
/*-----------------------------------*/
|
||||||
|
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
line-height: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
gap:0px;
|
||||||
|
}
|
21
demo/config/themes/typewriter/theme.yaml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
#-----------------------------------#
|
||||||
|
# Typewriter theme for Lumeex #
|
||||||
|
# https://git.djeex.fr/Djeex/lumeex #
|
||||||
|
#-----------------------------------#
|
||||||
|
colors:
|
||||||
|
primary: '#0065a1'
|
||||||
|
primary_dark: '#005384'
|
||||||
|
secondary: '#00b0f0'
|
||||||
|
accent: '#ffc700'
|
||||||
|
text_dark: '#616161'
|
||||||
|
background: '#fff'
|
||||||
|
browser_color: '#fff'
|
||||||
|
favicon:
|
||||||
|
path: favicon.png
|
||||||
|
fonts:
|
||||||
|
primary:
|
||||||
|
name: Trixie
|
||||||
|
fallback: sans-serif
|
||||||
|
secondary:
|
||||||
|
name: Trixie
|
||||||
|
fallback: serif
|
78
docker/.sh/entrypoint.sh
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CYAN="\033[1;36m"
|
||||||
|
NC="\033[0m"
|
||||||
|
|
||||||
|
copy_default_config() {
|
||||||
|
echo "Checking configuration directory..."
|
||||||
|
if [ ! -d "/app/config" ]; then
|
||||||
|
mkdir -p /app/config
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Checking if default config files need to be copied..."
|
||||||
|
files_copied=false
|
||||||
|
|
||||||
|
for file in /app/default/*; do
|
||||||
|
filename=$(basename "$file")
|
||||||
|
target="/app/config/$filename"
|
||||||
|
|
||||||
|
if [ ! -e "$target" ]; then
|
||||||
|
echo "Copying default config file: $filename"
|
||||||
|
cp -r "$file" "$target"
|
||||||
|
files_copied=true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$files_copied" = true ]; then
|
||||||
|
echo "Default configuration files copied successfully."
|
||||||
|
else
|
||||||
|
echo "No default files needed to be copied."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
start_server() {
|
||||||
|
# Clean up old FIFOs
|
||||||
|
[ -p /tmp/build_logs_fifo ] && rm /tmp/build_logs_fifo
|
||||||
|
[ -p /tmp/build_logs_fifo2 ] && rm /tmp/build_logs_fifo2
|
||||||
|
|
||||||
|
mkfifo /tmp/build_logs_fifo
|
||||||
|
mkfifo /tmp/build_logs_fifo2
|
||||||
|
|
||||||
|
cat /tmp/build_logs_fifo >&2 &
|
||||||
|
cat /tmp/build_logs_fifo2 >&2 &
|
||||||
|
|
||||||
|
echo "Starting HTTP server on port 3000..."
|
||||||
|
python3 -u -m http.server 3000 -d /app/output &
|
||||||
|
SERVER_PID=$!
|
||||||
|
trap "echo 'Stopping server...'; kill -TERM $SERVER_PID 2>/dev/null; wait $SERVER_PID; exit 0" SIGINT SIGTERM
|
||||||
|
wait $SERVER_PID
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
echo -e "${CYAN}╭───────────────────────────────────────────╮${NC}"
|
||||||
|
echo -e "${CYAN}│${NC} Lum${CYAN}eex${NC} - Version 1.3.1${NC} ${CYAN}│${NC}"
|
||||||
|
echo -e "${CYAN}├───────────────────────────────────────────┤${NC}"
|
||||||
|
echo -e "${CYAN}│${NC} Source: https://git.djeex.fr/Djeex/lumeex ${CYAN}│${NC}"
|
||||||
|
echo -e "${CYAN}│${NC} Mirror: https://github.com/Djeex/lumeex ${CYAN}│${NC}"
|
||||||
|
echo -e "${CYAN}│${NC} Documentation: https://lumeex.djeex.fr ${CYAN}│${NC}"
|
||||||
|
echo -e "${CYAN}╰───────────────────────────────────────────╯${NC}"
|
||||||
|
copy_default_config
|
||||||
|
start_server
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
build)
|
||||||
|
echo "Running build.py..."
|
||||||
|
python3 -u /app/build.py 2>&1 | tee /tmp/build_logs_fifo
|
||||||
|
;;
|
||||||
|
gallery)
|
||||||
|
echo "Running gallery.py..."
|
||||||
|
python3 -u /app/gallery.py 2>&1 | tee /tmp/build_logs_fifo2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown command: $1"
|
||||||
|
exec "$@"
|
||||||
|
;;
|
||||||
|
esac
|
10
docker/docker-compose.yaml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
lumeex:
|
||||||
|
container_name: lmx
|
||||||
|
build: ..
|
||||||
|
volumes:
|
||||||
|
- ../config:/app/config # mount config directory
|
||||||
|
- ../output:/app/output # mount output directory
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
80
gallery.py
@ -1,81 +1,7 @@
|
|||||||
import yaml
|
import logging
|
||||||
import os
|
from src.py.builder.gallery_builder import update_gallery, update_hero
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# YAML file paths
|
|
||||||
GALLERY_YAML = "config/gallery.yaml"
|
|
||||||
SITE_YAML = "config/site.yaml"
|
|
||||||
|
|
||||||
# Image directories
|
|
||||||
GALLERY_DIR = Path("config/photos/gallery")
|
|
||||||
HERO_DIR = Path("config/photos/hero")
|
|
||||||
|
|
||||||
def load_yaml(path):
|
|
||||||
print(f"[→] Loading {path}...")
|
|
||||||
if not os.path.exists(path):
|
|
||||||
print(f"[✗] File not found: {path}")
|
|
||||||
return {}
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
data = yaml.safe_load(f) or {}
|
|
||||||
images = data.get("images") or []
|
|
||||||
print(f"[✓] Loaded {len(images)} image(s) from {path}")
|
|
||||||
return data
|
|
||||||
|
|
||||||
def save_yaml(data, path):
|
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
|
||||||
yaml.dump(data, f, sort_keys=False, allow_unicode=True)
|
|
||||||
print(f"[✓] Saved updated YAML to {path}")
|
|
||||||
|
|
||||||
def get_all_image_paths(directory):
|
|
||||||
return sorted([
|
|
||||||
str(p.relative_to(directory.parent)).replace("\\", "/")
|
|
||||||
for p in directory.rglob("*")
|
|
||||||
if p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"]
|
|
||||||
])
|
|
||||||
|
|
||||||
def update_gallery():
|
|
||||||
print("\n=== Updating gallery.yaml ===")
|
|
||||||
gallery = load_yaml(GALLERY_YAML)
|
|
||||||
gallery_images = gallery.get("images") or []
|
|
||||||
known = {img["src"] for img in gallery_images}
|
|
||||||
all_images = get_all_image_paths(GALLERY_DIR)
|
|
||||||
|
|
||||||
new_images = [
|
|
||||||
{"src": path, "tags": []}
|
|
||||||
for path in all_images
|
|
||||||
if path not in known
|
|
||||||
]
|
|
||||||
|
|
||||||
if new_images:
|
|
||||||
gallery_images.extend(new_images)
|
|
||||||
gallery["images"] = gallery_images
|
|
||||||
save_yaml(gallery, GALLERY_YAML)
|
|
||||||
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml")
|
|
||||||
else:
|
|
||||||
print("[✓] No new images to add to gallery.yaml")
|
|
||||||
|
|
||||||
def update_hero():
|
|
||||||
print("\n=== Updating site.yaml (hero section) ===")
|
|
||||||
site = load_yaml(SITE_YAML)
|
|
||||||
hero_section = site.get("hero", {})
|
|
||||||
hero_images = hero_section.get("images") or []
|
|
||||||
known = {img["src"] for img in hero_images}
|
|
||||||
all_images = get_all_image_paths(HERO_DIR)
|
|
||||||
|
|
||||||
new_images = [
|
|
||||||
{"src": path}
|
|
||||||
for path in all_images
|
|
||||||
if path not in known
|
|
||||||
]
|
|
||||||
|
|
||||||
if new_images:
|
|
||||||
hero_images.extend(new_images)
|
|
||||||
site["hero"]["images"] = hero_images
|
|
||||||
save_yaml(site, SITE_YAML)
|
|
||||||
print(f"[✓] Added {len(new_images)} new image(s) to site.yaml (hero)")
|
|
||||||
else:
|
|
||||||
print("[✓] No new images to add to site.yaml")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
update_gallery()
|
update_gallery()
|
||||||
update_hero()
|
update_hero()
|
166
illustration/logo.svg
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 6031 1000">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0 {
|
||||||
|
fill: #55c3ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st1 {
|
||||||
|
fill: url(#Dégradé_sans_nom_265);
|
||||||
|
stroke: url(#Dégradé_sans_nom_33);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st1, .st2, .st3, .st4, .st5, .st6, .st7, .st8, .st9, .st10, .st11, .st12 {
|
||||||
|
stroke-miterlimit: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st2 {
|
||||||
|
fill: url(#Dégradé_sans_nom_269);
|
||||||
|
stroke: url(#Dégradé_sans_nom_334);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st3 {
|
||||||
|
fill: url(#Dégradé_sans_nom_268);
|
||||||
|
stroke: url(#Dégradé_sans_nom_333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st4 {
|
||||||
|
fill: url(#Dégradé_sans_nom_266);
|
||||||
|
stroke: url(#Dégradé_sans_nom_331);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st5 {
|
||||||
|
fill: url(#Dégradé_sans_nom_267);
|
||||||
|
stroke: url(#Dégradé_sans_nom_332);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st13 {
|
||||||
|
fill: url(#Dégradé_sans_nom_261);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st14 {
|
||||||
|
fill: url(#Dégradé_sans_nom_262);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st15 {
|
||||||
|
fill: url(#Dégradé_sans_nom_264);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st16 {
|
||||||
|
fill: url(#Dégradé_sans_nom_263);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st6 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2616);
|
||||||
|
stroke: url(#Dégradé_sans_nom_3311);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st7 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2615);
|
||||||
|
stroke: url(#Dégradé_sans_nom_3310);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st17 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st18 {
|
||||||
|
fill: url(#Dégradé_sans_nom_26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st8 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2610);
|
||||||
|
stroke: url(#Dégradé_sans_nom_335);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st9 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2613);
|
||||||
|
stroke: url(#Dégradé_sans_nom_338);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st10 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2614);
|
||||||
|
stroke: url(#Dégradé_sans_nom_339);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st11 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2611);
|
||||||
|
stroke: url(#Dégradé_sans_nom_336);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st12 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2612);
|
||||||
|
stroke: url(#Dégradé_sans_nom_337);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_26" data-name="Dégradé sans nom 26" x1="373.2" y1="159.5" x2="625.1" y2="411.5" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#55c3ec"/>
|
||||||
|
<stop offset="1" stop-color="#1d71b8" stop-opacity=".8"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_261" data-name="Dégradé sans nom 26" x1="143.1" y1="200.5" x2="395" y2="452.4" gradientTransform="translate(30.8 109.3)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_262" data-name="Dégradé sans nom 26" x1="81.3" y1="60.6" x2="333.2" y2="312.5" gradientTransform="translate(187.1 873.6) rotate(-90)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_263" data-name="Dégradé sans nom 26" x1="-44.4" y1="16.5" x2="207.5" y2="268.4" gradientTransform="translate(705.4 808.2) rotate(-180)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_264" data-name="Dégradé sans nom 26" x1="-67.9" y1="-58.9" x2="184" y2="193" gradientTransform="translate(770.9 385.1) rotate(90)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_265" data-name="Dégradé sans nom 26" x1="74.5" y1="560.2" x2="106.2" y2="591.8" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_33" data-name="Dégradé sans nom 33" x1="74.2" y1="559.9" x2="106.5" y2="592.2" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#55c3ec"/>
|
||||||
|
<stop offset="1" stop-color="#1d71b8" stop-opacity=".5"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_266" data-name="Dégradé sans nom 26" x1="158.2" y1="648.8" x2="176.7" y2="667.3" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_331" data-name="Dégradé sans nom 33" x1="157.8" y1="648.4" x2="177.1" y2="667.7" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_267" data-name="Dégradé sans nom 26" x1="210.2" y1="714.7" x2="249.8" y2="754.3" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_332" data-name="Dégradé sans nom 33" x1="209.9" y1="714.3" x2="250.2" y2="754.6" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_268" data-name="Dégradé sans nom 26" x1="-54" y1="72.2" x2="-14.4" y2="111.8" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_333" data-name="Dégradé sans nom 33" x1="-54.4" y1="71.9" x2="-14.1" y2="112.2" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_269" data-name="Dégradé sans nom 26" x1="485.1" y1="-9.2" x2="503.7" y2="9.4" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_334" data-name="Dégradé sans nom 33" x1="484.8" y1="-9.6" x2="504" y2="9.7" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2610" data-name="Dégradé sans nom 26" x1="825.1" y1="682.3" x2="856.8" y2="713.9" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_335" data-name="Dégradé sans nom 33" x1="824.8" y1="681.9" x2="857.1" y2="714.3" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2611" data-name="Dégradé sans nom 26" x1="308.5" y1="356.5" x2="340.3" y2="388.3" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_336" data-name="Dégradé sans nom 33" x1="308.1" y1="356.2" x2="340.7" y2="388.7" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2612" data-name="Dégradé sans nom 26" x1="540.4" y1="450.4" x2="559" y2="469" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_337" data-name="Dégradé sans nom 33" x1="540.1" y1="450" x2="559.4" y2="469.3" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2613" data-name="Dégradé sans nom 26" x1="-336.4" y1="326.9" x2="-296.8" y2="366.5" gradientTransform="translate(342.9 490.5) rotate(-175.4)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_338" data-name="Dégradé sans nom 33" x1="-336.7" y1="326.5" x2="-296.4" y2="366.8" gradientTransform="translate(342.9 490.5) rotate(-175.4)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2614" data-name="Dégradé sans nom 26" x1="115" y1="-29.9" x2="133.6" y2="-11.3" gradientTransform="translate(814.9 151.4) rotate(139.6)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_339" data-name="Dégradé sans nom 33" x1="114.7" y1="-30.2" x2="134" y2="-11" gradientTransform="translate(814.9 151.4) rotate(139.6)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2615" data-name="Dégradé sans nom 26" x1="94.5" y1="304.2" x2="124.4" y2="334" gradientTransform="translate(568 142) rotate(97.9)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_3310" data-name="Dégradé sans nom 33" x1="94.2" y1="303.8" x2="124.7" y2="334.4" gradientTransform="translate(568 142) rotate(97.9)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2616" data-name="Dégradé sans nom 26" x1="435.8" y1="254.1" x2="454.4" y2="272.7" gradientTransform="translate(257.1 -349) rotate(52.9)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_3311" data-name="Dégradé sans nom 33" x1="435.5" y1="253.8" x2="454.8" y2="273.1" gradientTransform="translate(257.1 -349) rotate(52.9)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
</defs>
|
||||||
|
<g id="Calque_1">
|
||||||
|
<circle class="st17" cx="499.5" cy="499.5" r="499.5"/>
|
||||||
|
<g>
|
||||||
|
<path class="st17" d="M1404,957.4V45h191v755h399v157.4h-590Z"/>
|
||||||
|
<path class="st17" d="M2321.5,971.3c-49.3,0-91.5-10.2-126.5-30.7-35-20.4-61.6-49.6-79.7-87.6-18.1-37.9-27.2-83.2-27.2-135.9v-437.6h184.6v399c0,44.3,10.4,78.6,31.3,103.1,20.9,24.5,51.9,36.7,93.3,36.7s39.3-3.6,56-10.7c16.6-7.2,30.9-17.4,42.7-30.7,11.8-13.3,20.9-29.1,27.2-47.4s9.5-38.5,9.5-60.4v-389.5h184.6v677.8h-184.6v-111.9h-4.4c-11.4,25.7-26.7,48.1-45.8,67-19.2,19-42.2,33.5-68.9,43.6-26.8,10.1-57.4,15.2-92,15.2Z"/>
|
||||||
|
<path class="st17" d="M2837.5,957.4V279.6h184.6v113.8h3.8c13.9-38.8,37.6-69.8,71.1-93,33.5-23.2,73-34.8,118.6-34.8s60.1,5.5,85.4,16.4c25.3,11,46.7,26.8,64.2,47.4,17.5,20.7,29.8,46,37,75.9h3.8c10.1-28.7,25.4-53.4,45.8-74.3,20.4-20.9,44.7-37,72.7-48.4,28-11.4,58.7-17.1,92-17.1s83,9.5,116.3,28.5c33.3,19,59.2,45.5,77.8,79.7,18.5,34.1,27.8,74.2,27.8,120.1v463.5h-184.6v-416.7c0-26.6-4.3-48.8-13-66.7-8.6-17.9-21.1-31.6-37.3-41.1-16.2-9.5-36.4-14.2-60.4-14.2s-43.5,5.4-61,16.1c-17.5,10.7-31.1,25.6-40.8,44.6-9.7,19-14.5,41.1-14.5,66.4v411.6h-177.7v-422.4c0-24.4-4.4-45.3-13.3-62.6-8.9-17.3-21.4-30.6-37.6-39.8-16.2-9.3-35.7-13.9-58.5-13.9s-43.6,5.6-61.3,16.8c-17.7,11.2-31.5,26.5-41.4,45.8-9.9,19.4-14.9,41.7-14.9,67v409.1h-184.6Z"/>
|
||||||
|
<path class="st0" d="M4264,971.3c-69.1,0-128.6-14.2-178.3-42.7-49.7-28.5-88.1-69-115.1-121.7-27-52.7-40.5-115.1-40.5-187.2v-.6c0-72.1,13.5-134.6,40.5-187.5,27-52.9,64.8-93.8,113.5-122.7,48.7-28.9,106.1-43.3,172.3-43.3s123.4,14,171.7,42c48.3,28,85.6,67.6,111.9,118.6,26.3,51,39.5,110.7,39.5,178.9v57.5h-558.3v-117h471.1l-87.9,109.4v-71.5c0-39.6-6.1-72.8-18.3-99.6-12.2-26.8-29.2-46.9-50.9-60.4-21.7-13.5-46.9-20.2-75.6-20.2s-54.1,7-76.2,20.9c-22.1,13.9-39.4,34.3-51.9,61-12.4,26.8-18.7,59.5-18.7,98.3v72.1c0,37.1,6.2,68.9,18.7,95.5,12.4,26.6,30.2,46.9,53.4,61,23.2,14.1,50.8,21.2,82.8,21.2s47.2-4,65.8-12c18.5-8,33.7-18.1,45.5-30.4,11.8-12.2,19.8-24.7,24-37.3l1.3-3.8h169.5l-1.9,7c-5.1,24.9-15,50-29.7,75.2-14.8,25.3-34.7,48.5-59.8,69.6-25.1,21.1-55.6,37.9-91.7,50.6-36,12.6-78.3,19-126.8,19Z"/>
|
||||||
|
<path class="st0" d="M4982.9,971.3c-69.1,0-128.6-14.2-178.3-42.7-49.7-28.5-88.1-69-115.1-121.7-27-52.7-40.5-115.1-40.5-187.2v-.6c0-72.1,13.5-134.6,40.5-187.5,27-52.9,64.8-93.8,113.5-122.7,48.7-28.9,106.1-43.3,172.3-43.3s123.4,14,171.7,42c48.3,28,85.6,67.6,111.9,118.6,26.3,51,39.5,110.7,39.5,178.9v57.5h-558.3v-117h471.1l-87.9,109.4v-71.5c0-39.6-6.1-72.8-18.3-99.6-12.2-26.8-29.2-46.9-50.9-60.4-21.7-13.5-46.9-20.2-75.6-20.2s-54.1,7-76.2,20.9c-22.1,13.9-39.4,34.3-51.9,61-12.4,26.8-18.7,59.5-18.7,98.3v72.1c0,37.1,6.2,68.9,18.7,95.5,12.4,26.6,30.2,46.9,53.4,61,23.2,14.1,50.8,21.2,82.8,21.2s47.2-4,65.8-12c18.5-8,33.7-18.1,45.5-30.4,11.8-12.2,19.8-24.7,24-37.3l1.3-3.8h169.5l-1.9,7c-5.1,24.9-15,50-29.7,75.2-14.8,25.3-34.7,48.5-59.8,69.6-25.1,21.1-55.6,37.9-91.7,50.6s-78.3,19-126.8,19Z"/>
|
||||||
|
<path class="st0" d="M5337,957.4l212.5-337.7-210.6-340.2h208l122,228.9h3.8l120.1-228.9h201.1l-211.8,335.1,209.9,342.7h-200.4l-129-234.6h-3.8l-127.1,234.6h-194.8Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="Calque_2">
|
||||||
|
<g id="Calque_3">
|
||||||
|
<ellipse class="st18" cx="499.2" cy="285.5" rx="139.8" ry="209.5"/>
|
||||||
|
<ellipse class="st13" cx="299.9" cy="435.8" rx="139.8" ry="209.5" transform="translate(-207.3 586.3) rotate(-72)"/>
|
||||||
|
<ellipse class="st14" cx="373.6" cy="666.3" rx="209.5" ry="139.8" transform="translate(-385.1 576.9) rotate(-54)"/>
|
||||||
|
<ellipse class="st16" cx="623.9" cy="665.8" rx="139.8" ry="209.5" transform="translate(-272.2 493.9) rotate(-36)"/>
|
||||||
|
<ellipse class="st15" cx="703.9" cy="443.1" rx="209.5" ry="139.8" transform="translate(-94.9 211.2) rotate(-16)"/>
|
||||||
|
<circle class="st1" cx="90.4" cy="576" r="22.4"/>
|
||||||
|
<circle class="st4" cx="175.6" cy="607.9" r="13.1"/>
|
||||||
|
<circle class="st5" cx="140.8" cy="691.6" r="28"/>
|
||||||
|
<circle class="st3" cx="829.7" cy="602.6" r="28"/>
|
||||||
|
<circle class="st2" cx="908.9" cy="562.1" r="13.1"/>
|
||||||
|
<circle class="st8" cx="840.9" cy="698.1" r="22.4"/>
|
||||||
|
<circle class="st11" cx="466.1" cy="876.5" r="22.5"/>
|
||||||
|
<circle class="st12" cx="538.6" cy="839.8" r="13.1"/>
|
||||||
|
<circle class="st9" cx="686.1" cy="170.1" r="28"/>
|
||||||
|
<circle class="st10" cx="733.7" cy="247.7" r="13.1"/>
|
||||||
|
<circle class="st7" cx="236.9" cy="206.5" r="21.1"/>
|
||||||
|
<circle class="st6" cx="315.4" cy="164.9" r="13.1"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 13 KiB |
@ -1,2 +1,3 @@
|
|||||||
pyyaml
|
pyyaml
|
||||||
pillow
|
pillow
|
||||||
|
flask
|
@ -17,24 +17,11 @@ const setupLoader = () => {
|
|||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const loader = document.querySelector('.page-loader');
|
const loader = document.querySelector('.page-loader');
|
||||||
if (loader) {
|
if (loader) loader.classList.add('hidden');
|
||||||
loader.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}, 50);
|
}, 50);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Gallery randomizer to shuffle gallery sections on page load
|
|
||||||
const shuffleGallery = () => {
|
|
||||||
const gallery = document.querySelector('.gallery');
|
|
||||||
if (!gallery) return;
|
|
||||||
const sections = Array.from(gallery.querySelectorAll('.section'));
|
|
||||||
while (sections.length) {
|
|
||||||
const randomIndex = Math.floor(Math.random() * sections.length);
|
|
||||||
gallery.appendChild(sections.splice(randomIndex, 1)[0]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hero background randomizer
|
// Hero background randomizer
|
||||||
const randomizeHeroBackground = () => {
|
const randomizeHeroBackground = () => {
|
||||||
const heroBg = document.querySelector(".hero-background");
|
const heroBg = document.querySelector(".hero-background");
|
||||||
@ -65,32 +52,74 @@ const randomizeHeroBackground = () => {
|
|||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Gallery randomizer to shuffle gallery sections on page load
|
||||||
|
const shuffleGallery = () => {
|
||||||
|
const gallery = document.querySelector('.gallery');
|
||||||
|
if (!gallery) return;
|
||||||
|
const sections = Array.from(gallery.querySelectorAll('.section'));
|
||||||
|
while (sections.length) {
|
||||||
|
const randomIndex = Math.floor(Math.random() * sections.length);
|
||||||
|
gallery.appendChild(sections.splice(randomIndex, 1)[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Tags filter functionality
|
// Tags filter functionality
|
||||||
const setupTagFilter = () => {
|
const setupTagFilter = () => {
|
||||||
|
const galleryContainer = document.querySelector('#gallery');
|
||||||
const allSections = document.querySelectorAll('.section[data-tags]');
|
const allSections = document.querySelectorAll('.section[data-tags]');
|
||||||
const allTags = document.querySelectorAll('.tag');
|
const allTags = document.querySelectorAll('.tag');
|
||||||
let activeTags = [];
|
let activeTags = [];
|
||||||
|
let lastClickedTag = null; // mémorise le dernier tag cliqué
|
||||||
|
|
||||||
const applyFilter = () => {
|
const applyFilter = () => {
|
||||||
|
let filteredSections = [];
|
||||||
|
let matchingSection = null;
|
||||||
|
|
||||||
allSections.forEach((section) => {
|
allSections.forEach((section) => {
|
||||||
const sectionTags = section.dataset.tags.toLowerCase().split(/\s+/);
|
const sectionTags = section.dataset.tags.toLowerCase().split(/\s+/);
|
||||||
const hasAllTags = activeTags.every((tag) => sectionTags.includes(tag));
|
const hasAllTags = activeTags.every((tag) => sectionTags.includes(tag));
|
||||||
section.style.display = hasAllTags ? '' : 'none';
|
section.style.display = hasAllTags ? '' : 'none';
|
||||||
|
|
||||||
|
if (hasAllTags) {
|
||||||
|
filteredSections.push(section);
|
||||||
|
if (lastClickedTag && sectionTags.includes(lastClickedTag) && !matchingSection) {
|
||||||
|
matchingSection = section;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Réorganise : la photo correspondante au dernier tag cliqué en premier
|
||||||
|
if (matchingSection && galleryContainer.contains(matchingSection)) {
|
||||||
|
galleryContainer.prepend(matchingSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Met à jour le style des tags
|
||||||
allTags.forEach((tagEl) => {
|
allTags.forEach((tagEl) => {
|
||||||
const tagText = tagEl.textContent.replace('#', '').toLowerCase();
|
const tagText = tagEl.textContent.replace('#', '').toLowerCase();
|
||||||
tagEl.classList.toggle('active', activeTags.includes(tagText));
|
tagEl.classList.toggle('active', activeTags.includes(tagText));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Met à jour l'URL
|
||||||
const base = window.location.pathname;
|
const base = window.location.pathname;
|
||||||
const query = activeTags.length > 0 ? `?tag=${activeTags.join(',')}` : '';
|
const query = activeTags.length > 0 ? `?tag=${activeTags.join(',')}` : '';
|
||||||
window.history.pushState({}, '', base + query);
|
window.history.pushState({}, '', base + query);
|
||||||
|
|
||||||
|
// Scroll jusqu'à la galerie
|
||||||
|
if (galleryContainer) {
|
||||||
|
galleryContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
allTags.forEach((tagEl) => {
|
allTags.forEach((tagEl) => {
|
||||||
tagEl.addEventListener('click', () => {
|
tagEl.addEventListener('click', () => {
|
||||||
const tagText = tagEl.textContent.replace('#', '').toLowerCase();
|
const tagText = tagEl.textContent.replace('#', '').toLowerCase();
|
||||||
activeTags = activeTags.includes(tagText)
|
lastClickedTag = tagText; // mémorise le dernier tag cliqué
|
||||||
? activeTags.filter((t) => t !== tagText)
|
|
||||||
: [...activeTags, tagText];
|
if (activeTags.includes(tagText)) {
|
||||||
|
activeTags = activeTags.filter((t) => t !== tagText);
|
||||||
|
} else {
|
||||||
|
activeTags.push(tagText);
|
||||||
|
}
|
||||||
applyFilter();
|
applyFilter();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -100,29 +129,24 @@ const setupTagFilter = () => {
|
|||||||
const urlTags = params.get('tag');
|
const urlTags = params.get('tag');
|
||||||
if (urlTags) {
|
if (urlTags) {
|
||||||
activeTags = urlTags.split(',').map((t) => t.toLowerCase());
|
activeTags = urlTags.split(',').map((t) => t.toLowerCase());
|
||||||
|
lastClickedTag = activeTags[activeTags.length - 1] || null;
|
||||||
applyFilter();
|
applyFilter();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Disable right-click context menu and image dragging
|
// Disable right click and drag
|
||||||
const disableRightClickAndDrag = () => {
|
const disableRightClickAndDrag = () => {
|
||||||
document.addEventListener("contextmenu", (e) => e.preventDefault());
|
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
document.addEventListener("dragstart", (e) => {
|
document.addEventListener('dragstart', (e) => e.preventDefault());
|
||||||
if (e.target.tagName === "IMG") {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Scroll-to-top button functionality
|
// Scroll to top button
|
||||||
const setupScrollToTopButton = () => {
|
const setupScrollToTopButton = () => {
|
||||||
const scrollBtn = document.getElementById("scrollToTop");
|
const scrollToTopButton = document.querySelector('.scroll-to-top');
|
||||||
window.addEventListener("scroll", () => {
|
if (!scrollToTopButton) return;
|
||||||
scrollBtn.style.display = window.scrollY > 300 ? "block" : "none";
|
scrollToTopButton.addEventListener('click', () => {
|
||||||
});
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
scrollBtn.addEventListener("click", () => {
|
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -333,8 +333,12 @@ h2 {
|
|||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sections */
|
|
||||||
|
|
||||||
|
.gallery {
|
||||||
|
padding-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
.section {
|
.section {
|
||||||
max-width: 1140px;
|
max-width: 1140px;
|
||||||
margin:auto;
|
margin:auto;
|
||||||
@ -486,6 +490,10 @@ h2 {
|
|||||||
font-size:18px;
|
font-size:18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section img {
|
||||||
|
margin: 0px 0 60px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
@ -504,4 +512,9 @@ h2 {
|
|||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
margin-top: 60px;
|
margin-top: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gallery {
|
||||||
|
margin: 10% 5% 0 5%;
|
||||||
|
padding-top: 15px;
|
||||||
|
}
|
||||||
}
|
}
|
@ -3,6 +3,7 @@ from pathlib import Path
|
|||||||
from shutil import copyfile
|
from shutil import copyfile
|
||||||
|
|
||||||
def generate_css_variables(colors_dict, output_path):
|
def generate_css_variables(colors_dict, output_path):
|
||||||
|
"""Generate css variables for theme colors"""
|
||||||
css_lines = [":root {"]
|
css_lines = [":root {"]
|
||||||
for key, value in colors_dict.items():
|
for key, value in colors_dict.items():
|
||||||
css_lines.append(f" --color-{key.replace('_', '-')}: {value};")
|
css_lines.append(f" --color-{key.replace('_', '-')}: {value};")
|
||||||
@ -13,6 +14,7 @@ def generate_css_variables(colors_dict, output_path):
|
|||||||
logging.info(f"[✓] CSS variables written to {output_path}")
|
logging.info(f"[✓] CSS variables written to {output_path}")
|
||||||
|
|
||||||
def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
|
def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
|
||||||
|
"""Generate css variables fonts"""
|
||||||
font_files = list(fonts_dir.glob("*"))
|
font_files = list(fonts_dir.glob("*"))
|
||||||
font_faces = {}
|
font_faces = {}
|
||||||
preload_links = []
|
preload_links = []
|
||||||
@ -57,6 +59,7 @@ def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
|
|||||||
return preload_links
|
return preload_links
|
||||||
|
|
||||||
def generate_google_fonts_link(fonts):
|
def generate_google_fonts_link(fonts):
|
||||||
|
"""Generate src link for Google fonts"""
|
||||||
if not fonts:
|
if not fonts:
|
||||||
return ""
|
return ""
|
||||||
families = []
|
families = []
|
114
src/py/builder/gallery_builder.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import yaml
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# YAML file paths
|
||||||
|
GALLERY_YAML = "config/gallery.yaml"
|
||||||
|
|
||||||
|
# Image directories
|
||||||
|
GALLERY_DIR = Path("config/photos/gallery")
|
||||||
|
HERO_DIR = Path("config/photos/hero")
|
||||||
|
|
||||||
|
def load_yaml(path):
|
||||||
|
"""Load gallery config .yaml file"""
|
||||||
|
print(f"[→] Loading {path}...")
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"[✗] File not found: {path}")
|
||||||
|
return {}
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
images = data.get("images", []) or []
|
||||||
|
print(f"[✓] Loaded {len(images)} image(s) from {path}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
def save_yaml(data, path):
|
||||||
|
"""Save modified gallery config .yaml file"""
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(data, f, sort_keys=False, allow_unicode=True)
|
||||||
|
print(f"[✓] Saved updated YAML to {path}")
|
||||||
|
|
||||||
|
def get_all_image_paths(directory):
|
||||||
|
"""Get the path to record for builded site"""
|
||||||
|
return sorted([
|
||||||
|
str(p.relative_to(directory.parent)).replace("\\", "/")
|
||||||
|
for p in directory.rglob("*")
|
||||||
|
if p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"]
|
||||||
|
])
|
||||||
|
|
||||||
|
def update_gallery():
|
||||||
|
"""Update the gallery photo list"""
|
||||||
|
print("\n=== Updating gallery.yaml (gallery section) ===")
|
||||||
|
gallery = load_yaml(GALLERY_YAML)
|
||||||
|
|
||||||
|
# Access the 'gallery' section within the gallery data, or initialize it if it doesn't exist
|
||||||
|
gallery_section = gallery.get("gallery", {})
|
||||||
|
|
||||||
|
# Access the 'images' list within the 'gallery' section, or initialize it if it doesn't exist
|
||||||
|
gallery_images = gallery_section.get("images", [])
|
||||||
|
|
||||||
|
all_images = set(get_all_image_paths(GALLERY_DIR))
|
||||||
|
known_images = {img["src"] for img in gallery_images}
|
||||||
|
|
||||||
|
# Add new images
|
||||||
|
new_images = [
|
||||||
|
{"src": path, "tags": []}
|
||||||
|
for path in all_images
|
||||||
|
if path not in known_images
|
||||||
|
]
|
||||||
|
if new_images:
|
||||||
|
gallery_images.extend(new_images)
|
||||||
|
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (gallery)")
|
||||||
|
|
||||||
|
# Remove deleted images
|
||||||
|
deleted_images = known_images - all_images
|
||||||
|
if deleted_images:
|
||||||
|
gallery_images = [img for img in gallery_images if img["src"] not in deleted_images]
|
||||||
|
print(f"[✓] Removed {len(deleted_images)} deleted image(s) from gallery.yaml (gallery)")
|
||||||
|
|
||||||
|
# Update the 'gallery' section with the modified 'images' list
|
||||||
|
gallery_section["images"] = gallery_images
|
||||||
|
gallery["gallery"] = gallery_section
|
||||||
|
|
||||||
|
save_yaml(gallery, GALLERY_YAML)
|
||||||
|
|
||||||
|
if not new_images and not deleted_images:
|
||||||
|
print("[✓] No changes to gallery.yaml (gallery)")
|
||||||
|
|
||||||
|
def update_hero():
|
||||||
|
"""Update the hero photo list"""
|
||||||
|
print("\n=== Updating gallery.yaml (hero section) ===")
|
||||||
|
gallery = load_yaml(GALLERY_YAML)
|
||||||
|
|
||||||
|
# Access the 'hero' section within the gallery data, or initialize it if it doesn't exist
|
||||||
|
hero_section = gallery.get("hero", {})
|
||||||
|
|
||||||
|
# Access the 'images' list within the 'hero' section, or initialize it if it doesn't exist
|
||||||
|
hero_images = hero_section.get("images", [])
|
||||||
|
|
||||||
|
all_images = set(get_all_image_paths(HERO_DIR))
|
||||||
|
known_images = {img["src"] for img in hero_images}
|
||||||
|
|
||||||
|
# Add new images
|
||||||
|
new_images = [
|
||||||
|
{"src": path}
|
||||||
|
for path in all_images
|
||||||
|
if path not in known_images
|
||||||
|
]
|
||||||
|
if new_images:
|
||||||
|
hero_images.extend(new_images)
|
||||||
|
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (hero)")
|
||||||
|
|
||||||
|
# Remove deleted images
|
||||||
|
deleted_images = known_images - all_images
|
||||||
|
if deleted_images:
|
||||||
|
hero_images = [img for img in hero_images if img["src"] not in deleted_images]
|
||||||
|
print(f"[✓] Removed {len(deleted_images)} deleted image(s) from gallery.yaml (hero)")
|
||||||
|
|
||||||
|
# Update the 'hero' section with the modified 'images' list
|
||||||
|
hero_section["images"] = hero_images
|
||||||
|
gallery["hero"] = hero_section
|
||||||
|
|
||||||
|
save_yaml(gallery, GALLERY_YAML)
|
||||||
|
|
||||||
|
if not new_images and not deleted_images:
|
||||||
|
print("[✓] No changes to gallery.yaml (hero)")
|
@ -3,6 +3,7 @@ import logging
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
def render_template(template_path, context):
|
def render_template(template_path, context):
|
||||||
|
"""Render html templates"""
|
||||||
with open(template_path, encoding="utf-8") as f:
|
with open(template_path, encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
for key, value in context.items():
|
for key, value in context.items():
|
||||||
@ -11,6 +12,7 @@ def render_template(template_path, context):
|
|||||||
return content
|
return content
|
||||||
|
|
||||||
def render_gallery_images(images):
|
def render_gallery_images(images):
|
||||||
|
"""Render the photo gallery"""
|
||||||
html = ""
|
html = ""
|
||||||
for img in images:
|
for img in images:
|
||||||
tags = " ".join(img.get("tags", []))
|
tags = " ".join(img.get("tags", []))
|
||||||
@ -23,9 +25,11 @@ def render_gallery_images(images):
|
|||||||
"""
|
"""
|
||||||
return html
|
return html
|
||||||
|
|
||||||
def generate_gallery_json_from_images(images, output_path):
|
def generate_gallery_json_from_images(images, output_dir):
|
||||||
|
"""Generte the hero carrousel photo list"""
|
||||||
try:
|
try:
|
||||||
img_list = [img["src"] for img in images]
|
img_list = [img["src"] for img in images]
|
||||||
|
output_path = output_dir / "data" / "gallery.json"
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(output_path, "w", encoding="utf-8") as f:
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(img_list, f, indent=2)
|
json.dump(img_list, f, indent=2)
|
||||||
@ -33,20 +37,36 @@ def generate_gallery_json_from_images(images, output_path):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"[✗] Error generating gallery JSON: {e}")
|
logging.error(f"[✗] Error generating gallery JSON: {e}")
|
||||||
|
|
||||||
def generate_robots_txt(canonical_url, allowed_paths):
|
def generate_robots_txt(canonical_url, allowed_paths, output_dir):
|
||||||
|
"""Generate the robot.txt"""
|
||||||
robots_lines = ["User-agent: *"]
|
robots_lines = ["User-agent: *"]
|
||||||
for path in allowed_paths:
|
|
||||||
robots_lines.append(f"Allow: {path}")
|
# Block everything by default
|
||||||
robots_lines.append("Disallow: /")
|
robots_lines.append("Disallow: /")
|
||||||
|
|
||||||
|
# Explicitly allow certain paths
|
||||||
|
for path in allowed_paths:
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = "/" + path
|
||||||
|
robots_lines.append(f"Allow: {path}")
|
||||||
|
|
||||||
robots_lines.append("")
|
robots_lines.append("")
|
||||||
robots_lines.append(f"Sitemap: {canonical_url}/sitemap.xml")
|
robots_lines.append(f"Sitemap: {canonical_url.rstrip('/')}/sitemap.xml")
|
||||||
|
|
||||||
content = "\n".join(robots_lines)
|
content = "\n".join(robots_lines)
|
||||||
output_path = Path(".output/robots.txt")
|
output_path = Path(output_dir) / "robots.txt"
|
||||||
|
|
||||||
|
try:
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(output_path, "w", encoding="utf-8") as f:
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
logging.info(f"[✓] robots.txt generated at {output_path}")
|
logging.info(f"[✓] robots.txt generated at {output_path}")
|
||||||
|
|
||||||
def generate_sitemap_xml(canonical_url, allowed_paths):
|
except Exception as e:
|
||||||
|
logging.error(f"[✗] Failed to write robots.txt: {e}")
|
||||||
|
|
||||||
|
def generate_sitemap_xml(canonical_url, allowed_paths, output_dir):
|
||||||
|
"""Generate the sitemap"""
|
||||||
urlset_start = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
urlset_start = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||||
urlset_end = '</urlset>\n'
|
urlset_end = '</urlset>\n'
|
||||||
urls = ""
|
urls = ""
|
||||||
@ -54,7 +74,7 @@ def generate_sitemap_xml(canonical_url, allowed_paths):
|
|||||||
loc = canonical_url.rstrip("/") + path
|
loc = canonical_url.rstrip("/") + path
|
||||||
urls += f" <url>\n <loc>{loc}</loc>\n </url>\n"
|
urls += f" <url>\n <loc>{loc}</loc>\n </url>\n"
|
||||||
sitemap_content = urlset_start + urls + urlset_end
|
sitemap_content = urlset_start + urls + urlset_end
|
||||||
output_path = Path(".output/sitemap.xml")
|
output_path = output_dir / "sitemap.xml"
|
||||||
with open(output_path, "w", encoding="utf-8") as f:
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
f.write(sitemap_content)
|
f.write(sitemap_content)
|
||||||
logging.info(f"[✓] sitemap.xml generated at {output_path}")
|
logging.info(f"[✓] sitemap.xml generated at {output_path}")
|
123
src/py/builder/image_processor.py
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image, features
|
||||||
|
from shutil import copyfile
|
||||||
|
|
||||||
|
def convert_and_resize_image(input_path, output_path, resize=True, max_width=1140):
|
||||||
|
"""Convert an image to WebP (or JPEG fallback) and optionally resize it."""
|
||||||
|
try:
|
||||||
|
if not input_path.exists():
|
||||||
|
logging.error(f"[✗] Image file not found: {input_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
img = Image.open(input_path)
|
||||||
|
if img.mode != "RGB":
|
||||||
|
img = img.convert("RGB")
|
||||||
|
|
||||||
|
if resize:
|
||||||
|
width, height = img.size
|
||||||
|
if width > max_width:
|
||||||
|
new_height = int((max_width / width) * height)
|
||||||
|
img = img.resize((max_width, new_height), Image.LANCZOS)
|
||||||
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Check WebP support, otherwise fallback to JPEG
|
||||||
|
fmt = "WEBP" if features.check("webp") else "JPEG"
|
||||||
|
if fmt == "JPEG":
|
||||||
|
output_path = output_path.with_suffix(".jpg")
|
||||||
|
|
||||||
|
img.save(output_path, fmt, quality=90 if fmt == "JPEG" else 100)
|
||||||
|
logging.info(f"[✓] Processed image: {input_path} → {output_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"[✗] Error processing image {input_path}: {e}")
|
||||||
|
|
||||||
|
def process_images(images, resize_images, img_dir, build_dir):
|
||||||
|
"""Process a list of image references and update paths to optimized versions."""
|
||||||
|
for img in images:
|
||||||
|
src_path = img_dir / img["src"]
|
||||||
|
webp_path = build_dir / "img" / Path(img["src"]).with_suffix(".webp")
|
||||||
|
convert_and_resize_image(src_path, webp_path, resize=resize_images)
|
||||||
|
|
||||||
|
if webp_path.exists():
|
||||||
|
img["src"] = str(Path(img["src"]).with_suffix(".webp"))
|
||||||
|
else:
|
||||||
|
# Fallback if WebP not created
|
||||||
|
jpg_path = webp_path.with_suffix(".jpg")
|
||||||
|
if jpg_path.exists():
|
||||||
|
img["src"] = str(Path(img["src"]).with_suffix(".jpg"))
|
||||||
|
|
||||||
|
def copy_original_images(images, img_dir, build_dir):
|
||||||
|
"""Copy original image files without processing."""
|
||||||
|
for img in images:
|
||||||
|
src_path = img_dir / img["src"]
|
||||||
|
dest_path = build_dir / "img" / img["src"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not src_path.exists():
|
||||||
|
logging.error(f"[✗] Original image not found: {src_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
copyfile(src_path, dest_path)
|
||||||
|
logging.info(f"[✓] Copied original: {src_path} → {dest_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"[✗] Error copying {src_path}: {e}")
|
||||||
|
|
||||||
|
def get_favicon_path(theme_vars, theme_dir):
|
||||||
|
"""Retrieve the favicon path from theme variables, ensuring it exists."""
|
||||||
|
fav_path = theme_vars.get("favicon", {}).get("path")
|
||||||
|
if not fav_path:
|
||||||
|
logging.warning("[~] No favicon path defined in theme.yaml")
|
||||||
|
return None
|
||||||
|
|
||||||
|
path = Path(fav_path)
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = theme_dir / path
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
logging.error(f"[✗] Favicon not found: {path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
def generate_favicons_from_logo(theme_vars, theme_dir, output_dir):
|
||||||
|
"""Generate multiple PNG favicons from a single source image."""
|
||||||
|
logo_path = get_favicon_path(theme_vars, theme_dir)
|
||||||
|
if not logo_path:
|
||||||
|
logging.warning("[~] PNG favicons not generated.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
specs = [
|
||||||
|
(32, "favicon-32.png"), (96, "favicon-96.png"), (128, "favicon-128.png"),
|
||||||
|
(192, "favicon-192.png"), (196, "favicon-196.png"),
|
||||||
|
(152, "favicon-152.png"), (180, "favicon-180.png")
|
||||||
|
]
|
||||||
|
img = Image.open(logo_path).convert("RGBA")
|
||||||
|
for size, name in specs:
|
||||||
|
img.resize((size, size), Image.LANCZOS).save(output_dir / name, format="PNG")
|
||||||
|
|
||||||
|
logging.info(f"[✓] PNG favicons generated in {output_dir}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"[✗] Error generating PNG favicons: {e}")
|
||||||
|
|
||||||
|
def generate_favicon_ico(theme_vars, theme_dir, output_path):
|
||||||
|
"""Generate a multi-size favicon.ico from a source image."""
|
||||||
|
logo_path = get_favicon_path(theme_vars, theme_dir)
|
||||||
|
if not logo_path:
|
||||||
|
logging.warning("[~] favicon.ico not generated.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = Image.open(logo_path).convert("RGBA")
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
img.save(output_path, format="ICO", sizes=[(16, 16), (32, 32), (48, 48)])
|
||||||
|
logging.info(f"[✓] favicon.ico generated in {output_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"[✗] Error generating favicon.ico: {e}")
|
189
src/py/builder/site_builder.py
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from shutil import copyfile
|
||||||
|
from PIL import Image
|
||||||
|
from .utils import ensure_dir, copy_assets, load_yaml, load_theme_config
|
||||||
|
from .css_generator import generate_css_variables, generate_fonts_css, generate_google_fonts_link
|
||||||
|
from .image_processor import process_images, copy_original_images, convert_and_resize_image, generate_favicons_from_logo, generate_favicon_ico
|
||||||
|
from .html_generator import render_template, render_gallery_images, generate_gallery_json_from_images, generate_robots_txt, generate_sitemap_xml
|
||||||
|
|
||||||
|
# Configure logging to display only the messages
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(message)s')
|
||||||
|
|
||||||
|
# Define key directories used throughout the script
|
||||||
|
SRC_DIR = Path.cwd()
|
||||||
|
BUILD_DIR = SRC_DIR / "output"
|
||||||
|
TEMPLATE_DIR = SRC_DIR / "src/templates"
|
||||||
|
IMG_DIR = SRC_DIR / "config/photos"
|
||||||
|
JS_DIR = SRC_DIR / "src/public/js"
|
||||||
|
STYLE_DIR = SRC_DIR / "src/public/style"
|
||||||
|
GALLERY_FILE = SRC_DIR / "config/gallery.yaml"
|
||||||
|
SITE_FILE = SRC_DIR / "config/site.yaml"
|
||||||
|
THEMES_DIR = SRC_DIR / "config/themes"
|
||||||
|
|
||||||
|
def build():
|
||||||
|
build_version = "v1.3.1"
|
||||||
|
logging.info("\n")
|
||||||
|
logging.info("=" * 24)
|
||||||
|
logging.info(f"🚀 Lumeex builder {build_version}")
|
||||||
|
logging.info("=" * 24)
|
||||||
|
logging.info("\n === Starting build === ")
|
||||||
|
ensure_dir(BUILD_DIR)
|
||||||
|
copy_assets(JS_DIR, STYLE_DIR, BUILD_DIR)
|
||||||
|
|
||||||
|
# Defining build vars
|
||||||
|
build_date = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||||
|
build_date_version = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
site_vars = load_yaml(SITE_FILE)
|
||||||
|
gallery_vars = load_yaml(GALLERY_FILE)
|
||||||
|
build_section = site_vars.get("build", {})
|
||||||
|
theme_name = site_vars.get("build", {}).get("theme", "default")
|
||||||
|
theme_vars, theme_dir = load_theme_config(theme_name, THEMES_DIR)
|
||||||
|
fonts_dir = theme_dir / "fonts"
|
||||||
|
theme_css_path = theme_dir / "theme.css"
|
||||||
|
canonical_url = site_vars.get("info", {}).get("canonical", "").rstrip("/")
|
||||||
|
canonical_home = f"{canonical_url}/"
|
||||||
|
canonical_legals = f"{canonical_url}/legals/"
|
||||||
|
|
||||||
|
# Copying theme.css if existing
|
||||||
|
if theme_css_path.exists():
|
||||||
|
dest_theme_css = BUILD_DIR / "style" / "theme.css"
|
||||||
|
dest_theme_css.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
copyfile(theme_css_path, dest_theme_css)
|
||||||
|
theme_css = f'<link rel="stylesheet" href="/style/theme.css?build_date={build_date}">'
|
||||||
|
logging.info(f"[✓] Theme CSS found, copied to build folder: {dest_theme_css}")
|
||||||
|
else:
|
||||||
|
theme_css = ""
|
||||||
|
logging.warning(f"[~] No theme.css found in {theme_css_path}, skipping theme CSS injection.")
|
||||||
|
|
||||||
|
preload_links = generate_fonts_css(fonts_dir, BUILD_DIR / "style" / "fonts.css", fonts_cfg=theme_vars.get("fonts"))
|
||||||
|
generate_css_variables(theme_vars.get("colors", {}), BUILD_DIR / "style" / "colors.css")
|
||||||
|
generate_favicons_from_logo(theme_vars, theme_dir, BUILD_DIR / "img" / "favicon")
|
||||||
|
generate_favicon_ico(theme_vars, theme_dir, BUILD_DIR / "favicon.ico")
|
||||||
|
|
||||||
|
# Converting and resizing images if enabled
|
||||||
|
convert_images = build_section.get("convert_images", True)
|
||||||
|
resize_images = build_section.get("resize_images", True)
|
||||||
|
logging.info(f"[~] convert_images = {convert_images}")
|
||||||
|
logging.info(f"[~] resize_images = {resize_images}")
|
||||||
|
|
||||||
|
hero_images = gallery_vars.get("hero", {}).get("images", [])
|
||||||
|
gallery_images = gallery_vars.get("gallery", {}).get("images", [])
|
||||||
|
|
||||||
|
if convert_images:
|
||||||
|
process_images(hero_images, resize_images, IMG_DIR, BUILD_DIR)
|
||||||
|
process_images(gallery_images, resize_images, IMG_DIR, BUILD_DIR)
|
||||||
|
else:
|
||||||
|
copy_original_images(hero_images, IMG_DIR, BUILD_DIR)
|
||||||
|
copy_original_images(gallery_images, IMG_DIR, BUILD_DIR)
|
||||||
|
|
||||||
|
if "hero" not in site_vars:
|
||||||
|
site_vars["hero"] = {} # Initialize an empty hero section
|
||||||
|
|
||||||
|
# Adding menu
|
||||||
|
menu_html = "\n".join(
|
||||||
|
f'<li class="nav-item appear"><a href="{item["href"]}">{item["label"]}</a></li>'
|
||||||
|
for item in site_vars.get("menu", {}).get("items", [])
|
||||||
|
)
|
||||||
|
site_vars["hero"]["menu_items"] = menu_html
|
||||||
|
if "footer" in site_vars:
|
||||||
|
site_vars["footer"]["menu_items"] = menu_html
|
||||||
|
|
||||||
|
# Adding Google fonts if existing
|
||||||
|
google_fonts_link = generate_google_fonts_link(theme_vars.get("google_fonts", []))
|
||||||
|
logging.info(f"[✓] Google Fonts link generated")
|
||||||
|
|
||||||
|
# Generating thumbnail
|
||||||
|
thumbnail_path = site_vars.get("social", {}).get("thumbnail")
|
||||||
|
if thumbnail_path:
|
||||||
|
src_thumb = IMG_DIR / thumbnail_path
|
||||||
|
dest_thumb_dir = BUILD_DIR / "img" / "social"
|
||||||
|
dest_thumb_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest_thumb = dest_thumb_dir / Path(thumbnail_path).name
|
||||||
|
try:
|
||||||
|
img = Image.open(src_thumb)
|
||||||
|
img = img.convert("RGB")
|
||||||
|
img = img.resize((1200, 630), Image.LANCZOS)
|
||||||
|
img.save(dest_thumb, "JPEG", quality=90)
|
||||||
|
logging.info(f"[✓] Thumbnail resized and saved to {dest_thumb}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"[✗] Failed to process thumbnail: {e}")
|
||||||
|
else:
|
||||||
|
logging.warning("[~] No thumbnail found in social section")
|
||||||
|
|
||||||
|
# Defining head variables
|
||||||
|
head_vars = dict(site_vars.get("info", {}))
|
||||||
|
head_vars.update(theme_vars.get("colors", {}))
|
||||||
|
head_vars.update(site_vars.get("social", {}))
|
||||||
|
head_vars["thumbnail"] = f"/img/social/{Path(thumbnail_path).name}" if thumbnail_path else ""
|
||||||
|
head_vars["google_fonts_link"] = google_fonts_link
|
||||||
|
head_vars["font_preloads"] = "\n".join(preload_links)
|
||||||
|
head_vars["theme_css"] = theme_css
|
||||||
|
head_vars["build_date"] = build_date
|
||||||
|
head_vars["canonical"] = canonical_home
|
||||||
|
|
||||||
|
# Render the home page
|
||||||
|
head = render_template(TEMPLATE_DIR / "head.html", head_vars)
|
||||||
|
hero = render_template(TEMPLATE_DIR / "hero.html", {**site_vars["hero"], **head_vars})
|
||||||
|
footer = render_template(TEMPLATE_DIR / "footer.html", {**site_vars.get("footer", {}), **head_vars})
|
||||||
|
gallery_html = render_gallery_images(gallery_images)
|
||||||
|
gallery = render_template(TEMPLATE_DIR / "gallery.html", {"gallery_images": gallery_html})
|
||||||
|
|
||||||
|
signature = f"<!-- Build with Lumeex {build_version} | https://git.djeex.fr/Djeex/lumeex | {build_date_version} -->"
|
||||||
|
body = f"""
|
||||||
|
<body>
|
||||||
|
<div class="page-loader"><div class="spinner"></div></div>
|
||||||
|
{hero}
|
||||||
|
{gallery}
|
||||||
|
{footer}
|
||||||
|
</body>
|
||||||
|
"""
|
||||||
|
output_file = BUILD_DIR / "index.html"
|
||||||
|
with open(output_file, "w", encoding="utf-8") as f:
|
||||||
|
f.write(f"<!DOCTYPE html>\n{signature}\n<html lang='en'>\n{head}\n{body}\n</html>")
|
||||||
|
logging.info(f"[✓] HTML generated: {output_file}")
|
||||||
|
|
||||||
|
# Rendering legals page
|
||||||
|
head_vars["canonical"] = canonical_legals
|
||||||
|
|
||||||
|
legals_vars = site_vars.get("legals", {})
|
||||||
|
if legals_vars:
|
||||||
|
head = render_template(TEMPLATE_DIR / "head.html", head_vars)
|
||||||
|
|
||||||
|
ip_paragraphs = legals_vars.get("intellectual_property", [])
|
||||||
|
paragraphs_html = "\n".join(f"<p>{item['paragraph']}</p>" for item in ip_paragraphs)
|
||||||
|
legals_context = {
|
||||||
|
"hoster_name": legals_vars.get("hoster_name", ""),
|
||||||
|
"hoster_adress": legals_vars.get("hoster_adress", ""),
|
||||||
|
"hoster_contact": legals_vars.get("hoster_contact", ""),
|
||||||
|
"intellectual_property": paragraphs_html,
|
||||||
|
}
|
||||||
|
legals_body = render_template(TEMPLATE_DIR / "legals.html", legals_context)
|
||||||
|
legals_html = f"<!DOCTYPE html>\n{signature}\n<html lang='en'>\n{head}\n{legals_body}\n{footer}\n</html>"
|
||||||
|
output_legals = BUILD_DIR / "legals" / "index.html"
|
||||||
|
output_legals.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(output_legals, "w", encoding="utf-8") as f:
|
||||||
|
f.write(legals_html)
|
||||||
|
logging.info(f"[✓] Legals page generated: {output_legals}")
|
||||||
|
else:
|
||||||
|
logging.warning("[~] No legals section found in site.yaml")
|
||||||
|
|
||||||
|
# Hero carrousel generator
|
||||||
|
if hero_images:
|
||||||
|
generate_gallery_json_from_images(hero_images, BUILD_DIR)
|
||||||
|
else:
|
||||||
|
logging.warning("[~] No hero images found, skipping JSON generation.")
|
||||||
|
|
||||||
|
# Sitemap and robot.txt generator
|
||||||
|
site_info = site_vars.get("info", {})
|
||||||
|
canonical_url = site_info.get("canonical", "").rstrip("/")
|
||||||
|
if canonical_url:
|
||||||
|
allowed_pages = ["/", "/legals/"]
|
||||||
|
generate_robots_txt(canonical_url, allowed_pages, BUILD_DIR)
|
||||||
|
generate_sitemap_xml(canonical_url, allowed_pages, BUILD_DIR)
|
||||||
|
else:
|
||||||
|
logging.warning("[~] No canonical URL found in site.yaml info section, skipping robots.txt and sitemap.xml generation.")
|
||||||
|
|
||||||
|
logging.info("✅ Build complete.")
|
||||||
|
|
@ -4,6 +4,7 @@ from pathlib import Path
|
|||||||
from shutil import copytree, rmtree, copyfile
|
from shutil import copytree, rmtree, copyfile
|
||||||
|
|
||||||
def load_yaml(path):
|
def load_yaml(path):
|
||||||
|
"""Load gallery and site .yaml conf"""
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
logging.warning(f"[!] YAML file not found: {path}")
|
logging.warning(f"[!] YAML file not found: {path}")
|
||||||
return {}
|
return {}
|
||||||
@ -11,6 +12,7 @@ def load_yaml(path):
|
|||||||
return yaml.safe_load(f)
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
def load_theme_config(theme_name, themes_dir):
|
def load_theme_config(theme_name, themes_dir):
|
||||||
|
"""Load theme.yaml"""
|
||||||
theme_dir = themes_dir / theme_name
|
theme_dir = themes_dir / theme_name
|
||||||
theme_config_path = theme_dir / "theme.yaml"
|
theme_config_path = theme_dir / "theme.yaml"
|
||||||
if not theme_config_path.exists():
|
if not theme_config_path.exists():
|
||||||
@ -19,12 +21,26 @@ def load_theme_config(theme_name, themes_dir):
|
|||||||
theme_vars = yaml.safe_load(f)
|
theme_vars = yaml.safe_load(f)
|
||||||
return theme_vars, theme_dir
|
return theme_vars, theme_dir
|
||||||
|
|
||||||
def ensure_dir(path):
|
def clear_dir(path: Path):
|
||||||
if path.exists():
|
"""Clear the output dir"""
|
||||||
rmtree(path)
|
if not path.exists():
|
||||||
path.mkdir(parents=True)
|
path.mkdir(parents=True)
|
||||||
|
return
|
||||||
|
for child in path.iterdir():
|
||||||
|
if child.is_file() or child.is_symlink():
|
||||||
|
child.unlink()
|
||||||
|
elif child.is_dir():
|
||||||
|
rmtree(child)
|
||||||
|
|
||||||
|
def ensure_dir(path: Path):
|
||||||
|
"""Create the output dir if it does not exist"""
|
||||||
|
if not path.exists():
|
||||||
|
path.mkdir(parents=True)
|
||||||
|
else:
|
||||||
|
clear_dir(path)
|
||||||
|
|
||||||
def copy_assets(js_dir, style_dir, build_dir):
|
def copy_assets(js_dir, style_dir, build_dir):
|
||||||
|
"""Copy public assets to output dir"""
|
||||||
for folder in [js_dir, style_dir]:
|
for folder in [js_dir, style_dir]:
|
||||||
if folder.exists():
|
if folder.exists():
|
||||||
dest = build_dir / folder.name
|
dest = build_dir / folder.name
|
@ -1,73 +0,0 @@
|
|||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from PIL import Image
|
|
||||||
from shutil import copyfile
|
|
||||||
|
|
||||||
def convert_and_resize_image(input_path, output_path, resize=True, max_width=1140):
|
|
||||||
try:
|
|
||||||
img = Image.open(input_path)
|
|
||||||
if img.mode != "RGB":
|
|
||||||
img = img.convert("RGB")
|
|
||||||
if resize:
|
|
||||||
width, height = img.size
|
|
||||||
if width > max_width:
|
|
||||||
new_height = int((max_width / width) * height)
|
|
||||||
img = img.resize((max_width, new_height), Image.LANCZOS)
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
img.save(output_path, "WEBP", quality=100)
|
|
||||||
logging.info(f"[✓] Processed: {input_path} → {output_path}")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"[✗] Failed to process {input_path}: {e}")
|
|
||||||
|
|
||||||
def process_images(images, resize_images, img_dir, build_dir):
|
|
||||||
for img in images:
|
|
||||||
src_path = img_dir / img["src"]
|
|
||||||
webp_path = build_dir / "img" / Path(img["src"]).with_suffix(".webp")
|
|
||||||
convert_and_resize_image(src_path, webp_path, resize=resize_images)
|
|
||||||
img["src"] = str(Path(img["src"]).with_suffix(".webp"))
|
|
||||||
|
|
||||||
def copy_original_images(images, img_dir, build_dir):
|
|
||||||
for img in images:
|
|
||||||
src_path = img_dir / img["src"]
|
|
||||||
dest_path = build_dir / "img" / img["src"]
|
|
||||||
try:
|
|
||||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
copyfile(src_path, dest_path)
|
|
||||||
logging.info(f"[✓] Copied original: {src_path} → {dest_path}")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"[✗] Failed to copy {src_path}: {e}")
|
|
||||||
|
|
||||||
def generate_favicons_from_logo(theme_vars, theme_dir, output_dir):
|
|
||||||
logo_path = get_favicon_path(theme_vars, theme_dir)
|
|
||||||
if not logo_path:
|
|
||||||
logging.warning("[~] No favicon path defined, skipping favicon PNGs.")
|
|
||||||
return
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
specs = [(32, "favicon-32.png"), (96, "favicon-96.png"), (128, "favicon-128.png"),
|
|
||||||
(192, "favicon-192.png"), (196, "favicon-196.png"), (152, "favicon-152.png"), (180, "favicon-180.png")]
|
|
||||||
img = Image.open(logo_path).convert("RGBA")
|
|
||||||
for size, name in specs:
|
|
||||||
img.resize((size, size), Image.LANCZOS).save(output_dir / name, format="PNG")
|
|
||||||
logging.info(f"[✓] Favicons generated in {output_dir}")
|
|
||||||
|
|
||||||
def generate_favicon_ico(theme_vars, theme_dir, output_path):
|
|
||||||
logo_path = get_favicon_path(theme_vars, theme_dir)
|
|
||||||
if not logo_path:
|
|
||||||
logging.warning("[~] No favicon path defined, skipping .ico generation.")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
img = Image.open(logo_path).convert("RGBA")
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
img.save(output_path, format="ICO", sizes=[(16, 16), (32, 32), (48, 48)])
|
|
||||||
logging.info(f"[✓] favicon.ico generated at {output_path}")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"[✗] Failed to generate favicon.ico: {e}")
|
|
||||||
|
|
||||||
def get_favicon_path(theme_vars, theme_dir):
|
|
||||||
fav_path = theme_vars.get("favicon", {}).get("path")
|
|
||||||
if not fav_path:
|
|
||||||
return None
|
|
||||||
path = Path(fav_path)
|
|
||||||
if not path.is_absolute():
|
|
||||||
path = theme_dir / path
|
|
||||||
return path if path.exists() else None
|
|
0
src/py/webui/__init__.py
Normal file
65
src/py/webui/upload.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from flask import Blueprint, request, current_app
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from src.py.builder.gallery_builder import update_gallery, update_hero
|
||||||
|
|
||||||
|
# --- Create Flask blueprint for upload routes ---
|
||||||
|
upload_bp = Blueprint("upload", __name__)
|
||||||
|
|
||||||
|
# --- Allowed file types ---
|
||||||
|
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "webp"}
|
||||||
|
|
||||||
|
def allowed_file(filename: str) -> bool:
|
||||||
|
"""Check if the uploaded file has an allowed extension."""
|
||||||
|
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||||
|
|
||||||
|
def save_uploaded_file(file, folder: Path):
|
||||||
|
"""Save an uploaded file to the specified folder."""
|
||||||
|
folder.mkdir(parents=True, exist_ok=True) # Create folder if not exists
|
||||||
|
filename = secure_filename(file.filename) # Sanitize filename
|
||||||
|
file.save(folder / filename) # Save to disk
|
||||||
|
logging.info(f"[✓] Uploaded {filename} to {folder}")
|
||||||
|
return filename
|
||||||
|
|
||||||
|
@upload_bp.route("/api/<section>/upload", methods=["POST"])
|
||||||
|
def upload_photo(section: str):
|
||||||
|
"""
|
||||||
|
Handle file uploads for gallery or hero section.
|
||||||
|
Accepts multiple files under 'files'.
|
||||||
|
"""
|
||||||
|
# Validate section
|
||||||
|
if section not in ["gallery", "hero"]:
|
||||||
|
return {"error": "Invalid section"}, 400
|
||||||
|
|
||||||
|
# Check if files are provided
|
||||||
|
if "files" not in request.files:
|
||||||
|
return {"error": "No files provided"}, 400
|
||||||
|
|
||||||
|
files = request.files.getlist("files")
|
||||||
|
if not files:
|
||||||
|
return {"error": "No selected files"}, 400
|
||||||
|
|
||||||
|
# Get photos directory from app config
|
||||||
|
PHOTOS_DIR = current_app.config.get("PHOTOS_DIR")
|
||||||
|
if not PHOTOS_DIR:
|
||||||
|
return {"error": "Server misconfiguration"}, 500
|
||||||
|
|
||||||
|
folder = PHOTOS_DIR / section # Target folder
|
||||||
|
uploaded = []
|
||||||
|
|
||||||
|
# Save each valid file
|
||||||
|
for file in files:
|
||||||
|
if file and allowed_file(file.filename):
|
||||||
|
filename = save_uploaded_file(file, folder)
|
||||||
|
uploaded.append(filename)
|
||||||
|
|
||||||
|
# Update YAML if any files were uploaded
|
||||||
|
if uploaded:
|
||||||
|
if section == "gallery":
|
||||||
|
update_gallery()
|
||||||
|
else:
|
||||||
|
update_hero()
|
||||||
|
return {"status": "ok", "uploaded": uploaded}
|
||||||
|
|
||||||
|
return {"error": "No valid files uploaded"}, 400
|
107
src/py/webui/webui.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from flask import Flask, jsonify, request, send_from_directory, render_template
|
||||||
|
from src.py.builder.gallery_builder import (
|
||||||
|
GALLERY_YAML, load_yaml, save_yaml, update_gallery, update_hero
|
||||||
|
)
|
||||||
|
from src.py.webui.upload import upload_bp
|
||||||
|
|
||||||
|
# --- Logging configuration ---
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
|
|
||||||
|
# --- Flask app setup ---
|
||||||
|
WEBUI_PATH = Path(__file__).parents[2] / "webui" # Path to static/templates
|
||||||
|
app = Flask(
|
||||||
|
__name__,
|
||||||
|
template_folder=WEBUI_PATH,
|
||||||
|
static_folder=WEBUI_PATH,
|
||||||
|
static_url_path=""
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Photos directory (configurable) ---
|
||||||
|
PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos"
|
||||||
|
app.config["PHOTOS_DIR"] = PHOTOS_DIR
|
||||||
|
|
||||||
|
# --- Register upload blueprint ---
|
||||||
|
app.register_blueprint(upload_bp)
|
||||||
|
|
||||||
|
# --- Routes ---
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
"""Serve the main HTML page."""
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
@app.route("/api/gallery", methods=["GET"])
|
||||||
|
def get_gallery():
|
||||||
|
"""Return JSON list of gallery images from YAML."""
|
||||||
|
data = load_yaml(GALLERY_YAML)
|
||||||
|
return jsonify(data.get("gallery", {}).get("images", []))
|
||||||
|
|
||||||
|
@app.route("/api/hero", methods=["GET"])
|
||||||
|
def get_hero():
|
||||||
|
"""Return JSON list of hero images from YAML."""
|
||||||
|
data = load_yaml(GALLERY_YAML)
|
||||||
|
return jsonify(data.get("hero", {}).get("images", []))
|
||||||
|
|
||||||
|
@app.route("/api/gallery/update", methods=["POST"])
|
||||||
|
def update_gallery_api():
|
||||||
|
"""Update gallery images in YAML from frontend JSON."""
|
||||||
|
images = request.json
|
||||||
|
data = load_yaml(GALLERY_YAML)
|
||||||
|
data["gallery"]["images"] = images
|
||||||
|
save_yaml(data, GALLERY_YAML)
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
@app.route("/api/hero/update", methods=["POST"])
|
||||||
|
def update_hero_api():
|
||||||
|
"""Update hero images in YAML from frontend JSON."""
|
||||||
|
images = request.json
|
||||||
|
data = load_yaml(GALLERY_YAML)
|
||||||
|
data["hero"]["images"] = images
|
||||||
|
save_yaml(data, GALLERY_YAML)
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
@app.route("/api/gallery/refresh", methods=["POST"])
|
||||||
|
def refresh_gallery():
|
||||||
|
"""Refresh gallery YAML from photos/gallery folder."""
|
||||||
|
update_gallery()
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
@app.route("/api/hero/refresh", methods=["POST"])
|
||||||
|
def refresh_hero():
|
||||||
|
"""Refresh hero YAML from photos/hero folder."""
|
||||||
|
update_hero()
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
@app.route("/api/gallery/delete", methods=["POST"])
|
||||||
|
def delete_gallery_photo():
|
||||||
|
"""Delete a gallery photo from disk and return status."""
|
||||||
|
data = request.json
|
||||||
|
src = data.get("src")
|
||||||
|
file_path = PHOTOS_DIR / "gallery" / src
|
||||||
|
if file_path.exists():
|
||||||
|
file_path.unlink()
|
||||||
|
return {"status": "ok"}
|
||||||
|
return {"error": "File not found"}, 404
|
||||||
|
|
||||||
|
@app.route("/api/hero/delete", methods=["POST"])
|
||||||
|
def delete_hero_photo():
|
||||||
|
"""Delete a hero photo from disk and return status."""
|
||||||
|
data = request.json
|
||||||
|
src = data.get("src")
|
||||||
|
file_path = PHOTOS_DIR / "hero" / src
|
||||||
|
if file_path.exists():
|
||||||
|
file_path.unlink()
|
||||||
|
return {"status": "ok"}
|
||||||
|
return {"error": "File not found"}, 404
|
||||||
|
|
||||||
|
@app.route("/photos/<section>/<path:filename>")
|
||||||
|
def photos(section, filename):
|
||||||
|
"""Serve uploaded photos from disk."""
|
||||||
|
return send_from_directory(PHOTOS_DIR / section, filename)
|
||||||
|
|
||||||
|
# --- Run server ---
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.info("Starting WebUI at http://127.0.0.1:5000")
|
||||||
|
app.run(debug=True)
|
BIN
src/webui/favicon.ico
Normal file
After Width: | Height: | Size: 7.0 KiB |
166
src/webui/img/logo.svg
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 6031 1000">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0 {
|
||||||
|
fill: #55c3ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st1 {
|
||||||
|
fill: url(#Dégradé_sans_nom_265);
|
||||||
|
stroke: url(#Dégradé_sans_nom_33);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st1, .st2, .st3, .st4, .st5, .st6, .st7, .st8, .st9, .st10, .st11, .st12 {
|
||||||
|
stroke-miterlimit: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st2 {
|
||||||
|
fill: url(#Dégradé_sans_nom_269);
|
||||||
|
stroke: url(#Dégradé_sans_nom_334);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st3 {
|
||||||
|
fill: url(#Dégradé_sans_nom_268);
|
||||||
|
stroke: url(#Dégradé_sans_nom_333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st4 {
|
||||||
|
fill: url(#Dégradé_sans_nom_266);
|
||||||
|
stroke: url(#Dégradé_sans_nom_331);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st5 {
|
||||||
|
fill: url(#Dégradé_sans_nom_267);
|
||||||
|
stroke: url(#Dégradé_sans_nom_332);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st13 {
|
||||||
|
fill: url(#Dégradé_sans_nom_261);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st14 {
|
||||||
|
fill: url(#Dégradé_sans_nom_262);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st15 {
|
||||||
|
fill: url(#Dégradé_sans_nom_264);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st16 {
|
||||||
|
fill: url(#Dégradé_sans_nom_263);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st6 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2616);
|
||||||
|
stroke: url(#Dégradé_sans_nom_3311);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st7 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2615);
|
||||||
|
stroke: url(#Dégradé_sans_nom_3310);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st17 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st18 {
|
||||||
|
fill: url(#Dégradé_sans_nom_26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st8 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2610);
|
||||||
|
stroke: url(#Dégradé_sans_nom_335);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st9 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2613);
|
||||||
|
stroke: url(#Dégradé_sans_nom_338);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st10 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2614);
|
||||||
|
stroke: url(#Dégradé_sans_nom_339);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st11 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2611);
|
||||||
|
stroke: url(#Dégradé_sans_nom_336);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st12 {
|
||||||
|
fill: url(#Dégradé_sans_nom_2612);
|
||||||
|
stroke: url(#Dégradé_sans_nom_337);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_26" data-name="Dégradé sans nom 26" x1="373.2" y1="159.5" x2="625.1" y2="411.5" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#55c3ec"/>
|
||||||
|
<stop offset="1" stop-color="#1d71b8" stop-opacity=".8"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_261" data-name="Dégradé sans nom 26" x1="143.1" y1="200.5" x2="395" y2="452.4" gradientTransform="translate(30.8 109.3)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_262" data-name="Dégradé sans nom 26" x1="81.3" y1="60.6" x2="333.2" y2="312.5" gradientTransform="translate(187.1 873.6) rotate(-90)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_263" data-name="Dégradé sans nom 26" x1="-44.4" y1="16.5" x2="207.5" y2="268.4" gradientTransform="translate(705.4 808.2) rotate(-180)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_264" data-name="Dégradé sans nom 26" x1="-67.9" y1="-58.9" x2="184" y2="193" gradientTransform="translate(770.9 385.1) rotate(90)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_265" data-name="Dégradé sans nom 26" x1="74.5" y1="560.2" x2="106.2" y2="591.8" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_33" data-name="Dégradé sans nom 33" x1="74.2" y1="559.9" x2="106.5" y2="592.2" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#55c3ec"/>
|
||||||
|
<stop offset="1" stop-color="#1d71b8" stop-opacity=".5"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_266" data-name="Dégradé sans nom 26" x1="158.2" y1="648.8" x2="176.7" y2="667.3" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_331" data-name="Dégradé sans nom 33" x1="157.8" y1="648.4" x2="177.1" y2="667.7" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_267" data-name="Dégradé sans nom 26" x1="210.2" y1="714.7" x2="249.8" y2="754.3" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_332" data-name="Dégradé sans nom 33" x1="209.9" y1="714.3" x2="250.2" y2="754.6" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_268" data-name="Dégradé sans nom 26" x1="-54" y1="72.2" x2="-14.4" y2="111.8" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_333" data-name="Dégradé sans nom 33" x1="-54.4" y1="71.9" x2="-14.1" y2="112.2" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_269" data-name="Dégradé sans nom 26" x1="485.1" y1="-9.2" x2="503.7" y2="9.4" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_334" data-name="Dégradé sans nom 33" x1="484.8" y1="-9.6" x2="504" y2="9.7" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2610" data-name="Dégradé sans nom 26" x1="825.1" y1="682.3" x2="856.8" y2="713.9" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_335" data-name="Dégradé sans nom 33" x1="824.8" y1="681.9" x2="857.1" y2="714.3" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2611" data-name="Dégradé sans nom 26" x1="308.5" y1="356.5" x2="340.3" y2="388.3" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_336" data-name="Dégradé sans nom 33" x1="308.1" y1="356.2" x2="340.7" y2="388.7" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2612" data-name="Dégradé sans nom 26" x1="540.4" y1="450.4" x2="559" y2="469" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_337" data-name="Dégradé sans nom 33" x1="540.1" y1="450" x2="559.4" y2="469.3" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2613" data-name="Dégradé sans nom 26" x1="-336.4" y1="326.9" x2="-296.8" y2="366.5" gradientTransform="translate(342.9 490.5) rotate(-175.4)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_338" data-name="Dégradé sans nom 33" x1="-336.7" y1="326.5" x2="-296.4" y2="366.8" gradientTransform="translate(342.9 490.5) rotate(-175.4)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2614" data-name="Dégradé sans nom 26" x1="115" y1="-29.9" x2="133.6" y2="-11.3" gradientTransform="translate(814.9 151.4) rotate(139.6)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_339" data-name="Dégradé sans nom 33" x1="114.7" y1="-30.2" x2="134" y2="-11" gradientTransform="translate(814.9 151.4) rotate(139.6)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2615" data-name="Dégradé sans nom 26" x1="94.5" y1="304.2" x2="124.4" y2="334" gradientTransform="translate(568 142) rotate(97.9)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_3310" data-name="Dégradé sans nom 33" x1="94.2" y1="303.8" x2="124.7" y2="334.4" gradientTransform="translate(568 142) rotate(97.9)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_2616" data-name="Dégradé sans nom 26" x1="435.8" y1="254.1" x2="454.4" y2="272.7" gradientTransform="translate(257.1 -349) rotate(52.9)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||||
|
<linearGradient id="Dégradé_sans_nom_3311" data-name="Dégradé sans nom 33" x1="435.5" y1="253.8" x2="454.8" y2="273.1" gradientTransform="translate(257.1 -349) rotate(52.9)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||||
|
</defs>
|
||||||
|
<g id="Calque_1">
|
||||||
|
<circle class="st17" cx="499.5" cy="499.5" r="499.5"/>
|
||||||
|
<g>
|
||||||
|
<path class="st17" d="M1404,957.4V45h191v755h399v157.4h-590Z"/>
|
||||||
|
<path class="st17" d="M2321.5,971.3c-49.3,0-91.5-10.2-126.5-30.7-35-20.4-61.6-49.6-79.7-87.6-18.1-37.9-27.2-83.2-27.2-135.9v-437.6h184.6v399c0,44.3,10.4,78.6,31.3,103.1,20.9,24.5,51.9,36.7,93.3,36.7s39.3-3.6,56-10.7c16.6-7.2,30.9-17.4,42.7-30.7,11.8-13.3,20.9-29.1,27.2-47.4s9.5-38.5,9.5-60.4v-389.5h184.6v677.8h-184.6v-111.9h-4.4c-11.4,25.7-26.7,48.1-45.8,67-19.2,19-42.2,33.5-68.9,43.6-26.8,10.1-57.4,15.2-92,15.2Z"/>
|
||||||
|
<path class="st17" d="M2837.5,957.4V279.6h184.6v113.8h3.8c13.9-38.8,37.6-69.8,71.1-93,33.5-23.2,73-34.8,118.6-34.8s60.1,5.5,85.4,16.4c25.3,11,46.7,26.8,64.2,47.4,17.5,20.7,29.8,46,37,75.9h3.8c10.1-28.7,25.4-53.4,45.8-74.3,20.4-20.9,44.7-37,72.7-48.4,28-11.4,58.7-17.1,92-17.1s83,9.5,116.3,28.5c33.3,19,59.2,45.5,77.8,79.7,18.5,34.1,27.8,74.2,27.8,120.1v463.5h-184.6v-416.7c0-26.6-4.3-48.8-13-66.7-8.6-17.9-21.1-31.6-37.3-41.1-16.2-9.5-36.4-14.2-60.4-14.2s-43.5,5.4-61,16.1c-17.5,10.7-31.1,25.6-40.8,44.6-9.7,19-14.5,41.1-14.5,66.4v411.6h-177.7v-422.4c0-24.4-4.4-45.3-13.3-62.6-8.9-17.3-21.4-30.6-37.6-39.8-16.2-9.3-35.7-13.9-58.5-13.9s-43.6,5.6-61.3,16.8c-17.7,11.2-31.5,26.5-41.4,45.8-9.9,19.4-14.9,41.7-14.9,67v409.1h-184.6Z"/>
|
||||||
|
<path class="st0" d="M4264,971.3c-69.1,0-128.6-14.2-178.3-42.7-49.7-28.5-88.1-69-115.1-121.7-27-52.7-40.5-115.1-40.5-187.2v-.6c0-72.1,13.5-134.6,40.5-187.5,27-52.9,64.8-93.8,113.5-122.7,48.7-28.9,106.1-43.3,172.3-43.3s123.4,14,171.7,42c48.3,28,85.6,67.6,111.9,118.6,26.3,51,39.5,110.7,39.5,178.9v57.5h-558.3v-117h471.1l-87.9,109.4v-71.5c0-39.6-6.1-72.8-18.3-99.6-12.2-26.8-29.2-46.9-50.9-60.4-21.7-13.5-46.9-20.2-75.6-20.2s-54.1,7-76.2,20.9c-22.1,13.9-39.4,34.3-51.9,61-12.4,26.8-18.7,59.5-18.7,98.3v72.1c0,37.1,6.2,68.9,18.7,95.5,12.4,26.6,30.2,46.9,53.4,61,23.2,14.1,50.8,21.2,82.8,21.2s47.2-4,65.8-12c18.5-8,33.7-18.1,45.5-30.4,11.8-12.2,19.8-24.7,24-37.3l1.3-3.8h169.5l-1.9,7c-5.1,24.9-15,50-29.7,75.2-14.8,25.3-34.7,48.5-59.8,69.6-25.1,21.1-55.6,37.9-91.7,50.6-36,12.6-78.3,19-126.8,19Z"/>
|
||||||
|
<path class="st0" d="M4982.9,971.3c-69.1,0-128.6-14.2-178.3-42.7-49.7-28.5-88.1-69-115.1-121.7-27-52.7-40.5-115.1-40.5-187.2v-.6c0-72.1,13.5-134.6,40.5-187.5,27-52.9,64.8-93.8,113.5-122.7,48.7-28.9,106.1-43.3,172.3-43.3s123.4,14,171.7,42c48.3,28,85.6,67.6,111.9,118.6,26.3,51,39.5,110.7,39.5,178.9v57.5h-558.3v-117h471.1l-87.9,109.4v-71.5c0-39.6-6.1-72.8-18.3-99.6-12.2-26.8-29.2-46.9-50.9-60.4-21.7-13.5-46.9-20.2-75.6-20.2s-54.1,7-76.2,20.9c-22.1,13.9-39.4,34.3-51.9,61-12.4,26.8-18.7,59.5-18.7,98.3v72.1c0,37.1,6.2,68.9,18.7,95.5,12.4,26.6,30.2,46.9,53.4,61,23.2,14.1,50.8,21.2,82.8,21.2s47.2-4,65.8-12c18.5-8,33.7-18.1,45.5-30.4,11.8-12.2,19.8-24.7,24-37.3l1.3-3.8h169.5l-1.9,7c-5.1,24.9-15,50-29.7,75.2-14.8,25.3-34.7,48.5-59.8,69.6-25.1,21.1-55.6,37.9-91.7,50.6s-78.3,19-126.8,19Z"/>
|
||||||
|
<path class="st0" d="M5337,957.4l212.5-337.7-210.6-340.2h208l122,228.9h3.8l120.1-228.9h201.1l-211.8,335.1,209.9,342.7h-200.4l-129-234.6h-3.8l-127.1,234.6h-194.8Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="Calque_2">
|
||||||
|
<g id="Calque_3">
|
||||||
|
<ellipse class="st18" cx="499.2" cy="285.5" rx="139.8" ry="209.5"/>
|
||||||
|
<ellipse class="st13" cx="299.9" cy="435.8" rx="139.8" ry="209.5" transform="translate(-207.3 586.3) rotate(-72)"/>
|
||||||
|
<ellipse class="st14" cx="373.6" cy="666.3" rx="209.5" ry="139.8" transform="translate(-385.1 576.9) rotate(-54)"/>
|
||||||
|
<ellipse class="st16" cx="623.9" cy="665.8" rx="139.8" ry="209.5" transform="translate(-272.2 493.9) rotate(-36)"/>
|
||||||
|
<ellipse class="st15" cx="703.9" cy="443.1" rx="209.5" ry="139.8" transform="translate(-94.9 211.2) rotate(-16)"/>
|
||||||
|
<circle class="st1" cx="90.4" cy="576" r="22.4"/>
|
||||||
|
<circle class="st4" cx="175.6" cy="607.9" r="13.1"/>
|
||||||
|
<circle class="st5" cx="140.8" cy="691.6" r="28"/>
|
||||||
|
<circle class="st3" cx="829.7" cy="602.6" r="28"/>
|
||||||
|
<circle class="st2" cx="908.9" cy="562.1" r="13.1"/>
|
||||||
|
<circle class="st8" cx="840.9" cy="698.1" r="22.4"/>
|
||||||
|
<circle class="st11" cx="466.1" cy="876.5" r="22.5"/>
|
||||||
|
<circle class="st12" cx="538.6" cy="839.8" r="13.1"/>
|
||||||
|
<circle class="st9" cx="686.1" cy="170.1" r="28"/>
|
||||||
|
<circle class="st10" cx="733.7" cy="247.7" r="13.1"/>
|
||||||
|
<circle class="st7" cx="236.9" cy="206.5" r="21.1"/>
|
||||||
|
<circle class="st6" cx="315.4" cy="164.9" r="13.1"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 13 KiB |
91
src/webui/index.html
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Photo WebUI</title>
|
||||||
|
|
||||||
|
<!-- Link to your CSS in the package -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style/style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Top bar -->
|
||||||
|
<div class="nav-bar">
|
||||||
|
<div class="content-inner nav">
|
||||||
|
<div class="nav-cta">
|
||||||
|
<div class="arrow">
|
||||||
|
→
|
||||||
|
</div>
|
||||||
|
<a class="button" href="#" target="_blank"><span id="step">🚀 Build !<i class="fa-solid fa-envelope"></i></span></a>
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" id="nav-check">
|
||||||
|
<div class="nav-header">
|
||||||
|
<div class="nav-title">
|
||||||
|
<img src="{{ url_for('static', filename='img/logo.svg') }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-btn">
|
||||||
|
<label for="nav-check">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-links">
|
||||||
|
<ul class="nav-list">
|
||||||
|
<li class="nav-item appear2"><a href="#">Site info</a>
|
||||||
|
<li class="nav-item appear2"><a href="#qui-suis-je">Theme info</a>
|
||||||
|
<li class="nav-item appear2"><a href="#mariages">Gallery</a>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Toast container for notifications -->
|
||||||
|
<div class="content-inner">
|
||||||
|
<div id="toast-container"></div>
|
||||||
|
|
||||||
|
<h1>Gallery editor</h1>
|
||||||
|
|
||||||
|
<!-- Hero Upload Section -->
|
||||||
|
<div class="upload-section">
|
||||||
|
<h2>Title Carrousel</h2>
|
||||||
|
<p> Select photos to display in the Title Carrousel</p>
|
||||||
|
<label for="upload-hero" class="custom-upload-btn">
|
||||||
|
📸 Upload photos
|
||||||
|
</label>
|
||||||
|
<input type="file" id="upload-hero" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
|
||||||
|
<div id="hero"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gallery Upload Section -->
|
||||||
|
<div class="upload-section">
|
||||||
|
<h2>Gallery</h2>
|
||||||
|
<p> Select and tags photos to display in the Gallery</p>
|
||||||
|
<label for="upload-gallery" class="custom-upload-btn">
|
||||||
|
📸 Upload photos
|
||||||
|
</label>
|
||||||
|
<input type="file" id="upload-gallery" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
|
||||||
|
<div id="gallery"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JS files for rendering, uploading, and actions -->
|
||||||
|
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/upload.js') }}"></script>
|
||||||
|
</div>
|
||||||
|
<!-- Delete confirmation modal -->
|
||||||
|
<div id="delete-modal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span id="delete-modal-close" class="modal-close">×</span>
|
||||||
|
<h3>Confirm Deletion</h3>
|
||||||
|
<p id="delete-modal-text">Are you sure you want to delete this image?</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="delete-modal-confirm" class="modal-btn danger">Delete</button>
|
||||||
|
<button id="delete-modal-cancel" class="modal-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
362
src/webui/js/main.js
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
// --- Arrays to store gallery and hero images ---
|
||||||
|
let galleryImages = [];
|
||||||
|
let heroImages = [];
|
||||||
|
let allTags = []; // global tag list
|
||||||
|
|
||||||
|
// --- Load images from server on page load ---
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
const galleryRes = await fetch('/api/gallery');
|
||||||
|
galleryImages = await galleryRes.json();
|
||||||
|
updateAllTags();
|
||||||
|
renderGallery();
|
||||||
|
|
||||||
|
const heroRes = await fetch('/api/hero');
|
||||||
|
heroImages = await heroRes.json();
|
||||||
|
renderHero();
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
showToast("Error loading images!", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Update global tag list from galleryImages ---
|
||||||
|
function updateAllTags() {
|
||||||
|
allTags = [];
|
||||||
|
galleryImages.forEach(img => {
|
||||||
|
if (img.tags) img.tags.forEach(t => {
|
||||||
|
if (!allTags.includes(t)) allTags.push(t);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Render gallery images with tags and delete buttons ---
|
||||||
|
function renderGallery() {
|
||||||
|
const container = document.getElementById('gallery');
|
||||||
|
container.innerHTML = '';
|
||||||
|
galleryImages.forEach((img, i) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'photo flex-item flex-column';
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="flex-item">
|
||||||
|
<img src="/photos/${img.src}">
|
||||||
|
</div>
|
||||||
|
<div class="tags-display" data-index="${i}"></div>
|
||||||
|
<div class="flex-item flex-full">
|
||||||
|
<div class="flex-item flex-end">
|
||||||
|
<button onclick="showDeleteModal('gallery', ${i})">🗑 Delete</button>
|
||||||
|
</div>
|
||||||
|
<div class="tag-input" data-index="${i}"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(div);
|
||||||
|
|
||||||
|
renderTags(i, img.tags || []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Render tags for a single image ---
|
||||||
|
function renderTags(imgIndex, tags) {
|
||||||
|
const tagsDisplay = document.querySelector(`.tags-display[data-index="${imgIndex}"]`);
|
||||||
|
const inputContainer = document.querySelector(`.tag-input[data-index="${imgIndex}"]`);
|
||||||
|
|
||||||
|
// vider
|
||||||
|
tagsDisplay.innerHTML = '';
|
||||||
|
inputContainer.innerHTML = '';
|
||||||
|
|
||||||
|
// --- rendre les tags (en haut) ---
|
||||||
|
tags.forEach(tag => {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'tag';
|
||||||
|
span.textContent = tag;
|
||||||
|
|
||||||
|
const remove = document.createElement('span');
|
||||||
|
remove.className = 'remove-tag';
|
||||||
|
remove.textContent = '×';
|
||||||
|
remove.onclick = () => {
|
||||||
|
tags.splice(tags.indexOf(tag), 1);
|
||||||
|
updateTags(imgIndex, tags);
|
||||||
|
renderTags(imgIndex, tags);
|
||||||
|
};
|
||||||
|
|
||||||
|
span.appendChild(remove);
|
||||||
|
tagsDisplay.appendChild(span);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- input (en bas) ---
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.placeholder = 'Add tag...';
|
||||||
|
inputContainer.appendChild(input);
|
||||||
|
|
||||||
|
// suggestion box
|
||||||
|
const suggestionBox = document.createElement('ul');
|
||||||
|
suggestionBox.className = 'suggestions';
|
||||||
|
inputContainer.appendChild(suggestionBox);
|
||||||
|
|
||||||
|
let selectedIndex = -1;
|
||||||
|
|
||||||
|
const addTag = (tag) => {
|
||||||
|
tag = tag.trim();
|
||||||
|
if (!tag) return;
|
||||||
|
if (!tags.includes(tag)) tags.push(tag);
|
||||||
|
updateTags(imgIndex, tags);
|
||||||
|
renderTags(imgIndex, tags);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSuggestions = () => {
|
||||||
|
const value = input.value.toLowerCase();
|
||||||
|
|
||||||
|
const allTagsFlat = galleryImages.flatMap(img => img.tags || []);
|
||||||
|
const tagCount = {};
|
||||||
|
allTagsFlat.forEach(t => tagCount[t] = (tagCount[t] || 0) + 1);
|
||||||
|
|
||||||
|
const allTagsSorted = Object.keys(tagCount)
|
||||||
|
.sort((a, b) => tagCount[b] - tagCount[a]);
|
||||||
|
|
||||||
|
const suggestions = allTagsSorted.filter(t => t.toLowerCase().startsWith(value) && !tags.includes(t));
|
||||||
|
|
||||||
|
suggestionBox.innerHTML = '';
|
||||||
|
selectedIndex = -1;
|
||||||
|
|
||||||
|
if (suggestions.length) {
|
||||||
|
suggestionBox.style.display = 'block';
|
||||||
|
suggestions.forEach((s, idx) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.style.fontStyle = 'italic';
|
||||||
|
li.style.textAlign = 'left';
|
||||||
|
|
||||||
|
const boldPart = `<b>${s.substring(0, input.value.length)}</b>`;
|
||||||
|
const rest = s.substring(input.value.length);
|
||||||
|
li.innerHTML = boldPart + rest;
|
||||||
|
|
||||||
|
li.addEventListener('mousedown', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag(s);
|
||||||
|
input.value = '';
|
||||||
|
input.focus();
|
||||||
|
updateSuggestions();
|
||||||
|
});
|
||||||
|
|
||||||
|
li.onmouseover = () => selectedIndex = idx;
|
||||||
|
suggestionBox.appendChild(li);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
suggestionBox.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener('input', updateSuggestions);
|
||||||
|
input.addEventListener('focus', updateSuggestions);
|
||||||
|
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
const items = suggestionBox.querySelectorAll('li');
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!items.length) return;
|
||||||
|
selectedIndex = (selectedIndex + 1) % items.length;
|
||||||
|
items.forEach((li, i) => li.classList.toggle('selected', i === selectedIndex));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!items.length) return;
|
||||||
|
selectedIndex = (selectedIndex - 1 + items.length) % items.length;
|
||||||
|
items.forEach((li, i) => li.classList.toggle('selected', i === selectedIndex));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedIndex >= 0 && items[selectedIndex]) {
|
||||||
|
addTag(items[selectedIndex].textContent);
|
||||||
|
} else {
|
||||||
|
addTag(input.value);
|
||||||
|
}
|
||||||
|
input.value = '';
|
||||||
|
updateSuggestions();
|
||||||
|
} else if ([' ', ','].includes(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag(input.value);
|
||||||
|
input.value = '';
|
||||||
|
updateSuggestions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('blur', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
suggestionBox.style.display = 'none';
|
||||||
|
input.value = '';
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
updateSuggestions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Update tags in galleryImages array ---
|
||||||
|
function updateTags(index, tags) {
|
||||||
|
galleryImages[index].tags = tags;
|
||||||
|
saveGallery();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Render hero images with delete buttons ---
|
||||||
|
function renderHero() {
|
||||||
|
const container = document.getElementById('hero');
|
||||||
|
container.innerHTML = '';
|
||||||
|
heroImages.forEach((img, i) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'photo flex-item flex-column';
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="flex-item">
|
||||||
|
<img src="/photos/${img.src}">
|
||||||
|
</div>
|
||||||
|
<div class="flex-item flex-full">
|
||||||
|
<div class="flex-item flex-end">
|
||||||
|
<button onclick="showDeleteModal('hero', ${i})">🗑 Delete</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Save gallery to server ---
|
||||||
|
async function saveGallery() {
|
||||||
|
await fetch('/api/gallery/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(galleryImages)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Save hero to server ---
|
||||||
|
async function saveHero() {
|
||||||
|
await fetch('/api/hero/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(heroImages)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Save all changes ---
|
||||||
|
async function saveChanges() {
|
||||||
|
await saveGallery();
|
||||||
|
await saveHero();
|
||||||
|
showToast('✅ Changes saved!', "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Refresh gallery from folder ---
|
||||||
|
async function refreshGallery() {
|
||||||
|
await fetch('/api/gallery/refresh', { method: 'POST' });
|
||||||
|
await loadData();
|
||||||
|
showToast('🔄 Gallery updated from photos/gallery folder', "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Refresh hero from folder ---
|
||||||
|
async function refreshHero() {
|
||||||
|
await fetch('/api/hero/refresh', { method: 'POST' });
|
||||||
|
await loadData();
|
||||||
|
showToast('🔄 Hero updated from photos/hero folder', "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Show toast notification ---
|
||||||
|
function showToast(message, type = "success", duration = 3000) {
|
||||||
|
const container = document.getElementById("toast-container");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const toast = document.createElement("div");
|
||||||
|
toast.className = `toast ${type}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => toast.classList.add("show"));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove("show");
|
||||||
|
toast.addEventListener("transitionend", () => toast.remove());
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pendingDelete = null; // { type: 'gallery'|'hero', index: number }
|
||||||
|
|
||||||
|
// --- Show delete confirmation modal ---
|
||||||
|
function showDeleteModal(type, index) {
|
||||||
|
pendingDelete = { type, index };
|
||||||
|
document.getElementById('delete-modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hide modal ---
|
||||||
|
function hideDeleteModal() {
|
||||||
|
document.getElementById('delete-modal').style.display = 'none';
|
||||||
|
pendingDelete = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Confirm deletion ---
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!pendingDelete) return;
|
||||||
|
if (pendingDelete.type === 'gallery') {
|
||||||
|
await actuallyDeleteGalleryImage(pendingDelete.index);
|
||||||
|
} else if (pendingDelete.type === 'hero') {
|
||||||
|
await actuallyDeleteHeroImage(pendingDelete.index);
|
||||||
|
}
|
||||||
|
hideDeleteModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Modal event listeners ---
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.getElementById('delete-modal-close').onclick = hideDeleteModal;
|
||||||
|
document.getElementById('delete-modal-cancel').onclick = hideDeleteModal;
|
||||||
|
document.getElementById('delete-modal-confirm').onclick = confirmDelete;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Actual delete functions ---
|
||||||
|
async function actuallyDeleteGalleryImage(index) {
|
||||||
|
const img = galleryImages[index];
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/gallery/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ src: img.src.split('/').pop() })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
galleryImages.splice(index, 1);
|
||||||
|
renderGallery();
|
||||||
|
await saveGallery();
|
||||||
|
showToast("✅ Gallery image deleted!", "success");
|
||||||
|
} else showToast("Error: " + data.error, "error");
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
showToast("Server error!", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function actuallyDeleteHeroImage(index) {
|
||||||
|
const img = heroImages[index];
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/hero/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ src: img.src.split('/').pop() })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
heroImages.splice(index, 1);
|
||||||
|
renderHero();
|
||||||
|
await saveHero();
|
||||||
|
showToast("✅ Hero image deleted!", "success");
|
||||||
|
} else showToast("Error: " + data.error, "error");
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
showToast("Server error!", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Modal event listeners ---
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.getElementById('delete-modal-close').onclick = hideDeleteModal;
|
||||||
|
document.getElementById('delete-modal-cancel').onclick = hideDeleteModal;
|
||||||
|
document.getElementById('delete-modal-confirm').onclick = confirmDelete;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// --- Initialize ---
|
||||||
|
loadData();
|
41
src/webui/js/upload.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
// --- Upload gallery images ---
|
||||||
|
document.getElementById('upload-gallery').addEventListener('change', async (e) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files.length) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const file of files) formData.append('files', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/gallery/upload', { method: 'POST', body: formData });
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
showToast(`✅ ${data.uploaded.length} gallery image(s) uploaded!`, "success");
|
||||||
|
refreshGallery();
|
||||||
|
} else showToast('Error: ' + data.error, "error");
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
showToast('Server error!', "error");
|
||||||
|
} finally { e.target.value = ''; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Upload hero images ---
|
||||||
|
document.getElementById('upload-hero').addEventListener('change', async (e) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files.length) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const file of files) formData.append('files', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/hero/upload', { method: 'POST', body: formData });
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
showToast(`✅ ${data.uploaded.length} hero image(s) uploaded!`, "success");
|
||||||
|
refreshHero();
|
||||||
|
} else showToast('Error: ' + data.error, "error");
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
showToast('Server error!', "error");
|
||||||
|
} finally { e.target.value = ''; }
|
||||||
|
});
|
428
src/webui/style/style.css
Normal file
@ -0,0 +1,428 @@
|
|||||||
|
body {
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
|
||||||
|
margin: 20px;
|
||||||
|
background:radial-gradient(ellipse at bottom center, #002a30, #000000bd), radial-gradient(ellipse at top center, #0558a8, #000000fa);
|
||||||
|
color: #FBFBFB;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin:0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-inner {
|
||||||
|
max-width: 90%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
h1, h2 {
|
||||||
|
color: #FBFBFB;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button {
|
||||||
|
margin-right: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button:hover {
|
||||||
|
background-color: #45a049;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-section {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-section label {
|
||||||
|
margin-right: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#gallery, #hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo {
|
||||||
|
background-color: rgb(67 67 67 / 26%);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 30px;
|
||||||
|
color: rgb(221, 221, 221);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo button {
|
||||||
|
padding: 4px;
|
||||||
|
border: none;
|
||||||
|
background-color:rgb(121 26 19);
|
||||||
|
color: white;
|
||||||
|
border-radius: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
margin: 5px 4px 0 4px;
|
||||||
|
width:100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo button:hover {
|
||||||
|
background-color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
body {
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-section label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast notifications */
|
||||||
|
#toast-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
background: rgba(0,0,0,0.85);
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
transition: opacity 0.5s ease, transform 0.5s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success { background-color: #28a745; }
|
||||||
|
.toast.error { background-color: #dc3545; }
|
||||||
|
|
||||||
|
/* Tags */
|
||||||
|
.tag-input {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap-reverse;
|
||||||
|
align-content: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-input input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
min-width: 60px;
|
||||||
|
background-color: #1f2223;
|
||||||
|
border: 1px solid #585858;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
background-color: #074053;
|
||||||
|
padding: 0.2em 0.5em;
|
||||||
|
border-radius: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag .remove-tag {
|
||||||
|
margin-left: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-input ul.suggestions {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #181a1b;
|
||||||
|
border-top: none;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 999; /* ensure it displays above other elements */
|
||||||
|
display: none;
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15); /* subtle shadow for visibility */
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-input ul.suggestions li {
|
||||||
|
padding: 6px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-input ul.suggestions li:hover {
|
||||||
|
background-color: #007782;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-display {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions li.selected {
|
||||||
|
background-color: #007782;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.suggestions li {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-item {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-column {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-full {
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-end {
|
||||||
|
align-items: flex-end;
|
||||||
|
width: 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top bar */
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-bar {
|
||||||
|
height: 70px;
|
||||||
|
width: 100%;
|
||||||
|
background-color: #0c0d0c29;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid #21212157;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav img {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > .nav-header {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > .nav-header > .nav-title {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > .nav-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > .nav-links {
|
||||||
|
display: inline;
|
||||||
|
float: right;
|
||||||
|
font-size: 14px;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: inline;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list {
|
||||||
|
list-style-type: disc;
|
||||||
|
margin: 0px;
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > .nav-links > .nav-list > .nav-item > a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0px 15px 0px 15px;
|
||||||
|
text-decoration: none;
|
||||||
|
height: 100%;
|
||||||
|
font-weight: 700;
|
||||||
|
color:#fff
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > .nav-links > .nav-list > .nav-item > a:hover {
|
||||||
|
color: #00b0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > #nav-check {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.nav-list > li + li::before{
|
||||||
|
content: " → ";
|
||||||
|
color: #ffc700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-cta {
|
||||||
|
display: inline;
|
||||||
|
float: right;
|
||||||
|
height: 70px;
|
||||||
|
line-height: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-cta > .arrow {
|
||||||
|
font-size: 12px;
|
||||||
|
display: inline;
|
||||||
|
color: #ffc700;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-cta > .button {
|
||||||
|
padding: 10px 25px;
|
||||||
|
border-radius: 40px;
|
||||||
|
margin: 15px 20px 15px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
display: inline;
|
||||||
|
background: linear-gradient(135deg, #26c4ff, #016074);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-cta > .button:hover {
|
||||||
|
background: linear-gradient(135deg, #72d9ff, #26657e);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links > ul {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom upload buttons */
|
||||||
|
.custom-upload-btn {
|
||||||
|
display: inline-block;
|
||||||
|
background: #09A0C1;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
border-radius: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.1s ease;
|
||||||
|
user-select: none;
|
||||||
|
box-shadow: 0 4px 10px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-upload-btn:hover {
|
||||||
|
background: #55c3ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal styles */
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
background: #ffffff29;
|
||||||
|
color: #fff;
|
||||||
|
padding: 2rem 2.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.25);
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 90vw;
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px; right: 18px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #fff;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.modal-close:hover { opacity: 1; }
|
||||||
|
.modal-actions {
|
||||||
|
margin-top: 2rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-btn {
|
||||||
|
padding: 0.5em 1.5em;
|
||||||
|
border-radius: 30px;
|
||||||
|
border: none;
|
||||||
|
background: #09A0C1;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.modal-btn.danger {
|
||||||
|
background: #c62828;
|
||||||
|
}
|
||||||
|
.modal-btn:hover {
|
||||||
|
background: #55c3ec;
|
||||||
|
}
|
||||||
|
.modal-btn.danger:hover {
|
||||||
|
background: #d32f2f;
|
||||||
|
}
|