48 Commits

Author SHA1 Message Date
e9a3a5a189 Build system with zip download 2025-08-19 19:29:06 +02:00
4ac176f8a9 Theme editor UI refinement 2025-08-19 12:17:40 +02:00
1ea94b469b Theme editor UI ajdustment 2025-08-19 11:52:38 +02:00
2ec4be624b Theme editor 2025-08-18 23:36:36 +02:00
369704a87c Better h2 title 2025-08-18 22:21:59 +02:00
330e467dcb Better global UI 2025-08-18 20:05:08 +02:00
305042b365 Lumeex scroll 2 top merge 2025-08-18 13:11:34 +02:00
7a7876e5ef Better form 2025-08-18 12:29:45 +02:00
8cb81a74cf Refreshed site.yaml 2025-08-17 23:51:15 +02:00
c193fd49aa Better UI form 2025-08-17 23:49:36 +02:00
5d863223e3 Form UI 1st step 2025-08-17 22:44:54 +02:00
a8f3c1b497 All fields OK 2025-08-17 14:26:31 +02:00
b74f1bb350 Site-info front 2025-08-17 13:52:25 +02:00
5a6f08644a Fixed responsive issue 2025-08-17 12:38:29 +02:00
031ff62168 Better css 2025-08-17 11:26:21 +02:00
b56d03303e Remove all button 2025-08-17 10:57:59 +02:00
d3484a4b50 Confirmation modale for deletion 2025-08-17 00:37:55 +02:00
9d37b0a60f Better tag UI 2025-08-16 22:57:56 +02:00
080eb2593d Header 2025-08-16 21:40:38 +02:00
73a0dd0ce6 Better UI 2025-08-16 16:30:56 +02:00
97645b06fa Most used tags 2025-08-16 14:08:34 +02:00
142c042b86 Better ui tag system 2025-08-16 13:14:04 +02:00
041db66b3d Reworked flow 2025-08-16 11:17:15 +02:00
1b0b228273 Gallery front 2025-08-16 10:29:51 +02:00
41450837f2 Versionning 2025-08-13 22:14:16 +00:00
4edeb8709a Click on photo tag -> Photo on top + scroll to top 2025-08-13 22:10:03 +00:00
6fc573c510 Merge pull request 'Slimmer docker image + fifo file check' (#7) from docker into main
Reviewed-on: #7
2025-08-13 18:21:24 +02:00
43c007c1fe Slimmer docker image + fifo file check 2025-08-13 16:20:46 +00:00
dfbd532efd Merge pull request 'Docker support' (#6) from docker into main
Reviewed-on: #6
2025-08-13 17:47:25 +02:00
efe1bbca29 new ensure_dir logic
+ better logs
+ docker files
+ new docs
2025-08-13 15:45:33 +00:00
7e1a5e659f Merge pull request 'errors' (#5) from errors into main
Reviewed-on: #5
2025-08-12 19:35:54 +02:00
f5a5aefd09 gallery.py starter + rename builder.py 2025-08-12 17:31:46 +00:00
f76420b2c3 favicon log + better robot.txt + modular starter 2025-08-12 17:08:31 +00:00
3901bf8acf Fixed typo 2025-08-11 17:15:46 +00:00
39b24a05cb Fixed div balise 2025-08-11 12:51:46 +00:00
d379fc63d1 Merge pull request 'New documentations and default files' (#4) from docs into main
Reviewed-on: #4
2025-08-11 14:50:42 +02:00
af6b2289e0 Fixed typo 2025-08-11 12:48:28 +00:00
5728ebb649 Logo 2025-08-11 12:46:54 +00:00
7f86f8f522 Better readme 2025-08-11 12:36:40 +00:00
080209d202 New modern favicon 2025-08-11 09:58:49 +00:00
e4a9c57b31 Output folder instead of .output + now build.py pass the build dir var to html_generator.py + new README with link to the wiki 2025-08-10 09:29:56 +00:00
d0fe57fe9c Updating build.py version 2025-08-09 23:40:35 +00:00
bf71ac6dde Hero carrousel in gallery.py instead of site.yaml + removed deleted img for gallery.py 2025-08-09 23:25:23 +00:00
f069ee1065 Demo folder + default config 2025-08-09 22:21:21 +00:00
b0c991af58 Convert and resize by default to True 2025-08-08 22:29:04 +00:00
224441f629 Hotfix - Build date version 2025-08-08 11:34:22 +00:00
b378e9a386 Hotfix - Build version 2025-08-08 11:18:13 +00:00
2749302082 Hotfix - Mobile img margin-top 2025-08-08 11:13:27 +00:00
62 changed files with 4344 additions and 667 deletions

8
.gitignore vendored
View File

@ -1,3 +1,5 @@
.venv .*
.output !.sh
__pycache__ !.gitignore
output/
__pycache__/

19
Dockerfile Normal file
View 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
View File

@ -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. Youll 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.
> Youll just need to tag the photos in `gallery.yaml`.
**`site.yaml`**
This file contains all your sites 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
View File

@ -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()

View File

@ -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"]

View File

@ -1,59 +1,36 @@
# 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. convert_images: true # true to enable image conversion
resize_images: true # use true to automatically resize to width 1140px (maximum width used in the gallery) resize_images: true # true to enable image resizing
# 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."

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 16 KiB

36
demo/config/gallery.yaml Normal file
View 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: [landscape, 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

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

Before

Width:  |  Height:  |  Size: 5.7 MiB

After

Width:  |  Height:  |  Size: 5.7 MiB

View File

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 364 KiB

View File

Before

Width:  |  Height:  |  Size: 5.6 MiB

After

Width:  |  Height:  |  Size: 5.6 MiB

View File

Before

Width:  |  Height:  |  Size: 8.4 MiB

After

Width:  |  Height:  |  Size: 8.4 MiB

View File

Before

Width:  |  Height:  |  Size: 706 KiB

After

Width:  |  Height:  |  Size: 706 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

49
demo/config/site.yaml Normal file
View 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."

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View 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;
}
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,13 @@
/*-----------------------------------*/
/* Typewriter theme for Lumeex */
/* https://git.djeex.fr/Djeex/lumeex */
/*-----------------------------------*/
.tag {
line-height: 0.5em;
}
.tags {
gap:0px;
}

View 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
View 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

View 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"

View File

@ -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
View 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

View File

@ -1,2 +1,3 @@
pyyaml pyyaml
pillow pillow
flask

View File

@ -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,87 @@ 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; // remembers the last clicked tag
let lastClickedSection = null; // remembers the last clicked section (photo)
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) {
if (lastClickedSection === section) {
matchingSection = section;
} else {
filteredSections.push(section);
}
}
}); });
// Remove all filtered sections from DOM before reordering
if (galleryContainer) {
[matchingSection, ...filteredSections].forEach(section => {
if (section && galleryContainer.contains(section)) {
galleryContainer.removeChild(section);
}
});
if (matchingSection) {
galleryContainer.prepend(matchingSection);
}
filteredSections.forEach(section => {
galleryContainer.appendChild(section);
});
}
// Update tag styles
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));
}); });
// Update the 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 to the gallery
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; // remembers the last clicked tag
? activeTags.filter((t) => t !== tagText) lastClickedSection = tagEl.closest('.section'); // remembers the last clicked section
: [...activeTags, tagText];
if (activeTags.includes(tagText)) {
activeTags = activeTags.filter((t) => t !== tagText);
} else {
activeTags.push(tagText);
}
applyFilter(); applyFilter();
}); });
}); });
@ -100,19 +142,17 @@ 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;
lastClickedSection = null; // No section selected from URL
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 functionality

View File

@ -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;
}
} }

View File

@ -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 = []

View 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)")

View File

@ -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}")

View 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}")

View 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.")

View File

@ -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

View File

@ -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
View File

66
src/py/webui/upload.py Normal file
View File

@ -0,0 +1,66 @@
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

445
src/py/webui/webui.py Normal file
View File

@ -0,0 +1,445 @@
# --- Imports ---
import logging
import yaml
import subprocess
import zipfile
import os
from pathlib import Path
from flask import (
Flask, jsonify, request, send_from_directory, render_template,
send_file, after_this_request
)
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=""
)
# --- Config paths ---
SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos"
app.config["PHOTOS_DIR"] = PHOTOS_DIR
# --- Register upload blueprint ---
app.register_blueprint(upload_bp)
# --- Theme editor helper functions ---
def get_theme_name():
"""Get current theme name from site.yaml."""
site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
with open(site_yaml_path, "r") as f:
site_yaml = yaml.safe_load(f)
return site_yaml.get("build", {}).get("theme", "modern")
def get_theme_yaml(theme_name):
"""Load theme.yaml for a given theme."""
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
with open(theme_yaml_path, "r") as f:
return yaml.safe_load(f)
def save_theme_yaml(theme_name, theme_yaml):
"""Save theme.yaml for a given theme."""
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
with open(theme_yaml_path, "w") as f:
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
def get_local_fonts(theme_name):
"""List local font files for a theme."""
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
if not fonts_dir.exists():
return []
return [f.name for f in fonts_dir.glob("*") if f.is_file() and f.suffix in [".woff", ".woff2"]]
# --- ROUTES ---
# --- Main page ---
@app.route("/")
def index():
return render_template("index.html")
# --- Gallery & Hero API ---
@app.route("/api/gallery", methods=["GET"])
def get_gallery():
"""Get gallery images."""
data = load_yaml(GALLERY_YAML)
return jsonify(data.get("gallery", {}).get("images", []))
@app.route("/api/hero", methods=["GET"])
def get_hero():
"""Get hero images."""
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."""
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."""
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 images from disk."""
update_gallery()
return jsonify({"status": "ok"})
@app.route("/api/hero/refresh", methods=["POST"])
def refresh_hero():
"""Refresh hero images from disk."""
update_hero()
return jsonify({"status": "ok"})
# --- Gallery & Hero photo deletion ---
@app.route("/api/gallery/delete", methods=["POST"])
def delete_gallery_photo():
"""Delete a gallery photo."""
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."""
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("/api/gallery/delete_all", methods=["POST"])
def delete_all_gallery_photos():
"""Delete all gallery photos."""
gallery_dir = PHOTOS_DIR / "gallery"
deleted = 0
for file in gallery_dir.glob("*"):
if file.is_file():
file.unlink()
deleted += 1
data = load_yaml(GALLERY_YAML)
data["gallery"]["images"] = []
save_yaml(data, GALLERY_YAML)
return jsonify({"status": "ok", "deleted": deleted})
@app.route("/api/hero/delete_all", methods=["POST"])
def delete_all_hero_photos():
"""Delete all hero photos."""
hero_dir = PHOTOS_DIR / "hero"
deleted = 0
for file in hero_dir.glob("*"):
if file.is_file():
file.unlink()
deleted += 1
data = load_yaml(GALLERY_YAML)
data["hero"]["images"] = []
save_yaml(data, GALLERY_YAML)
return jsonify({"status": "ok", "deleted": deleted})
# --- Serve photos ---
@app.route("/photos/<section>/<path:filename>")
def photos(section, filename):
"""Serve a photo from a section."""
return send_from_directory(PHOTOS_DIR / section, filename)
@app.route("/photos/<path:filename>")
def serve_photo(filename):
"""Serve a photo from the photos directory."""
photos_dir = Path(__file__).resolve().parents[3] / "config" / "photos"
return send_from_directory(photos_dir, filename)
# --- Site info page & API ---
@app.route("/site-info")
def site_info():
"""Render site info editor page."""
return render_template("site-info/index.html")
@app.route("/api/site-info", methods=["GET"])
def get_site_info():
"""Get site info YAML as JSON."""
with open(SITE_YAML, "r") as f:
data = yaml.safe_load(f)
return jsonify(data)
@app.route("/api/site-info", methods=["POST"])
def update_site_info():
"""Update site info YAML."""
data = request.json
with open(SITE_YAML, "w") as f:
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"})
# --- Theme management ---
@app.route("/api/themes")
def list_themes():
"""List available themes."""
themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
themes = [d.name for d in themes_dir.iterdir() if d.is_dir()]
return jsonify(themes)
# --- Thumbnail upload/remove ---
@app.route("/api/thumbnail/upload", methods=["POST"])
def upload_thumbnail():
"""Upload thumbnail image and update site.yaml."""
PHOTOS_DIR = app.config["PHOTOS_DIR"]
file = request.files.get("file")
if not file:
return {"error": "❌ No file provided"}, 400
filename = "thumbnail.png"
file.save(PHOTOS_DIR / filename)
with open(SITE_YAML, "r") as f:
data = yaml.safe_load(f)
data.setdefault("social", {})["thumbnail"] = filename
with open(SITE_YAML, "w") as f:
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok", "filename": filename})
@app.route("/api/thumbnail/remove", methods=["POST"])
def remove_thumbnail():
"""Remove thumbnail image and update site.yaml."""
PHOTOS_DIR = app.config["PHOTOS_DIR"]
thumbnail_path = PHOTOS_DIR / "thumbnail.png"
if thumbnail_path.exists():
thumbnail_path.unlink()
with open(SITE_YAML, "r") as f:
data = yaml.safe_load(f)
if "social" in data and "thumbnail" in data["social"]:
data["social"]["thumbnail"] = ""
with open(SITE_YAML, "w") as f:
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"})
# --- Theme upload ---
@app.route("/api/theme/upload", methods=["POST"])
def upload_theme():
"""Upload a custom theme folder."""
themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
files = request.files.getlist("files")
if not files:
return jsonify({"error": "❌ No files provided"}), 400
first_path = files[0].filename
folder_name = first_path.split("/")[0] if "/" in first_path else "custom"
theme_folder = themes_dir / folder_name
theme_folder.mkdir(parents=True, exist_ok=True)
for file in files:
rel_path = Path(file.filename)
dest_path = theme_folder / rel_path.relative_to(folder_name)
dest_path.parent.mkdir(parents=True, exist_ok=True)
file.save(dest_path)
return jsonify({"status": "ok", "theme": folder_name})
# --- Theme editor page & API ---
@app.route("/theme-editor")
def theme_editor():
"""Render theme editor page."""
return render_template("theme-editor/index.html")
@app.route("/api/theme-info", methods=["GET", "POST"])
def api_theme_info():
"""Get or update theme.yaml for current theme."""
theme_name = get_theme_name()
if request.method == "GET":
theme_yaml = get_theme_yaml(theme_name)
google_fonts = theme_yaml.get("google_fonts", [])
return jsonify({
"theme_name": theme_name,
"theme_yaml": theme_yaml,
"google_fonts": google_fonts
})
else:
data = request.get_json()
theme_yaml = data.get("theme_yaml")
theme_name = data.get("theme_name", theme_name)
save_theme_yaml(theme_name, theme_yaml)
return jsonify({"status": "ok"})
@app.route("/api/local-fonts")
def api_local_fonts():
"""List local fonts for a theme."""
theme_name = request.args.get("theme")
fonts = get_local_fonts(theme_name)
return jsonify(fonts)
# --- Favicon upload/remove ---
@app.route("/api/favicon/upload", methods=["POST"])
def upload_favicon():
"""Upload favicon for a theme."""
theme_name = request.form.get("theme")
file = request.files.get("file")
if not file or not theme_name:
return jsonify({"error": "❌ Missing file or theme"}), 400
ext = Path(file.filename).suffix.lower()
if ext not in [".png", ".jpg", ".jpeg", ".ico"]:
return jsonify({"error": "❌ Invalid file type"}), 400
filename = "favicon" + ext
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name
file.save(theme_dir / filename)
theme_yaml_path = theme_dir / "theme.yaml"
with open(theme_yaml_path, "r") as f:
theme_yaml = yaml.safe_load(f)
theme_yaml.setdefault("favicon", {})["path"] = filename
with open(theme_yaml_path, "w") as f:
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok", "filename": filename})
@app.route("/api/favicon/remove", methods=["POST"])
def remove_favicon():
"""Remove favicon for a theme."""
data = request.get_json()
theme_name = data.get("theme")
if not theme_name:
return jsonify({"error": "❌ Missing theme"}), 400
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name
for ext in [".png", ".jpg", ".jpeg", ".ico"]:
favicon_path = theme_dir / f"favicon{ext}"
if favicon_path.exists():
favicon_path.unlink()
theme_yaml_path = theme_dir / "theme.yaml"
with open(theme_yaml_path, "r") as f:
theme_yaml = yaml.safe_load(f)
if "favicon" in theme_yaml:
theme_yaml["favicon"]["path"] = ""
with open(theme_yaml_path, "w") as f:
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"})
# --- Serve theme assets ---
@app.route("/themes/<theme>/<filename>")
def serve_theme_asset(theme, filename):
"""Serve a theme asset file."""
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme
return send_from_directory(theme_dir, filename)
# --- Font upload/remove ---
@app.route("/api/font/upload", methods=["POST"])
def upload_font():
"""Upload a font file for a theme."""
theme_name = request.form.get("theme")
file = request.files.get("file")
if not file or not theme_name:
return jsonify({"error": "❌ Missing theme or font"}), 400
ext = Path(file.filename).suffix.lower()
if ext not in [".woff", ".woff2"]:
return jsonify({"error": "❌ Invalid font file type"}), 400
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True)
file.save(fonts_dir / file.filename)
return jsonify({"status": "ok", "filename": file.filename})
@app.route("/api/font/remove", methods=["POST"])
def remove_font():
"""Remove a font file for a theme."""
data = request.get_json()
theme_name = data.get("theme")
font = data.get("font")
if not theme_name or not font:
return jsonify({"error": "❌ Missing theme or font"}), 400
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
font_path = fonts_dir / font
if font_path.exists():
font_path.unlink()
return jsonify({"status": "ok"})
return jsonify({"error": "❌ Font not found"}), 404
# --- Build & Download ZIP ---
@app.route("/api/build", methods=["POST"])
def trigger_build():
"""
Validate site.yaml and run build.py.
Does NOT create zip here; zip is created on demand in download route.
"""
site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
output_folder = Path(__file__).resolve().parents[3] / "output"
if not site_yaml_path.exists():
return jsonify({"status": "error", "message": "❌ site.yaml not found"}), 400
with open(site_yaml_path, "r") as f:
site_data = yaml.safe_load(f) or {}
# Dynamically check all main sections and nested keys
main_sections = list(site_data.keys())
for section in main_sections:
value = site_data.get(section)
if not value:
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
if isinstance(value, dict):
for k, v in value.items():
if v is None or v == "" or (isinstance(v, list) and not v):
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}.{k}"}), 400
elif isinstance(value, list):
if not value:
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
for idx, item in enumerate(value):
if isinstance(item, dict):
for k, v in item.items():
if v is None or v == "" or (isinstance(v, list) and not v):
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}[{idx}].{k}"}), 400
elif item is None or item == "":
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}[{idx}]"}), 400
else:
if value is None or value == "":
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
try:
subprocess.run(["python3", "build.py"], check=True)
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"status": "error", "message": f"{str(e)}"}), 500
@app.route("/download-output-zip", methods=["POST"])
def download_output_zip():
"""
Create output zip on demand and send it to the user.
Zip is deleted after sending.
"""
output_folder = Path(__file__).resolve().parents[3] / "output"
zip_path = Path(__file__).resolve().parents[3] / "site_output.zip" # Store in lumeex/ root
# Create zip on demand
with zipfile.ZipFile(zip_path, "w") as zipf:
for root, dirs, files in os.walk(output_folder):
for file in files:
file_path = Path(root) / file
zipf.write(file_path, file_path.relative_to(output_folder))
@after_this_request
def remove_file(response):
try:
os.remove(zip_path)
except Exception:
pass
return response
return send_file(zip_path, as_attachment=True)
# --- 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="UTF-8">
<title>Lumeex</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style/style.css') }}">
</head>
<body>
<!-- Top bar -->
<div class="nav-bar">
<div class="content-inner nav">
<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"><a href="/gallery-editor">Gallery</a>
<li class="nav-item"><a href="/site-info">Site info</a></li>
<li class="nav-item"><a href="/theme-editor">Theme info</a></li>
<li class="nav-item">
<button id="build-btn" class="button">Build !</button>
</li>
</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>
<div class="upload-actions-row">
<label for="upload-hero" class="up-btn">
📸 Upload photos
</label>
<button id="remove-all-hero" class="up-btn">🗑 Delete all</button>
</div>
<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>
<div class="upload-actions-row">
<label for="upload-gallery" class="up-btn">
📸 Upload photos
</label>
<button id="remove-all-gallery" class="up-btn">🗑 Delete all</button>
</div>
<input type="file" id="upload-gallery" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
<div id="gallery"></div>
</div>
<script src="{{ url_for('static', filename='js/main.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/upload.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/build.js') }}" defer></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">&times;</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>
<!-- Build success modal -->
<div id="build-success-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="build-success-modal-close" class="modal-close">&times;</span>
<h3>✅ Build completed!</h3>
<p>Your files are available in the output folder.</p>
<button id="download-zip-btn" class="modal-btn">Download ZIP</button>
<div id="zip-loader" style="display:none;">Creating ZIP...</div>
</div>
</div>
</body>
</html>

166
src/webui/img/logo.svg Normal file
View 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

81
src/webui/js/build.js Normal file
View File

@ -0,0 +1,81 @@
/**
* Show a toast notification.
* @param {string} message - The message to display.
* @param {string} type - "success" or "error".
* @param {number} duration - Duration in ms.
*/
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");
setTimeout(() => container.removeChild(toast), 300);
}, duration);
}
document.addEventListener("DOMContentLoaded", () => {
// Get build button and modal elements
const buildBtn = document.getElementById("build-btn");
const buildModal = document.getElementById("build-success-modal");
const buildModalClose = document.getElementById("build-success-modal-close");
const downloadZipBtn = document.getElementById("download-zip-btn");
const zipLoader = document.getElementById("zip-loader");
// Handle build button click
if (buildBtn) {
buildBtn.addEventListener("click", async () => {
// Trigger build on backend
const res = await fetch("/api/build", { method: "POST" });
const result = await res.json();
if (result.status === "ok") {
// Show build success modal
if (buildModal) buildModal.style.display = "flex";
} else {
showToast(result.message || "❌ Build failed!", "error");
}
});
}
// Handle download zip button click
if (downloadZipBtn) {
downloadZipBtn.addEventListener("click", async () => {
if (zipLoader) zipLoader.style.display = "block";
downloadZipBtn.disabled = true;
// Request zip creation and download from backend
const res = await fetch("/download-output-zip", { method: "POST" });
if (res.ok) {
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "site_output.zip";
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} else {
showToast("❌ Error creating ZIP", "error");
}
if (zipLoader) zipLoader.style.display = "none";
downloadZipBtn.disabled = false;
});
}
// Modal close logic
if (buildModal && buildModalClose) {
buildModalClose.onclick = () => {
buildModal.style.display = "none";
};
window.onclick = function(event) {
if (event.target === buildModal) {
buildModal.style.display = "none";
}
};
}
});

411
src/webui/js/main.js Normal file
View File

@ -0,0 +1,411 @@
// --- 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 || []);
});
// Show/hide Remove All button
const removeAllBtn = document.getElementById('remove-all-gallery');
if (removeAllBtn) {
removeAllBtn.style.display = galleryImages.length > 0 ? 'inline-block' : 'none';
}
}
// --- 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}"]`);
tagsDisplay.innerHTML = '';
inputContainer.innerHTML = '';
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);
});
const input = document.createElement('input');
input.type = 'text';
input.placeholder = 'Add tag...';
inputContainer.appendChild(input);
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>
</div>
`;
container.appendChild(div);
});
// Show/hide Remove All button
const removeAllBtn = document.getElementById('remove-all-hero');
if (removeAllBtn) {
removeAllBtn.style.display = heroImages.length > 0 ? 'inline-block' : 'none';
}
}
// --- 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'|'gallery-all'|'hero-all', index: number|null }
// --- Show delete confirmation modal ---
function showDeleteModal(type, index = null) {
pendingDelete = { type, index };
const modalText = document.getElementById('delete-modal-text');
if (type === 'gallery-all') {
modalText.textContent = "Are you sure you want to delete ALL gallery images?";
} else if (type === 'hero-all') {
modalText.textContent = "Are you sure you want to delete ALL hero images?";
} else {
modalText.textContent = "Are you sure you want to delete this image?";
}
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);
} else if (pendingDelete.type === 'gallery-all') {
await actuallyDeleteAllGalleryImages();
} else if (pendingDelete.type === 'hero-all') {
await actuallyDeleteAllHeroImages();
}
hideDeleteModal();
}
// --- 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");
}
}
// --- Bulk delete functions ---
async function actuallyDeleteAllGalleryImages() {
try {
const res = await fetch('/api/gallery/delete_all', { method: 'POST' });
const data = await res.json();
if (res.ok) {
galleryImages = [];
renderGallery();
await saveGallery();
showToast("✅ All gallery images removed!", "success");
} else showToast("Error: " + data.error, "error");
} catch(err) {
console.error(err);
showToast("Server error!", "error");
}
}
async function actuallyDeleteAllHeroImages() {
try {
const res = await fetch('/api/hero/delete_all', { method: 'POST' });
const data = await res.json();
if (res.ok) {
heroImages = [];
renderHero();
await saveHero();
showToast("✅ All hero images removed!", "success");
} else showToast("Error: " + data.error, "error");
} catch(err) {
console.error(err);
showToast("Server error!", "error");
}
}
// --- Modal event listeners and bulk delete buttons ---
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('delete-modal-close').onclick = hideDeleteModal;
document.getElementById('delete-modal-cancel').onclick = hideDeleteModal;
document.getElementById('delete-modal-confirm').onclick = confirmDelete;
// Bulk delete buttons
const removeAllGalleryBtn = document.getElementById('remove-all-gallery');
const removeAllHeroBtn = document.getElementById('remove-all-hero');
if (removeAllGalleryBtn) removeAllGalleryBtn.onclick = () => showDeleteModal('gallery-all');
if (removeAllHeroBtn) removeAllHeroBtn.onclick = () => showDeleteModal('hero-all');
});
// --- Initialize ---
loadData();

367
src/webui/js/site-info.js Normal file
View File

@ -0,0 +1,367 @@
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);
}
document.addEventListener("DOMContentLoaded", () => {
// Form and menu logic
const form = document.getElementById("site-info-form");
const menuList = document.getElementById("menu-items-list");
const addMenuBtn = document.getElementById("add-menu-item");
let menuItems = [];
// Render menu items
function renderMenuItems() {
menuList.innerHTML = "";
menuItems.forEach((item, idx) => {
const div = document.createElement("div");
div.style.display = "flex";
div.style.gap = "8px";
div.style.marginBottom = "6px";
div.innerHTML = `
<input type="text" placeholder="Label" value="${item.label || ""}" style="flex:1;" data-idx="${idx}" data-type="label" required>
<input type="text" placeholder="?tag=tag1,tag2" value="${item.href || ""}" style="flex:2;" data-idx="${idx}" data-type="href" required>
<button type="button" class="remove-menu-item" data-idx="${idx}">🗑</button>
`;
menuList.appendChild(div);
});
}
// Update menu items from inputs
function updateMenuItemsFromInputs() {
const inputs = menuList.querySelectorAll("input");
const items = [];
for (let i = 0; i < inputs.length; i += 2) {
const label = inputs[i].value.trim();
const href = inputs[i + 1].value.trim();
if (label || href) items.push({ label, href });
}
menuItems = items;
}
// Intellectual property paragraphs logic
const ipList = document.getElementById("ip-list");
const addIpBtn = document.getElementById("add-ip-paragraph");
let ipParagraphs = [];
// Render IP paragraphs
function renderIpParagraphs() {
ipList.innerHTML = "";
ipParagraphs.forEach((item, idx) => {
const div = document.createElement("div");
div.style.display = "flex";
div.style.gap = "8px";
div.style.marginBottom = "6px";
div.innerHTML = `
<textarea placeholder="Paragraph" required style="flex:1;" data-idx="${idx}">${item.paragraph || ""}</textarea>
<button type="button" class="remove-ip-paragraph" data-idx="${idx}">🗑</button>
`;
ipList.appendChild(div);
});
}
// Update IP paragraphs from textareas
function updateIpParagraphsFromInputs() {
const textareas = ipList.querySelectorAll("textarea");
ipParagraphs = Array.from(textareas).map(textarea => ({
paragraph: textarea.value.trim()
})).filter(item => item.paragraph !== "");
}
// Build options
const convertImagesCheckbox = document.getElementById("convert-images-checkbox");
const resizeImagesCheckbox = document.getElementById("resize-images-checkbox");
// Theme select
const themeSelect = document.getElementById("theme-select");
// Thumbnail upload and modal logic
const thumbnailInput = form?.elements["social.thumbnail"];
const thumbnailUpload = document.getElementById("thumbnail-upload");
const chooseThumbnailBtn = document.getElementById("choose-thumbnail-btn");
const thumbnailPreview = document.getElementById("thumbnail-preview");
const removeThumbnailBtn = document.getElementById("remove-thumbnail-btn");
// Modal elements for delete confirmation
const deleteModal = document.getElementById("delete-modal");
const deleteModalClose = document.getElementById("delete-modal-close");
const deleteModalConfirm = document.getElementById("delete-modal-confirm");
const deleteModalCancel = document.getElementById("delete-modal-cancel");
// Show/hide thumbnail preview, remove button, and choose button
function updateThumbnailPreview(src) {
if (thumbnailPreview) {
thumbnailPreview.src = src || "";
thumbnailPreview.style.display = src ? "block" : "none";
}
if (removeThumbnailBtn) {
removeThumbnailBtn.style.display = src ? "inline-block" : "none";
}
if (chooseThumbnailBtn) {
chooseThumbnailBtn.style.display = src ? "none" : "inline-block";
}
}
// Choose thumbnail button triggers file input
if (chooseThumbnailBtn && thumbnailUpload) {
chooseThumbnailBtn.addEventListener("click", () => thumbnailUpload.click());
}
// Handle thumbnail upload and refresh preview (with cache busting)
if (thumbnailUpload) {
thumbnailUpload.addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/thumbnail/upload", { method: "POST", body: formData });
const result = await res.json();
if (result.status === "ok") {
if (thumbnailInput) thumbnailInput.value = result.filename;
updateThumbnailPreview(`/photos/${result.filename}?t=${Date.now()}`);
showToast("✅ Thumbnail uploaded!", "success");
} else {
showToast("❌ Error uploading thumbnail", "error");
}
});
}
// Remove thumbnail button triggers modal
if (removeThumbnailBtn) {
removeThumbnailBtn.addEventListener("click", () => {
deleteModal.style.display = "flex";
});
}
// Modal logic for thumbnail deletion
if (deleteModal && deleteModalClose && deleteModalConfirm && deleteModalCancel) {
deleteModalClose.onclick = deleteModalCancel.onclick = () => {
deleteModal.style.display = "none";
};
window.onclick = function(event) {
if (event.target === deleteModal) {
deleteModal.style.display = "none";
}
};
deleteModalConfirm.onclick = async () => {
const res = await fetch("/api/thumbnail/remove", { method: "POST" });
const result = await res.json();
if (result.status === "ok") {
if (thumbnailInput) thumbnailInput.value = "";
updateThumbnailPreview("");
showToast("✅ Thumbnail removed!", "success");
} else {
showToast("❌ Error removing thumbnail", "error");
}
deleteModal.style.display = "none";
};
}
// Theme upload logic (custom theme folder)
const themeUpload = document.getElementById("theme-upload");
const chooseThemeBtn = document.getElementById("choose-theme-btn");
if (chooseThemeBtn && themeUpload) {
chooseThemeBtn.addEventListener("click", () => themeUpload.click());
themeUpload.addEventListener("change", async (e) => {
const files = Array.from(e.target.files);
if (files.length === 0) return;
const formData = new FormData();
files.forEach(file => {
formData.append("files", file, file.webkitRelativePath || file.name);
});
const res = await fetch("/api/theme/upload", { method: "POST", body: formData });
const result = await res.json();
if (result.status === "ok") {
showToast("✅ Theme uploaded!", "success");
// Refresh theme select after upload
fetch("/api/themes")
.then(res => res.json())
.then(themes => {
themeSelect.innerHTML = "";
themes.forEach(theme => {
const option = document.createElement("option");
option.value = theme;
option.textContent = theme;
themeSelect.appendChild(option);
});
});
} else {
showToast("❌ Error uploading theme", "error");
}
});
}
// Fetch theme list and populate select
if (themeSelect) {
fetch("/api/themes")
.then(res => res.json())
.then(themes => {
themeSelect.innerHTML = "";
themes.forEach(theme => {
const option = document.createElement("option");
option.value = theme;
option.textContent = theme;
themeSelect.appendChild(option);
});
// Set selected value after loading config
fetch("/api/site-info")
.then(res => res.json())
.then(data => {
themeSelect.value = data.build?.theme || "";
});
});
}
// Load config from server and populate form
if (form) {
fetch("/api/site-info")
.then(res => res.json())
.then(data => {
ipParagraphs = Array.isArray(data.legals?.intellectual_property)
? data.legals.intellectual_property
: [];
renderIpParagraphs();
menuItems = Array.isArray(data.menu?.items) ? data.menu.items : [];
renderMenuItems();
form.elements["info.title"].value = data.info?.title || "";
form.elements["info.subtitle"].value = data.info?.subtitle || "";
form.elements["info.description"].value = data.info?.description || "";
form.elements["info.canonical"].value = data.info?.canonical || "";
form.elements["info.keywords"].value = Array.isArray(data.info?.keywords) ? data.info.keywords.join(", ") : (data.info?.keywords || "");
form.elements["info.author"].value = data.info?.author || "";
form.elements["social.instagram_url"].value = data.social?.instagram_url || "";
if (thumbnailInput) thumbnailInput.value = data.social?.thumbnail || "";
updateThumbnailPreview(data.social?.thumbnail ? `/photos/${data.social.thumbnail}?t=${Date.now()}` : "");
form.elements["footer.copyright"].value = data.footer?.copyright || "";
form.elements["footer.legal_label"].value = data.footer?.legal_label || "";
if (themeSelect) {
themeSelect.value = data.build?.theme || "";
}
form.elements["legals.hoster_name"].value = data.legals?.hoster_name || "";
form.elements["legals.hoster_address"].value = data.legals?.hoster_address || "";
form.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || "";
// Build checkboxes
if (convertImagesCheckbox) {
convertImagesCheckbox.checked = !!data.build?.convert_images;
}
if (resizeImagesCheckbox) {
resizeImagesCheckbox.checked = !!data.build?.resize_images;
}
});
}
// Add menu item
if (addMenuBtn) {
addMenuBtn.addEventListener("click", () => {
menuItems.push({ label: "", href: "" });
renderMenuItems();
});
}
// Remove menu item
menuList.addEventListener("click", (e) => {
if (e.target.classList.contains("remove-menu-item")) {
const idx = parseInt(e.target.getAttribute("data-idx"));
menuItems.splice(idx, 1);
renderMenuItems();
}
});
// Update menuItems on input change
menuList.addEventListener("input", () => {
updateMenuItemsFromInputs();
});
// Add paragraph
if (addIpBtn) {
addIpBtn.addEventListener("click", () => {
ipParagraphs.push({ paragraph: "" });
renderIpParagraphs();
});
}
// Remove paragraph
ipList.addEventListener("click", (e) => {
if (e.target.classList.contains("remove-ip-paragraph")) {
const idx = parseInt(e.target.getAttribute("data-idx"));
ipParagraphs.splice(idx, 1);
renderIpParagraphs();
}
});
// Update ipParagraphs on input change
ipList.addEventListener("input", () => {
updateIpParagraphsFromInputs();
});
// Save config to server
if (form) {
form.addEventListener("submit", async (e) => {
e.preventDefault();
updateMenuItemsFromInputs();
updateIpParagraphsFromInputs();
// Check if thumbnail is set before saving (uploaded or present in input)
if (!thumbnailInput || !thumbnailInput.value) {
showToast("❌ Thumbnail is required.", "error");
return;
}
const build = {
theme: themeSelect ? themeSelect.value : "",
convert_images: !!(convertImagesCheckbox && convertImagesCheckbox.checked),
resize_images: !!(resizeImagesCheckbox && resizeImagesCheckbox.checked)
};
const payload = {
info: {
title: form.elements["info.title"].value,
subtitle: form.elements["info.subtitle"].value,
description: form.elements["info.description"].value,
canonical: form.elements["info.canonical"].value,
keywords: form.elements["info.keywords"].value.split(",").map(i => i.trim()).filter(Boolean),
author: form.elements["info.author"].value
},
social: {
instagram_url: form.elements["social.instagram_url"].value,
thumbnail: thumbnailInput ? thumbnailInput.value : ""
},
menu: {
items: menuItems
},
footer: {
copyright: form.elements["footer.copyright"].value,
legal_label: form.elements["footer.legal_label"].value
},
build,
legals: {
hoster_name: form.elements["legals.hoster_name"].value,
hoster_address: form.elements["legals.hoster_address"].value,
hoster_contact: form.elements["legals.hoster_contact"].value,
intellectual_property: ipParagraphs
}
};
const res = await fetch("/api/site-info", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const result = await res.json();
if (result.status === "ok") {
showToast("✅ Site info saved!", "success");
} else {
showToast("❌ Error saving site info", "error");
}
});
}
});

View File

@ -0,0 +1,399 @@
async function fetchThemeInfo() {
const res = await fetch("/api/theme-info");
return await res.json();
}
async function fetchLocalFonts(theme) {
const res = await fetch(`/api/local-fonts?theme=${encodeURIComponent(theme)}`);
return await res.json();
}
async function removeFont(theme, font) {
const res = await fetch("/api/font/remove", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme, font })
});
return await res.json();
}
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");
setTimeout(() => container.removeChild(toast), 300);
}, duration);
}
function setupColorPicker(colorId, btnId, textId, initial) {
const colorInput = document.getElementById(colorId);
const colorBtn = document.getElementById(btnId);
const textInput = document.getElementById(textId);
colorInput.value = initial;
colorBtn.style.background = initial;
textInput.value = initial.toUpperCase();
// Color input is positioned over the button and is clickable
colorInput.addEventListener("input", () => {
colorBtn.style.background = colorInput.value;
textInput.value = colorInput.value.toUpperCase();
});
textInput.addEventListener("input", () => {
if (/^#[0-9A-F]{6}$/i.test(textInput.value)) {
colorInput.value = textInput.value;
colorBtn.style.background = textInput.value;
}
});
}
function setFontDropdown(selectId, value, options) {
const select = document.getElementById(selectId);
if (!select) return;
select.innerHTML = options.map(opt =>
`<option value="${opt}"${opt === value ? " selected" : ""}>${opt}</option>`
).join("");
}
function setFallbackDropdown(selectId, value) {
const select = document.getElementById(selectId);
if (!select) return;
select.value = (value === "serif" || value === "sans-serif") ? value : "sans-serif";
}
function setTextInput(inputId, value) {
const input = document.getElementById(inputId);
if (input) input.value = value;
}
function renderGoogleFonts(googleFonts) {
const container = document.getElementById("google-fonts-fields");
container.innerHTML = "";
googleFonts.forEach((font, idx) => {
container.innerHTML += `
<div class="input-field" data-idx="${idx}">
<label>Family</label>
<input type="text" name="google_fonts[${idx}][family]" value="${font.family || ""}">
<label>Weights (comma separated)</label>
<input type="text" name="google_fonts[${idx}][weights]" value="${(font.weights || []).join(',')}">
<button type="button" class="remove-google-font remove-btn" data-idx="${idx}"> 🗑️ Remove</button>
</div>
`;
});
}
function renderLocalFonts(fonts) {
const listDiv = document.getElementById("local-fonts-list");
if (!listDiv) return;
listDiv.innerHTML = "";
fonts.forEach(font => {
listDiv.innerHTML += `
<div class="font-item">
<span class="font-name">${font}</span>
<button type="button" class="remove-font-btn danger remove-btn" data-font="${font}">🗑️</button>
</div>
`;
});
}
document.addEventListener("DOMContentLoaded", async () => {
const themeInfo = await fetchThemeInfo();
const themeNameSpan = document.getElementById("current-theme");
if (themeNameSpan) themeNameSpan.textContent = themeInfo.theme_name;
const themeYaml = themeInfo.theme_yaml;
const googleFonts = themeYaml.google_fonts ? JSON.parse(JSON.stringify(themeYaml.google_fonts)) : [];
let localFonts = await fetchLocalFonts(themeInfo.theme_name);
// Colors
if (themeYaml.colors) {
setupColorPicker("color-primary", "color-primary-btn", "color-primary-text", themeYaml.colors.primary || "#0065a1");
setupColorPicker("color-primary-dark", "color-primary-dark-btn", "color-primary-dark-text", themeYaml.colors.primary_dark || "#005384");
setupColorPicker("color-secondary", "color-secondary-btn", "color-secondary-text", themeYaml.colors.secondary || "#00b0f0");
setupColorPicker("color-accent", "color-accent-btn", "color-accent-text", themeYaml.colors.accent || "#ffc700");
setupColorPicker("color-text-dark", "color-text-dark-btn", "color-text-dark-text", themeYaml.colors.text_dark || "#616161");
setupColorPicker("color-background", "color-background-btn", "color-background-text", themeYaml.colors.background || "#fff");
setupColorPicker("color-browser-color", "color-browser-color-btn", "color-browser-color-text", themeYaml.colors.browser_color || "#fff");
}
// Fonts
function refreshFontDropdowns() {
setFontDropdown("font-primary", document.getElementById("font-primary").value, [
...googleFonts.map(f => f.family),
...localFonts
]);
setFontDropdown("font-secondary", document.getElementById("font-secondary").value, [
...googleFonts.map(f => f.family),
...localFonts
]);
}
if (themeYaml.fonts) {
setFontDropdown("font-primary", themeYaml.fonts.primary?.name || "Lato", [
...googleFonts.map(f => f.family),
...localFonts
]);
setFallbackDropdown("font-primary-fallback", themeYaml.fonts.primary?.fallback || "sans-serif");
setFontDropdown("font-secondary", themeYaml.fonts.secondary?.name || "Montserrat", [
...googleFonts.map(f => f.family),
...localFonts
]);
setFallbackDropdown("font-secondary-fallback", themeYaml.fonts.secondary?.fallback || "serif");
}
// Font upload logic
const fontUploadInput = document.getElementById("font-upload");
const chooseFontBtn = document.getElementById("choose-font-btn");
const fontUploadStatus = document.getElementById("font-upload-status");
const localFontsList = document.getElementById("local-fonts-list");
// Modal logic for font deletion
const deleteFontModal = document.getElementById("delete-font-modal");
const deleteFontModalClose = document.getElementById("delete-font-modal-close");
const deleteFontModalConfirm = document.getElementById("delete-font-modal-confirm");
const deleteFontModalCancel = document.getElementById("delete-font-modal-cancel");
let fontToDelete = null;
function refreshLocalFonts() {
renderLocalFonts(localFonts);
refreshFontDropdowns();
}
if (chooseFontBtn && fontUploadInput) {
chooseFontBtn.addEventListener("click", () => fontUploadInput.click());
}
if (fontUploadInput) {
fontUploadInput.addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
const ext = file.name.split('.').pop().toLowerCase();
if (!["woff", "woff2"].includes(ext)) {
showToast("Only .woff and .woff2 fonts are allowed.", "error");
return;
}
const formData = new FormData();
formData.append("file", file);
formData.append("theme", themeInfo.theme_name);
const res = await fetch("/api/font/upload", { method: "POST", body: formData });
const result = await res.json();
if (result.status === "ok") {
showToast("✅ Font uploaded!", "success");
localFonts = await fetchLocalFonts(themeInfo.theme_name);
refreshLocalFonts();
} else {
showToast("Error uploading font.", "error");
}
});
}
// Remove font button triggers modal
if (localFontsList) {
localFontsList.addEventListener("click", (e) => {
if (e.target.classList.contains("remove-font-btn")) {
fontToDelete = e.target.dataset.font;
document.getElementById("delete-font-modal-text").textContent =
`Are you sure you want to remove the font "${fontToDelete}"?`;
deleteFontModal.style.display = "flex";
}
});
}
// Modal logic for font deletion
if (deleteFontModal && deleteFontModalClose && deleteFontModalConfirm && deleteFontModalCancel) {
deleteFontModalClose.onclick = deleteFontModalCancel.onclick = () => {
deleteFontModal.style.display = "none";
fontToDelete = null;
};
window.onclick = function(event) {
if (event.target === deleteFontModal) {
deleteFontModal.style.display = "none";
fontToDelete = null;
}
};
deleteFontModalConfirm.onclick = async () => {
if (!fontToDelete) return;
const result = await removeFont(themeInfo.theme_name, fontToDelete);
if (result.status === "ok") {
showToast("Font removed!", "✅ success");
localFonts = await fetchLocalFonts(themeInfo.theme_name);
refreshLocalFonts();
} else {
showToast("Error removing font.", "error");
}
deleteFontModal.style.display = "none";
fontToDelete = null;
};
}
// Initial render of local fonts
refreshLocalFonts();
// Favicon logic
const faviconInput = document.getElementById("favicon-path");
const faviconUpload = document.getElementById("favicon-upload");
const chooseFaviconBtn = document.getElementById("choose-favicon-btn");
const faviconPreview = document.getElementById("favicon-preview");
const removeFaviconBtn = document.getElementById("remove-favicon-btn");
const deleteFaviconModal = document.getElementById("delete-favicon-modal");
const deleteFaviconModalClose = document.getElementById("delete-favicon-modal-close");
const deleteFaviconModalConfirm = document.getElementById("delete-favicon-modal-confirm");
const deleteFaviconModalCancel = document.getElementById("delete-favicon-modal-cancel");
function updateFaviconPreview(src) {
if (faviconPreview) {
faviconPreview.src = src || "";
faviconPreview.style.display = src ? "block" : "none";
}
if (removeFaviconBtn) {
removeFaviconBtn.style.display = src ? "block" : "none";
}
if (chooseFaviconBtn) {
chooseFaviconBtn.style.display = src ? "none" : "block";
}
}
if (chooseFaviconBtn && faviconUpload) {
chooseFaviconBtn.addEventListener("click", () => faviconUpload.click());
}
if (faviconUpload) {
faviconUpload.addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
const ext = file.name.split('.').pop().toLowerCase();
if (!["png", "jpg", "jpeg", "ico"].includes(ext)) {
showToast("Invalid file type for favicon.", "error");
return;
}
const formData = new FormData();
formData.append("file", file);
formData.append("theme", themeInfo.theme_name);
const res = await fetch("/api/favicon/upload", { method: "POST", body: formData });
const result = await res.json();
if (result.status === "ok") {
faviconInput.value = result.filename;
updateFaviconPreview(`/themes/${themeInfo.theme_name}/${result.filename}?t=${Date.now()}`);
showToast("✅ Favicon uploaded!", "success");
} else {
showToast("Error uploading favicon", "error");
}
});
}
if (removeFaviconBtn) {
removeFaviconBtn.addEventListener("click", () => {
deleteFaviconModal.style.display = "flex";
});
}
if (deleteFaviconModal && deleteFaviconModalClose && deleteFaviconModalConfirm && deleteFaviconModalCancel) {
deleteFaviconModalClose.onclick = deleteFaviconModalCancel.onclick = () => {
deleteFaviconModal.style.display = "none";
};
window.onclick = function(event) {
if (event.target === deleteFaviconModal) {
deleteFaviconModal.style.display = "none";
}
};
deleteFaviconModalConfirm.onclick = async () => {
const res = await fetch("/api/favicon/remove", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme: themeInfo.theme_name })
});
const result = await res.json();
if (result.status === "ok") {
faviconInput.value = "";
updateFaviconPreview("");
showToast("✅ Favicon removed!", "success");
} else {
showToast("Error removing favicon", "error");
}
deleteFaviconModal.style.display = "none";
};
}
if (themeYaml.favicon && themeYaml.favicon.path) {
faviconInput.value = themeYaml.favicon.path;
updateFaviconPreview(`/themes/${themeInfo.theme_name}/${themeYaml.favicon.path}?t=${Date.now()}`);
} else {
updateFaviconPreview("");
}
// Google Fonts
renderGoogleFonts(googleFonts);
// Add Google Font
const addGoogleFontBtn = document.getElementById("add-google-font");
if (addGoogleFontBtn) {
addGoogleFontBtn.addEventListener("click", () => {
googleFonts.push({ family: "", weights: [] });
renderGoogleFonts(googleFonts);
});
}
// Remove Google Font
const googleFontsFields = document.getElementById("google-fonts-fields");
if (googleFontsFields) {
googleFontsFields.addEventListener("click", (e) => {
if (e.target.classList.contains("remove-google-font")) {
const idx = parseInt(e.target.dataset.idx, 10);
googleFonts.splice(idx, 1);
renderGoogleFonts(googleFonts);
}
});
}
// Form submit
document.getElementById("theme-editor-form").addEventListener("submit", async (e) => {
e.preventDefault();
const data = {};
data.colors = {
primary: document.getElementById("color-primary-text").value,
primary_dark: document.getElementById("color-primary-dark-text").value,
secondary: document.getElementById("color-secondary-text").value,
accent: document.getElementById("color-accent-text").value,
text_dark: document.getElementById("color-text-dark-text").value,
background: document.getElementById("color-background-text").value,
browser_color: document.getElementById("color-browser-color-text").value
};
data.fonts = {
primary: {
name: document.getElementById("font-primary").value,
fallback: document.getElementById("font-primary-fallback").value
},
secondary: {
name: document.getElementById("font-secondary").value,
fallback: document.getElementById("font-secondary-fallback").value
}
};
data.favicon = {
path: faviconInput.value
};
data.google_fonts = [];
document.querySelectorAll("#google-fonts-fields .input-field").forEach(field => {
const family = field.querySelector('input[name^="google_fonts"][name$="[family]"]').value;
const weights = field.querySelector('input[name^="google_fonts"][name$="[weights]"]').value
.split(",").map(w => w.trim()).filter(w => w);
if (family) data.google_fonts.push({ family, weights });
});
const res = await fetch("/api/theme-info", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme_name: themeInfo.theme_name, theme_yaml: data })
});
if (res.ok) {
showToast("✅ Theme saved!", "success");
} else {
showToast("Error saving theme.", "error");
}
});
});

41
src/webui/js/upload.js Normal file
View 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 = ''; }
});

View File

@ -0,0 +1,187 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="UTF-8">
<title>Lumeex</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style/style.css') }}">
</head>
<body>
<!-- Top bar -->
<div class="nav-bar">
<div class="content-inner nav">
<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"><a href="/gallery-editor">Gallery</a>
<li class="nav-item"><a href="/site-info">Site info</a></li>
<li class="nav-item"><a href="/theme-editor">Theme info</a></li>
<li class="nav-item">
<button id="build-btn" class="button">Build !</button>
</li>
</ul>
</div>
</div>
</div>
<!-- Toast container for notifications -->
<div id="site-info" class="content-inner">
<div id="toast-container"></div>
<h1>Edit Site Info</h1>
<form id="site-info-form">
<!-- Info Section -->
<fieldset>
<h2>Info</h2>
<div class="fields">
<div class="input-field">
<label>Title</label>
<input type="text" name="info.title" placeholder="Your site title" required>
</div>
<div class="input-field">
<label>Subtitle</label>
<input type="text" name="info.subtitle" placeholder="Your site subtitle" required>
</div>
<div class="input-field">
<label>Description</label>
<input type="text" name="info.description" placeholder="Your site description" required>
</div>
<div class="input-field">
<label>Canonical URL</label>
<input type="text" name="info.canonical" placeholder="https://yoursite.com" required>
</div>
<div class="input-field">
<label>Keywords (comma separated)</label>
<input type="text" name="info.keywords" placeholder="photo, gallery, photography" required>
</div>
<div class="input-field">
<label>Author</label>
<input type="text" name="info.author" placeholder="Your Name" required>
</div>
</div>
</fieldset>
<!-- Social Section -->
<fieldset>
<h2>Social</h2>
<div class="fields">
<div class="input-field">
<label>Instagram URL</label>
<input type="text" name="social.instagram_url" placeholder="https://instagram.com/yourprofile" required>
<label class="thumbnail-form-label">Thumbnail</label>
<input type="file" id="thumbnail-upload" accept="image/png,image/jpeg,image/webp" style="display:none;">
<button type="button" id="choose-thumbnail-btn" class="up-btn">📸 Upload a photo</button>
<div class="thumbnail-form">
<input type="hidden" name="social.thumbnail" id="social-thumbnail">
<img id="thumbnail-preview" src="" alt="Thumbnail preview" style="max-width:100px;display:none;">
<button type="button" id="remove-thumbnail-btn" class="remove-btn up-btn danger" style="display:none;">Remove</button>
</div>
</div>
</div>
</fieldset>
<!-- Menu Section -->
<fieldset>
<h2>Menu</h2>
<div class="fields">
<div class="input-field" style="flex: 1 1 100%;">
<div id="menu-items-list"></div>
<button type="button" id="add-menu-item">+ Add menu item</button>
</div>
</div>
</fieldset>
<!-- Footer Section -->
<fieldset>
<h2>Footer</h2>
<div class="fields">
<div class="input-field">
<label>Copyright</label>
<input type="text" name="footer.copyright" required>
</div>
<div class="input-field">
<label>Legal Label</label>
<input type="text" name="footer.legal_label" re>
</div>
</div>
</fieldset>
<!-- Legals Section -->
<fieldset>
<h2>Legals</h2>
<div class="fields">
<div class="input-field">
<label>Hoster Name</label>
<input type="text" name="legals.hoster_name" placeholder="Name" required>
</div>
<div class="input-field">
<label>Hoster Address</label>
<input type="text" name="legals.hoster_address" placeholder="Street, Postal Code, City, Country" required>
</div>
<div class="input-field">
<label>Hoster Contact</label>
<input type="text" name="legals.hoster_contact" placeholder="Email or/and Phone" required>
</div>
<div class="input-field" style="flex: 1 1 100%;">
<label>Intellectual Property</label>
<div id="ip-list"></div>
<button type="button" id="add-ip-paragraph">+ Add paragraph</button>
</div>
</div>
</fieldset>
<!-- Build Section -->
<fieldset>
<h2>Build</h2>
<div class="fields">
<div class="input-field">
<label>Theme</label>
<select name="build.theme" id="theme-select" required></select>
<input type="file" id="theme-upload" webkitdirectory directory multiple style="display:none;">
<button type="button" id="choose-theme-btn" class="up-btn">📂 Upload custom theme folder</button>
<label class="thumbnail-form-label">Images processing</label>
<label>
<input class="thumbnail-form-label" type="checkbox" name="build.convert_images" id="convert-images-checkbox">
Convert images
</label>
<label>
<input type="checkbox" name="build.resize_images" id="resize-images-checkbox">
Resize images
</label>
</div>
</div>
</fieldset>
<button type="submit">Save</button>
</form>
</div>
<!-- Delete confirmation modal (now outside .content-inner) -->
<div id="delete-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="delete-modal-close" class="modal-close">&times;</span>
<h3>Confirm Deletion</h3>
<p id="delete-modal-text">Are you sure you want to remove this thumbnail?</p>
<div class="modal-actions">
<button id="delete-modal-confirm" class="modal-btn danger">Remove</button>
<button id="delete-modal-cancel" class="modal-btn">Cancel</button>
</div>
</div>
</div>
<!-- Build success modal -->
<div id="build-success-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="build-success-modal-close" class="modal-close">&times;</span>
<h3>✅ Build completed!</h3>
<p>Your files are available in the output folder.</p>
<button id="download-zip-btn" class="modal-btn">Download ZIP</button>
<div id="zip-loader" style="display:none;">Creating ZIP...</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/site-info.js')}}"></script>
<script src="{{ url_for('static', filename='js/build.js') }}"></script>
</body>
</html>

744
src/webui/style/style.css Normal file
View File

@ -0,0 +1,744 @@
/* --- Base Styles --- */
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: #111010;
/* background:radial-gradient(ellipse at bottom center, #002a30, #000000bd), radial-gradient(ellipse at top center, #0558a8, #000000fa); */
color: #FBFBFB;
min-height: 100vh;
margin:0px;
padding-top: 70px;
}
.content-inner {
max-width: 90%;
margin: 0 auto;
}
h1, h2 {
color: #FBFBFB;
}
h2 {
color: #55c3ec;
}
/* --- Toolbar --- */
.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 --- */
.upload-section {
margin-bottom: 30px;
background-color: rgb(67 67 67 / 26%);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border: 1px solid #2f2e2e80;
border-radius: 8px;
padding: 0px 20px 20px 20px;
}
.upload-section label {
cursor: pointer;
}
/* --- Gallery & Hero Grid --- */
#gallery, #hero {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 15px;
margin-top: 30px;
}
/* --- Photo Card --- */
.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);
border: 1px solid #2f2e2e80;
}
.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;
bottom: 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: 30px;
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;
backdrop-filter: blur(20px);
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast.success { background-color: #28a7468c; }
.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;
display: none;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.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 Utilities --- */
.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 & Navigation --- */
.nav {
height: 100%;
max-width: 1140px;
padding: 0 40px;
}
.nav-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 70px;
background-color: #0c0d0c29;
z-index: 1000;
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-btn label {
display: flex;
flex-direction: column;
justify-content: center;
cursor: pointer;
width: 32px;
height: 32px;
}
.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-links > .nav-list > .nav-item > a:active {
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-bar .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;
border: none;
cursor: pointer;
}
.nav-bar .button:hover {
background: linear-gradient(135deg, #72d9ff, #26657e);
transition: all 0.2s ease;
cursor: pointer;
}
.nav-links > ul {
display: inline-block;
}
.nav-btn span {
display: block;
height: 4px;
width: 28px;
background: #fff;
margin: 4px 0;
border-radius: 2px;
transition: all 0.3s;
}
/* --- Custom Upload Buttons --- */
.up-btn {
display: inline-block;
background: #00000000;
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);*/
font-size: 14px;
border: 1px solid #585858;
}
.up-btn:hover {
background: #2d2d2d;
}
/* --- 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: #131313;
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;
}
.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;
}
/* --- Upload Actions Row --- */
.upload-actions-row {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 10px;
}
/* Remove All Buttons */
#remove-all-hero, #remove-all-gallery {
background: #2d2d2d;
color: white;
display: none;
}
#remove-all-gallery:hover,
#remove-all-hero:hover {
background: rgb(121, 26, 19);
}
/* Responsive: stack buttons vertically on small screens */
@media (max-width: 500px) {
.upload-actions-row {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
}
/* --- Site Info --- */
/* --- Site Info Form --- */
#site-info.content-inner, #theme-editor.content-inner {
margin-right: auto;
margin-left: auto;
max-width: 1140px;
padding: 0 40px 40px 40px;
}
fieldset {
background-color: rgb(67 67 67 / 26%);
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border: 1px solid #2f2e2e80;
margin-bottom: 28px;
padding: 0 28px 32px 28px;
margin: 32px auto;
}
legend {
font-size: 1.2em;
font-weight: 700;
color: #26c4ff;
margin-bottom: 12px;
letter-spacing: 1px;
}
.fields {
display: flex;
flex-wrap: wrap;
gap: 18px;
}
.input-field {
flex: 1 1 calc(33.333% - 18px);
min-width: 220px;
max-width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
margin-bottom: 10px;
}
label {
font-size: 13px;
font-weight: 600;
color: #e3e3e3;
margin-bottom: 6px;
letter-spacing: 0.5px;
}
#site-info-form input, #theme-editor-form input,
#site-info-form textarea, #theme-editor-form textarea,
#site-info-form select, #theme-editor-form select {
/* background: rgba(4, 44, 60, 0.55);*/
background: #1f2223;
color: #fff;
border: 1px solid #585858;
border-radius: 8px;
font-size: 15px;
font-weight: 400;
padding: 10px 14px;
margin-bottom: 4px;
outline: none;
transition: border-color 0.2s, background 0.2s;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
}
#site-info-form input::placeholder,
#theme-editor-form input::placeholder,
#site-info-form textarea::placeholder,
#theme-editor-form textarea::placeholder {
color: #585858;
font-style: italic;
}
#site-info-form input:focus,
#theme-editor-form input:focus,
#site-info-form textarea:focus,
#theme-editor-form textarea:focus,
#site-info-form select:focus,
#theme-editor-form select:focus {
border-color: #585858;
background: #161616;
}
#site-info-form textarea,
#theme-editor-form textarea {
min-height: 60px;
resize: vertical;
}
#input[type="file"] {
background: none;
color: #fff;
border: none;
padding: 0;
margin-top: 2px;
}
img#thumbnail-preview {
margin-top: 8px;
border-radius: 8px;
border: 1px solid #585858;
}
#site-info-form button[type="submit"], #theme-editor-form button[type="submit"] {
background: linear-gradient(135deg, #26c4ff, #016074);
color: #fff;
font-weight: 700;
border: none;
border-radius: 30px;
padding: 12px 32px;
font-size: 1.1em;
margin-top: 18px;
cursor: pointer;
box-shadow: 0 4px 16px rgba(38,196,255,0.15);
transition: background 0.2s;
}
#site-info-form button[type="submit"]:hover, #theme-editor-form button[type="submit"]:hover {
background: linear-gradient(135deg, #72d9ff, #26657e);
}
#site-info-form button[type="button"], #theme-editor-form button[type="button"] {
background: #00000000;
color: #fff;
border: none;
border-radius: 18px;
padding: 7px 18px;
font-size: 0.98em;
margin-top: 8px;
cursor: pointer;
transition: background 0.2s;
border: 1px solid #585858;
}
#site-info-form button[type="button"]:hover, #theme-editor-form button[type="button"]:hover {
background: #2d2d2d;
color: #fff;
}
@media (max-width: 900px) {
#site-info-form, #theme-editor-form {
padding: 18px 8px;
}
.fields,
fieldset {
flex-direction: column;
gap: 0;
}
.input-field {
min-width: 100%;
margin-bottom: 12px;
}
}
#site-info-form button.remove-menu-item, #site-info-form button.remove-ip-paragraph, #theme-editor-form button.remove-menu-item, #theme-editor-form button.remove-ip-paragraph {
margin-top: 0px;
margin-bottom: 4px;
border-radius: 30px;
background: #2d2d2d;
}
#site-info-form button.remove-menu-item:hover, #site-info-form button.remove-ip-paragraph:hover, #theme-editor-form button.remove-menu-item:hover, #theme-editor-form button.remove-ip-paragraph:hover {
background: rgb(121, 26, 19);
}
#site-info-form button.remove-btn, #theme-editor-form button.remove-btn {
border-radius: 30px;
background: #2d2d2d;
}
#site-info-form button.remove-btn:hover, #theme-editor-form button.remove-btn:hover {
background: rgb(121, 26, 19);
}
#site-info-form .thumbnail-form-label, #theme-editor-form .thumbnail-form-label {
margin-top: 10px;
}
#favicon-preview {
margin-top: 8px;
}
.color-fields {
gap: 8px;
position: relative;
}
#theme-editor button.color-btn {
height: 100%;
border-radius: 8px;
cursor: pointer;
display: inline-block;
margin-top: 0px;
margin-bottom: 4px;
}
input[type="color"].color-input {
margin:0;
padding: 0;
cursor: pointer;
height:100%;
position:absolute;
left:0;
top:0;
opacity:0;
}
fieldset p {
font-size: 14px;
font-style: italic;
color: #b3b3b3;
margin-top: 0px;
}
.font-name {
background: #1f2223;
color: #b3b3b3;
border: 1px solid #585858;
border-radius: 8px;
font-size: 15px;
font-weight: 400;
padding: 10px 14px;
margin-bottom: 4px;
outline: none;
transition: border-color 0.2s, background 0.2s;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
}
#theme-editor-form button[type="button"]#choose-font-btn {
margin-top: 16px;
}

View File

@ -0,0 +1,210 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="UTF-8">
<title>Theme Editor</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style/style.css') }}">
</head>
<body>
<!-- Top bar -->
<div class="nav-bar">
<div class="content-inner nav">
<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"><a href="/gallery-editor">Gallery</a>
<li class="nav-item"><a href="/site-info">Site info</a></li>
<li class="nav-item"><a href="/theme-editor">Theme info</a></li>
<li class="nav-item">
<button id="build-btn" class="button">Build !</button>
</li>
</ul>
</div>
</div>
</div>
<!-- Toast container for notifications -->
<div id="theme-editor" class="content-inner">
<div id="toast-container"></div>
<h1>Edit Theme</h1>
<!-- Show current theme -->
<div class="theme-info">
<strong>Current theme:</strong> <span id="current-theme"></span>
</div>
<form id="theme-editor-form">
<!-- Colors Section -->
<fieldset>
<h2>Colors</h2>
<p>Set the color values for your theme</p>
<div class="fields">
<!-- Example for one color field, repeat for all -->
<div class="input-field">
<label>Primary</label>
<div class="fields color-fields">
<button type="button" id="color-primary-btn" class="color-btn"></button>
<input type="color" name="colors.primary" id="color-primary" class="color-input">
<input type="text" name="colors.primary_text" id="color-primary-text" style="width:100px;">
</div>
</div>
<div class="input-field">
<label>Primary Dark</label>
<div class="fields color-fields">
<button type="button" id="color-primary-dark-btn" class="color-btn"></button>
<input type="color" name="colors.primary_dark" id="color-primary-dark" class="color-input">
<input type="text" name="colors.primary_dark_text" id="color-primary-dark-text" style="width:100px;">
</div>
</div>
<div class="input-field">
<label>Secondary</label>
<div class="fields color-fields">
<button type="button" id="color-secondary-btn" class="color-btn"></button>
<input type="color" name="colors.secondary" id="color-secondary" class="color-input">
<input type="text" name="colors.secondary_text" id="color-secondary-text" style="width:100px;">
</div>
</div>
<div class="input-field">
<label>Accent</label>
<div class="fields color-fields">
<button type="button" id="color-accent-btn" class="color-btn"></button>
<input type="color" name="colors.accent" id="color-accent" class="color-input">
<input type="text" name="colors.accent_text" id="color-accent-text" style="width:100px;">
</div>
</div>
<div class="input-field">
<label>Text Dark</label>
<div class="fields color-fields">
<button type="button" id="color-text-dark-btn" class="color-btn"></button>
<input type="color" name="colors.text_dark" id="color-text-dark" class="color-input">
<input type="text" name="colors.text_dark_text" id="color-text-dark-text" style="width:100px;">
</div>
</div>
<div class="input-field">
<label>Background</label>
<div class="fields color-fields">
<button type="button" id="color-background-btn" class="color-btn"></button>
<input type="color" name="colors.background" id="color-background" class="color-input">
<input type="text" name="colors.background_text" id="color-background-text" style="width:100px;">
</div>
</div>
<div class="input-field">
<label>Browser Color</label>
<div class="fields color-fields">
<button type="button" id="color-browser-color-btn" class="color-btn"></button>
<input type="color" name="colors.browser_color" id="color-browser-color" class="color-input">
<input type="text" name="colors.browser_color_text" id="color-browser-color-text" style="width:100px;">
</div>
</div>
</div>
</fieldset>
<!-- Google Fonts Section -->
<fieldset>
<h2>Google Fonts</h2>
<p>Add Google Fonts to your theme</p>
<div class="fields" id="google-fonts-fields">
<!-- JS will render font family and weights inputs here -->
</div>
<button type="button" id="add-google-font">Add Google Font</button>
</fieldset>
<!-- Custom Font Upload Section -->
<fieldset>
<h2>Upload Custom Font</h2>
<p>Supported formats: .woff, .woff2</p>
<input type="file" id="font-upload" accept=".woff,.woff2" style="display:none;">
<div id="local-fonts-list" class="font-list"></div>
<button type="button" id="choose-font-btn" class="up-btn">🖋️ Upload font</button>
</fieldset>
<!-- Fonts Section -->
<fieldset>
<h2>Fonts</h2>
<p>Select where to apply your fonts</p>
<div class="fields">
<div class="input-field">
<label>Primary Font</label>
<select name="fonts.primary.name" id="font-primary"></select>
<label>Fallback</label>
<select name="fonts.primary.fallback" id="font-primary-fallback">
<option value="sans-serif">sans-serif</option>
<option value="serif">serif</option>
</select>
</div>
<div class="input-field">
<label>Secondary Font</label>
<select name="fonts.secondary.name" id="font-secondary"></select>
<label>Fallback</label>
<select name="fonts.secondary.fallback" id="font-secondary-fallback">
<option value="sans-serif">sans-serif</option>
<option value="serif">serif</option>
</select>
</div>
</div>
</fieldset>
<!-- Favicon Section -->
<fieldset>
<h2>Favicon</h2>
<p>Supported formats: .png, .jpg, .jpeg</p>
<div class="fields">
<div class="input-field">
<label>Favicon Path</label>
<input type="text" name="favicon.path" id="favicon-path" readonly>
<input type="file" id="favicon-upload" accept=".png,.jpg,.jpeg,.ico" style="display:none;">
<button type="button" id="choose-favicon-btn" class="up-btn">🖼️ Upload favicon</button>
<div class="favicon-form">
<img id="favicon-preview" src="" alt="Favicon preview" style="max-width:48px;display:none;">
<button type="button" id="remove-favicon-btn" class="remove-btn up-btn danger" style="display:none;">Remove</button>
</div>
</div>
</div>
</fieldset>
<button type="submit">Save Theme</button>
</form>
</div>
<!-- Delete confirmation modal for favicon -->
<div id="delete-favicon-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="delete-favicon-modal-close" class="modal-close">&times;</span>
<h3>Confirm Deletion</h3>
<p id="delete-favicon-modal-text">Are you sure you want to remove this favicon?</p>
<div class="modal-actions">
<button id="delete-favicon-modal-confirm" class="modal-btn danger">Remove</button>
<button id="delete-favicon-modal-cancel" class="modal-btn">Cancel</button>
</div>
</div>
</div>
<!-- Delete confirmation modal for font -->
<div id="delete-font-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="delete-font-modal-close" class="modal-close">&times;</span>
<h3>Confirm Deletion</h3>
<p id="delete-font-modal-text">Are you sure you want to remove this font?</p>
<div class="modal-actions">
<button id="delete-font-modal-confirm" class="modal-btn danger">Remove</button>
<button id="delete-font-modal-cancel" class="modal-btn">Cancel</button>
</div>
</div>
</div>
<!-- Build success modal -->
<div id="build-success-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="build-success-modal-close" class="modal-close">&times;</span>
<h3>✅ Build completed!</h3>
<p>Your files are available in the output folder.</p>
<button id="download-zip-btn" class="modal-btn">Download ZIP</button>
<div id="zip-loader" style="display:none;">Creating ZIP...</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/theme-editor.js') }}"></script>
<script src="{{ url_for('static', filename='js/build.js') }}"></script>
</body>
</html>