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