103 Commits
Author SHA1 Message Date
Djeex 444bed894d Update python Docker tag to v3.14.7
CI / build-and-scan (pull_request) Successful in 1m37s
2026-08-24 03:03:30 +00:00
Djeex 18c894f780 Merge pull request 'New release template' (#30) from dev-ci into main
CI / build-and-scan (push) Successful in 1m1s
Reviewed-on: #30
2026-08-23 23:35:50 +02:00
Djeex ca4240c84a Link each commit in the release changelog to its own commit page
CI / build-and-scan (pull_request) Successful in 1m13s
COMMIT_LIST only rendered the bare subject line per commit, with no
way to jump to that specific commit — only the triggering commit
(Source:) had a link. Each line now reads
"- [<short-sha>](<repo>/commit/<sha>) <subject>", matching the same
link style already used for Source.
2026-08-23 23:20:39 +02:00
Djeex 83d2dc7ee6 Match adguard-cidre's current template exactly (post 2026-08-23 update)
adguard-cidre switched its release changelog from a changed-files list
to a commits list (with --- dividers) after the previous commit here
was written from an already-stale read of that file. Also drops the
glob-based smoke test (never actually part of the real template, only
of nvidia-stock-bot's own drift) for an explicit per-file check
matching the canonical error-handling shape, and aligns the remaining
step names/commit message wording.
2026-08-23 23:09:35 +02:00
Djeex 8f4d763048 Align CI release workflow with the adguard-cidre/nvidia-stock-bot template
Pin Trivy to 0.74.0 instead of latest, make the deprecation check
non-blocking (warning only), log the release API's HTTP status code so
a failure is actually visible in the CI logs, drop the redundant
Docker socket mount on the Trivy steps, and switch the version-bump
push to the same URL-embedded-token approach and extraheader key the
other repos use. Also generalizes the smoke test to every top-level
.py file instead of a hardcoded list, and enriches the release body
(changelog header, source links, changed files) to match the other
repos' output.

Adds the weekly Monday 6am schedule trigger too, so a Trivy scan still
runs even on a week Renovate finds nothing to bump.
2026-08-23 22:47:27 +02:00
gitea-ci d9b821c066 chore: bump version to 2.1.3 [skip ci] 2026-08-23 08:48:28 +00:00
Djeex 7130069adf Merge pull request 'Add pytest suite and Gitea Actions CI/CD pipeline' (#29) from dev-ci into main
CI / build-and-scan (push) Successful in 1m14s
Reviewed-on: #29
2026-08-23 10:47:18 +02:00
Djeex 142a48ad5e Add pytest suite and Gitea Actions CI/CD pipeline
CI / build-and-scan (pull_request) Successful in 56s
83 tests (94% coverage of src/py) covering the builder pipeline
(gallery sync, HTML/CSS generation, image processing, full site
build) and the Flask webui (routes, uploads, theme/font management).

Dockerfile gains a `test` stage (pytest) between the wheel builder
and the prod image, and pins the alpine base to a full patch tag so
Renovate can classify updates. CI workflow builds, smoke-tests, runs
the suite, scans with Trivy, and publishes/releases on merge to main,
following the same pipeline already running on adguard-cidre.
2026-08-23 10:24:02 +02:00
Djeex fc5b1c4234 Merge pull request 'Hotfix - HDR artefacts and ICC issues' (#27) from dev into main
Reviewed-on: #27
2026-08-17 15:27:39 +02:00
Djeex 2968e875a7 Add Lumeex-web link to README.MD 2026-08-17 15:20:06 +02:00
Djeex a7ce49b989 v2.1.2 2026-08-17 15:13:36 +02:00
Djeex 26e3ca684b Fixed: Preserve embedded ICC color profiles when converting and resizing images
Image.open() reads the source's ICC profile into img.info, but that dict resets to empty after convert() or resize(), and save() was never passing icc_profile anyway. DCI-P3 and Rec.2020 photos were silently falling back to sRGB interpretation once processed.
2026-08-17 15:13:36 +02:00
Djeex a938234495 Merge pull request 'Hotfix - Chrome HDR artefacting fix' (#26) from dev into main
Reviewed-on: #26
2026-08-16 23:06:36 +02:00
Djeex 193b1704f8 Chrome HDR artefacting fix 2026-08-16 22:43:02 +02:00
Djeex 9d56d729c1 v2.1.2 2026-08-16 22:43:02 +02:00
Djeex 40a01c4701 Merge pull request 'Multi-stage smaller alpine docker image and python 3.13' (#24) from alpine into main
Reviewed-on: #24
2025-09-19 22:51:22 +02:00
Djeex 28e3b087b0 Alpine - smaller image 2025-09-19 22:40:30 +02:00
Djeex 116a9574ce Merge branch 'Beta-2.1' 2025-09-04 11:23:59 +00:00
Djeex 6c7216c0e1 Hotfix - default copy 2025-09-04 13:18:42 +02:00
Djeex 7c98a6ceaf Merge pull request 'Beta-2.1 - The clearer, the faster' (#22) from Beta-2.1 into main
Reviewed-on: #22
2025-09-04 12:50:26 +02:00
Djeex 69d303c7a1 Refactored site-info 2025-09-04 12:28:01 +02:00
Djeex 1d1d7d8304 Refactored Galley Editor 2025-09-04 12:22:52 +02:00
Djeex 205dcae2bc Refactored upload.js 2025-09-04 12:16:08 +02:00
Djeex b7fdcacf77 Fixed wrong focus and suggestion drop down delay 2025-09-04 12:11:58 +02:00
Djeex 9cdf6bbd32 Fixed hero images 2025-09-04 11:10:04 +02:00
Djeex f92060603d Fixed custom font issue 2025-09-04 10:41:58 +02:00
Djeex 54e9281793 Fixed mobile theme css issue 2025-09-04 10:10:39 +02:00
Djeex 5fb6ef18d1 Fixed log issues 2025-09-04 10:00:57 +02:00
Djeex 1119647884 Fixed default file copy 2025-09-04 09:56:56 +02:00
Djeex 2cc0a213c9 Fixed laggy menu appearance 2025-09-04 00:24:54 +02:00
Djeex 24113a4aa8 Fixed build section status 2025-09-03 10:43:54 +02:00
Djeex be8ce4f62c Fixed message theme selector 2025-09-03 10:18:05 +02:00
Djeex edca473e29 New suggestion list css 2025-09-03 10:12:18 +02:00
Djeex eac90820d2 Fixed hero img fade in 2025-09-03 09:43:52 +02:00
Djeex 191fa82711 Cleaning 2025-09-02 23:44:18 +02:00
Djeex b17652e471 Fade in img 2025-09-02 23:10:23 +02:00
Djeex 65fd62e342 Untagged filter 2025-09-02 22:53:21 +02:00
Djeex 6b03ee30fa Fixes save menu issues 2025-09-02 21:58:58 +02:00
Djeex fd45ebbd53 Fixed UI and conf template 2025-09-02 20:40:02 +02:00
Djeex 795f5fbd13 Redone form save logic 2025-09-02 14:44:54 +02:00
Djeex f8bebb9c95 Fixed forms and key issues 2025-09-01 22:13:16 +00:00
Djeex 3198755576 Top save button + fixed some CSS 2025-09-01 16:46:43 +00:00
Djeex f98f2d598f Fixed footer icon and git link 2025-09-01 16:16:27 +00:00
Djeex e8718e71ab Fixed port 2025-09-01 14:59:13 +00:00
Djeex 5b65e5efe3 Better logging 2025-09-01 14:57:52 +00:00
Djeex 021e0c7974 Preview link 2025-09-01 14:37:31 +00:00
Djeex debbf07280 New bottom upload btn + images count 2025-08-31 14:34:22 +02:00
Djeex df96782500 Merge pull request 'Fixing footer link and menu appear' (#17) from fixing into main
Reviewed-on: #17
2025-08-28 19:21:59 +02:00
Djeex febf4d2be5 Version 2025-08-28 19:13:01 +02:00
Djeex 617545e1bb Fixed img margin-bottom for mobile 2025-08-28 18:49:46 +02:00
Djeex 922ce99679 Fixed menu appear > 6 2025-08-28 18:46:45 +02:00
Djeex cd0428990a Footer link updated 2025-08-28 18:36:07 +02:00
Djeex d3af86be8c Hotfix - code hanging 2025-08-26 20:47:51 +02:00
Djeex c825798b13 Typo 2025-08-26 11:53:58 +02:00
Djeex b5375343a8 Merge pull request '2.0 - WebUI builder ("Cielight" merge)' (#9) from beta into main
Reviewed-on: #9
2025-08-26 10:52:12 +02:00
Djeex 757e676d2d Typo 2025-08-26 10:36:16 +02:00
Djeex 0079c166e8 Dark reader lock 2025-08-26 00:09:49 +02:00
Djeex ee6d4a1fa2 Fixed inner issues + README 2025-08-23 10:30:35 +02:00
Djeex c6c3162b83 Merge branch 'main' into front 2025-08-22 18:41:36 +02:00
Djeex 04c1214cd1 Fully responsive 2025-08-22 18:30:49 +02:00
Djeex b03779b487 Responsive 2025-08-22 16:35:10 +02:00
Djeex b5f8ceeb31 Styled loader and modal 2025-08-22 15:29:18 +02:00
Djeex 1591886505 Build and upload loader 2025-08-22 12:30:10 +02:00
Djeex a6b63c2d2b Added tag validate button 2025-08-22 11:54:29 +02:00
Djeex 8a04fe5aa6 Better title 2025-08-21 23:25:15 +02:00
Djeex 2cb171806c Fixed title 2025-08-21 23:19:06 +02:00
Djeex ded97700d9 Fixed color btn 2025-08-21 23:17:27 +02:00
Djeex 8533ce72e9 Flask webui templates 2025-08-21 23:12:10 +02:00
Djeex b2ba1d7c7f Fixed compose 2025-08-21 18:55:43 +02:00
Djeex 5d238fcf33 Version and docker OK 2025-08-21 18:55:05 +02:00
Djeex 7675b90909 Footer 2025-08-21 18:07:15 +02:00
Djeex a916c80c2a Stepper 2025-08-21 00:00:37 +02:00
Djeex cb91b92555 4 steps OK 2025-08-20 20:19:24 +02:00
Djeex f6e6a11fc1 Changed gallery url 2025-08-19 19:42:36 +02:00
Djeex e9a3a5a189 Build system with zip download 2025-08-19 19:29:06 +02:00
Djeex 4ac176f8a9 Theme editor UI refinement 2025-08-19 12:17:40 +02:00
Djeex 1ea94b469b Theme editor UI ajdustment 2025-08-19 11:52:38 +02:00
Djeex 2ec4be624b Theme editor 2025-08-18 23:36:36 +02:00
Djeex 369704a87c Better h2 title 2025-08-18 22:21:59 +02:00
Djeex 330e467dcb Better global UI 2025-08-18 20:05:08 +02:00
Djeex 305042b365 Lumeex scroll 2 top merge 2025-08-18 13:11:34 +02:00
Djeex 7a95ef0255 Fixed demo tag 2025-08-18 11:07:04 +00:00
Djeex 906699f023 Merge pull request 'v1.3.2 - Hotfix -> Scroll to tup button + tag selection move to top' (#8) from comments into main
Reviewed-on: #8
2025-08-18 13:05:28 +02:00
Djeex 643a729f94 Hotfix 2025-08-18 13:01:51 +02:00
Djeex 7a7876e5ef Better form 2025-08-18 12:29:45 +02:00
Djeex a02da47e73 fixed scroll to tup button 2025-08-18 10:24:08 +00:00
Djeex 8cb81a74cf Refreshed site.yaml 2025-08-17 23:51:15 +02:00
Djeex c193fd49aa Better UI form 2025-08-17 23:49:36 +02:00
Djeex 5d863223e3 Form UI 1st step 2025-08-17 22:44:54 +02:00
Djeex a8f3c1b497 All fields OK 2025-08-17 14:26:31 +02:00
Djeex b74f1bb350 Site-info front 2025-08-17 13:52:25 +02:00
Djeex 5a6f08644a Fixed responsive issue 2025-08-17 12:38:29 +02:00
Djeex 031ff62168 Better css 2025-08-17 11:26:21 +02:00
Djeex b56d03303e Remove all button 2025-08-17 10:57:59 +02:00
Djeex d3484a4b50 Confirmation modale for deletion 2025-08-17 00:37:55 +02:00
Djeex 9d37b0a60f Better tag UI 2025-08-16 22:57:56 +02:00
Djeex 080eb2593d Header 2025-08-16 21:40:38 +02:00
Djeex 73a0dd0ce6 Better UI 2025-08-16 16:30:56 +02:00
Djeex 97645b06fa Most used tags 2025-08-16 14:08:34 +02:00
Djeex 142c042b86 Better ui tag system 2025-08-16 13:14:04 +02:00
Djeex 041db66b3d Reworked flow 2025-08-16 11:17:15 +02:00
Djeex 1b0b228273 Gallery front 2025-08-16 10:29:51 +02:00
Djeex f7f2356510 Better comments 2025-08-15 13:36:48 +00:00
61 changed files with 5930 additions and 188 deletions
+189
View File
@@ -0,0 +1,189 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1"
jobs:
build-and-scan:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: Build Docker image
run: |
docker build -t lumeex:ci . 2>&1 | tee build.log
if grep -q "Building wheel for" build.log; then
echo "::warning::A dependency was built from source — check Python/Alpine compatibility"
fi
- name: Smoke test (syntax check)
run: |
docker run --rm --entrypoint python lumeex:ci -c "
import ast
ok = True
for f in ('build.py', 'gallery.py'):
with open(f) as fh:
source = fh.read()
try:
ast.parse(source)
except SyntaxError as e:
print(f'::error::Syntax error in {f}: {e}')
ok = False
if not ok:
exit(1)
print('OK: syntax is valid')
"
- name: Run unit tests
run: |
docker build --target test -t lumeex:test .
docker run --rm lumeex:test pytest -v
- name: Check deprecation warnings
run: |
docker run --rm --entrypoint python lumeex:ci -W error::DeprecationWarning -c "import src.py.webui.webui" 2>&1 | tee deprecation.log || true
if grep -qi "deprecat" deprecation.log; then
echo "::warning::Deprecation warning detected, check logs"
fi
- name: Scan with Trivy (critical - blocking)
run: |
docker run --rm \
-e DOCKER_HOST=tcp://dockerhost:2375 \
--add-host=dockerhost:host-gateway \
aquasec/trivy:0.74.0 image --exit-code 1 --severity CRITICAL lumeex:ci
- name: Scan with Trivy (high - informative)
run: |
docker run --rm \
-e DOCKER_HOST=tcp://dockerhost:2375 \
--add-host=dockerhost:host-gateway \
aquasec/trivy:0.74.0 image --exit-code 0 --severity HIGH lumeex:ci
- name: Publish tagged image
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
BEFORE="${{ github.event.before }}"
if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$BEFORE" 2>/dev/null; then
BASE_REF="$BEFORE"
else
BASE_REF="HEAD~1"
fi
CHANGED=$(git diff --name-only "$BASE_REF" "${{ github.sha }}")
echo "Changed files:"
echo "$CHANGED"
if ! echo "$CHANGED" | grep -qE '^(Dockerfile|requirements\.txt|VERSION|build\.py|gallery\.py)$|^(src|config)/|^docker/\.sh/entrypoint\.sh$'; then
echo "No container-relevant file changed, skipping publish."
exit 0
fi
if echo "$CHANGED" | grep -qE '^VERSION$'; then
echo "VERSION was manually edited in this push, using it as-is."
else
echo "VERSION untouched but container files changed, auto-bumping the build number (Z)."
OLD_VERSION=$(tr -d '[:space:]' < VERSION)
IFS='.' read -r MAJOR MINOR PATCH <<< "$OLD_VERSION"
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
echo "$NEW_VERSION" > VERSION
git config user.name "lumeex-ci"
git config user.email "[email protected]"
git add VERSION
git commit -m "Bump build version to $NEW_VERSION [skip ci]"
# Belt and suspenders: actions/checkout can leave its own ephemeral
# credential injected as an extraheader, which would silently override
# the URL-embedded token below. persist-credentials:false on checkout
# should already prevent this, but strip it here too just in case.
git config --unset-all http.https://git.djeex.fr/.extraheader || true
git push "https://Djeex:${{ secrets.CI_PUSH_TOKEN }}@git.djeex.fr/Djeex/lumeex.git" HEAD:main
fi
VERSION=$(tr -d '[:space:]' < VERSION)
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
MINOR_TAG="${MAJOR}.${MINOR}"
IMAGE=git.djeex.fr/djeex/lumeex
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.djeex.fr -u Djeex --password-stdin
# Retag the already-built, already-scanned image — never rebuild for publish,
# so what ships is byte-for-byte what Trivy just scanned.
docker tag lumeex:ci "$IMAGE:latest"
docker tag lumeex:ci "$IMAGE:$MINOR_TAG"
docker tag lumeex:ci "$IMAGE:$VERSION"
docker push "$IMAGE:latest"
docker push "$IMAGE:$MINOR_TAG"
docker push "$IMAGE:$VERSION"
TRIGGER_MSG=$(git log -1 --format=%s "${{ github.sha }}")
PR_NUM=$(echo "$TRIGGER_MSG" | grep -oE '#[0-9]+' | head -1 | tr -d '#' || true)
CATEGORY="🔧 Maintenance"
CHANGE_TITLE="$TRIGGER_MSG"
if [ -n "$PR_NUM" ]; then
PR_JSON=$(curl -s -H "Authorization: token ${{ secrets.CI_PUSH_TOKEN }}" \
"https://git.djeex.fr/api/v1/repos/Djeex/lumeex/pulls/$PR_NUM")
PR_TITLE=$(echo "$PR_JSON" | jq -r '.title // empty' 2>/dev/null || true)
LABELS=$(echo "$PR_JSON" | jq -r '.labels[]?.name' 2>/dev/null || true)
if [ -n "$PR_TITLE" ]; then
CHANGE_TITLE="$PR_TITLE"
fi
if echo "$LABELS" | grep -qx 'bug'; then
CATEGORY="⚠️ Hotfix"
elif echo "$LABELS" | grep -qx 'major'; then
CATEGORY="💥 Breaking change"
elif echo "$LABELS" | grep -qx 'minor'; then
CATEGORY="✨ Update"
fi
fi
REPO_URL="https://git.djeex.fr/Djeex/lumeex"
COMMIT_LIST=$(git log --no-merges --format="- [%h](${REPO_URL}/commit/%H) %s" "$BASE_REF".."${{ github.sha }}")
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
SOURCE_LINE="[${SHORT_SHA}](${REPO_URL}/commit/${{ github.sha }})"
if [ -n "$PR_NUM" ]; then
SOURCE_LINE="[#${PR_NUM}](${REPO_URL}/pulls/${PR_NUM}) · ${SOURCE_LINE}"
fi
BODY=$(cat <<EOF
## Changelog
---
### ${CATEGORY}
---
${CHANGE_TITLE}
**Source:** ${SOURCE_LINE}
**Image:** \`${IMAGE}:${VERSION}\`
**Commits:**
${COMMIT_LIST}
EOF
)
JSON_PAYLOAD=$(jq -n \
--arg tag "$VERSION" \
--arg name "$VERSION" \
--arg body "$BODY" \
'{tag_name: $tag, name: $name, target_commitish: "main", body: $body}')
curl -s -o /dev/null -w "Release API response: %{http_code}\n" -X POST \
-H "Authorization: token ${{ secrets.CI_PUSH_TOKEN }}" \
-H "Content-Type: application/json" \
-d "$JSON_PAYLOAD" \
"https://git.djeex.fr/api/v1/repos/Djeex/lumeex/releases"
+5
View File
@@ -1,5 +1,10 @@
.*
!.env
!.sh
!.gitignore
!.gitea
!.gitea/**
output/
__pycache__/
htmlcov/
site_output.zip
+19 -6
View File
@@ -1,19 +1,32 @@
FROM python:3.11-slim
FROM python:3.14.7-alpine AS builder
WORKDIR /app
RUN apk add --no-cache gcc musl-dev jpeg-dev zlib-dev
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir=/wheels -r requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.14.7-alpine AS base
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --find-links=/wheels /wheels/* && rm -rf /wheels
COPY build.py gallery.py VERSION /app/
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
FROM base AS test
COPY requirements.txt requirements-dev.txt pytest.ini /app/
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY ./tests/ ./tests/
COPY ./demo/ ./demo/
FROM base
ENTRYPOINT ["/app/entrypoint.sh"]
+16 -8
View File
@@ -12,13 +12,15 @@
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.
👉 __Check the online version : [Lumeex-web](https://lumeex.djeex.fr)__
The project includes two thoughtfully designed themes—one modern, one minimalistic—both crafted to keep the spotlight on your photos:
- **Modern** — [View Demo](https://modern.djeex.fr)
- **Typewriter** — [View 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.
> _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 and releases_.
## 📌 Table of Contents
@@ -41,20 +43,26 @@ The project includes two thoughtfully designed themes—one modern, one minimali
- Typewriter — [Demo](https://typewriter.djeex.fr)
- Supports Google Fonts and locally hosted fonts
### No-Code Builder (YAML Based)
### No-Code Builder (WebUI Manager)
- 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
&nbsp;
<div align="center">
<img src="https://git.djeex.fr/Djeex/lumeex/raw/branch/main/illustration/lumeex-webui.png" alt="Lumeex Screenshot" />
</div>
&nbsp;
### Simple Build Process
- Compiles static site from YAML configuration files (themes, templates, fonts, colors)
- Configure site info, SEO, colors, fonts, and more through a simple convenient WebUI
- Add and tag your photo photos without any coding required
- Converts favicon automatically to all required formats
- 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
- Build your static site in one click and get a zip archive or an output folder, ready to deploy to your preferred webserver
### Don't want a WebUI ?
- CLI process is documented
## 🐳 Docker or 🐍 Python Installation
For comprehensive documentation on installation, configuration options, customization, and demos, please visit:
+1
View File
@@ -0,0 +1 @@
2.1.3
+1 -1
View File
@@ -1,5 +1,5 @@
import logging
from src.py.site_builder import build
from src.py.builder.site_builder import build
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(message)s")
+7 -8
View File
@@ -1,4 +1,3 @@
# Please change this by your settings.
info:
title:
subtitle:
@@ -9,7 +8,7 @@ info:
social:
instagram_url:
thumbnail:
thumbnail: ''
menu:
items:
@@ -18,17 +17,17 @@ menu:
footer:
copyright: Copyright © 2025
legal_link: '/legals/'
legal_link: /legals/
legal_label: Legal notice
# Build parameters
build:
theme: modern # choose a theme in config/theme folder
theme: modern
convert_images: true
resize_images: true
# Change this by your legals
legals:
hoster_name:
hoster_adress:
hoster_address:
hoster_contact:
intellectual_property:
- paragraph: ""
- paragraph: ''
+1 -4
View File
@@ -35,16 +35,13 @@ img, tag {
#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 {
@@ -53,7 +50,7 @@ img, tag {
.hero-background {
max-width: 90%;
margin: auto;
margin: 0 auto;
}
.back-button {
+21 -22
View File
@@ -1,32 +1,31 @@
#-----------------------------------#
# Modern theme for Lumeex #
# https://git.djeex.fr/Djeex/lumeex #
#-----------------------------------#
colors:
primary: '#0065a1'
primary: '#0065A1'
primary_dark: '#005384'
secondary: '#00b0f0'
accent: '#ffc700'
secondary: '#00B0F0'
accent: '#FFC700'
text_dark: '#616161'
background: '#fff'
browser_color: '#fff'
background: '#FFFFFF'
browser_color: '#FFFFFF'
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
name: Lato
secondary:
name: Montserrat
fallback: serif
name: Montserrat
google_fonts:
- family: 'Lato'
weights:
- '200'
- '400'
- '700'
- family: Montserrat
weights:
- '200'
- '400'
- '700'
+7 -11
View File
@@ -1,21 +1,17 @@
#-----------------------------------#
# Typewriter theme for Lumeex #
# https://git.djeex.fr/Djeex/lumeex #
#-----------------------------------#
colors:
primary: '#0065a1'
accent: '#FFC700'
background: '#FFFFFF'
browser_color: '#FFFFFF'
primary: '#0065A1'
primary_dark: '#005384'
secondary: '#00b0f0'
accent: '#ffc700'
secondary: '#00B0F0'
text_dark: '#616161'
background: '#fff'
browser_color: '#fff'
favicon:
path: favicon.png
fonts:
primary:
name: Trixie
name: trixie
fallback: sans-serif
secondary:
name: Trixie
name: trixie
fallback: serif
+1 -1
View File
@@ -19,7 +19,7 @@ gallery:
- src: gallery/gilley-aguilar-ywGDhTlf93E-unsplash.jpg
tags: [landscape, sky, cloud, mountains]
- src: gallery/jacob-reinikainen-nGG6m3RbjSk-unsplash.jpg
tags: [lanscape, sunset, mountains]
tags: [landscape, sunset, mountains]
- src: gallery/jonas-degener-LueP5EdWGFY-unsplash.jpg
tags: [landscape, mountains, fog]
- src: gallery/michiel-annaert-M27pZnHV6M0-unsplash.jpg
+21 -22
View File
@@ -1,32 +1,31 @@
#-----------------------------------#
# Modern theme for Lumeex #
# https://git.djeex.fr/Djeex/lumeex #
#-----------------------------------#
colors:
primary: '#0065a1'
primary: '#0065A1'
primary_dark: '#005384'
secondary: '#00b0f0'
accent: '#ffc700'
secondary: '#00B0F0'
accent: '#FFC700'
text_dark: '#616161'
background: '#fff'
browser_color: '#fff'
background: '#FFFFFF'
browser_color: '#FFFFFF'
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
name: Lato
secondary:
name: Montserrat
fallback: serif
name: Montserrat
google_fonts:
- family: ''
weights:
- '200'
- '400'
- '700'
- family: Montserrat
weights:
- '200'
- '400'
- '700'
+9 -11
View File
@@ -1,21 +1,19 @@
#-----------------------------------#
# Typewriter theme for Lumeex #
# https://git.djeex.fr/Djeex/lumeex #
#-----------------------------------#
colors:
primary: '#0065a1'
accent: '#FFC700'
background: '#FFFFFF'
browser_color: '#FFFFFF'
primary: '#0065A1'
primary_dark: '#005384'
secondary: '#00b0f0'
accent: '#ffc700'
secondary: '#00B0F0'
text_dark: '#616161'
background: '#fff'
browser_color: '#fff'
favicon:
path: favicon.png
fonts:
primary:
name: Trixie
name: trixie.woff
fallback: sans-serif
secondary:
name: Trixie
name: trixie.woff
fallback: serif
+2
View File
@@ -0,0 +1,2 @@
PREVIEW_PORT=3000
WEBUI_PORT=5000
+33 -20
View File
@@ -1,33 +1,36 @@
#!/bin/bash
#!/bin/sh
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 configuration directory..."
mkdir -p /app/config
echo "Checking if default config files need to be copied..."
echo "[~] Checking if default config files/folders need to be copied..."
files_copied=false
for file in /app/default/*; do
filename=$(basename "$file")
target="/app/config/$filename"
for src in $(find /app/default -mindepth 1); do
relpath="${src#/app/default/}"
target="/app/config/$relpath"
if [ ! -e "$target" ]; then
echo "Copying default config file: $filename"
cp -r "$file" "$target"
echo "[→] Copying: $relpath"
if [ -d "$src" ]; then
mkdir -p "$target"
cp -r "$src/" "$target/"
else
mkdir -p "$(dirname "$target")"
cp "$src" "$target"
fi
files_copied=true
fi
done
if [ "$files_copied" = true ]; then
echo "Default configuration files copied successfully."
echo "[✓] Default configuration files/folders copied successfully."
else
echo "No default files needed to be copied."
echo "[✓] No default files/folders needed to be copied."
fi
}
@@ -43,16 +46,26 @@ start_server() {
cat /tmp/build_logs_fifo >&2 &
cat /tmp/build_logs_fifo2 >&2 &
echo "Starting HTTP server on port 3000..."
PREVIEW_PORT="${PREVIEW_PORT:-3000}"
echo "[~]Starting preview HTTP server on port 3000..."
echo "[i] Preview host port is set to: ${PREVIEW_PORT}"
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
echo "[~] Starting Lumeex Flask webui..."
python3 -u -m src.py.webui.webui &
WEBUI_PID=$!
trap "echo 'Stopping servers...'; kill -TERM $SERVER_PID $WEBUI_PID 2>/dev/null; wait $SERVER_PID $WEBUI_PID; exit 0" SIGINT SIGTERM
wait $SERVER_PID
wait $WEBUI_PID
}
VERSION=$(cat VERSION)
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} Lum${CYAN}eex${NC} - Version ${VERSION}${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}"
@@ -64,15 +77,15 @@ fi
case "$1" in
build)
echo "Running build.py..."
echo "[~] Running build.py..."
python3 -u /app/build.py 2>&1 | tee /tmp/build_logs_fifo
;;
gallery)
echo "Running gallery.py..."
echo "[~] Running gallery.py..."
python3 -u /app/gallery.py 2>&1 | tee /tmp/build_logs_fifo2
;;
*)
echo "Unknown command: $1"
echo "[!] Unknown command: $1"
exec "$@"
;;
esac
+7 -1
View File
@@ -2,9 +2,15 @@ services:
lumeex:
container_name: lmx
build: ..
env_file:
- .env
environment:
- PREVIEW_PORT=${PREVIEW_PORT:-3000} # port for preview server - set it in .env file
- WEBUI_PORT=${WEBUI_PORT:-5000} # port for webui server - set it in .env file
volumes:
- ../config:/app/config # mount config directory
- ../output:/app/output # mount output directory
ports:
- "3000:3000"
- "${PREVIEW_PORT:-3000}:3000"
- "${WEBUI_PORT:-5000}:5000"
+1 -1
View File
@@ -1,5 +1,5 @@
import logging
from src.py.gallery_builder import update_gallery, update_hero
from src.py.builder.gallery_builder import update_gallery, update_hero
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(message)s")
Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

+3
View File
@@ -0,0 +1,3 @@
[pytest]
pythonpath = .
testpaths = tests
+29
View File
@@ -0,0 +1,29 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"timezone": "Europe/Paris",
"labels": ["bot"],
"packageRules": [
{
"matchManagers": ["pip_requirements"],
"matchUpdateTypes": ["patch", "minor"],
"automerge": true
},
{
"matchManagers": ["dockerfile"],
"matchUpdateTypes": ["patch"],
"automerge": true
},
{
"matchUpdateTypes": ["major"],
"addLabels": ["major"]
},
{
"matchUpdateTypes": ["minor"],
"addLabels": ["minor"]
}
],
"vulnerabilityAlerts": {
"addLabels": ["bug"]
}
}
+3
View File
@@ -0,0 +1,3 @@
-r requirements.txt
pytest==8.4.2
pytest-cov==7.0.0
+1
View File
@@ -1,2 +1,3 @@
pyyaml
pillow
flask
+8 -1
View File
@@ -9,7 +9,6 @@ window.addEventListener("DOMContentLoaded", () => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
console.log("Lazy-loading image:", img.dataset.src);
img.src = img.dataset.src;
img.onload = () => {
img.classList.add("loaded");
@@ -33,6 +32,14 @@ window.addEventListener("DOMContentLoaded", () => {
img.classList.add("loaded");
};
// Chrome HDR artefacting fix
const clearWillChange = (e) => {
if (e.propertyName !== "transform") return;
img.style.willChange = "auto";
img.removeEventListener("transitionend", clearWillChange);
};
img.addEventListener("transitionend", clearWillChange);
if (img.complete && img.naturalHeight !== 0) {
onLoad(); // already loaded
} else {
+39 -15
View File
@@ -3,6 +3,13 @@
// Fade in effect for elements with class 'appear'
const setupIntersectionObserver = () => {
document.querySelectorAll('.appear').forEach(parent => {
const children = parent.querySelectorAll('.appear');
children.forEach((child, i) => {
child.style.transitionDelay = `${i * 0.2}s`;
});
});
const items = document.querySelectorAll('.appear');
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
@@ -32,6 +39,7 @@ const randomizeHeroBackground = () => {
if (images.length === 0) return;
let currentIndex = Math.floor(Math.random() * images.length);
heroBg.style.backgroundImage = `url(/img/${images[currentIndex]})`;
if (images.length < 2) return; // <-- Prevent interval if only one image
setInterval(() => {
let nextIndex;
do {
@@ -69,7 +77,8 @@ const setupTagFilter = () => {
const allSections = document.querySelectorAll('.section[data-tags]');
const allTags = document.querySelectorAll('.tag');
let activeTags = [];
let lastClickedTag = null; // mémorise le dernier tag cliqué
let lastClickedTag = null; // remembers the last clicked tag
let lastClickedSection = null; // remembers the last clicked section (photo)
const applyFilter = () => {
let filteredSections = [];
@@ -81,30 +90,41 @@ const setupTagFilter = () => {
section.style.display = hasAllTags ? '' : 'none';
if (hasAllTags) {
filteredSections.push(section);
if (lastClickedTag && sectionTags.includes(lastClickedTag) && !matchingSection) {
if (lastClickedSection === section) {
matchingSection = section;
} else {
filteredSections.push(section);
}
}
});
// Réorganise : la photo correspondante au dernier tag cliqué en premier
if (matchingSection && galleryContainer.contains(matchingSection)) {
galleryContainer.prepend(matchingSection);
// 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);
});
}
// Met à jour le style des tags
// Update tag styles
allTags.forEach((tagEl) => {
const tagText = tagEl.textContent.replace('#', '').toLowerCase();
tagEl.classList.toggle('active', activeTags.includes(tagText));
});
// Met à jour l'URL
// Update the URL
const base = window.location.pathname;
const query = activeTags.length > 0 ? `?tag=${activeTags.join(',')}` : '';
window.history.pushState({}, '', base + query);
// Scroll jusqu'à la galerie
// Scroll to the gallery
if (galleryContainer) {
galleryContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
@@ -113,7 +133,8 @@ const setupTagFilter = () => {
allTags.forEach((tagEl) => {
tagEl.addEventListener('click', () => {
const tagText = tagEl.textContent.replace('#', '').toLowerCase();
lastClickedTag = tagText; // mémorise le dernier tag cliqué
lastClickedTag = tagText; // remembers the last clicked tag
lastClickedSection = tagEl.closest('.section'); // remembers the last clicked section
if (activeTags.includes(tagText)) {
activeTags = activeTags.filter((t) => t !== tagText);
@@ -130,6 +151,7 @@ const setupTagFilter = () => {
if (urlTags) {
activeTags = urlTags.split(',').map((t) => t.toLowerCase());
lastClickedTag = activeTags[activeTags.length - 1] || null;
lastClickedSection = null; // No section selected from URL
applyFilter();
}
});
@@ -141,12 +163,14 @@ const disableRightClickAndDrag = () => {
document.addEventListener('dragstart', (e) => e.preventDefault());
};
// Scroll to top button
// Scroll-to-top button functionality
const setupScrollToTopButton = () => {
const scrollToTopButton = document.querySelector('.scroll-to-top');
if (!scrollToTopButton) return;
scrollToTopButton.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
const scrollBtn = document.getElementById("scrollToTop");
window.addEventListener("scroll", () => {
scrollBtn.style.display = window.scrollY > 300 ? "block" : "none";
});
scrollBtn.addEventListener("click", () => {
window.scrollTo({ top: 0, behavior: "smooth" });
});
};
+24 -38
View File
@@ -78,6 +78,8 @@ html,body {
font-weight: 400;
line-height:1.5;
color:var(--color-primary-dark);
display: flex;
flex-direction: column;
}
html {
@@ -179,8 +181,8 @@ h2 {
/* animation */
.appear {
-webkit-transition: all 0.3s;
transition: all 0.3s;
-webkit-transition: all 1s;
transition: all 1s;
opacity: 0;
-webkit-transform: translateY(20px);
transform: translateY(20px);
@@ -192,36 +194,6 @@ h2 {
transform: none;
}
.appear.inview:nth-child(1) {
-webkit-transition-delay: 0s;
transition-delay: 0s;
}
.appear.inview:nth-child(2) {
-webkit-transition-delay: 0.2s;
transition-delay: 0.2s;
}
.appear.inview:nth-child(3) {
-webkit-transition-delay: 0.4s;
transition-delay: 0.4s;
}
.appear.inview:nth-child(4) {
-webkit-transition-delay: 0.6s;
transition-delay: 0.6s;
}
.appear.inview:nth-child(5) {
-webkit-transition-delay: 0.8s;
transition-delay: 0.8s;
}
.appear.inview:nth-child(6) {
-webkit-transition-delay: 1s;
transition-delay: 1s;
}
/* img fade in */
.fade-in-img {
@@ -267,12 +239,23 @@ h2 {
/* Hero */
#hero {
height: 100%;
width: 100%;
min-height: 100vh;
flex: 1 0 auto;
display: flex;
flex-direction: column;
}
#hero .section {
height: 100%;
display: flex;
flex-direction: column;
width: 100%;
}
#hero .content-wrapper, #hero .section {
height:100%;
}
.hero-background {
height: 66%;
width: 100%;
@@ -333,7 +316,6 @@ h2 {
font-size: 22px;
}
.gallery {
padding-top: 15px;
}
@@ -371,6 +353,11 @@ h2 {
/* Footer */
#footer {
margin: auto 0 0 0;
width: 100%;
}
.navigation {
text-align: center;
padding-top: 40px;
@@ -491,7 +478,7 @@ h2 {
}
.section img {
margin: 0px 0 60px 0;
margin: 0px 0 40px 0;
}
.tag {
@@ -500,8 +487,7 @@ h2 {
#legals.content-wrapper {
max-width: 90%;
margin: auto;
margin-top: 50px;
margin: 50px auto;
}
.legals-content {
@@ -3,6 +3,7 @@ from pathlib import Path
from shutil import copyfile
def generate_css_variables(colors_dict, output_path):
"""Generate css variables for theme colors"""
css_lines = [":root {"]
for key, value in colors_dict.items():
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}")
def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
"""Generate css variables fonts"""
font_files = list(fonts_dir.glob("*"))
font_faces = {}
preload_links = []
@@ -57,6 +59,7 @@ def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
return preload_links
def generate_google_fonts_link(fonts):
"""Generate src link for Google fonts"""
if not fonts:
return ""
families = []
@@ -10,6 +10,7 @@ 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}")
@@ -21,11 +22,13 @@ def load_yaml(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("*")
@@ -33,6 +36,7 @@ def get_all_image_paths(directory):
])
def update_gallery():
"""Update the gallery photo list"""
print("\n=== Updating gallery.yaml (gallery section) ===")
gallery = load_yaml(GALLERY_YAML)
@@ -71,6 +75,7 @@ def update_gallery():
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)
@@ -3,6 +3,7 @@ import logging
from pathlib import Path
def render_template(template_path, context):
"""Render html templates"""
with open(template_path, encoding="utf-8") as f:
content = f.read()
for key, value in context.items():
@@ -11,6 +12,7 @@ def render_template(template_path, context):
return content
def render_gallery_images(images):
"""Render the photo gallery"""
html = ""
for img in images:
tags = " ".join(img.get("tags", []))
@@ -24,6 +26,7 @@ def render_gallery_images(images):
return html
def generate_gallery_json_from_images(images, output_dir):
"""Generte the hero carrousel photo list"""
try:
img_list = [img["src"] for img in images]
output_path = output_dir / "data" / "gallery.json"
@@ -35,6 +38,7 @@ def generate_gallery_json_from_images(images, output_dir):
logging.error(f"[✗] Error generating gallery JSON: {e}")
def generate_robots_txt(canonical_url, allowed_paths, output_dir):
"""Generate the robot.txt"""
robots_lines = ["User-agent: *"]
# Block everything by default
@@ -62,6 +66,7 @@ def generate_robots_txt(canonical_url, allowed_paths, output_dir):
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_end = '</urlset>\n'
urls = ""
@@ -11,6 +11,7 @@ def convert_and_resize_image(input_path, output_path, resize=True, max_width=114
return
img = Image.open(input_path)
icc_profile = img.info.get("icc_profile")
if img.mode != "RGB":
img = img.convert("RGB")
@@ -27,7 +28,10 @@ def convert_and_resize_image(input_path, output_path, resize=True, max_width=114
if fmt == "JPEG":
output_path = output_path.with_suffix(".jpg")
img.save(output_path, fmt, quality=90 if fmt == "JPEG" else 100)
save_kwargs = {"quality": 90 if fmt == "JPEG" else 100}
if icc_profile:
save_kwargs["icc_profile"] = icc_profile
img.save(output_path, fmt, **save_kwargs)
logging.info(f"[✓] Processed image: {input_path}{output_path}")
except Exception as e:
@@ -21,12 +21,14 @@ 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"
VERSION_FILE = SRC_DIR / "VERSION"
with open(VERSION_FILE, "r") as vf:
build_version = vf.read().strip()
def build():
build_version = "v1.3.1"
logging.info("\n")
logging.info("=" * 24)
logging.info(f"🚀 Lumeex builder {build_version}")
logging.info(f"🚀 Lumeex builder v{build_version}")
logging.info("=" * 24)
logging.info("\n === Starting build === ")
ensure_dir(BUILD_DIR)
+7 -6
View File
@@ -4,6 +4,7 @@ from pathlib import Path
from shutil import copytree, rmtree, copyfile
def load_yaml(path):
"""Load gallery and site .yaml conf"""
if not path.exists():
logging.warning(f"[!] YAML file not found: {path}")
return {}
@@ -11,6 +12,7 @@ def load_yaml(path):
return yaml.safe_load(f)
def load_theme_config(theme_name, themes_dir):
"""Load theme.yaml"""
theme_dir = themes_dir / theme_name
theme_config_path = theme_dir / "theme.yaml"
if not theme_config_path.exists():
@@ -20,26 +22,25 @@ def load_theme_config(theme_name, themes_dir):
return theme_vars, theme_dir
def clear_dir(path: Path):
"""Clear the output dir"""
if not path.exists():
path.mkdir(parents=True)
return
# Remove all files and subdirectories inside path, but not path itself
for child in path.iterdir():
if child.is_file() or child.is_symlink():
child.unlink() # delete file or symlink
child.unlink()
elif child.is_dir():
rmtree(child) # delete directory and contents
# Then replace your ensure_dir with this:
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):
"""Copy public assets to output dir"""
for folder in [js_dir, style_dir]:
if folder.exists():
dest = build_dir / folder.name
View File
+66
View File
@@ -0,0 +1,66 @@
import logging
from pathlib import Path
from flask import Blueprint, request, current_app
from werkzeug.utils import secure_filename
from src.py.builder.gallery_builder import update_gallery, update_hero
# --- Create Flask blueprint for upload routes ---
upload_bp = Blueprint("upload", __name__)
# --- Allowed file types ---
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "webp"}
def allowed_file(filename: str) -> bool:
"""Check if the uploaded file has an allowed extension."""
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
def save_uploaded_file(file, folder: Path):
"""Save an uploaded file to the specified folder."""
folder.mkdir(parents=True, exist_ok=True) # Create folder if not exists
filename = secure_filename(file.filename) # Sanitize filename
file.save(folder / filename) # Save to disk
logging.info(f"[✓] Uploaded {filename} to {folder}")
return filename
@upload_bp.route("/api/<section>/upload", methods=["POST"])
def upload_photo(section: str):
"""
Handle file uploads for gallery or hero section.
Accepts multiple files under 'files'.
"""
# Validate section
if section not in ["gallery", "hero"]:
return {"error": "Invalid section"}, 400
# Check if files are provided
if "files" not in request.files:
return {"error": "No files provided"}, 400
files = request.files.getlist("files")
if not files:
return {"error": "No selected files"}, 400
# Get photos directory from app config
PHOTOS_DIR = current_app.config.get("PHOTOS_DIR")
if not PHOTOS_DIR:
return {"error": "Server misconfiguration"}, 500
folder = PHOTOS_DIR / section # Target folder
uploaded = []
# Save each valid file
for file in files:
if file and allowed_file(file.filename):
filename = save_uploaded_file(file, folder)
uploaded.append(filename)
# Update YAML if any files were uploaded
if uploaded:
if section == "gallery":
update_gallery()
else:
update_hero()
return {"status": "ok", "uploaded": uploaded}
return {"error": "No valid files uploaded"}, 400
+512
View File
@@ -0,0 +1,512 @@
# --- Imports ---
import logging
import yaml
import subprocess
import zipfile
import os
from pathlib import Path
from flask import (
Flask, jsonify, request, send_from_directory, render_template,
send_file, after_this_request
)
from src.py.builder.gallery_builder import (
GALLERY_YAML, load_yaml, save_yaml, update_gallery, update_hero
)
from src.py.webui.upload import upload_bp
# --- Logging configuration ---
logging.basicConfig(level=logging.INFO, format="%(message)s")
# --- Flask app setup ---
VERSION_FILE = Path(__file__).resolve().parents[3] / "VERSION"
with open(VERSION_FILE, "r") as vf:
lumeex_version = vf.read().strip()
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=""
)
WEBUI_PORT = int(os.getenv("WEBUI_PORT", 5000))
# --- Config paths ---
SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos"
app.config["PHOTOS_DIR"] = PHOTOS_DIR
# --- Register upload blueprint ---
app.register_blueprint(upload_bp)
# --- Theme editor helper functions ---
def get_theme_name():
"""Get current theme name from site.yaml."""
site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
with open(site_yaml_path, "r") as f:
site_yaml = yaml.safe_load(f)
return site_yaml.get("build", {}).get("theme", "modern")
def get_theme_yaml(theme_name):
"""Load theme.yaml for a given theme."""
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
with open(theme_yaml_path, "r") as f:
return yaml.safe_load(f)
def save_theme_yaml(theme_name, theme_yaml):
"""Save theme.yaml for a given theme."""
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
with open(theme_yaml_path, "w") as f:
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
def get_local_fonts(theme_name):
"""List local font files for a theme."""
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
if not fonts_dir.exists():
return []
return [f.name for f in fonts_dir.glob("*") if f.is_file() and f.suffix in [".woff", ".woff2"]]
# --- ROUTES ---
# --- Main page ---
@app.route("/")
def index():
return render_template("index.html")
PREVIEW_PORT = int(os.getenv("PREVIEW_PORT", 3000))
@app.context_processor
def inject_version():
return dict(
lumeex_version=lumeex_version,
preview_port=PREVIEW_PORT
)
# --- Gallery & Hero API ---
@app.route("/gallery-editor")
def gallery_editor():
"""Render gallery editor page."""
return render_template("gallery-editor/index.html")
@app.route("/api/gallery", methods=["GET"])
def get_gallery():
"""Get gallery images."""
data = load_yaml(GALLERY_YAML)
return jsonify(data.get("gallery", {}).get("images", []))
@app.route("/api/hero", methods=["GET"])
def get_hero():
"""Get hero images."""
data = load_yaml(GALLERY_YAML)
return jsonify(data.get("hero", {}).get("images", []))
@app.route("/api/gallery/update", methods=["POST"])
def update_gallery_api():
"""Update gallery images."""
images = request.json
data = load_yaml(GALLERY_YAML)
data["gallery"]["images"] = images
save_yaml(data, GALLERY_YAML)
return jsonify({"status": "ok"})
@app.route("/api/hero/update", methods=["POST"])
def update_hero_api():
"""Update hero images."""
images = request.json
data = load_yaml(GALLERY_YAML)
data["hero"]["images"] = images
save_yaml(data, GALLERY_YAML)
return jsonify({"status": "ok"})
@app.route("/api/gallery/refresh", methods=["POST"])
def refresh_gallery():
"""Refresh gallery images from disk."""
update_gallery()
return jsonify({"status": "ok"})
@app.route("/api/hero/refresh", methods=["POST"])
def refresh_hero():
"""Refresh hero images from disk."""
update_hero()
return jsonify({"status": "ok"})
# --- Gallery & Hero photo deletion ---
@app.route("/api/gallery/delete", methods=["POST"])
def delete_gallery_photo():
"""Delete a gallery photo."""
data = request.json
src = data.get("src")
file_path = PHOTOS_DIR / "gallery" / src
if file_path.exists():
file_path.unlink()
return {"status": "ok"}
return {"error": "❌ File not found"}, 404
@app.route("/api/hero/delete", methods=["POST"])
def delete_hero_photo():
"""Delete a hero photo."""
data = request.json
src = data.get("src")
file_path = PHOTOS_DIR / "hero" / src
if file_path.exists():
file_path.unlink()
return {"status": "ok"}
return {"error": "❌ File not found"}, 404
@app.route("/api/gallery/delete_all", methods=["POST"])
def delete_all_gallery_photos():
"""Delete all gallery photos."""
gallery_dir = PHOTOS_DIR / "gallery"
deleted = 0
for file in gallery_dir.glob("*"):
if file.is_file():
file.unlink()
deleted += 1
data = load_yaml(GALLERY_YAML)
data["gallery"]["images"] = []
save_yaml(data, GALLERY_YAML)
return jsonify({"status": "ok", "deleted": deleted})
@app.route("/api/hero/delete_all", methods=["POST"])
def delete_all_hero_photos():
"""Delete all hero photos."""
hero_dir = PHOTOS_DIR / "hero"
deleted = 0
for file in hero_dir.glob("*"):
if file.is_file():
file.unlink()
deleted += 1
data = load_yaml(GALLERY_YAML)
data["hero"]["images"] = []
save_yaml(data, GALLERY_YAML)
return jsonify({"status": "ok", "deleted": deleted})
# --- Serve photos ---
@app.route("/photos/<section>/<path:filename>")
def photos(section, filename):
"""Serve a photo from a section."""
return send_from_directory(PHOTOS_DIR / section, filename)
@app.route("/photos/<path:filename>")
def serve_photo(filename):
"""Serve a photo from the photos directory."""
photos_dir = Path(__file__).resolve().parents[3] / "config" / "photos"
return send_from_directory(photos_dir, filename)
# --- Site info page & API ---
@app.route("/site-info")
def site_info():
"""Render site info editor page."""
return render_template("site-info/index.html")
@app.route("/api/site-info", methods=["GET"])
def get_site_info():
"""Get site info YAML as JSON."""
with open(SITE_YAML, "r") as f:
data = yaml.safe_load(f)
return jsonify(data)
@app.route("/api/site-info", methods=["POST"])
def update_site_info():
"""Update site info YAML."""
new_data = request.json
with open(SITE_YAML, "r") as f:
old_data = yaml.safe_load(f) or {}
def deep_merge(old, new):
for k, v in new.items():
if isinstance(v, dict) and isinstance(old.get(k), dict):
old[k] = deep_merge(old[k], v)
else:
old[k] = v
return old
merged = deep_merge(old_data, new_data)
with open(SITE_YAML, "w") as f:
yaml.safe_dump(merged, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"})
# --- Theme management ---
@app.route("/api/themes")
def list_themes():
"""List available themes."""
themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
themes = [d.name for d in themes_dir.iterdir() if d.is_dir()]
return jsonify(themes)
# --- Thumbnail upload/remove ---
@app.route("/api/thumbnail/upload", methods=["POST"])
def upload_thumbnail():
"""Upload thumbnail image and update site.yaml."""
PHOTOS_DIR = app.config["PHOTOS_DIR"]
file = request.files.get("file")
if not file:
return {"error": "❌ No file provided"}, 400
filename = "thumbnail.png"
file.save(PHOTOS_DIR / filename)
with open(SITE_YAML, "r") as f:
data = yaml.safe_load(f)
data.setdefault("social", {})["thumbnail"] = filename
with open(SITE_YAML, "w") as f:
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok", "filename": filename})
@app.route("/api/thumbnail/remove", methods=["POST"])
def remove_thumbnail():
"""Remove thumbnail image and update site.yaml."""
PHOTOS_DIR = app.config["PHOTOS_DIR"]
thumbnail_path = PHOTOS_DIR / "thumbnail.png"
if thumbnail_path.exists():
thumbnail_path.unlink()
with open(SITE_YAML, "r") as f:
data = yaml.safe_load(f)
if "social" in data and "thumbnail" in data["social"]:
data["social"]["thumbnail"] = ""
with open(SITE_YAML, "w") as f:
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"})
# --- Theme upload ---
@app.route("/api/theme/upload", methods=["POST"])
def upload_theme():
"""Upload a custom theme folder."""
themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
files = request.files.getlist("files")
if not files:
return jsonify({"error": "❌ No files provided"}), 400
first_path = files[0].filename
folder_name = first_path.split("/")[0] if "/" in first_path else "custom"
theme_folder = themes_dir / folder_name
theme_folder.mkdir(parents=True, exist_ok=True)
for file in files:
rel_path = Path(file.filename)
dest_path = theme_folder / rel_path.relative_to(folder_name)
dest_path.parent.mkdir(parents=True, exist_ok=True)
file.save(dest_path)
return jsonify({"status": "ok", "theme": folder_name})
@app.route("/api/theme/remove", methods=["POST"])
def remove_theme():
"""Remove a custom theme folder."""
data = request.get_json()
theme_name = data.get("theme")
if not theme_name:
return jsonify({"error": "❌ Missing theme"}), 400
themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
theme_folder = themes_dir / theme_name
if not theme_folder.exists() or not theme_folder.is_dir():
return jsonify({"error": "❌ Theme not found"}), 404
# Prevent removing default themes
if theme_name in ["modern", "classic"]:
return jsonify({"error": "❌ Cannot remove default theme"}), 400
# Remove folder and all contents
import shutil
shutil.rmtree(theme_folder)
return jsonify({"status": "ok"})
# --- Theme editor page & API ---
@app.route("/theme-editor")
def theme_editor():
"""Render theme editor page."""
return render_template("theme-editor/index.html")
@app.route("/api/theme-info", methods=["GET", "POST"])
def api_theme_info():
"""Get or update theme.yaml for current theme."""
theme_name = get_theme_name()
if request.method == "GET":
theme_yaml = get_theme_yaml(theme_name)
google_fonts = theme_yaml.get("google_fonts", [])
return jsonify({
"theme_name": theme_name,
"theme_yaml": theme_yaml,
"google_fonts": google_fonts
})
else:
data = request.get_json()
theme_yaml = data.get("theme_yaml")
theme_name = data.get("theme_name", theme_name)
save_theme_yaml(theme_name, theme_yaml)
return jsonify({"status": "ok"})
@app.route("/api/theme-google-fonts", methods=["POST"])
def update_theme_google_fonts():
"""Update only google_fonts in theme.yaml for current theme."""
data = request.get_json()
theme_name = data.get("theme_name")
google_fonts = data.get("google_fonts", [])
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
with open(theme_yaml_path, "r") as f:
theme_yaml = yaml.safe_load(f)
theme_yaml["google_fonts"] = google_fonts
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("/api/local-fonts")
def api_local_fonts():
"""List local fonts for a theme."""
theme_name = request.args.get("theme")
fonts = get_local_fonts(theme_name)
return jsonify(fonts)
# --- Favicon upload/remove ---
@app.route("/api/favicon/upload", methods=["POST"])
def upload_favicon():
"""Upload favicon for a theme."""
theme_name = request.form.get("theme")
file = request.files.get("file")
if not file or not theme_name:
return jsonify({"error": "❌ Missing file or theme"}), 400
ext = Path(file.filename).suffix.lower()
if ext not in [".png", ".jpg", ".jpeg", ".ico"]:
return jsonify({"error": "❌ Invalid file type"}), 400
filename = "favicon" + ext
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name
file.save(theme_dir / filename)
theme_yaml_path = theme_dir / "theme.yaml"
with open(theme_yaml_path, "r") as f:
theme_yaml = yaml.safe_load(f)
theme_yaml.setdefault("favicon", {})["path"] = filename
with open(theme_yaml_path, "w") as f:
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok", "filename": filename})
@app.route("/api/favicon/remove", methods=["POST"])
def remove_favicon():
"""Remove favicon for a theme."""
data = request.get_json()
theme_name = data.get("theme")
if not theme_name:
return jsonify({"error": "❌ Missing theme"}), 400
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name
for ext in [".png", ".jpg", ".jpeg", ".ico"]:
favicon_path = theme_dir / f"favicon{ext}"
if favicon_path.exists():
favicon_path.unlink()
theme_yaml_path = theme_dir / "theme.yaml"
with open(theme_yaml_path, "r") as f:
theme_yaml = yaml.safe_load(f)
if "favicon" in theme_yaml:
theme_yaml["favicon"]["path"] = ""
with open(theme_yaml_path, "w") as f:
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"})
# --- Serve theme assets ---
@app.route("/themes/<theme>/<filename>")
def serve_theme_asset(theme, filename):
"""Serve a theme asset file."""
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme
return send_from_directory(theme_dir, filename)
# --- Font upload/remove ---
@app.route("/api/font/upload", methods=["POST"])
def upload_font():
"""Upload a font file for a theme."""
theme_name = request.form.get("theme")
file = request.files.get("file")
if not file or not theme_name:
return jsonify({"error": "❌ Missing theme or font"}), 400
ext = Path(file.filename).suffix.lower()
if ext not in [".woff", ".woff2"]:
return jsonify({"error": "❌ Invalid font file type"}), 400
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True)
file.save(fonts_dir / file.filename)
font_basename = Path(file.filename).stem
return jsonify({"status": "ok", "filename": font_basename})
@app.route("/api/font/remove", methods=["POST"])
def remove_font():
"""Remove a font file for a theme."""
data = request.get_json()
theme_name = data.get("theme")
font = data.get("font")
if not theme_name or not font:
return jsonify({"error": "❌ Missing theme or font"}), 400
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
font_path = fonts_dir / font
if font_path.exists():
font_path.unlink()
return jsonify({"status": "ok"})
return jsonify({"error": "❌ Font not found"}), 404
# --- Build & Download ZIP ---
@app.route("/api/build", methods=["POST"])
def trigger_build():
"""
Validate site.yaml and run build.py.
Does NOT create zip here; zip is created on demand in download route.
"""
site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
output_folder = Path(__file__).resolve().parents[3] / "output"
if not site_yaml_path.exists():
return jsonify({"status": "error", "message": "❌ site.yaml not found"}), 400
with open(site_yaml_path, "r") as f:
site_data = yaml.safe_load(f) or {}
# Dynamically check all main sections and nested keys
main_sections = list(site_data.keys())
for section in main_sections:
value = site_data.get(section)
if not value:
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
if isinstance(value, dict):
for k, v in value.items():
if v is None or v == "" or (isinstance(v, list) and not v):
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}.{k}"}), 400
elif isinstance(value, list):
if not value:
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
for idx, item in enumerate(value):
if isinstance(item, dict):
for k, v in item.items():
if v is None or v == "" or (isinstance(v, list) and not v):
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}[{idx}].{k}"}), 400
elif item is None or item == "":
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}[{idx}]"}), 400
else:
if value is None or value == "":
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
try:
subprocess.run(["python3", "build.py"], check=True)
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"status": "error", "message": f"{str(e)}"}), 500
@app.route("/download-output-zip", methods=["POST"])
def download_output_zip():
"""
Create output zip on demand and send it to the user.
Zip is deleted after sending.
"""
output_folder = Path(__file__).resolve().parents[3] / "output"
zip_path = Path(__file__).resolve().parents[3] / "site_output.zip" # Store in lumeex/ root
# Create zip on demand
with zipfile.ZipFile(zip_path, "w") as zipf:
for root, dirs, files in os.walk(output_folder):
for file in files:
file_path = Path(root) / file
zipf.write(file_path, file_path.relative_to(output_folder))
@after_this_request
def remove_file(response):
try:
os.remove(zip_path)
except Exception:
pass
return response
return send_file(zip_path, as_attachment=True)
# --- Run server ---
if __name__ == "__main__":
logging.info("[~] Starting WebUI at http://0.0.0.0:5000")
logging.info(f"[i] WebUI host port is set to {WEBUI_PORT}")
app.run(host="0.0.0.0", port=5000, debug=True)
+1 -1
View File
@@ -12,7 +12,7 @@
</div>
<div class="inner bottom-link appear">
<p><span class="navigation-subtitle appear">{{ copyright }}</span><span class="nav-separator"></span><span class="navigation-bottom-link appear"><a href="{{ legal_link }}">{{ legal_label }}</a></span></p>
<p class="navigation-subtitle appear"> Built with <a href="https://git.djeex.fr/Djeex/lumeex">Lumeex</a></p>
<p class="navigation-subtitle appear"> Built with <a href="https://lumeex.djeex.fr">Lumeex</a></p>
</div>
</div>
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

+98
View File
@@ -0,0 +1,98 @@
{% extends "template/base.html" %}
{% block title %}Lumeex - Gallery Editor{% endblock %}
{% block content %}
<h1>Gallery editor</h1>
<!-- Hero Upload Section -->
<div class="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>
<span id="hero-count" class="photo-count"></span>
<div id="hero"></div>
<input type="file" id="upload-hero-bottom" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
<div id="bottom-hero-upload" class="upload-actions-row bottom-action-row">
<span id="hero-count-bottom" class="photo-count"></span>
<label for="upload-hero-bottom" class="up-btn">
📸 Upload photos
</label>
<button id="remove-all-hero-bottom" class="up-btn bottom-remove-btn">🗑 Delete all</button>
</div>
</div>
<!-- Gallery Upload Section -->
<div class="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>
<div class="filter-row">
<label>
<input type="radio" name="gallery-filter" id="show-all-radio" checked>
All
</label>
<label>
<input type="radio" name="gallery-filter" id="show-untagged-radio">
Untagged
</label>
</div>
<span id="gallery-count" class="photo-count"></span>
<input type="file" id="upload-gallery" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
<div id="gallery"></div>
<input type="file" id="upload-gallery-bottom" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
<div id="bottom-gallery-upload" class="upload-actions-row bottom-action-row">
<span id="gallery-count-bottom" class="photo-count"></span>
<label for="upload-gallery-bottom" class="up-btn">
📸 Upload photos
</label>
<button id="remove-all-gallery-bottom" class="up-btn bottom-remove-btn">🗑 Delete all</button>
</div>
</div>
<div class="section">
<h2>Steps</h2>
<p> Follow the steps to generate your static gallery</p>
<ul id="stepper">
<li><a class="step-active" href="/gallery-editor">Upload your photos</a></li>
<div></div>
<li><a href="/site-info">Configure site info</a></li>
<div></div>
<li><a href="/theme-editor">Customize your theme</a></li>
<div></div>
<li><button id="stepper-build">Generate your static site!</button></li>
</ul>
</div>
<!-- Delete confirmation modal -->
<div id="delete-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="delete-modal-close" class="modal-close">&times;</span>
<h3>Confirm Deletion</h3>
<p id="delete-modal-text">Are you sure you want to delete this image?</p>
<div class="modal-actions">
<button id="delete-modal-confirm" class="modal-btn danger">Delete</button>
<button id="delete-modal-cancel" class="modal-btn">Cancel</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/gallery-editor.js') }}"></script>
<script src="{{ url_for('static', filename='js/upload.js') }}"></script>
{% endblock %}
+154
View File
@@ -0,0 +1,154 @@
<?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 1000 1000">
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
<defs>
<style>
.st0 {
fill: url(#Dégradé_sans_nom_265);
stroke: url(#Dégradé_sans_nom_33);
}
.st0, .st1, .st2, .st3, .st4, .st5, .st6, .st7, .st8, .st9, .st10, .st11 {
stroke-miterlimit: 10;
}
.st1 {
fill: url(#Dégradé_sans_nom_269);
stroke: url(#Dégradé_sans_nom_334);
}
.st2 {
fill: url(#Dégradé_sans_nom_268);
stroke: url(#Dégradé_sans_nom_333);
}
.st3 {
fill: url(#Dégradé_sans_nom_266);
stroke: url(#Dégradé_sans_nom_331);
}
.st4 {
fill: url(#Dégradé_sans_nom_267);
stroke: url(#Dégradé_sans_nom_332);
}
.st12 {
fill: url(#Dégradé_sans_nom_261);
}
.st13 {
fill: url(#Dégradé_sans_nom_262);
}
.st14 {
fill: url(#Dégradé_sans_nom_264);
}
.st15 {
fill: url(#Dégradé_sans_nom_263);
}
.st5 {
fill: url(#Dégradé_sans_nom_2616);
stroke: url(#Dégradé_sans_nom_3311);
}
.st6 {
fill: url(#Dégradé_sans_nom_2615);
stroke: url(#Dégradé_sans_nom_3310);
}
.st16 {
fill: #fff;
}
.st17 {
fill: url(#Dégradé_sans_nom_26);
}
.st7 {
fill: url(#Dégradé_sans_nom_2610);
stroke: url(#Dégradé_sans_nom_335);
}
.st8 {
fill: url(#Dégradé_sans_nom_2613);
stroke: url(#Dégradé_sans_nom_338);
}
.st9 {
fill: url(#Dégradé_sans_nom_2614);
stroke: url(#Dégradé_sans_nom_339);
}
.st10 {
fill: url(#Dégradé_sans_nom_2611);
stroke: url(#Dégradé_sans_nom_336);
}
.st11 {
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="st16" cx="499.5" cy="499.5" r="499.5"/>
</g>
<g id="Calque_2">
<g id="Calque_3">
<ellipse class="st17" cx="499.2" cy="285.5" rx="139.8" ry="209.5"/>
<ellipse class="st12" cx="299.9" cy="435.8" rx="139.8" ry="209.5" transform="translate(-207.3 586.3) rotate(-72)"/>
<ellipse class="st13" cx="373.6" cy="666.3" rx="209.5" ry="139.8" transform="translate(-385.1 576.9) rotate(-54)"/>
<ellipse class="st15" cx="623.9" cy="665.8" rx="139.8" ry="209.5" transform="translate(-272.2 493.9) rotate(-36)"/>
<ellipse class="st14" cx="703.9" cy="443.1" rx="209.5" ry="139.8" transform="translate(-94.9 211.2) rotate(-16)"/>
<circle class="st0" cx="90.4" cy="576" r="22.4"/>
<circle class="st3" cx="175.6" cy="607.9" r="13.1"/>
<circle class="st4" cx="140.8" cy="691.6" r="28"/>
<circle class="st2" cx="829.7" cy="602.6" r="28"/>
<circle class="st1" cx="908.9" cy="562.1" r="13.1"/>
<circle class="st7" cx="840.9" cy="698.1" r="22.4"/>
<circle class="st10" cx="466.1" cy="876.5" r="22.5"/>
<circle class="st11" cx="538.6" cy="839.8" r="13.1"/>
<circle class="st8" cx="686.1" cy="170.1" r="28"/>
<circle class="st9" cx="733.7" cy="247.7" r="13.1"/>
<circle class="st6" cx="236.9" cy="206.5" r="21.1"/>
<circle class="st5" cx="315.4" cy="164.9" r="13.1"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.6 KiB

+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#48cf51ff" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Gitea icon</title><path d="M4.186 5.421C2.341 5.417-.13 6.59.006 9.531c.213 4.594 4.92 5.02 6.801 5.057.206.862 2.42 3.834 4.059 3.99h7.18c4.306-.286 7.53-13.022 5.14-13.07-3.953.186-6.296.28-8.305.296v3.975l-.626-.277-.004-3.696c-2.306-.001-4.336-.108-8.189-.298-.482-.003-1.154-.085-1.876-.087zm.261 1.625h.22c.262 2.355.688 3.732 1.55 5.836-2.2-.26-4.072-.899-4.416-3.285-.178-1.235.422-2.524 2.646-2.552zm8.557 2.315c.15.002.303.03.447.096l.749.323-.537.979a.672.597 0 0 0-.241.038.672.597 0 0 0-.405.764.672.597 0 0 0 .112.174l-.926 1.686a.672.597 0 0 0-.222.038.672.597 0 0 0-.405.764.672.597 0 0 0 .86.36.672.597 0 0 0 .404-.765.672.597 0 0 0-.158-.22l.902-1.642a.672.597 0 0 0 .293-.03.672.597 0 0 0 .213-.112c.348.146.633.265.838.366.308.152.417.253.45.365.033.11-.003.322-.177.694-.13.277-.345.67-.599 1.133a.672.597 0 0 0-.251.038.672.597 0 0 0-.405.764.672.597 0 0 0 .86.36.672.597 0 0 0 .404-.764.672.597 0 0 0-.137-.202c.251-.458.467-.852.606-1.148.188-.402.286-.701.2-.99-.086-.289-.35-.477-.7-.65-.23-.113-.517-.233-.86-.377a.672.597 0 0 0-.038-.239.672.597 0 0 0-.145-.209l.528-.963 2.924 1.263c.528.229.746.79.49 1.26l-2.01 3.68c-.257.469-.888.663-1.416.435l-4.137-1.788c-.528-.228-.747-.79-.49-1.26l2.01-3.679c.176-.323.53-.515.905-.53h.064z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>github [#142]</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-140.000000, -7559.000000)" fill="#ffffffff">
<g id="icons" transform="translate(56.000000, 160.000000)">
<path d="M94,7399 C99.523,7399 104,7403.59 104,7409.253 C104,7413.782 101.138,7417.624 97.167,7418.981 C96.66,7419.082 96.48,7418.762 96.48,7418.489 C96.48,7418.151 96.492,7417.047 96.492,7415.675 C96.492,7414.719 96.172,7414.095 95.813,7413.777 C98.04,7413.523 100.38,7412.656 100.38,7408.718 C100.38,7407.598 99.992,7406.684 99.35,7405.966 C99.454,7405.707 99.797,7404.664 99.252,7403.252 C99.252,7403.252 98.414,7402.977 96.505,7404.303 C95.706,7404.076 94.85,7403.962 94,7403.958 C93.15,7403.962 92.295,7404.076 91.497,7404.303 C89.586,7402.977 88.746,7403.252 88.746,7403.252 C88.203,7404.664 88.546,7405.707 88.649,7405.966 C88.01,7406.684 87.619,7407.598 87.619,7408.718 C87.619,7412.646 89.954,7413.526 92.175,7413.785 C91.889,7414.041 91.63,7414.493 91.54,7415.156 C90.97,7415.418 89.522,7415.871 88.63,7414.304 C88.63,7414.304 88.101,7413.319 87.097,7413.247 C87.097,7413.247 86.122,7413.234 87.029,7413.87 C87.029,7413.87 87.684,7414.185 88.139,7415.37 C88.139,7415.37 88.726,7417.2 91.508,7416.58 C91.513,7417.437 91.522,7418.245 91.522,7418.489 C91.522,7418.76 91.338,7419.077 90.839,7418.982 C86.865,7417.627 84,7413.783 84,7409.253 C84,7403.59 88.478,7399 94,7399" id="github-[#142]">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+166
View File
@@ -0,0 +1,166 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 6031 1000">
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
<defs>
<style>
.st0 {
fill: #55c3ec;
}
.st1 {
fill: url(#Dégradé_sans_nom_265);
stroke: url(#Dégradé_sans_nom_33);
}
.st1, .st2, .st3, .st4, .st5, .st6, .st7, .st8, .st9, .st10, .st11, .st12 {
stroke-miterlimit: 10;
}
.st2 {
fill: url(#Dégradé_sans_nom_269);
stroke: url(#Dégradé_sans_nom_334);
}
.st3 {
fill: url(#Dégradé_sans_nom_268);
stroke: url(#Dégradé_sans_nom_333);
}
.st4 {
fill: url(#Dégradé_sans_nom_266);
stroke: url(#Dégradé_sans_nom_331);
}
.st5 {
fill: url(#Dégradé_sans_nom_267);
stroke: url(#Dégradé_sans_nom_332);
}
.st13 {
fill: url(#Dégradé_sans_nom_261);
}
.st14 {
fill: url(#Dégradé_sans_nom_262);
}
.st15 {
fill: url(#Dégradé_sans_nom_264);
}
.st16 {
fill: url(#Dégradé_sans_nom_263);
}
.st6 {
fill: url(#Dégradé_sans_nom_2616);
stroke: url(#Dégradé_sans_nom_3311);
}
.st7 {
fill: url(#Dégradé_sans_nom_2615);
stroke: url(#Dégradé_sans_nom_3310);
}
.st17 {
fill: #fff;
}
.st18 {
fill: url(#Dégradé_sans_nom_26);
}
.st8 {
fill: url(#Dégradé_sans_nom_2610);
stroke: url(#Dégradé_sans_nom_335);
}
.st9 {
fill: url(#Dégradé_sans_nom_2613);
stroke: url(#Dégradé_sans_nom_338);
}
.st10 {
fill: url(#Dégradé_sans_nom_2614);
stroke: url(#Dégradé_sans_nom_339);
}
.st11 {
fill: url(#Dégradé_sans_nom_2611);
stroke: url(#Dégradé_sans_nom_336);
}
.st12 {
fill: url(#Dégradé_sans_nom_2612);
stroke: url(#Dégradé_sans_nom_337);
}
</style>
<linearGradient id="Dégradé_sans_nom_26" data-name="Dégradé sans nom 26" x1="373.2" y1="159.5" x2="625.1" y2="411.5" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#55c3ec"/>
<stop offset="1" stop-color="#1d71b8" stop-opacity=".8"/>
</linearGradient>
<linearGradient id="Dégradé_sans_nom_261" data-name="Dégradé sans nom 26" x1="143.1" y1="200.5" x2="395" y2="452.4" gradientTransform="translate(30.8 109.3)" xlink:href="#Dégradé_sans_nom_26"/>
<linearGradient id="Dégradé_sans_nom_262" data-name="Dégradé sans nom 26" x1="81.3" y1="60.6" x2="333.2" y2="312.5" gradientTransform="translate(187.1 873.6) rotate(-90)" xlink:href="#Dégradé_sans_nom_26"/>
<linearGradient id="Dégradé_sans_nom_263" data-name="Dégradé sans nom 26" x1="-44.4" y1="16.5" x2="207.5" y2="268.4" gradientTransform="translate(705.4 808.2) rotate(-180)" xlink:href="#Dégradé_sans_nom_26"/>
<linearGradient id="Dégradé_sans_nom_264" data-name="Dégradé sans nom 26" x1="-67.9" y1="-58.9" x2="184" y2="193" gradientTransform="translate(770.9 385.1) rotate(90)" xlink:href="#Dégradé_sans_nom_26"/>
<linearGradient id="Dégradé_sans_nom_265" data-name="Dégradé sans nom 26" x1="74.5" y1="560.2" x2="106.2" y2="591.8" xlink:href="#Dégradé_sans_nom_26"/>
<linearGradient id="Dégradé_sans_nom_33" data-name="Dégradé sans nom 33" x1="74.2" y1="559.9" x2="106.5" y2="592.2" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#55c3ec"/>
<stop offset="1" stop-color="#1d71b8" stop-opacity=".5"/>
</linearGradient>
<linearGradient id="Dégradé_sans_nom_266" data-name="Dégradé sans nom 26" x1="158.2" y1="648.8" x2="176.7" y2="667.3" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
<linearGradient id="Dégradé_sans_nom_331" data-name="Dégradé sans nom 33" x1="157.8" y1="648.4" x2="177.1" y2="667.7" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
<linearGradient id="Dégradé_sans_nom_267" data-name="Dégradé sans nom 26" x1="210.2" y1="714.7" x2="249.8" y2="754.3" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
<linearGradient id="Dégradé_sans_nom_332" data-name="Dégradé sans nom 33" x1="209.9" y1="714.3" x2="250.2" y2="754.6" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
<linearGradient id="Dégradé_sans_nom_268" data-name="Dégradé sans nom 26" x1="-54" y1="72.2" x2="-14.4" y2="111.8" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
<linearGradient id="Dégradé_sans_nom_333" data-name="Dégradé sans nom 33" x1="-54.4" y1="71.9" x2="-14.1" y2="112.2" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
<linearGradient id="Dégradé_sans_nom_269" data-name="Dégradé sans nom 26" x1="485.1" y1="-9.2" x2="503.7" y2="9.4" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
<linearGradient id="Dégradé_sans_nom_334" data-name="Dégradé sans nom 33" x1="484.8" y1="-9.6" x2="504" y2="9.7" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
<linearGradient id="Dégradé_sans_nom_2610" data-name="Dégradé sans nom 26" x1="825.1" y1="682.3" x2="856.8" y2="713.9" xlink:href="#Dégradé_sans_nom_26"/>
<linearGradient id="Dégradé_sans_nom_335" data-name="Dégradé sans nom 33" x1="824.8" y1="681.9" x2="857.1" y2="714.3" xlink:href="#Dégradé_sans_nom_33"/>
<linearGradient id="Dégradé_sans_nom_2611" data-name="Dégradé sans nom 26" x1="308.5" y1="356.5" x2="340.3" y2="388.3" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
<linearGradient id="Dégradé_sans_nom_336" data-name="Dégradé sans nom 33" x1="308.1" y1="356.2" x2="340.7" y2="388.7" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
<linearGradient id="Dégradé_sans_nom_2612" data-name="Dégradé sans nom 26" x1="540.4" y1="450.4" x2="559" y2="469" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
<linearGradient id="Dégradé_sans_nom_337" data-name="Dégradé sans nom 33" x1="540.1" y1="450" x2="559.4" y2="469.3" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
<linearGradient id="Dégradé_sans_nom_2613" data-name="Dégradé sans nom 26" x1="-336.4" y1="326.9" x2="-296.8" y2="366.5" gradientTransform="translate(342.9 490.5) rotate(-175.4)" xlink:href="#Dégradé_sans_nom_26"/>
<linearGradient id="Dégradé_sans_nom_338" data-name="Dégradé sans nom 33" x1="-336.7" y1="326.5" x2="-296.4" y2="366.8" gradientTransform="translate(342.9 490.5) rotate(-175.4)" xlink:href="#Dégradé_sans_nom_33"/>
<linearGradient id="Dégradé_sans_nom_2614" data-name="Dégradé sans nom 26" x1="115" y1="-29.9" x2="133.6" y2="-11.3" gradientTransform="translate(814.9 151.4) rotate(139.6)" xlink:href="#Dégradé_sans_nom_26"/>
<linearGradient id="Dégradé_sans_nom_339" data-name="Dégradé sans nom 33" x1="114.7" y1="-30.2" x2="134" y2="-11" gradientTransform="translate(814.9 151.4) rotate(139.6)" xlink:href="#Dégradé_sans_nom_33"/>
<linearGradient id="Dégradé_sans_nom_2615" data-name="Dégradé sans nom 26" x1="94.5" y1="304.2" x2="124.4" y2="334" gradientTransform="translate(568 142) rotate(97.9)" xlink:href="#Dégradé_sans_nom_26"/>
<linearGradient id="Dégradé_sans_nom_3310" data-name="Dégradé sans nom 33" x1="94.2" y1="303.8" x2="124.7" y2="334.4" gradientTransform="translate(568 142) rotate(97.9)" xlink:href="#Dégradé_sans_nom_33"/>
<linearGradient id="Dégradé_sans_nom_2616" data-name="Dégradé sans nom 26" x1="435.8" y1="254.1" x2="454.4" y2="272.7" gradientTransform="translate(257.1 -349) rotate(52.9)" xlink:href="#Dégradé_sans_nom_26"/>
<linearGradient id="Dégradé_sans_nom_3311" data-name="Dégradé sans nom 33" x1="435.5" y1="253.8" x2="454.8" y2="273.1" gradientTransform="translate(257.1 -349) rotate(52.9)" xlink:href="#Dégradé_sans_nom_33"/>
</defs>
<g id="Calque_1">
<circle class="st17" cx="499.5" cy="499.5" r="499.5"/>
<g>
<path class="st17" d="M1404,957.4V45h191v755h399v157.4h-590Z"/>
<path class="st17" d="M2321.5,971.3c-49.3,0-91.5-10.2-126.5-30.7-35-20.4-61.6-49.6-79.7-87.6-18.1-37.9-27.2-83.2-27.2-135.9v-437.6h184.6v399c0,44.3,10.4,78.6,31.3,103.1,20.9,24.5,51.9,36.7,93.3,36.7s39.3-3.6,56-10.7c16.6-7.2,30.9-17.4,42.7-30.7,11.8-13.3,20.9-29.1,27.2-47.4s9.5-38.5,9.5-60.4v-389.5h184.6v677.8h-184.6v-111.9h-4.4c-11.4,25.7-26.7,48.1-45.8,67-19.2,19-42.2,33.5-68.9,43.6-26.8,10.1-57.4,15.2-92,15.2Z"/>
<path class="st17" d="M2837.5,957.4V279.6h184.6v113.8h3.8c13.9-38.8,37.6-69.8,71.1-93,33.5-23.2,73-34.8,118.6-34.8s60.1,5.5,85.4,16.4c25.3,11,46.7,26.8,64.2,47.4,17.5,20.7,29.8,46,37,75.9h3.8c10.1-28.7,25.4-53.4,45.8-74.3,20.4-20.9,44.7-37,72.7-48.4,28-11.4,58.7-17.1,92-17.1s83,9.5,116.3,28.5c33.3,19,59.2,45.5,77.8,79.7,18.5,34.1,27.8,74.2,27.8,120.1v463.5h-184.6v-416.7c0-26.6-4.3-48.8-13-66.7-8.6-17.9-21.1-31.6-37.3-41.1-16.2-9.5-36.4-14.2-60.4-14.2s-43.5,5.4-61,16.1c-17.5,10.7-31.1,25.6-40.8,44.6-9.7,19-14.5,41.1-14.5,66.4v411.6h-177.7v-422.4c0-24.4-4.4-45.3-13.3-62.6-8.9-17.3-21.4-30.6-37.6-39.8-16.2-9.3-35.7-13.9-58.5-13.9s-43.6,5.6-61.3,16.8c-17.7,11.2-31.5,26.5-41.4,45.8-9.9,19.4-14.9,41.7-14.9,67v409.1h-184.6Z"/>
<path class="st0" d="M4264,971.3c-69.1,0-128.6-14.2-178.3-42.7-49.7-28.5-88.1-69-115.1-121.7-27-52.7-40.5-115.1-40.5-187.2v-.6c0-72.1,13.5-134.6,40.5-187.5,27-52.9,64.8-93.8,113.5-122.7,48.7-28.9,106.1-43.3,172.3-43.3s123.4,14,171.7,42c48.3,28,85.6,67.6,111.9,118.6,26.3,51,39.5,110.7,39.5,178.9v57.5h-558.3v-117h471.1l-87.9,109.4v-71.5c0-39.6-6.1-72.8-18.3-99.6-12.2-26.8-29.2-46.9-50.9-60.4-21.7-13.5-46.9-20.2-75.6-20.2s-54.1,7-76.2,20.9c-22.1,13.9-39.4,34.3-51.9,61-12.4,26.8-18.7,59.5-18.7,98.3v72.1c0,37.1,6.2,68.9,18.7,95.5,12.4,26.6,30.2,46.9,53.4,61,23.2,14.1,50.8,21.2,82.8,21.2s47.2-4,65.8-12c18.5-8,33.7-18.1,45.5-30.4,11.8-12.2,19.8-24.7,24-37.3l1.3-3.8h169.5l-1.9,7c-5.1,24.9-15,50-29.7,75.2-14.8,25.3-34.7,48.5-59.8,69.6-25.1,21.1-55.6,37.9-91.7,50.6-36,12.6-78.3,19-126.8,19Z"/>
<path class="st0" d="M4982.9,971.3c-69.1,0-128.6-14.2-178.3-42.7-49.7-28.5-88.1-69-115.1-121.7-27-52.7-40.5-115.1-40.5-187.2v-.6c0-72.1,13.5-134.6,40.5-187.5,27-52.9,64.8-93.8,113.5-122.7,48.7-28.9,106.1-43.3,172.3-43.3s123.4,14,171.7,42c48.3,28,85.6,67.6,111.9,118.6,26.3,51,39.5,110.7,39.5,178.9v57.5h-558.3v-117h471.1l-87.9,109.4v-71.5c0-39.6-6.1-72.8-18.3-99.6-12.2-26.8-29.2-46.9-50.9-60.4-21.7-13.5-46.9-20.2-75.6-20.2s-54.1,7-76.2,20.9c-22.1,13.9-39.4,34.3-51.9,61-12.4,26.8-18.7,59.5-18.7,98.3v72.1c0,37.1,6.2,68.9,18.7,95.5,12.4,26.6,30.2,46.9,53.4,61,23.2,14.1,50.8,21.2,82.8,21.2s47.2-4,65.8-12c18.5-8,33.7-18.1,45.5-30.4,11.8-12.2,19.8-24.7,24-37.3l1.3-3.8h169.5l-1.9,7c-5.1,24.9-15,50-29.7,75.2-14.8,25.3-34.7,48.5-59.8,69.6-25.1,21.1-55.6,37.9-91.7,50.6s-78.3,19-126.8,19Z"/>
<path class="st0" d="M5337,957.4l212.5-337.7-210.6-340.2h208l122,228.9h3.8l120.1-228.9h201.1l-211.8,335.1,209.9,342.7h-200.4l-129-234.6h-3.8l-127.1,234.6h-194.8Z"/>
</g>
</g>
<g id="Calque_2">
<g id="Calque_3">
<ellipse class="st18" cx="499.2" cy="285.5" rx="139.8" ry="209.5"/>
<ellipse class="st13" cx="299.9" cy="435.8" rx="139.8" ry="209.5" transform="translate(-207.3 586.3) rotate(-72)"/>
<ellipse class="st14" cx="373.6" cy="666.3" rx="209.5" ry="139.8" transform="translate(-385.1 576.9) rotate(-54)"/>
<ellipse class="st16" cx="623.9" cy="665.8" rx="139.8" ry="209.5" transform="translate(-272.2 493.9) rotate(-36)"/>
<ellipse class="st15" cx="703.9" cy="443.1" rx="209.5" ry="139.8" transform="translate(-94.9 211.2) rotate(-16)"/>
<circle class="st1" cx="90.4" cy="576" r="22.4"/>
<circle class="st4" cx="175.6" cy="607.9" r="13.1"/>
<circle class="st5" cx="140.8" cy="691.6" r="28"/>
<circle class="st3" cx="829.7" cy="602.6" r="28"/>
<circle class="st2" cx="908.9" cy="562.1" r="13.1"/>
<circle class="st8" cx="840.9" cy="698.1" r="22.4"/>
<circle class="st11" cx="466.1" cy="876.5" r="22.5"/>
<circle class="st12" cx="538.6" cy="839.8" r="13.1"/>
<circle class="st9" cx="686.1" cy="170.1" r="28"/>
<circle class="st10" cx="733.7" cy="247.7" r="13.1"/>
<circle class="st7" cx="236.9" cy="206.5" r="21.1"/>
<circle class="st6" cx="315.4" cy="164.9" r="13.1"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

+31
View File
@@ -0,0 +1,31 @@
{% extends "template/base.html" %}
{% block title %}Lumeex{% endblock %}
{% block content %}
<h1>Static Gallery Generator</h1>
<p>Use this generator to create a static gallery from your photos. Then, upload the static files to your preferred web server.</p>
<!-- Hero Upload Section -->
<div class="section">
<h2>Steps</h2>
<p> Follow the steps to generate your static gallery</p>
<div class="stepper">
<ul id="stepper">
<li><a href="/gallery-editor">Upload your photos</a></li>
<div></div>
<li><a href="/site-info">Configure site info</a></li>
<div></div>
<li><a href="/theme-editor">Customize your theme</a></li>
<div></div>
<li><button id="stepper-build">Generate your static site!</button></li>
</ul>
</div>
</div>
{% endblock %}
{% block scripts %}
{% endblock %}
+118
View File
@@ -0,0 +1,118 @@
/**
* Show a toast notification.
* @param {string} message - The message to display.
* @param {string} type - "success" or "error".
* @param {number} duration - Duration in ms.
*/
// --- Loader helpers ---
function showLoader(text = "Uploading...") {
const loader = document.getElementById("global-loader");
if (loader) {
loader.classList.add("active");
document.getElementById("loader-text").textContent = text;
}
}
function hideLoader() {
const loader = document.getElementById("global-loader");
if (loader) loader.classList.remove("active");
}
// --- Toast helpers ---
function showToast(message, type = "success", duration = 3000) {
const container = document.getElementById("toast-container");
if (!container) return;
const toast = document.createElement("div");
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add("show"));
setTimeout(() => {
toast.classList.remove("show");
setTimeout(() => container.removeChild(toast), 300);
}, duration);
}
document.addEventListener("DOMContentLoaded", () => {
// Get build button and modal elements
const buildBtn = document.getElementById("build-btn");
const stepperBuildBtn = document.getElementById("stepper-build"); // Added for stepper build button
const buildModal = document.getElementById("build-success-modal");
const buildModalClose = document.getElementById("build-success-modal-close");
const downloadZipBtn = document.getElementById("download-zip-btn");
const zipLoader = document.getElementById("zip-loader");
// Build action handler
async function handleBuildClick() {
showLoader("Building static site...");
// Trigger build on backend
const res = await fetch("/api/build", { method: "POST" });
const result = await res.json();
hideLoader();
if (result.status === "ok") {
// Show build success modal
if (buildModal) buildModal.style.display = "flex";
} else {
showToast(result.message || "❌ Build failed!", "error");
}
}
// Handle build button click
if (buildBtn) {
buildBtn.addEventListener("click", handleBuildClick);
}
// Handle stepper-build button click
if (stepperBuildBtn) {
stepperBuildBtn.addEventListener("click", handleBuildClick);
}
// Handle download zip button click
if (downloadZipBtn) {
downloadZipBtn.addEventListener("click", async () => {
if (zipLoader) zipLoader.style.display = "block";
downloadZipBtn.disabled = true;
// Request zip creation and download from backend
const res = await fetch("/download-output-zip", { method: "POST" });
if (res.ok) {
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "site_output.zip";
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} else {
showToast("❌ Error creating ZIP", "error");
}
if (zipLoader) zipLoader.style.display = "none";
downloadZipBtn.disabled = false;
});
}
// Preview Site button
const previewBtn = document.getElementById("preview-site-btn");
if (previewBtn) {
const previewPort = previewBtn.getAttribute("data-preview-port") || "3000";
previewBtn.onclick = () => {
const host = window.location.hostname;
const protocol = window.location.protocol;
const url = `${protocol}//${host}:${previewPort}/`;
window.open(url, "_blank");
};
}
// Modal close logic
if (buildModal && buildModalClose) {
buildModalClose.onclick = () => {
buildModal.style.display = "none";
};
window.onclick = function(event) {
if (event.target === buildModal) {
buildModal.style.display = "none";
}
};
}
});
+450
View File
@@ -0,0 +1,450 @@
let galleryImages = [];
let heroImages = [];
let allTags = [];
let showOnlyUntagged = false;
// --- Fade-in helper ---
function applyFadeInImages(container) {
container.querySelectorAll("img.fade-in-img").forEach(img => {
const onLoad = () => img.classList.add("loaded");
if (img.complete && img.naturalHeight !== 0) onLoad();
else {
img.addEventListener("load", onLoad, { once: true });
img.addEventListener("error", () => {
console.warn("Image failed to load:", img.dataset?.src || img.src);
});
}
});
}
// --- Load images from server on page load ---
async function loadData() {
try {
galleryImages = await (await fetch('/api/gallery')).json();
updateAllTags();
renderGallery();
heroImages = await (await fetch('/api/hero')).json();
renderHero();
} catch(err) {
console.error(err);
showToast("Error loading images!", "error");
}
}
// --- Update global tag list from galleryImages ---
function updateAllTags() {
allTags = [];
galleryImages.forEach(img => {
(img.tags || []).forEach(t => {
if (!allTags.includes(t)) allTags.push(t);
});
});
}
// --- Helper: update count and button visibility ---
function updateCountAndButtons(prefix, count) {
const countTop = document.getElementById(`${prefix}-count`);
const countBottom = document.getElementById(`${prefix}-count-bottom`);
if (countTop) countTop.innerHTML = `<p>${count} photos</p>`;
if (countBottom) countBottom.innerHTML = `<p>${count} photos</p>`;
const removeAllBtn = document.getElementById(`remove-all-${prefix}`);
const removeAllBtnBottom = document.getElementById(`remove-all-${prefix}-bottom`);
if (removeAllBtn) removeAllBtn.style.display = count > 0 ? 'inline-block' : 'none';
if (removeAllBtnBottom) removeAllBtnBottom.style.display = count > 0 ? 'inline-block' : 'none';
const bottomUpload = document.getElementById(`bottom-${prefix}-upload`);
if (bottomUpload) bottomUpload.style.display = count > 0 ? 'flex' : 'none';
}
// --- Render gallery images with tags and delete buttons ---
function renderGallery() {
const container = document.getElementById('gallery');
container.innerHTML = '';
let imagesToShow = showOnlyUntagged
? galleryImages.filter(img => !img.tags || img.tags.length === 0)
: galleryImages;
imagesToShow.forEach((img) => {
const i = galleryImages.indexOf(img);
const div = document.createElement('div');
div.className = 'photo flex-item flex-column';
div.innerHTML = `
<div class="flex-item">
<img class="fade-in-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 || []);
});
updateCountAndButtons('gallery', imagesToShow.length);
applyFadeInImages(container);
}
// --- 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 validateBtn = document.createElement('button');
validateBtn.textContent = '✔️';
validateBtn.className = 'validate-tag-btn';
validateBtn.style.display = 'none';
validateBtn.style.marginLeft = '4px';
inputContainer.appendChild(validateBtn);
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';
li.innerHTML = `<b>${s.substring(0, input.value.length)}</b>${s.substring(input.value.length)}`;
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();
validateBtn.style.display = input.value.trim() ? 'inline-block' : 'none';
});
input.addEventListener('focus', () => {
updateSuggestions();
validateBtn.style.display = input.value.trim() ? 'inline-block' : 'none';
});
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();
validateBtn.style.display = 'none';
} else if ([' ', ','].includes(e.key)) {
e.preventDefault();
addTag(input.value);
input.value = '';
updateSuggestions();
validateBtn.style.display = 'none';
}
});
input.addEventListener('blur', () => {
suggestionBox.style.display = 'none';
input.value = '';
validateBtn.style.display = 'none';
});
validateBtn.addEventListener('mousedown', (e) => {
e.preventDefault();
if (input.value.trim()) {
addTag(input.value.trim());
input.value = '';
updateSuggestions();
validateBtn.style.display = 'none';
}
});
updateSuggestions();
if (!input.value.trim()) suggestionBox.style.display = 'none';
}
// --- 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 class="fade-in-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);
});
updateCountAndButtons('hero', heroImages.length);
applyFadeInImages(container);
}
// --- 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;
// --- Show delete confirmation modal ---
function showDeleteModal(type, index = null) {
pendingDelete = { type, index };
const modalText = document.getElementById('delete-modal-text');
modalText.textContent =
type === 'gallery-all' ? "Are you sure you want to delete ALL gallery images?"
: type === 'hero-all' ? "Are you sure you want to delete ALL hero images?"
: "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', () => {
['delete-modal-close', 'delete-modal-cancel'].forEach(id => {
const el = document.getElementById(id);
if (el) el.onclick = hideDeleteModal;
});
const confirmBtn = document.getElementById('delete-modal-confirm');
if (confirmBtn) confirmBtn.onclick = confirmDelete;
// Gallery filter radios
const showAllRadio = document.getElementById('show-all-radio');
const showUntaggedRadio = document.getElementById('show-untagged-radio');
if (showAllRadio) showAllRadio.addEventListener('change', () => {
showOnlyUntagged = false;
renderGallery();
});
if (showUntaggedRadio) showUntaggedRadio.addEventListener('change', () => {
showOnlyUntagged = true;
renderGallery();
});
// Bulk delete buttons
[
['remove-all-gallery', 'gallery-all'],
['remove-all-gallery-bottom', 'gallery-all'],
['remove-all-hero', 'hero-all'],
['remove-all-hero-bottom', 'hero-all']
].forEach(([btnId, type]) => {
const btn = document.getElementById(btnId);
if (btn) btn.onclick = () => showDeleteModal(type);
});
});
// --- Initialize ---
loadData();
+534
View File
@@ -0,0 +1,534 @@
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);
}
function showLoader(text = "Uploading...") {
const loader = document.getElementById("global-loader");
if (loader) {
loader.classList.add("active");
document.getElementById("loader-text").textContent = text;
}
}
function hideLoader() {
const loader = document.getElementById("global-loader");
if (loader) loader.classList.remove("active");
}
document.addEventListener("DOMContentLoaded", () => {
// --- Section Forms ---
const forms = {
info: document.getElementById("info-form"),
social: document.getElementById("social-form"),
menu: document.getElementById("menu-form"),
footer: document.getElementById("footer-form"),
legals: document.getElementById("legals-form"),
build: document.getElementById("build-form")
};
// --- Menu logic ---
const menuList = document.getElementById("menu-items-list");
const addMenuBtn = document.getElementById("add-menu-item");
let menuItems = [];
function renderMenuItems() {
menuList.innerHTML = "";
menuItems.forEach((item, idx) => {
menuList.innerHTML += `
<div style="display:flex;gap:8px;margin-bottom:6px;">
<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>
</div>
`;
});
}
function updateMenuItemsFromInputs() {
const inputs = menuList.querySelectorAll("input");
menuItems = [];
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) menuItems.push({ label, href });
}
}
// --- Intellectual property paragraphs logic ---
const ipList = document.getElementById("ip-list");
const addIpBtn = document.getElementById("add-ip-paragraph");
let ipParagraphs = [];
function renderIpParagraphs() {
ipList.innerHTML = "";
ipParagraphs.forEach((item, idx) => {
ipList.innerHTML += `
<div style="display:flex;gap:8px;margin-bottom:6px;">
<textarea placeholder="Paragraph" required style="flex:1;" data-idx="${idx}">${item.paragraph || ""}</textarea>
<button type="button" class="remove-ip-paragraph" data-idx="${idx}">🗑</button>
</div>
`;
});
}
function updateIpParagraphsFromInputs() {
ipParagraphs = Array.from(ipList.querySelectorAll("textarea"))
.map(textarea => ({ paragraph: textarea.value.trim() }))
.filter(item => item.paragraph !== "");
}
// --- Build options & Theme select ---
const convertImagesCheckbox = document.getElementById("convert-images-checkbox");
const resizeImagesCheckbox = document.getElementById("resize-images-checkbox");
const themeSelect = document.getElementById("theme-select");
// --- Thumbnail upload and modal logic ---
const thumbnailInput = document.getElementById("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 helpers ---
function setupModal(modal, closeBtn, confirmBtn, cancelBtn, onConfirm) {
if (!modal) return;
if (closeBtn) closeBtn.onclick = () => modal.style.display = "none";
if (cancelBtn) cancelBtn.onclick = () => modal.style.display = "none";
window.addEventListener("click", (e) => {
if (e.target === modal) modal.style.display = "none";
});
if (confirmBtn && onConfirm) confirmBtn.onclick = onConfirm;
}
// --- Thumbnail preview logic ---
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";
}
if (chooseThumbnailBtn && thumbnailUpload) {
chooseThumbnailBtn.addEventListener("click", () => thumbnailUpload.click());
}
if (thumbnailUpload) {
thumbnailUpload.addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
showLoader("Uploading thumbnail...");
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/thumbnail/upload", { method: "POST", body: formData });
const result = await res.json();
hideLoader();
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");
}
updateSectionStatus("social");
});
}
if (removeThumbnailBtn) {
removeThumbnailBtn.addEventListener("click", () => {
document.getElementById("delete-modal").style.display = "flex";
});
}
setupModal(
document.getElementById("delete-modal"),
document.getElementById("delete-modal-close"),
document.getElementById("delete-modal-confirm"),
document.getElementById("delete-modal-cancel"),
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");
}
document.getElementById("delete-modal").style.display = "none";
updateSectionStatus("social");
}
);
// --- Theme upload logic ---
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) return;
showLoader("Uploading theme...");
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();
hideLoader();
if (result.status === "ok") {
showToast("✅ Theme uploaded!", "success");
refreshThemes();
} else {
showToast("❌ Error uploading theme", "error");
}
updateSectionStatus("build");
});
}
// --- Remove theme logic ---
const removeThemeBtn = document.getElementById("remove-theme-btn");
let themeToDelete = null;
if (removeThemeBtn && themeSelect) {
removeThemeBtn.addEventListener("click", () => {
const theme = themeSelect.value;
if (!theme) return showToast("❌ No theme selected", "error");
if (["modern", "classic"].includes(theme)) {
showToast("❌ Cannot remove default theme", "error");
return;
}
themeToDelete = theme;
document.getElementById("delete-theme-modal-text").textContent = `Are you sure you want to remove theme "${theme}"?`;
document.getElementById("delete-theme-modal").style.display = "flex";
});
}
setupModal(
document.getElementById("delete-theme-modal"),
document.getElementById("delete-theme-modal-close"),
document.getElementById("delete-theme-modal-confirm"),
document.getElementById("delete-theme-modal-cancel"),
async () => {
if (!themeToDelete) return;
showLoader("Removing theme...");
const res = await fetch("/api/theme/remove", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme: themeToDelete })
});
const result = await res.json();
hideLoader();
if (result.status === "ok") {
showToast("✅ Theme removed!", "success");
refreshThemes();
} else {
showToast(result.error || "❌ Error removing theme", "error");
}
document.getElementById("delete-theme-modal").style.display = "none";
themeToDelete = null;
updateSectionStatus("build");
}
);
// --- Theme select refresh ---
function refreshThemes() {
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);
});
loadConfigAndUpdateBuildStatus();
});
}
// --- Config loading ---
let loadedConfig = {};
function loadConfigAndUpdateBuildStatus() {
fetch("/api/site-info")
.then(res => res.json())
.then(data => {
loadedConfig = data;
// Info
if (forms.info) {
forms.info.elements["info.title"].value = data.info?.title || "";
forms.info.elements["info.subtitle"].value = data.info?.subtitle || "";
forms.info.elements["info.description"].value = data.info?.description || "";
forms.info.elements["info.canonical"].value = data.info?.canonical || "";
forms.info.elements["info.keywords"].value = Array.isArray(data.info?.keywords) ? data.info.keywords.join(", ") : (data.info?.keywords || "");
forms.info.elements["info.author"].value = data.info?.author || "";
}
// Social
if (forms.social) {
forms.social.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()}` : "");
}
// Menu
menuItems = Array.isArray(data.menu?.items) ? data.menu.items : [];
renderMenuItems();
// Footer
if (forms.footer) {
forms.footer.elements["footer.copyright"].value = data.footer?.copyright || "";
forms.footer.elements["footer.legal_label"].value = data.footer?.legal_label || "";
}
// Legals
ipParagraphs = Array.isArray(data.legals?.intellectual_property)
? data.legals.intellectual_property
: [];
renderIpParagraphs();
if (forms.legals) {
forms.legals.elements["legals.hoster_name"].value = data.legals?.hoster_name || "";
forms.legals.elements["legals.hoster_address"].value = data.legals?.hoster_address || "";
forms.legals.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || "";
}
// Build
if (themeSelect) themeSelect.value = data.build?.theme || "";
if (convertImagesCheckbox) convertImagesCheckbox.checked = !!data.build?.convert_images;
if (resizeImagesCheckbox) resizeImagesCheckbox.checked = !!data.build?.resize_images;
["info", "social", "menu", "footer", "legals"].forEach(updateSectionStatus);
updateSectionStatus("build");
});
}
if (themeSelect) refreshThemes();
else loadConfigAndUpdateBuildStatus();
// --- Add/remove menu items ---
if (addMenuBtn) addMenuBtn.addEventListener("click", () => {
menuItems.push({ label: "", href: "" });
renderMenuItems();
updateSectionStatus("menu");
});
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();
updateSectionStatus("menu");
}
});
menuList.addEventListener("input", () => {
updateMenuItemsFromInputs();
updateSectionStatus("menu");
});
// --- Add/remove IP paragraphs ---
if (addIpBtn) addIpBtn.addEventListener("click", () => {
ipParagraphs.push({ paragraph: "" });
renderIpParagraphs();
updateSectionStatus("legals");
});
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();
updateSectionStatus("legals");
}
});
ipList.addEventListener("input", () => {
updateIpParagraphsFromInputs();
updateSectionStatus("legals");
});
// --- Section value helpers ---
function getSectionValues(section) {
switch (section) {
case "info":
return {
title: forms.info.elements["info.title"].value,
subtitle: forms.info.elements["info.subtitle"].value,
description: forms.info.elements["info.description"].value,
canonical: forms.info.elements["info.canonical"].value,
keywords: forms.info.elements["info.keywords"].value.split(",").map(i => i.trim()).filter(Boolean),
author: forms.info.elements["info.author"].value
};
case "social":
return {
instagram_url: forms.social.elements["social.instagram_url"].value,
thumbnail: thumbnailInput ? thumbnailInput.value : ""
};
case "menu":
updateMenuItemsFromInputs();
return { items: menuItems };
case "footer":
return {
copyright: forms.footer.elements["footer.copyright"].value,
legal_label: forms.footer.elements["footer.legal_label"].value
};
case "legals":
updateIpParagraphsFromInputs();
return {
hoster_name: forms.legals.elements["legals.hoster_name"].value,
hoster_address: forms.legals.elements["legals.hoster_address"].value,
hoster_contact: forms.legals.elements["legals.hoster_contact"].value,
intellectual_property: ipParagraphs
};
case "build":
return {
theme: themeSelect ? themeSelect.value : "",
convert_images: !!(convertImagesCheckbox && convertImagesCheckbox.checked),
resize_images: !!(resizeImagesCheckbox && resizeImagesCheckbox.checked)
};
default:
return {};
}
}
function isSectionSaved(section) {
const values = getSectionValues(section);
const config = loadedConfig[section] || {};
function normalizeMenuItems(items) {
return (items || []).map(item => ({
label: item.label || "",
href: item.href || ""
}));
}
switch (section) {
case "info":
return Object.keys(values).every(
key => values[key] && (
key === "keywords"
? Array.isArray(config.keywords) && values.keywords.join(",") === config.keywords.join(",")
: values[key] === (config[key] || "")
)
);
case "social":
return values.instagram_url && values.thumbnail &&
values.instagram_url === (config.instagram_url || "") &&
values.thumbnail === (config.thumbnail || "");
case "menu":
return JSON.stringify(normalizeMenuItems(values.items)) === JSON.stringify(normalizeMenuItems(config.items));
case "footer":
return values.copyright && values.legal_label &&
values.copyright === (config.copyright || "") &&
values.legal_label === (config.legal_label || "");
case "legals":
return values.hoster_name && values.hoster_address && values.hoster_contact &&
values.hoster_name === (config.hoster_name || "") &&
values.hoster_address === (config.hoster_address || "") &&
values.hoster_contact === (config.hoster_contact || "") &&
JSON.stringify(values.intellectual_property) === JSON.stringify(config.intellectual_property || []);
case "build":
return values.theme === (config.theme || "") &&
!!values.convert_images === !!config.convert_images &&
!!values.resize_images === !!config.resize_images;
default:
return true;
}
}
function isSectionComplete(section) {
const values = getSectionValues(section);
switch (section) {
case "info":
return (
values.title &&
values.subtitle &&
values.description &&
values.canonical &&
values.keywords.length > 0 &&
values.author
);
case "social":
return values.instagram_url && values.thumbnail;
case "menu":
return Array.isArray(values.items) && values.items.every(item => item.label && item.href);
case "footer":
return values.copyright && values.legal_label;
case "legals":
return (
values.hoster_name &&
values.hoster_address &&
values.hoster_contact &&
Array.isArray(values.intellectual_property) &&
values.intellectual_property.length > 0 &&
values.intellectual_property.every(ip => ip.paragraph)
);
case "build":
return !!values.theme;
default:
return true;
}
}
function updateSectionStatus(section) {
const statusEl = document.querySelector(`#${section}-section .section-status`);
if (!statusEl) return;
if (!isSectionComplete(section)) {
statusEl.innerHTML = "⚠️ Section not yet saved. Please fill required fields";
statusEl.style.color = "#ffc700";
statusEl.style.display = "";
statusEl.style.fontStyle = "normal";
return;
}
if (isSectionSaved(section)) {
statusEl.innerHTML = "";
statusEl.style.display = "none";
} else {
statusEl.innerHTML = "⚠️ Section not yet saved";
statusEl.style.color = "#ffc700";
statusEl.style.display = "";
statusEl.style.fontStyle = "normal";
}
}
// --- Listen for changes in each section ---
Object.entries(forms).forEach(([section, form]) => {
if (!form) return;
form.addEventListener("input", () => updateSectionStatus(section));
form.addEventListener("change", () => updateSectionStatus(section));
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!form.reportValidity()) {
showToast("❌ Please fill all required fields before saving.", "error");
updateSectionStatus(section);
return;
}
if (section === "social" && (!thumbnailInput || !thumbnailInput.value)) {
showToast("❌ Thumbnail is required.", "error");
updateSectionStatus(section);
return;
}
if (section === "menu") {
updateMenuItemsFromInputs();
if (!menuItems.length || !menuItems.every(item => item.label && item.href)) {
showToast("❌ Please fill all menu item fields.", "error");
updateSectionStatus(section);
return;
}
}
if (section === "legals") {
updateIpParagraphsFromInputs();
if (!ipParagraphs.length || !ipParagraphs.every(ip => ip.paragraph)) {
showToast("❌ Please fill all intellectual property paragraphs.", "error");
updateSectionStatus(section);
return;
}
}
let payload = {};
payload[section] = getSectionValues(section);
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("✅ Section saved!", "success");
fetch("/api/site-info")
.then(res => res.json())
.then(data => {
loadedConfig = data;
updateSectionStatus(section);
});
} else {
showToast("❌ Error saving section", "error");
}
});
});
});
+608
View File
@@ -0,0 +1,608 @@
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);
}
// --- Loader helpers ---
function showLoader(text = "Uploading...") {
const loader = document.getElementById("global-loader");
if (loader) {
loader.classList.add("active");
document.getElementById("loader-text").textContent = text;
}
}
function hideLoader() {
const loader = document.getElementById("global-loader");
if (loader) loader.classList.remove("active");
}
// --- Color Picker
function setupColorPicker(colorId, btnId, textId, initial) {
const colorInput = document.getElementById(colorId);
const colorBtn = document.getElementById(btnId);
const textInput = document.getElementById(textId);
colorInput.value = initial;
colorBtn.style.background = initial;
textInput.value = initial.toUpperCase();
colorInput.addEventListener("input", () => {
colorBtn.style.background = colorInput.value;
textInput.value = colorInput.value.toUpperCase();
});
textInput.addEventListener("input", () => {
if (/^#[0-9A-F]{6}$/i.test(textInput.value)) {
colorInput.value = textInput.value;
colorBtn.style.background = textInput.value;
}
});
}
function setFontDropdown(selectId, value, options) {
const select = document.getElementById(selectId);
if (!select) return;
select.innerHTML = options.map(opt => {
// Remove extension if present
const base = opt.replace(/\.(woff2?|ttf|otf)$/, "");
return `<option value="${base}"${base === value ? " selected" : ""}>${base}</option>`;
}).join("");
}
function setFallbackDropdown(selectId, value) {
const select = document.getElementById(selectId);
if (!select) return;
select.value = (value === "serif" || value === "sans-serif") ? value : "sans-serif";
}
function setTextInput(inputId, value) {
const input = document.getElementById(inputId);
if (input) input.value = value;
}
function renderGoogleFonts(googleFonts) {
const container = document.getElementById("google-fonts-fields");
container.innerHTML = "";
googleFonts.forEach((font, idx) => {
container.innerHTML += `
<div class="input-field" data-idx="${idx}">
<label>Family</label>
<input type="text" name="google_fonts[${idx}][family]" value="${font.family || ""}">
<label>Weights (comma separated)</label>
<input type="text" name="google_fonts[${idx}][weights]" value="${(font.weights || []).join(',')}">
<button type="button" class="remove-google-font remove-btn" data-idx="${idx}"> 🗑️ Remove</button>
</div>
`;
});
}
function renderLocalFonts(fonts) {
const listDiv = document.getElementById("local-fonts-list");
if (!listDiv) return;
listDiv.innerHTML = "";
fonts.forEach(font => {
listDiv.innerHTML += `
<div class="font-item">
<span class="font-name">${font}</span>
<button type="button" class="remove-font-btn danger remove-btn" data-font="${font}">🗑️</button>
</div>
`;
});
}
// --- Section helpers ---
function getSectionValues(section) {
switch (section) {
case "colors":
return {
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
};
case "google-fonts":
const googleFontsFields = document.getElementById("google-fonts-fields");
const fonts = [];
if (googleFontsFields) {
googleFontsFields.querySelectorAll(".input-field").forEach(field => {
const family = field.querySelector('input[name^="google_fonts"][name$="[family]"]').value.trim();
const weights = field.querySelector('input[name^="google_fonts"][name$="[weights]"]').value
.split(",").map(w => w.trim()).filter(Boolean);
if (family) fonts.push({ family, weights });
});
}
return fonts;
case "fonts":
return {
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
}
};
case "favicon":
return {
path: document.getElementById("favicon-path").value
};
default:
return {};
}
}
function isSectionComplete(section) {
switch (section) {
case "colors":
const v = getSectionValues("colors");
return (
v.primary &&
v.primary_dark &&
v.secondary &&
v.accent &&
v.text_dark &&
v.background &&
v.browser_color
);
case "google-fonts":
const fonts = getSectionValues("google-fonts");
return fonts.every(f => f.family);
case "fonts":
const f = getSectionValues("fonts");
return f.primary.name && f.primary.fallback && f.secondary.name && f.secondary.fallback;
case "favicon":
const fav = getSectionValues("favicon");
return !!fav.path;
default:
return true;
}
}
function isSectionSaved(section, loadedConfig) {
switch (section) {
case "colors":
const v = getSectionValues("colors");
const c = loadedConfig.colors || {};
return (
v.primary === c.primary &&
v.primary_dark === c.primary_dark &&
v.secondary === c.secondary &&
v.accent === c.accent &&
v.text_dark === c.text_dark &&
v.background === c.background &&
v.browser_color === c.browser_color
);
case "google-fonts":
const fonts = getSectionValues("google-fonts");
const cf = loadedConfig.google_fonts || [];
return JSON.stringify(fonts) === JSON.stringify(cf);
case "fonts":
const f = getSectionValues("fonts");
const cfnt = loadedConfig.fonts || {};
return (
f.primary.name === (cfnt.primary?.name || "") &&
f.primary.fallback === (cfnt.primary?.fallback || "") &&
f.secondary.name === (cfnt.secondary?.name || "") &&
f.secondary.fallback === (cfnt.secondary?.fallback || "")
);
case "favicon":
const fav = getSectionValues("favicon");
const cfav = loadedConfig.favicon || {};
return fav.path === (cfav.path || "");
default:
return true;
}
}
function updateSectionStatus(section, loadedConfig) {
const statusEl = document.querySelector(`#${section}-form .section-status`);
if (!statusEl) return;
if (!isSectionComplete(section)) {
statusEl.innerHTML = "⚠️ Section not yet saved. Please fill required fields";
statusEl.style.color = "#ffc700";
statusEl.style.display = "";
statusEl.style.fontStyle = "normal";
return;
}
if (isSectionSaved(section, loadedConfig)) {
statusEl.innerHTML = "";
statusEl.style.display = "none";
} else {
statusEl.innerHTML = "⚠️ Section not yet saved";
statusEl.style.color = "#ffc700";
statusEl.style.display = "";
statusEl.style.fontStyle = "normal";
}
}
document.addEventListener("DOMContentLoaded", async () => {
const themeInfo = await fetchThemeInfo();
const themeNameSpan = document.getElementById("current-theme");
if (themeNameSpan) themeNameSpan.textContent = themeInfo.theme_name;
let loadedConfig = themeInfo.theme_yaml;
let googleFonts = loadedConfig.google_fonts ? JSON.parse(JSON.stringify(loadedConfig.google_fonts)) : [];
let localFonts = await fetchLocalFonts(themeInfo.theme_name);
// Colors
if (loadedConfig.colors) {
setupColorPicker("color-primary", "color-primary-btn", "color-primary-text", loadedConfig.colors.primary || "#0065a1");
setupColorPicker("color-primary-dark", "color-primary-dark-btn", "color-primary-dark-text", loadedConfig.colors.primary_dark || "#005384");
setupColorPicker("color-secondary", "color-secondary-btn", "color-secondary-text", loadedConfig.colors.secondary || "#00b0f0");
setupColorPicker("color-accent", "color-accent-btn", "color-accent-text", loadedConfig.colors.accent || "#ffc700");
setupColorPicker("color-text-dark", "color-text-dark-btn", "color-text-dark-text", loadedConfig.colors.text_dark || "#616161");
setupColorPicker("color-background", "color-background-btn", "color-background-text", loadedConfig.colors.background || "#fff");
setupColorPicker("color-browser-color", "color-browser-color-btn", "color-browser-color-text", loadedConfig.colors.browser_color || "#fff");
}
// Fonts
function refreshFontDropdowns() {
setFontDropdown("font-primary", loadedConfig.fonts?.primary?.name || "Lato", [
...googleFonts.map(f => f.family),
...localFonts
]);
setFontDropdown("font-secondary", loadedConfig.fonts?.secondary?.name || "Montserrat", [
...googleFonts.map(f => f.family),
...localFonts
]);
setFallbackDropdown("font-primary-fallback", loadedConfig.fonts?.primary?.fallback || "sans-serif");
setFallbackDropdown("font-secondary-fallback", loadedConfig.fonts?.secondary?.fallback || "serif");
}
refreshFontDropdowns();
// Font upload logic
const fontUploadInput = document.getElementById("font-upload");
const chooseFontBtn = document.getElementById("choose-font-btn");
const localFontsList = document.getElementById("local-fonts-list");
// Modal logic for font deletion
const deleteFontModal = document.getElementById("delete-font-modal");
const deleteFontModalClose = document.getElementById("delete-font-modal-close");
const deleteFontModalConfirm = document.getElementById("delete-font-modal-confirm");
const deleteFontModalCancel = document.getElementById("delete-font-modal-cancel");
let fontToDelete = null;
function refreshLocalFonts() {
renderLocalFonts(localFonts);
refreshFontDropdowns();
}
if (chooseFontBtn && fontUploadInput) {
chooseFontBtn.addEventListener("click", () => fontUploadInput.click());
}
if (fontUploadInput) {
fontUploadInput.addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
const ext = file.name.split('.').pop().toLowerCase();
if (!["woff", "woff2"].includes(ext)) {
showToast("Only .woff and .woff2 fonts are allowed.", "error");
return;
}
showLoader("Uploading font...");
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();
hideLoader();
if (result.status === "ok") {
showToast("✅ Font uploaded!", "success");
localFonts = await fetchLocalFonts(themeInfo.theme_name);
refreshLocalFonts();
} else {
showToast("Error uploading font.", "error");
}
});
}
// Remove font button triggers modal
if (localFontsList) {
localFontsList.addEventListener("click", (e) => {
if (e.target.classList.contains("remove-font-btn")) {
fontToDelete = e.target.dataset.font;
document.getElementById("delete-font-modal-text").textContent =
`Are you sure you want to remove the font "${fontToDelete}"?`;
deleteFontModal.style.display = "flex";
}
});
}
// Modal logic for font deletion
if (deleteFontModal && deleteFontModalClose && deleteFontModalConfirm && deleteFontModalCancel) {
deleteFontModalClose.onclick = deleteFontModalCancel.onclick = () => {
deleteFontModal.style.display = "none";
fontToDelete = null;
};
window.onclick = function(event) {
if (event.target === deleteFontModal) {
deleteFontModal.style.display = "none";
fontToDelete = null;
}
};
deleteFontModalConfirm.onclick = async () => {
if (!fontToDelete) return;
showLoader("Removing font...");
const result = await removeFont(themeInfo.theme_name, fontToDelete);
hideLoader();
if (result.status === "ok") {
showToast("Font removed!", "success");
localFonts = await fetchLocalFonts(themeInfo.theme_name);
refreshLocalFonts();
} else {
showToast("Error removing font.", "error");
}
deleteFontModal.style.display = "none";
fontToDelete = null;
};
}
// Initial render of local fonts
refreshLocalFonts();
// Favicon logic
const faviconInput = document.getElementById("favicon-path");
const faviconUpload = document.getElementById("favicon-upload");
const chooseFaviconBtn = document.getElementById("choose-favicon-btn");
const faviconPreview = document.getElementById("favicon-preview");
const removeFaviconBtn = document.getElementById("remove-favicon-btn");
const deleteFaviconModal = document.getElementById("delete-favicon-modal");
const deleteFaviconModalClose = document.getElementById("delete-favicon-modal-close");
const deleteFaviconModalConfirm = document.getElementById("delete-favicon-modal-confirm");
const deleteFaviconModalCancel = document.getElementById("delete-favicon-modal-cancel");
function updateFaviconPreview(src) {
if (faviconPreview) {
faviconPreview.src = src || "";
faviconPreview.style.display = src ? "block" : "none";
}
if (removeFaviconBtn) {
removeFaviconBtn.style.display = src ? "block" : "none";
}
if (chooseFaviconBtn) {
chooseFaviconBtn.style.display = src ? "none" : "block";
}
}
if (chooseFaviconBtn && faviconUpload) {
chooseFaviconBtn.addEventListener("click", () => faviconUpload.click());
}
if (faviconUpload) {
faviconUpload.addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
const ext = file.name.split('.').pop().toLowerCase();
if (!["png", "jpg", "jpeg", "ico"].includes(ext)) {
showToast("Invalid file type for favicon.", "error");
return;
}
showLoader("Uploading favicon...");
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();
hideLoader();
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 () => {
showLoader("Removing favicon...");
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();
hideLoader();
if (result.status === "ok") {
faviconInput.value = "";
updateFaviconPreview("");
showToast("✅ Favicon removed!", "success");
} else {
showToast("Error removing favicon", "error");
}
deleteFaviconModal.style.display = "none";
};
}
if (loadedConfig.favicon && loadedConfig.favicon.path) {
faviconInput.value = loadedConfig.favicon.path;
updateFaviconPreview(`/themes/${themeInfo.theme_name}/${loadedConfig.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", async () => {
googleFonts.push({ family: "", weights: [] });
await fetch("/api/theme-google-fonts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme_name: themeInfo.theme_name, google_fonts: googleFonts })
});
const updatedThemeInfo = await fetchThemeInfo();
const updatedGoogleFonts = updatedThemeInfo.theme_yaml.google_fonts || [];
googleFonts.length = 0;
googleFonts.push(...updatedGoogleFonts);
renderGoogleFonts(googleFonts);
refreshFontDropdowns();
updateSectionStatus("google-fonts", loadedConfig);
});
}
const googleFontsFields = document.getElementById("google-fonts-fields");
if (googleFontsFields) {
googleFontsFields.addEventListener("blur", async (e) => {
if (
e.target.name &&
(e.target.name.endsWith("[family]") || e.target.name.endsWith("[weights]"))
) {
const fontFields = googleFontsFields.querySelectorAll(".input-field");
googleFonts.length = 0;
fontFields.forEach(field => {
const family = field.querySelector('input[name^="google_fonts"][name$="[family]"]').value.trim();
const weights = field.querySelector('input[name^="google_fonts"][name$="[weights]"]').value
.split(",").map(w => w.trim()).filter(Boolean);
googleFonts.push({ family, weights });
});
await fetch("/api/theme-google-fonts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme_name: themeInfo.theme_name, google_fonts: googleFonts })
});
const updatedThemeInfo = await fetchThemeInfo();
const updatedGoogleFonts = updatedThemeInfo.theme_yaml.google_fonts || [];
googleFonts.length = 0;
googleFonts.push(...updatedGoogleFonts);
renderGoogleFonts(googleFonts);
refreshFontDropdowns();
updateSectionStatus("google-fonts", loadedConfig);
}
}, true);
googleFontsFields.addEventListener("click", async (e) => {
if (e.target.classList.contains("remove-google-font")) {
const idx = Number(e.target.dataset.idx);
googleFonts.splice(idx, 1);
await fetch("/api/theme-google-fonts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme_name: themeInfo.theme_name, google_fonts: googleFonts })
});
const updatedThemeInfo = await fetchThemeInfo();
const updatedGoogleFonts = updatedThemeInfo.theme_yaml.google_fonts || [];
googleFonts.length = 0;
googleFonts.push(...updatedGoogleFonts);
renderGoogleFonts(googleFonts);
refreshFontDropdowns();
updateSectionStatus("google-fonts", loadedConfig);
}
});
}
// --- Section status listeners ---
[
{ form: document.getElementById("colors-form"), section: "colors" },
{ form: document.getElementById("google-fonts-form"), section: "google-fonts" },
{ form: document.getElementById("fonts-form"), section: "fonts" },
{ form: document.getElementById("favicon-form"), section: "favicon" }
].forEach(({ form, section }) => {
if (!form) return;
form.addEventListener("input", () => updateSectionStatus(section, loadedConfig));
form.addEventListener("change", () => updateSectionStatus(section, loadedConfig));
});
// --- Section save handlers ---
[
{ form: document.getElementById("colors-form"), section: "colors" },
{ form: document.getElementById("google-fonts-form"), section: "google-fonts" },
{ form: document.getElementById("fonts-form"), section: "fonts" },
{ form: document.getElementById("favicon-form"), section: "favicon" }
].forEach(({ form, section }) => {
if (!form) return;
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!form.reportValidity() || !isSectionComplete(section)) {
showToast("❌ Please fill all required fields before saving.", "error");
updateSectionStatus(section, loadedConfig);
return;
}
// Merge with loadedConfig to avoid overwriting other sections
let payload = { ...loadedConfig };
switch (section) {
case "colors":
payload.colors = getSectionValues("colors");
break;
case "google-fonts":
payload.google_fonts = getSectionValues("google-fonts");
break;
case "fonts":
payload.fonts = getSectionValues("fonts");
break;
case "favicon":
payload.favicon = getSectionValues("favicon");
break;
}
showLoader("Saving...");
const res = await fetch("/api/theme-info", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme_name: themeInfo.theme_name, theme_yaml: payload })
});
hideLoader();
if (res.ok) {
showToast("✅ Section saved!", "success");
const updatedThemeInfo = await fetchThemeInfo();
loadedConfig = updatedThemeInfo.theme_yaml;
updateSectionStatus(section, loadedConfig);
} else {
showToast("Error saving section.", "error");
}
});
});
// Initial status update
["colors", "google-fonts", "fonts", "favicon"].forEach(section => updateSectionStatus(section, loadedConfig));
});
+47
View File
@@ -0,0 +1,47 @@
// --- Loader helpers ---
function showLoader(text = "Uploading...") {
const loader = document.getElementById("global-loader");
if (loader) {
loader.classList.add("active");
document.getElementById("loader-text").textContent = text;
}
}
function hideLoader() {
const loader = document.getElementById("global-loader");
if (loader) loader.classList.remove("active");
}
// --- Generic upload handler ---
function setupUpload(inputId, apiUrl, loaderText, successMsg, refreshFn) {
const input = document.getElementById(inputId);
if (!input) return;
input.addEventListener('change', async (e) => {
const files = e.target.files;
if (!files.length) return;
showLoader(loaderText);
const formData = new FormData();
for (const file of files) formData.append('files', file);
try {
const res = await fetch(apiUrl, { method: 'POST', body: formData });
const data = await res.json();
hideLoader();
if (res.ok) {
showToast(`${data.uploaded.length} ${successMsg}`, "success");
if (typeof refreshFn === "function") refreshFn();
} else showToast('Error: ' + data.error, "error");
} catch(err) {
hideLoader();
console.error(err);
showToast('Server error!', "error");
} finally {
e.target.value = '';
}
});
}
// --- Setup all upload inputs ---
setupUpload('upload-gallery', '/api/gallery/upload', "Uploading photos...", "gallery image(s) uploaded!", refreshGallery);
setupUpload('upload-hero', '/api/hero/upload', "Uploading hero photos...", "hero image(s) uploaded!", refreshHero);
setupUpload('upload-gallery-bottom', '/api/gallery/upload', "Uploading photos...", "gallery image(s) uploaded!", refreshGallery);
setupUpload('upload-hero-bottom', '/api/hero/upload', "Uploading hero photos...", "hero image(s) uploaded!", refreshHero);
+210
View File
@@ -0,0 +1,210 @@
{% extends "template/base.html" %}
{% block title %}Lumeex - Site Info{% endblock %}
{% block content %}
<h1>Edit Site Info</h1>
<!-- Info Section -->
<form id="info-form" autocomplete="off">
<fieldset id="info-section">
<h2>Info</h2>
<p class="section-status"></p>
<p>Set the basic information for your site and SEO</p>
<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>
<button type="submit" class="section-save-btn" data-section="info">Save</button>
</fieldset>
</form>
<!-- Social Section -->
<form id="social-form" autocomplete="off">
<fieldset id="social-section">
<h2>Social</h2>
<p class="section-status"></p>
<p>Set your social media links and thumbnail for link sharing</p>
<div class="fields">
<div class="input-field">
<label>Instagram URL</label>
<input type="text" name="social.instagram_url" placeholder="https://instagram.com/yourprofile" required>
<label class="thumbnail-form-label">Thumbnail</label>
<input type="file" id="thumbnail-upload" accept="image/png,image/jpeg,image/webp" style="display:none;">
<button type="button" id="choose-thumbnail-btn" class="up-btn">📸 Upload a photo</button>
<div class="thumbnail-form">
<input type="hidden" name="social.thumbnail" id="social-thumbnail" required>
<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>
<button type="submit" class="section-save-btn" data-section="social">Save</button>
</fieldset>
</form>
<!-- Menu Section -->
<form id="menu-form" autocomplete="off">
<fieldset id="menu-section">
<h2>Menu</h2>
<p class="section-status"></p>
<p>Manage your site menu items. You can use tag combination to propose custom filters</p>
<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>
<button type="submit" class="section-save-btn" data-section="menu">Save</button>
</fieldset>
</form>
<!-- Footer Section -->
<form id="footer-form" autocomplete="off">
<fieldset id="footer-section">
<h2>Footer</h2>
<p class="section-status"></p>
<p>Set your copyright informations and legal link name</p>
<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" required>
</div>
</div>
<button type="submit" class="section-save-btn" data-section="footer">Save</button>
</fieldset>
</form>
<!-- Legals Section -->
<form id="legals-form" autocomplete="off">
<fieldset id="legals-section">
<h2>Legals</h2>
<p class="section-status"></p>
<p>Set your legal informations</p>
<div class="fields">
<div class="input-field">
<label>Hoster Name</label>
<input type="text" name="legals.hoster_name" placeholder="Name" required>
</div>
<div class="input-field">
<label>Hoster Address</label>
<input type="text" name="legals.hoster_address" placeholder="Street, Postal Code, City, Country" required>
</div>
<div class="input-field">
<label>Hoster Contact</label>
<input type="text" name="legals.hoster_contact" placeholder="Email or/and Phone" required>
</div>
<div class="input-field" style="flex: 1 1 100%;">
<label>Intellectual Property</label>
<div id="ip-list"></div>
<button type="button" id="add-ip-paragraph">+ Add paragraph</button>
</div>
</div>
<button type="submit" class="section-save-btn" data-section="legals">Save</button>
</fieldset>
</form>
<!-- Build Section -->
<form id="build-form" autocomplete="off">
<fieldset id="build-section">
<h2>Build</h2>
<p class="section-status"></p>
<p>Select a theme from the dropdown menu or add your custom theme folder</p>
<div class="fields">
<div class="input-field">
<label>Theme</label>
<select name="build.theme" id="theme-select"></select>
<button type="button" id="remove-theme-btn" class="remove-btn up-btn danger">🗑 Remove selected theme</button>
<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>
<p>If checked, images will be converted for web and resized to fit the theme</p>
<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>
<button type="submit" class="section-save-btn" data-section="build">Save</button>
</fieldset>
</form>
<!-- Stepper -->
<div class="section">
<h2>Steps</h2>
<p>Follow the steps to generate your static gallery</p>
<ul id="stepper">
<li><a href="/gallery-editor">Upload your photos</a></li>
<div></div>
<li><a class="step-active" href="/site-info">Configure site info</a></li>
<div></div>
<li><a href="/theme-editor">Customize your theme</a></li>
<div></div>
<li><button id="stepper-build">Generate your static site!</button></li>
</ul>
</div>
<!-- Delete thumbnail confirmation modal-->
<div class="content-inner">
<div id="delete-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="delete-modal-close" class="modal-close">&times;</span>
<h3>Confirm Deletion</h3>
<p id="delete-modal-text">Are you sure you want to remove this thumbnail?</p>
<div class="modal-actions">
<button id="delete-modal-confirm" class="modal-btn danger">Remove</button>
<button id="delete-modal-cancel" class="modal-btn">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Delete theme confirmation modal -->
<div class="content-inner">
<div id="delete-theme-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="delete-theme-modal-close" class="modal-close">&times;</span>
<h3>Confirm Theme Deletion</h3>
<p id="delete-theme-modal-text">Are you sure you want to remove this theme?</p>
<div class="modal-actions">
<button id="delete-theme-modal-confirm" class="modal-btn danger">Remove</button>
<button id="delete-theme-modal-cancel" class="modal-btn">Cancel</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/site-info.js') }}"></script>
{% endblock %}
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="UTF-8">
<title>{% block title %}Lumeex{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style/style.css') }}">
<meta name="darkreader-lock">
</head>
<body>
<!-- Top bar -->
<div class="nav-bar">
<div class="content-inner nav">
<div class="nav-header">
<a href="/" class="nav-title">
<img src="{{ url_for('static', filename='img/logo.svg') }}">
</a>
</div>
<!-- Burger toggle input and label -->
<input type="checkbox" id="nav-toggle" class="nav-toggle" hidden>
<label for="nav-toggle" class="nav-burger">
<span></span>
<span></span>
<span></span>
</label>
<div class="nav-links">
<ul class="nav-list">
<li class="nav-item"><a href="/gallery-editor">Gallery</a></li>
<li class="nav-item"><a href="/site-info">Site info</a></li>
<li class="nav-item"><a href="/theme-editor">Theme info</a></li>
<li class="nav-item">
<button id="build-btn" class="button">🚀 Build!</button>
</li>
</ul>
</div>
</div>
</div>
<!-- Toast container for notifications -->
<div class="content-inner first-content">
<div class="inner">
<div id="toast-container"></div>
<!-- Page content -->
{% block content %}{% endblock %}
<!-- Build success modal -->
<div class="content-inner">
<div id="build-success-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="build-success-modal-close" class="modal-close">&times;</span>
<h3>✅ Build completed!</h3>
<p>Your files are available in the output folder.</p>
<div class="modal-actions-row">
<button id="preview-site-btn" class="modal-btn" data-preview-port="{{ preview_port }}">🔎 Preview Site</button>
<button id="download-zip-btn" class="modal-btn">📦 Download ZIP</button>
</div>
<div id="zip-loader" style="display:none;">Creating ZIP...</div>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div id="footer">
<div class="content-inner">
<div class="inner">
<div class="footer-container">
<div class="footer-credit">
<p><a href="https//lumeex.djeex.fr"><span class="lum-first">Lum</span><span class="lum-second">eex</span> v{{ lumeex_version }}</a> — © 2025</p>
</div>
<div class="footer-links">
<a class="footer-link documentation" href="https://lumeex.djeex.fr"><span class="icon"><img src="/img/favicon.svg"></span><span class="icon-text">Documentation</span></a>
<a class="footer-link gitea" href="https://git.djeex.fr/Djeex/lumeex"><span class="icon"><img src="/img/gitea.svg"></span><span class="icon-text">Giteex</span></a>
<a class="footer-link github" href="https://github.com/Djeex/lumeex"><span class="icon"><img src="/img/github.svg"></span><span class="icon-text">Github</span></a>
</div>
</div>
</div>
</div>
</div>
<!-- Loader -->
<div id="global-loader">
<div class="loader-inner">
<div class="loader-spinner"></div>
<div id="loader-text">Uploading...</div>
</div>
</div>
<!-- Scripts -->
<script src="{{ url_for('static', filename='js/build.js') }}" defer></script>
{% block scripts %}{% endblock %}
</body>
</html>
+199
View File
@@ -0,0 +1,199 @@
{% extends "template/base.html" %}
{% block title %}Lumeex - Theme Editor{% endblock %}
{% block content %}
<h1>Edit Theme</h1>
<!-- Show current theme -->
<div class="theme-info">
<strong>Current theme:</strong> <span id="current-theme"></span>
</div>
<div id="theme-editor-form">
<!-- Colors Section -->
<form id="colors-form" autocomplete="off">
<fieldset id="color-picker">
<h2>Colors</h2>
<p class="section-status"></p>
<p>Set the color values for your theme</p>
<div class="fields">
<div class="input-field">
<label>Primary</label>
<div class="fields color-fields">
<button type="button" id="color-primary-btn" class="color-btn"></button>
<input type="color" name="colors.primary" id="color-primary" class="color-input" required>
<input type="text" name="colors.primary_text" id="color-primary-text" style="width:100px;" required>
</div>
</div>
<div class="input-field">
<label>Primary Dark</label>
<div class="fields color-fields">
<button type="button" id="color-primary-dark-btn" class="color-btn"></button>
<input type="color" name="colors.primary_dark" id="color-primary-dark" class="color-input" required>
<input type="text" name="colors.primary_dark_text" id="color-primary-dark-text" style="width:100px;" required>
</div>
</div>
<div class="input-field">
<label>Secondary</label>
<div class="fields color-fields">
<button type="button" id="color-secondary-btn" class="color-btn"></button>
<input type="color" name="colors.secondary" id="color-secondary" class="color-input" required>
<input type="text" name="colors.secondary_text" id="color-secondary-text" style="width:100px;" required>
</div>
</div>
<div class="input-field">
<label>Accent</label>
<div class="fields color-fields">
<button type="button" id="color-accent-btn" class="color-btn"></button>
<input type="color" name="colors.accent" id="color-accent" class="color-input" required>
<input type="text" name="colors.accent_text" id="color-accent-text" style="width:100px;" required>
</div>
</div>
<div class="input-field">
<label>Text Dark</label>
<div class="fields color-fields">
<button type="button" id="color-text-dark-btn" class="color-btn"></button>
<input type="color" name="colors.text_dark" id="color-text-dark" class="color-input" required>
<input type="text" name="colors.text_dark_text" id="color-text-dark-text" style="width:100px;" required>
</div>
</div>
<div class="input-field">
<label>Background</label>
<div class="fields color-fields">
<button type="button" id="color-background-btn" class="color-btn"></button>
<input type="color" name="colors.background" id="color-background" class="color-input" required>
<input type="text" name="colors.background_text" id="color-background-text" style="width:100px;" required>
</div>
</div>
<div class="input-field">
<label>Browser Color</label>
<div class="fields color-fields">
<button type="button" id="color-browser-color-btn" class="color-btn"></button>
<input type="color" name="colors.browser_color" id="color-browser-color" class="color-input" required>
<input type="text" name="colors.browser_color_text" id="color-browser-color-text" style="width:100px;" required>
</div>
</div>
</div>
<button type="submit" class="section-save-btn" data-section="colors">Save</button>
</fieldset>
</form>
<!-- Google Fonts Section -->
<form id="google-fonts-form" autocomplete="off">
<fieldset>
<h2>Google Fonts</h2>
<p class="section-status"></p>
<p>Add Google Fonts to your theme</p>
<div class="fields" id="google-fonts-fields">
<!-- JS will render font family and weights inputs here -->
</div>
<button type="button" id="add-google-font">Add Google Font</button>
<button type="submit" class="section-save-btn" data-section="google-fonts">Save</button>
</fieldset>
</form>
<!-- Custom Font Upload Section -->
<form id="font-upload-form" autocomplete="off">
<fieldset>
<h2>Upload Custom Font</h2>
<p class="section-status"></p>
<p>Supported formats: .woff, .woff2</p>
<input type="file" id="font-upload" accept=".woff,.woff2" style="display:none;">
<div id="local-fonts-list" class="font-list"></div>
<button type="button" id="choose-font-btn" class="up-btn">🖋️ Upload font</button>
<!-- Save button not needed for upload-only section -->
</fieldset>
</form>
<!-- Fonts Section -->
<form id="fonts-form" autocomplete="off">
<fieldset>
<h2>Fonts</h2>
<p class="section-status"></p>
<p>Select where to apply your fonts</p>
<div class="fields">
<div class="input-field">
<label>Primary Font</label>
<select name="fonts.primary.name" id="font-primary" required></select>
<label>Fallback</label>
<select name="fonts.primary.fallback" id="font-primary-fallback" required>
<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" required></select>
<label>Fallback</label>
<select name="fonts.secondary.fallback" id="font-secondary-fallback" required>
<option value="sans-serif">sans-serif</option>
<option value="serif">serif</option>
</select>
</div>
</div>
<button type="submit" class="section-save-btn" data-section="fonts">Save</button>
</fieldset>
</form>
<!-- Favicon Section -->
<form id="favicon-form" autocomplete="off">
<fieldset>
<h2>Favicon</h2>
<p class="section-status"></p>
<p>Supported formats: .png, .jpg, .jpeg</p>
<div class="fields">
<div class="input-field">
<label>Favicon Path</label>
<input type="text" name="favicon.path" id="favicon-path" readonly>
<input type="file" id="favicon-upload" accept=".png,.jpg,.jpeg,.ico" style="display:none;">
<button type="button" id="choose-favicon-btn" class="up-btn">🖼️ Upload favicon</button>
<div class="favicon-form">
<img id="favicon-preview" src="" alt="Favicon preview" style="max-width:48px;display:none;">
<button type="button" id="remove-favicon-btn" class="remove-btn up-btn danger" style="display:none;">Remove</button>
</div>
</div>
</div>
<button type="submit" class="section-save-btn" data-section="favicon">Save</button>
</fieldset>
</form>
</div>
<!-- Stepper -->
<div class="section">
<h2>Steps</h2>
<p>Follow the steps to generate your static gallery</p>
<ul id="stepper">
<li><a href="/gallery-editor">Upload your photos</a></li>
<div></div>
<li><a href="/site-info">Configure site info</a></li>
<div></div>
<li><a class="step-active" href="/theme-editor">Customize your theme</a></li>
<div></div>
<li><button id="stepper-build">Generate your static site!</button></li>
</ul>
</div>
<!-- Delete confirmation modal for favicon -->
<div id="delete-favicon-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="delete-favicon-modal-close" class="modal-close">&times;</span>
<h3>Confirm Deletion</h3>
<p id="delete-favicon-modal-text">Are you sure you want to remove this favicon?</p>
<div class="modal-actions">
<button id="delete-favicon-modal-confirm" class="modal-btn danger">Remove</button>
<button id="delete-favicon-modal-cancel" class="modal-btn">Cancel</button>
</div>
</div>
</div>
<!-- Delete confirmation modal for font -->
<div id="delete-font-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="delete-font-modal-close" class="modal-close">&times;</span>
<h3>Confirm Deletion</h3>
<p id="delete-font-modal-text">Are you sure you want to remove this font?</p>
<div class="modal-actions">
<button id="delete-font-modal-confirm" class="modal-btn danger">Remove</button>
<button id="delete-font-modal-cancel" class="modal-btn">Cancel</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/theme-editor.js') }}"></script>
{% endblock %}
+86
View File
@@ -0,0 +1,86 @@
from pathlib import Path
import pytest
from PIL import Image
REPO_ROOT = Path(__file__).resolve().parent.parent
DEMO_ROOT = REPO_ROOT / "demo" / "config"
@pytest.fixture
def repo_root():
return REPO_ROOT
@pytest.fixture
def demo_root():
return DEMO_ROOT
@pytest.fixture
def make_image():
"""Factory that writes a small real image to disk and returns its path."""
def _make(path: Path, size=(64, 48), color=(200, 100, 50), fmt="JPEG", **save_kwargs):
path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", size, color).save(path, fmt, **save_kwargs)
return path
return _make
@pytest.fixture
def app_env(tmp_path, monkeypatch, make_image):
"""
Isolate the Flask webui from the real repo filesystem.
webui.py resolves most config paths from `Path(__file__).resolve().parents[3]`
(recomputed inline in most routes) or from module-level globals computed once
at import time (PHOTOS_DIR, SITE_YAML). We patch `__file__` so inline lookups
land in a throwaway tmp_path repo skeleton, patch the two import-time globals
directly, and chdir so the plain-relative paths used by gallery_builder
(GALLERY_YAML, GALLERY_DIR, HERO_DIR) resolve there too.
"""
import src.py.webui.webui as webui
root = tmp_path
(root / "config" / "photos" / "gallery").mkdir(parents=True)
(root / "config" / "photos" / "hero").mkdir(parents=True)
theme_dir = root / "config" / "themes" / "modern"
theme_dir.mkdir(parents=True)
(theme_dir / "theme.yaml").write_text(
"colors:\n browser_color: '#ffffff'\n"
"favicon:\n path: favicon.png\n"
"google_fonts: []\n",
encoding="utf-8",
)
make_image(theme_dir / "favicon.png", size=(32, 32), fmt="PNG")
(root / "config" / "gallery.yaml").write_text(
"hero:\n images: []\ngallery:\n images: []\n", encoding="utf-8"
)
(root / "config" / "site.yaml").write_text(
"info:\n title: Test\n canonical: https://example.com\n"
"social:\n thumbnail: ''\n",
encoding="utf-8",
)
fake_module_file = root / "src" / "py" / "webui" / "webui.py"
fake_module_file.parent.mkdir(parents=True, exist_ok=True)
monkeypatch.chdir(root)
monkeypatch.setattr(webui, "__file__", str(fake_module_file))
monkeypatch.setattr(webui, "PHOTOS_DIR", root / "config" / "photos")
monkeypatch.setattr(webui, "SITE_YAML", root / "config" / "site.yaml")
webui.app.config["PHOTOS_DIR"] = root / "config" / "photos"
webui.app.config["TESTING"] = True
return root
@pytest.fixture
def client(app_env):
import src.py.webui.webui as webui
return webui.app.test_client()
+93
View File
@@ -0,0 +1,93 @@
from pathlib import Path
from src.py.builder import utils
def test_load_yaml_missing_file_returns_empty_dict(tmp_path, caplog):
result = utils.load_yaml(tmp_path / "missing.yaml")
assert result == {}
def test_load_yaml_parses_existing_file(tmp_path):
path = tmp_path / "data.yaml"
path.write_text("foo: bar\nnum: 1\n", encoding="utf-8")
assert utils.load_yaml(path) == {"foo": "bar", "num": 1}
def test_load_theme_config_success(tmp_path):
themes_dir = tmp_path / "themes"
theme_dir = themes_dir / "modern"
theme_dir.mkdir(parents=True)
(theme_dir / "theme.yaml").write_text("colors:\n primary: '#000'\n", encoding="utf-8")
theme_vars, returned_dir = utils.load_theme_config("modern", themes_dir)
assert theme_vars == {"colors": {"primary": "#000"}}
assert returned_dir == theme_dir
def test_load_theme_config_missing_raises(tmp_path):
themes_dir = tmp_path / "themes"
try:
utils.load_theme_config("missing", themes_dir)
assert False, "expected FileNotFoundError"
except FileNotFoundError:
pass
def test_clear_dir_creates_missing_dir(tmp_path):
target = tmp_path / "out"
utils.clear_dir(target)
assert target.is_dir()
assert list(target.iterdir()) == []
def test_clear_dir_removes_existing_content(tmp_path):
target = tmp_path / "out"
target.mkdir()
(target / "file.txt").write_text("x", encoding="utf-8")
sub = target / "sub"
sub.mkdir()
(sub / "nested.txt").write_text("y", encoding="utf-8")
utils.clear_dir(target)
assert target.is_dir()
assert list(target.iterdir()) == []
def test_ensure_dir_creates_and_clears(tmp_path):
target = tmp_path / "out"
utils.ensure_dir(target)
assert target.is_dir()
(target / "stale.txt").write_text("x", encoding="utf-8")
utils.ensure_dir(target)
assert list(target.iterdir()) == []
def test_copy_assets_copies_existing_folders(tmp_path):
js_dir = tmp_path / "js"
style_dir = tmp_path / "style"
build_dir = tmp_path / "output"
js_dir.mkdir()
style_dir.mkdir()
(js_dir / "app.js").write_text("console.log(1)", encoding="utf-8")
(style_dir / "style.css").write_text("body{}", encoding="utf-8")
utils.copy_assets(js_dir, style_dir, build_dir)
assert (build_dir / "js" / "app.js").exists()
assert (build_dir / "style" / "style.css").exists()
def test_copy_assets_skips_missing_folder(tmp_path, caplog):
js_dir = tmp_path / "missing_js"
style_dir = tmp_path / "style"
style_dir.mkdir()
build_dir = tmp_path / "output"
utils.copy_assets(js_dir, style_dir, build_dir)
assert not (build_dir / "missing_js").exists()
assert (build_dir / "style").exists()
+68
View File
@@ -0,0 +1,68 @@
from src.py.builder import css_generator as cg
def test_generate_css_variables(tmp_path):
output_path = tmp_path / "style" / "colors.css"
cg.generate_css_variables({"primary_dark": "#005384", "accent": "#FFC700"}, output_path)
content = output_path.read_text(encoding="utf-8")
assert ":root {" in content
assert "--color-primary-dark: #005384;" in content
assert "--color-accent: #FFC700;" in content
def test_generate_fonts_css_with_font_files_and_config(tmp_path):
fonts_dir = tmp_path / "theme" / "fonts"
fonts_dir.mkdir(parents=True)
(fonts_dir / "trixie.woff2").write_bytes(b"fake-woff2")
(fonts_dir / "trixie.woff").write_bytes(b"fake-woff")
(fonts_dir / "readme.txt").write_text("skip me", encoding="utf-8")
output_path = tmp_path / "build" / "style" / "fonts.css"
fonts_cfg = {
"primary": {"name": "Lato", "fallback": "sans-serif"},
"secondary": {"name": "Trixie", "fallback": "monospace"},
}
preload_links = cg.generate_fonts_css(fonts_dir, output_path, fonts_cfg=fonts_cfg)
content = output_path.read_text(encoding="utf-8")
assert "@font-face {" in content
assert "font-family: 'trixie';" in content
assert "--font-primary: 'Lato', sans-serif;" in content
assert "--font-secondary: 'Trixie', monospace;" in content
assert (tmp_path / "build" / "fonts" / "trixie.woff2").exists()
assert (tmp_path / "build" / "fonts" / "trixie.woff").exists()
assert len(preload_links) == 2
assert any("trixie.woff2" in link for link in preload_links)
def test_generate_fonts_css_with_no_fonts(tmp_path):
fonts_dir = tmp_path / "empty_fonts"
fonts_dir.mkdir()
output_path = tmp_path / "build" / "fonts.css"
preload_links = cg.generate_fonts_css(fonts_dir, output_path)
assert preload_links == []
assert output_path.exists()
def test_generate_google_fonts_link_empty():
assert cg.generate_google_fonts_link([]) == ""
assert cg.generate_google_fonts_link(None) == ""
def test_generate_google_fonts_link_with_weights():
fonts = [
{"family": "Lato", "weights": ["200", "400", "700"]},
{"family": "Open Sans"},
]
link = cg.generate_google_fonts_link(fonts)
assert "family=Lato:wght@200;400;700" in link
assert "family=Open+Sans" in link
assert link.startswith('<link href="https://fonts.googleapis.com/css2?')
assert link.endswith('&display=swap" rel="stylesheet">')
+113
View File
@@ -0,0 +1,113 @@
import yaml
from src.py.builder import gallery_builder as gb
def _setup_repo(tmp_path, monkeypatch):
(tmp_path / "config" / "photos" / "gallery").mkdir(parents=True)
(tmp_path / "config" / "photos" / "hero").mkdir(parents=True)
monkeypatch.chdir(tmp_path)
def test_load_yaml_missing_file(tmp_path, monkeypatch):
_setup_repo(tmp_path, monkeypatch)
assert gb.load_yaml("config/gallery.yaml") == {}
def test_load_yaml_and_save_yaml_roundtrip(tmp_path, monkeypatch):
_setup_repo(tmp_path, monkeypatch)
data = {"gallery": {"images": [{"src": "gallery/a.jpg", "tags": ["x"]}]}}
gb.save_yaml(data, "config/gallery.yaml")
loaded = gb.load_yaml("config/gallery.yaml")
assert loaded == data
def test_get_all_image_paths_filters_and_sorts(tmp_path, monkeypatch, make_image):
_setup_repo(tmp_path, monkeypatch)
gallery_dir = tmp_path / "config" / "photos" / "gallery"
make_image(gallery_dir / "b.jpg")
make_image(gallery_dir / "a.png", fmt="PNG")
(gallery_dir / "notes.txt").write_text("ignore me", encoding="utf-8")
paths = gb.get_all_image_paths(gallery_dir)
assert paths == ["gallery/a.png", "gallery/b.jpg"]
def test_update_gallery_adds_new_and_removes_deleted(tmp_path, monkeypatch, make_image, capsys):
_setup_repo(tmp_path, monkeypatch)
gallery_dir = tmp_path / "config" / "photos" / "gallery"
make_image(gallery_dir / "kept.jpg")
make_image(gallery_dir / "new.jpg")
gb.save_yaml(
{
"gallery": {
"images": [
{"src": "gallery/kept.jpg", "tags": ["portrait"]},
{"src": "gallery/gone.jpg", "tags": ["stale"]},
]
}
},
gb.GALLERY_YAML,
)
gb.update_gallery()
result = gb.load_yaml(gb.GALLERY_YAML)
images = {img["src"]: img for img in result["gallery"]["images"]}
assert set(images) == {"gallery/kept.jpg", "gallery/new.jpg"}
assert images["gallery/kept.jpg"]["tags"] == ["portrait"]
assert images["gallery/new.jpg"]["tags"] == []
def test_update_gallery_no_changes(tmp_path, monkeypatch, make_image, capsys):
_setup_repo(tmp_path, monkeypatch)
gallery_dir = tmp_path / "config" / "photos" / "gallery"
make_image(gallery_dir / "only.jpg")
gb.save_yaml(
{"gallery": {"images": [{"src": "gallery/only.jpg", "tags": []}]}},
gb.GALLERY_YAML,
)
gb.update_gallery()
out = capsys.readouterr().out
assert "No changes to gallery.yaml (gallery)" in out
def test_update_hero_adds_new_and_removes_deleted(tmp_path, monkeypatch, make_image):
_setup_repo(tmp_path, monkeypatch)
hero_dir = tmp_path / "config" / "photos" / "hero"
make_image(hero_dir / "kept.jpg")
make_image(hero_dir / "new.jpg")
gb.save_yaml(
{
"hero": {
"images": [
{"src": "hero/kept.jpg"},
{"src": "hero/gone.jpg"},
]
}
},
gb.GALLERY_YAML,
)
gb.update_hero()
result = gb.load_yaml(gb.GALLERY_YAML)
srcs = {img["src"] for img in result["hero"]["images"]}
assert srcs == {"hero/kept.jpg", "hero/new.jpg"}
def test_update_gallery_on_empty_yaml_initializes_section(tmp_path, monkeypatch, make_image):
_setup_repo(tmp_path, monkeypatch)
gallery_dir = tmp_path / "config" / "photos" / "gallery"
make_image(gallery_dir / "only.jpg")
gb.update_gallery()
result = gb.load_yaml(gb.GALLERY_YAML)
assert result["gallery"]["images"][0]["src"] == "gallery/only.jpg"
+67
View File
@@ -0,0 +1,67 @@
import json
from src.py.builder import html_generator as hg
def test_render_template_replaces_placeholders(tmp_path):
template = tmp_path / "t.html"
template.write_text("<h1>{{ title }}</h1><p>{{ missing }}</p>", encoding="utf-8")
result = hg.render_template(template, {"title": "Hello", "missing": None})
assert result == "<h1>Hello</h1><p></p>"
def test_render_template_leaves_unknown_placeholders(tmp_path):
template = tmp_path / "t.html"
template.write_text("<h1>{{ title }}</h1>", encoding="utf-8")
result = hg.render_template(template, {})
assert result == "<h1>{{ title }}</h1>"
def test_render_gallery_images_with_and_without_tags():
images = [
{"src": "gallery/a.jpg", "tags": ["nature", "sky"], "alt": "A photo"},
{"src": "gallery/b.jpg"},
]
html = hg.render_gallery_images(images)
assert 'data-tags="nature sky"' in html
assert '<span class="tag">#nature</span>' in html
assert '<span class="tag">#sky</span>' in html
assert 'data-src="/img/gallery/a.jpg"' in html
assert 'alt="A photo"' in html
assert 'data-tags=""' in html
assert 'data-src="/img/gallery/b.jpg"' in html
assert 'alt=""' in html
def test_generate_gallery_json_from_images(tmp_path):
images = [{"src": "hero/a.jpg"}, {"src": "hero/b.jpg"}]
hg.generate_gallery_json_from_images(images, tmp_path)
output_path = tmp_path / "data" / "gallery.json"
assert json.loads(output_path.read_text(encoding="utf-8")) == ["hero/a.jpg", "hero/b.jpg"]
def test_generate_robots_txt(tmp_path):
hg.generate_robots_txt("https://example.com/", ["/", "legals"], tmp_path)
content = (tmp_path / "robots.txt").read_text(encoding="utf-8")
assert "Disallow: /" in content
assert "Allow: /" in content
assert "Allow: /legals" in content
assert "Sitemap: https://example.com/sitemap.xml" in content
def test_generate_sitemap_xml(tmp_path):
hg.generate_sitemap_xml("https://example.com", ["/", "/legals/"], tmp_path)
content = (tmp_path / "sitemap.xml").read_text(encoding="utf-8")
assert "<loc>https://example.com/</loc>" in content
assert "<loc>https://example.com/legals/</loc>" in content
assert content.startswith('<?xml version="1.0" encoding="UTF-8"?>')
+134
View File
@@ -0,0 +1,134 @@
from pathlib import Path
from PIL import Image
from src.py.builder import image_processor as ip
def test_convert_and_resize_image_missing_input_logs_and_skips(tmp_path):
output_path = tmp_path / "out.webp"
ip.convert_and_resize_image(tmp_path / "missing.jpg", output_path)
assert not output_path.exists()
def test_convert_and_resize_image_resizes_down(tmp_path, make_image):
input_path = make_image(tmp_path / "in.jpg", size=(2000, 1000))
output_path = tmp_path / "out.webp"
ip.convert_and_resize_image(input_path, output_path, resize=True, max_width=1000)
actual_output = output_path if output_path.exists() else output_path.with_suffix(".jpg")
assert actual_output.exists()
with Image.open(actual_output) as img:
assert img.width == 1000
assert img.height == 500
def test_convert_and_resize_image_no_resize_keeps_dimensions(tmp_path, make_image):
input_path = make_image(tmp_path / "in.jpg", size=(500, 300))
output_path = tmp_path / "out.webp"
ip.convert_and_resize_image(input_path, output_path, resize=False)
actual_output = output_path if output_path.exists() else output_path.with_suffix(".jpg")
with Image.open(actual_output) as img:
assert img.size == (500, 300)
def test_convert_and_resize_image_preserves_icc_profile(tmp_path):
input_path = tmp_path / "in.jpg"
icc_bytes = b"fake-icc-profile-bytes"
Image.new("RGB", (100, 80), (10, 20, 30)).save(input_path, "JPEG", icc_profile=icc_bytes)
output_path = tmp_path / "out.webp"
ip.convert_and_resize_image(input_path, output_path, resize=False)
actual_output = output_path if output_path.exists() else output_path.with_suffix(".jpg")
with Image.open(actual_output) as img:
assert img.info.get("icc_profile") == icc_bytes
def test_process_images_updates_src_to_processed_extension(tmp_path, make_image):
img_dir = tmp_path / "photos"
build_dir = tmp_path / "output"
make_image(img_dir / "gallery" / "a.jpg", size=(200, 200))
images = [{"src": "gallery/a.jpg", "tags": []}]
ip.process_images(images, resize_images=True, img_dir=img_dir, build_dir=build_dir)
assert images[0]["src"] in ("gallery/a.webp", "gallery/a.jpg")
produced = build_dir / "img" / images[0]["src"]
assert produced.exists()
def test_copy_original_images_copies_existing_and_skips_missing(tmp_path, make_image, caplog):
img_dir = tmp_path / "photos"
build_dir = tmp_path / "output"
make_image(img_dir / "gallery" / "present.jpg")
images = [{"src": "gallery/present.jpg"}, {"src": "gallery/absent.jpg"}]
ip.copy_original_images(images, img_dir, build_dir)
assert (build_dir / "img" / "gallery" / "present.jpg").exists()
assert not (build_dir / "img" / "gallery" / "absent.jpg").exists()
def test_get_favicon_path_missing_config_returns_none(tmp_path):
assert ip.get_favicon_path({}, tmp_path) is None
def test_get_favicon_path_relative_and_absolute(tmp_path, make_image):
theme_dir = tmp_path / "theme"
icon_path = make_image(theme_dir / "favicon.png", fmt="PNG")
result = ip.get_favicon_path({"favicon": {"path": "favicon.png"}}, theme_dir)
assert result == icon_path
absolute_result = ip.get_favicon_path({"favicon": {"path": str(icon_path)}}, theme_dir)
assert absolute_result == icon_path
def test_get_favicon_path_file_not_found(tmp_path):
result = ip.get_favicon_path({"favicon": {"path": "missing.png"}}, tmp_path)
assert result is None
def test_generate_favicons_from_logo_creates_all_sizes(tmp_path, make_image):
theme_dir = tmp_path / "theme"
make_image(theme_dir / "favicon.png", size=(256, 256), fmt="PNG")
output_dir = tmp_path / "output" / "favicon"
ip.generate_favicons_from_logo({"favicon": {"path": "favicon.png"}}, theme_dir, output_dir)
expected = [
"favicon-32.png", "favicon-96.png", "favicon-128.png",
"favicon-192.png", "favicon-196.png", "favicon-152.png", "favicon-180.png",
]
for name in expected:
assert (output_dir / name).exists()
with Image.open(output_dir / "favicon-32.png") as img:
assert img.size == (32, 32)
def test_generate_favicons_from_logo_no_favicon_skips(tmp_path):
output_dir = tmp_path / "output" / "favicon"
ip.generate_favicons_from_logo({}, tmp_path, output_dir)
assert not output_dir.exists()
def test_generate_favicon_ico_creates_file(tmp_path, make_image):
theme_dir = tmp_path / "theme"
make_image(theme_dir / "favicon.png", size=(64, 64), fmt="PNG")
output_path = tmp_path / "output" / "favicon.ico"
ip.generate_favicon_ico({"favicon": {"path": "favicon.png"}}, theme_dir, output_path)
assert output_path.exists()
with Image.open(output_path) as img:
assert img.format == "ICO"
def test_generate_favicon_ico_no_favicon_skips(tmp_path):
output_path = tmp_path / "output" / "favicon.ico"
ip.generate_favicon_ico({}, tmp_path, output_path)
assert not output_path.exists()
+85
View File
@@ -0,0 +1,85 @@
import yaml
from src.py.builder import site_builder
def test_build_end_to_end_with_demo_content(tmp_path, monkeypatch, demo_root):
output_dir = tmp_path / "output"
monkeypatch.setattr(site_builder, "BUILD_DIR", output_dir)
monkeypatch.setattr(site_builder, "GALLERY_FILE", demo_root / "gallery.yaml")
monkeypatch.setattr(site_builder, "SITE_FILE", demo_root / "site.yaml")
monkeypatch.setattr(site_builder, "IMG_DIR", demo_root / "photos")
monkeypatch.setattr(site_builder, "THEMES_DIR", demo_root / "themes")
site_builder.build()
index_html = (output_dir / "index.html").read_text(encoding="utf-8")
assert "<h1>Lumeex</h1>" in index_html
assert "https://lumeex.djeex.fr/" in index_html
assert '<img class="fade-in-img lazyload"' in index_html
assert (output_dir / "legals" / "index.html").exists()
assert (output_dir / "robots.txt").exists()
assert (output_dir / "sitemap.xml").exists()
assert (output_dir / "data" / "gallery.json").exists()
assert (output_dir / "style" / "colors.css").exists()
assert (output_dir / "style" / "fonts.css").exists()
assert (output_dir / "style" / "theme.css").exists()
assert (output_dir / "favicon.ico").exists()
assert (output_dir / "img" / "favicon" / "favicon-192.png").exists()
assert (output_dir / "img" / "social").is_dir()
assert any((output_dir / "img" / "social").iterdir())
# convert_images=true in the demo config: originals become webp (or jpg fallback)
processed = list((output_dir / "img" / "gallery").glob("*"))
assert processed
assert all(p.suffix in (".webp", ".jpg") for p in processed)
def test_build_without_image_conversion_copies_originals(tmp_path, monkeypatch, demo_root):
site_data = yaml.safe_load((demo_root / "site.yaml").read_text(encoding="utf-8"))
site_data["build"]["convert_images"] = False
site_data["build"]["resize_images"] = False
site_file = tmp_path / "site.yaml"
site_file.write_text(yaml.dump(site_data), encoding="utf-8")
output_dir = tmp_path / "output"
monkeypatch.setattr(site_builder, "BUILD_DIR", output_dir)
monkeypatch.setattr(site_builder, "GALLERY_FILE", demo_root / "gallery.yaml")
monkeypatch.setattr(site_builder, "SITE_FILE", site_file)
monkeypatch.setattr(site_builder, "IMG_DIR", demo_root / "photos")
monkeypatch.setattr(site_builder, "THEMES_DIR", demo_root / "themes")
site_builder.build()
copied = list((output_dir / "img" / "gallery").glob("*.jpg"))
assert copied, "original jpg files should be copied unmodified"
def test_build_minimal_site_skips_optional_sections(tmp_path, monkeypatch, demo_root):
gallery_file = tmp_path / "gallery.yaml"
gallery_file.write_text("hero:\n images: []\ngallery:\n images: []\n", encoding="utf-8")
site_file = tmp_path / "site.yaml"
site_file.write_text(
"info:\n title: Minimal\n"
"menu:\n items: []\n"
"build:\n theme: modern\n convert_images: true\n resize_images: true\n",
encoding="utf-8",
)
output_dir = tmp_path / "output"
monkeypatch.setattr(site_builder, "BUILD_DIR", output_dir)
monkeypatch.setattr(site_builder, "GALLERY_FILE", gallery_file)
monkeypatch.setattr(site_builder, "SITE_FILE", site_file)
monkeypatch.setattr(site_builder, "IMG_DIR", demo_root / "photos")
monkeypatch.setattr(site_builder, "THEMES_DIR", demo_root / "themes")
site_builder.build()
assert (output_dir / "index.html").exists()
assert not (output_dir / "legals").exists()
assert not (output_dir / "robots.txt").exists()
assert not (output_dir / "sitemap.xml").exists()
assert not (output_dir / "data" / "gallery.json").exists()
assert not (output_dir / "img" / "social").exists()
+82
View File
@@ -0,0 +1,82 @@
import io
from src.py.webui.upload import allowed_file, save_uploaded_file
def test_allowed_file_accepts_known_extensions():
for name in ["a.jpg", "a.JPEG", "a.png", "a.webp"]:
assert allowed_file(name) is True
def test_allowed_file_rejects_unknown_or_missing_extension():
assert allowed_file("a.gif") is False
assert allowed_file("noextension") is False
def test_save_uploaded_file_sanitizes_filename(tmp_path):
class FakeFile:
filename = "../../evil.jpg"
def save(self, path):
self.saved_path = path
folder = tmp_path / "gallery"
fake_file = FakeFile()
result = save_uploaded_file(fake_file, folder)
assert result == "evil.jpg"
assert fake_file.saved_path == folder / "evil.jpg"
assert folder.exists()
def test_upload_photo_invalid_section(client):
resp = client.post(
"/api/invalid/upload",
data={"files": (io.BytesIO(b"data"), "a.jpg")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
def test_upload_photo_no_files_key(client):
resp = client.post("/api/gallery/upload", data={}, content_type="multipart/form-data")
assert resp.status_code == 400
def test_upload_photo_skips_disallowed_extensions(client):
resp = client.post(
"/api/gallery/upload",
data={"files": (io.BytesIO(b"data"), "notes.txt")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
assert "No valid files uploaded" in resp.get_json()["error"]
def test_upload_photo_saves_valid_files_and_updates_gallery(client, app_env):
resp = client.post(
"/api/gallery/upload",
data={"files": (io.BytesIO(b"fake-jpg-bytes"), "photo.jpg")},
content_type="multipart/form-data",
)
body = resp.get_json()
assert body["status"] == "ok"
assert body["uploaded"] == ["photo.jpg"]
assert (app_env / "config" / "photos" / "gallery" / "photo.jpg").exists()
gallery = client.get("/api/gallery").get_json()
assert gallery[0]["src"] == "gallery/photo.jpg"
def test_upload_photo_hero_section(client, app_env):
resp = client.post(
"/api/hero/upload",
data={"files": (io.BytesIO(b"fake-jpg-bytes"), "photo.jpg")},
content_type="multipart/form-data",
)
assert resp.get_json()["status"] == "ok"
assert (app_env / "config" / "photos" / "hero" / "photo.jpg").exists()
hero = client.get("/api/hero").get_json()
assert hero[0]["src"] == "hero/photo.jpg"
+312
View File
@@ -0,0 +1,312 @@
import io
import zipfile
import yaml
def test_index_page(client):
resp = client.get("/")
assert resp.status_code == 200
def test_get_gallery_and_hero_start_empty(client):
assert client.get("/api/gallery").get_json() == []
assert client.get("/api/hero").get_json() == []
def test_update_gallery_and_hero(client, app_env):
images = [{"src": "gallery/a.jpg", "tags": ["x"]}]
resp = client.post("/api/gallery/update", json=images)
assert resp.get_json() == {"status": "ok"}
assert client.get("/api/gallery").get_json() == images
hero_images = [{"src": "hero/a.jpg"}]
resp = client.post("/api/hero/update", json=hero_images)
assert resp.get_json() == {"status": "ok"}
assert client.get("/api/hero").get_json() == hero_images
def test_refresh_gallery_and_hero_pick_up_new_files(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "gallery" / "new.jpg")
make_image(app_env / "config" / "photos" / "hero" / "new.jpg")
client.post("/api/gallery/refresh")
client.post("/api/hero/refresh")
gallery = client.get("/api/gallery").get_json()
hero = client.get("/api/hero").get_json()
assert gallery[0]["src"] == "gallery/new.jpg"
assert hero[0]["src"] == "hero/new.jpg"
def test_delete_gallery_photo(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "gallery" / "a.jpg")
resp = client.post("/api/gallery/delete", json={"src": "a.jpg"})
assert resp.get_json() == {"status": "ok"}
assert not (app_env / "config" / "photos" / "gallery" / "a.jpg").exists()
def test_delete_gallery_photo_not_found(client):
resp = client.post("/api/gallery/delete", json={"src": "missing.jpg"})
assert resp.status_code == 404
def test_delete_hero_photo(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "hero" / "a.jpg")
resp = client.post("/api/hero/delete", json={"src": "a.jpg"})
assert resp.get_json() == {"status": "ok"}
assert not (app_env / "config" / "photos" / "hero" / "a.jpg").exists()
def test_delete_all_gallery_photos(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "gallery" / "a.jpg")
make_image(app_env / "config" / "photos" / "gallery" / "b.jpg")
client.post("/api/gallery/update", json=[{"src": "gallery/a.jpg"}, {"src": "gallery/b.jpg"}])
resp = client.post("/api/gallery/delete_all")
body = resp.get_json()
assert body["status"] == "ok"
assert body["deleted"] == 2
assert client.get("/api/gallery").get_json() == []
assert list((app_env / "config" / "photos" / "gallery").iterdir()) == []
def test_delete_all_hero_photos(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "hero" / "a.jpg")
client.post("/api/hero/update", json=[{"src": "hero/a.jpg"}])
resp = client.post("/api/hero/delete_all")
body = resp.get_json()
assert body["deleted"] == 1
assert client.get("/api/hero").get_json() == []
def test_serve_photo_by_section(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "gallery" / "a.jpg")
resp = client.get("/photos/gallery/a.jpg")
assert resp.status_code == 200
def test_serve_photo_at_root(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "thumbnail.png", fmt="PNG")
resp = client.get("/photos/thumbnail.png")
assert resp.status_code == 200
def test_site_info_get_and_post(client, app_env):
resp = client.get("/api/site-info")
data = resp.get_json()
assert data["info"]["title"] == "Test"
client.post("/api/site-info", json={"info": {"subtitle": "New subtitle"}, "social": {"instagram_url": "https://insta.example"}})
updated = client.get("/api/site-info").get_json()
assert updated["info"]["title"] == "Test"
assert updated["info"]["subtitle"] == "New subtitle"
assert updated["social"]["instagram_url"] == "https://insta.example"
def test_list_themes(client):
assert client.get("/api/themes").get_json() == ["modern"]
def test_thumbnail_upload_and_remove(client, app_env):
data = {"file": (io.BytesIO(b"fake-png-bytes"), "thumb.png")}
resp = client.post("/api/thumbnail/upload", data=data, content_type="multipart/form-data")
body = resp.get_json()
assert body["status"] == "ok"
assert (app_env / "config" / "photos" / "thumbnail.png").exists()
site_info = client.get("/api/site-info").get_json()
assert site_info["social"]["thumbnail"] == "thumbnail.png"
resp = client.post("/api/thumbnail/remove")
assert resp.get_json() == {"status": "ok"}
assert not (app_env / "config" / "photos" / "thumbnail.png").exists()
site_info = client.get("/api/site-info").get_json()
assert site_info["social"]["thumbnail"] == ""
def test_thumbnail_upload_missing_file(client):
resp = client.post("/api/thumbnail/upload", data={}, content_type="multipart/form-data")
assert resp.status_code == 400
def test_theme_upload_and_remove(client, app_env):
data = {
"files": [
(io.BytesIO(b"colors: {}"), "custom/theme.yaml"),
(io.BytesIO(b"body{}"), "custom/theme.css"),
]
}
resp = client.post("/api/theme/upload", data=data, content_type="multipart/form-data")
body = resp.get_json()
assert body["status"] == "ok"
assert body["theme"] == "custom"
assert (app_env / "config" / "themes" / "custom" / "theme.yaml").exists()
assert (app_env / "config" / "themes" / "custom" / "theme.css").exists()
resp = client.post("/api/theme/remove", json={"theme": "custom"})
assert resp.get_json() == {"status": "ok"}
assert not (app_env / "config" / "themes" / "custom").exists()
def test_theme_remove_protected_theme(client):
resp = client.post("/api/theme/remove", json={"theme": "modern"})
assert resp.status_code == 400
def test_theme_remove_missing_theme(client):
resp = client.post("/api/theme/remove", json={"theme": "does-not-exist"})
assert resp.status_code == 404
def test_theme_info_get_and_post(client, app_env):
resp = client.get("/api/theme-info")
body = resp.get_json()
assert body["theme_name"] == "modern"
assert body["theme_yaml"]["favicon"]["path"] == "favicon.png"
new_theme_yaml = dict(body["theme_yaml"])
new_theme_yaml["colors"] = {"primary": "#123456"}
resp = client.post(
"/api/theme-info",
json={"theme_name": "modern", "theme_yaml": new_theme_yaml},
)
assert resp.get_json() == {"status": "ok"}
saved = yaml.safe_load((app_env / "config" / "themes" / "modern" / "theme.yaml").read_text())
assert saved["colors"]["primary"] == "#123456"
def test_update_theme_google_fonts(client, app_env):
resp = client.post(
"/api/theme-google-fonts",
json={"theme_name": "modern", "google_fonts": [{"family": "Lato"}]},
)
assert resp.get_json() == {"status": "ok"}
saved = yaml.safe_load((app_env / "config" / "themes" / "modern" / "theme.yaml").read_text())
assert saved["google_fonts"] == [{"family": "Lato"}]
def test_local_fonts_empty_then_populated(client, app_env):
assert client.get("/api/local-fonts?theme=modern").get_json() == []
fonts_dir = app_env / "config" / "themes" / "modern" / "fonts"
fonts_dir.mkdir(parents=True)
(fonts_dir / "trixie.woff2").write_bytes(b"fake")
assert client.get("/api/local-fonts?theme=modern").get_json() == ["trixie.woff2"]
def test_favicon_upload_invalid_extension(client):
data = {"theme": "modern", "file": (io.BytesIO(b"not-an-image"), "favicon.txt")}
resp = client.post("/api/favicon/upload", data=data, content_type="multipart/form-data")
assert resp.status_code == 400
def test_favicon_upload_and_remove(client, app_env):
data = {"theme": "modern", "file": (io.BytesIO(b"fake-png"), "favicon.png")}
resp = client.post("/api/favicon/upload", data=data, content_type="multipart/form-data")
assert resp.get_json()["status"] == "ok"
assert (app_env / "config" / "themes" / "modern" / "favicon.png").exists()
resp = client.post("/api/favicon/remove", json={"theme": "modern"})
assert resp.get_json() == {"status": "ok"}
assert not (app_env / "config" / "themes" / "modern" / "favicon.png").exists()
def test_serve_theme_asset(client, app_env):
(app_env / "config" / "themes" / "modern" / "theme.css").write_text("body{}", encoding="utf-8")
resp = client.get("/themes/modern/theme.css")
assert resp.status_code == 200
def test_font_upload_invalid_extension(client):
data = {"theme": "modern", "file": (io.BytesIO(b"data"), "font.ttf")}
resp = client.post("/api/font/upload", data=data, content_type="multipart/form-data")
assert resp.status_code == 400
def test_font_upload_and_remove(client, app_env):
data = {"theme": "modern", "file": (io.BytesIO(b"data"), "trixie.woff2")}
resp = client.post("/api/font/upload", data=data, content_type="multipart/form-data")
body = resp.get_json()
assert body["status"] == "ok"
assert body["filename"] == "trixie"
resp = client.post("/api/font/remove", json={"theme": "modern", "font": "trixie.woff2"})
assert resp.get_json() == {"status": "ok"}
def test_font_remove_missing(client):
resp = client.post("/api/font/remove", json={"theme": "modern", "font": "missing.woff2"})
assert resp.status_code == 404
def test_trigger_build_missing_site_yaml(client, app_env):
(app_env / "config" / "site.yaml").unlink()
resp = client.post("/api/build")
assert resp.status_code == 400
assert "site.yaml not found" in resp.get_json()["message"]
def test_trigger_build_missing_required_field(client, app_env):
resp = client.post("/api/build")
assert resp.status_code == 400
assert "Site info are not set" in resp.get_json()["message"]
def test_trigger_build_success_invokes_subprocess(client, app_env, monkeypatch):
import src.py.webui.webui as webui
(app_env / "config" / "site.yaml").write_text(
"info:\n title: Test\n canonical: https://example.com\nsocial:\n thumbnail: t.png\n",
encoding="utf-8",
)
calls = []
monkeypatch.setattr(
webui.subprocess, "run", lambda *a, **k: calls.append((a, k))
)
resp = client.post("/api/build")
assert resp.get_json() == {"status": "ok"}
assert calls
def test_trigger_build_subprocess_failure_returns_500(client, app_env, monkeypatch):
import src.py.webui.webui as webui
(app_env / "config" / "site.yaml").write_text(
"info:\n title: Test\n canonical: https://example.com\nsocial:\n thumbnail: t.png\n",
encoding="utf-8",
)
def _raise(*a, **k):
raise RuntimeError("boom")
monkeypatch.setattr(webui.subprocess, "run", _raise)
resp = client.post("/api/build")
assert resp.status_code == 500
assert "boom" in resp.get_json()["message"]
def test_download_output_zip(client, app_env):
output_dir = app_env / "output"
output_dir.mkdir()
(output_dir / "index.html").write_text("<html></html>", encoding="utf-8")
resp = client.post("/download-output-zip")
assert resp.status_code == 200
zip_bytes = io.BytesIO(resp.data)
with zipfile.ZipFile(zip_bytes) as zf:
assert "index.html" in zf.namelist()
assert not (app_env / "site_output.zip").exists()