Compare commits
32
Commits
5fb6ef18d1
..
2.1.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8f42b429a | ||
|
|
f22634e9d9 | ||
|
|
edd39febd1 | ||
|
|
33766f0b0b | ||
|
|
f954ace53b | ||
|
|
6df2d561d9 | ||
|
|
18c894f780 | ||
|
|
ca4240c84a | ||
|
|
83d2dc7ee6 | ||
|
|
8f4d763048 | ||
|
|
d9b821c066 | ||
|
|
7130069adf | ||
|
|
142a48ad5e | ||
|
|
fc5b1c4234 | ||
|
|
2968e875a7 | ||
|
|
a7ce49b989 | ||
|
|
26e3ca684b | ||
|
|
a938234495 | ||
|
|
193b1704f8 | ||
|
|
9d56d729c1 | ||
|
|
40a01c4701 | ||
|
|
28e3b087b0 | ||
|
|
116a9574ce | ||
|
|
6c7216c0e1 | ||
|
|
7c98a6ceaf | ||
|
|
69d303c7a1 | ||
|
|
1d1d7d8304 | ||
|
|
205dcae2bc | ||
|
|
b7fdcacf77 | ||
|
|
9cdf6bbd32 | ||
|
|
f92060603d | ||
|
|
54e9281793 |
@@ -0,0 +1,285 @@
|
|||||||
|
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: Scan for secrets
|
||||||
|
run: |
|
||||||
|
# docker cp, not a build COPY: a repo's own .dockerignore (e.g. one that
|
||||||
|
# excludes .git for prod builds) would otherwise silently give an empty,
|
||||||
|
# falsely-clean scan.
|
||||||
|
CID=$(docker create zricethezav/gitleaks:v8.30.1 detect --source=/repo --no-banner -v)
|
||||||
|
docker cp . "$CID:/repo"
|
||||||
|
docker start -a "$CID"
|
||||||
|
STATUS=$?
|
||||||
|
docker rm "$CID" > /dev/null
|
||||||
|
exit $STATUS
|
||||||
|
|
||||||
|
- name: Lint Dockerfile with hadolint
|
||||||
|
run: docker run --rm -i hadolint/hadolint:v2.15.1-alpine hadolint --failure-threshold error - < Dockerfile
|
||||||
|
|
||||||
|
- 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 --cov=. --cov-report=term-missing --cov-fail-under=90
|
||||||
|
|
||||||
|
- name: Lint with ruff
|
||||||
|
run: docker build --target lint -t lumeex:lint .
|
||||||
|
|
||||||
|
- 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)
|
||||||
|
id: trivy_critical
|
||||||
|
continue-on-error: true
|
||||||
|
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: Handle CRITICAL findings
|
||||||
|
if: steps.trivy_critical.outcome == 'failure'
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" != "schedule" ]; then
|
||||||
|
echo "::error::CRITICAL vulnerabilities found, failing the build."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Scheduled scan found CRITICAL vulnerabilities — attempting an automatic apk upgrade + rescan."
|
||||||
|
|
||||||
|
sed -i '/^FROM .* AS base$/a RUN apk upgrade --no-cache' Dockerfile
|
||||||
|
docker build -t lumeex:remediated .
|
||||||
|
|
||||||
|
if 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:remediated; then
|
||||||
|
echo "apk upgrade clears the CRITICAL finding(s) — opening a PR for review."
|
||||||
|
|
||||||
|
BRANCH="auto/cve-fix-$(date +%Y%m%d)-$(echo "${{ github.sha }}" | cut -c1-7)"
|
||||||
|
git config user.name "lumeex-ci"
|
||||||
|
git config user.email "[email protected]"
|
||||||
|
git checkout -b "$BRANCH"
|
||||||
|
git add Dockerfile
|
||||||
|
git commit -m "Auto-remediate CRITICAL CVE via apk upgrade"
|
||||||
|
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:$BRANCH"
|
||||||
|
|
||||||
|
PR_JSON=$(curl -s -X POST \
|
||||||
|
-H "Authorization: token ${{ secrets.CI_PUSH_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n --arg head "$BRANCH" '{title: "🔒 Auto: remediate CRITICAL CVE via apk upgrade", head: $head, base: "main", body: "Opened automatically by the scheduled CVE scan. An `apk upgrade --no-cache` cleared the CRITICAL Trivy finding(s) in a rebuild — review the diff and merge to publish the fix."}')" \
|
||||||
|
"https://git.djeex.fr/api/v1/repos/Djeex/lumeex/pulls")
|
||||||
|
echo "PR API response: $(echo "$PR_JSON" | jq -r '.html_url // .message // "unknown"')"
|
||||||
|
else
|
||||||
|
echo "::error::apk upgrade does not clear the CRITICAL finding(s) — no automatic fix available, needs manual review."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- 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"
|
||||||
|
|
||||||
|
GHCR_IMAGE=ghcr.io/djeex/lumeex
|
||||||
|
echo "${{ secrets.GH_TOKEN }}" | docker login ghcr.io -u Djeex --password-stdin
|
||||||
|
|
||||||
|
docker tag lumeex:ci "$GHCR_IMAGE:latest"
|
||||||
|
docker tag lumeex:ci "$GHCR_IMAGE:$MINOR_TAG"
|
||||||
|
docker tag lumeex:ci "$GHCR_IMAGE:$VERSION"
|
||||||
|
docker push "$GHCR_IMAGE:latest"
|
||||||
|
docker push "$GHCR_IMAGE:$MINOR_TAG"
|
||||||
|
docker push "$GHCR_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="- %s ([%h](${REPO_URL}/commit/%H))" "$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"
|
||||||
|
|
||||||
|
MIRROR_NOTICE="_This github repo is a mirror of https://git.djeex.fr/Djeex/lumeex. You'll find full package, PR, history and release note there._"
|
||||||
|
GH_BODY=$(printf '%s\n\n%s' "$MIRROR_NOTICE" "$BODY")
|
||||||
|
|
||||||
|
GH_JSON_PAYLOAD=$(jq -n \
|
||||||
|
--arg tag "$VERSION" \
|
||||||
|
--arg name "$VERSION" \
|
||||||
|
--arg body "$GH_BODY" \
|
||||||
|
--arg sha "${{ github.sha }}" \
|
||||||
|
'{tag_name: $tag, name: $name, target_commitish: $sha, body: $body}')
|
||||||
|
|
||||||
|
GH_STATUS=0
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
GH_STATUS=$(curl -s -o /tmp/gh_release.json -w "%{http_code}" -X POST \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.GH_TOKEN }}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$GH_JSON_PAYLOAD" \
|
||||||
|
"https://api.github.com/repos/Djeex/lumeex/releases")
|
||||||
|
if [ "$GH_STATUS" = "201" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "GitHub Release attempt $i failed (HTTP $GH_STATUS) — mirror may not have synced this commit yet, retrying in 15s..."
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
echo "GitHub Release API response: $GH_STATUS"
|
||||||
|
if [ "$GH_STATUS" != "201" ]; then
|
||||||
|
cat /tmp/gh_release.json 2>/dev/null || true
|
||||||
|
fi
|
||||||
@@ -2,5 +2,9 @@
|
|||||||
!.env
|
!.env
|
||||||
!.sh
|
!.sh
|
||||||
!.gitignore
|
!.gitignore
|
||||||
|
!.gitea
|
||||||
|
!.gitea/**
|
||||||
output/
|
output/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
htmlcov/
|
||||||
|
site_output.zip
|
||||||
+26
-5
@@ -1,10 +1,18 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.14.7-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
RUN apk add --no-cache gcc musl-dev jpeg-dev zlib-dev
|
||||||
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
COPY requirements.txt .
|
||||||
|
RUN pip wheel --no-cache-dir --wheel-dir=/wheels -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 build.py gallery.py VERSION /app/
|
||||||
COPY ./src/ ./src/
|
COPY ./src/ ./src/
|
||||||
@@ -12,7 +20,20 @@ COPY ./config /app/default
|
|||||||
COPY ./docker/.sh/entrypoint.sh /app/entrypoint.sh
|
COPY ./docker/.sh/entrypoint.sh /app/entrypoint.sh
|
||||||
RUN chmod +x /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 && \
|
FROM base AS test
|
||||||
printf '#!/bin/sh\n/app/entrypoint.sh gallery\n' > /usr/local/bin/gallery && chmod +x /usr/local/bin/gallery
|
|
||||||
|
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 AS lint
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir ruff==0.16.4
|
||||||
|
COPY ruff.toml /app/ruff.toml
|
||||||
|
COPY ./tests/ ./tests/
|
||||||
|
RUN ruff check . && ruff format --check .
|
||||||
|
|
||||||
|
FROM base
|
||||||
|
|
||||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
Lumeex is a static site generator designed to create minimalist photo galleries that highlight your artworks over the author. It empowers users to organize and explore images using tags, with each page load presenting photos in a random order to encourage discovery of new content.
|
Lumeex is a static site generator 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:
|
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)
|
- **Modern** — [View Demo](https://modern.djeex.fr)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from src.py.builder.site_builder import build
|
from src.py.builder.site_builder import build
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ img, tag {
|
|||||||
|
|
||||||
.hero-background {
|
.hero-background {
|
||||||
max-width: 90%;
|
max-width: 90%;
|
||||||
margin: auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-button {
|
.back-button {
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ colors:
|
|||||||
primary_dark: '#005384'
|
primary_dark: '#005384'
|
||||||
secondary: '#00B0F0'
|
secondary: '#00B0F0'
|
||||||
text_dark: '#616161'
|
text_dark: '#616161'
|
||||||
|
|
||||||
favicon:
|
favicon:
|
||||||
path: favicon.png
|
path: favicon.png
|
||||||
|
|
||||||
fonts:
|
fonts:
|
||||||
primary:
|
primary:
|
||||||
name: trixie.woff
|
name: trixie
|
||||||
fallback: sans-serif
|
fallback: sans-serif
|
||||||
secondary:
|
secondary:
|
||||||
name: trixie.woff
|
name: trixie
|
||||||
fallback: serif
|
fallback: serif
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/bash
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
CYAN="\033[1;36m"
|
CYAN="\033[1;36m"
|
||||||
@@ -6,27 +6,26 @@ NC="\033[0m"
|
|||||||
|
|
||||||
copy_default_config() {
|
copy_default_config() {
|
||||||
echo "[~] Checking configuration directory..."
|
echo "[~] Checking configuration directory..."
|
||||||
if [ ! -d "/app/config" ]; then
|
|
||||||
mkdir -p /app/config
|
mkdir -p /app/config
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[~] Checking if default config files/folders need to be copied..."
|
echo "[~] Checking if default config files/folders need to be copied..."
|
||||||
files_copied=false
|
files_copied=false
|
||||||
|
|
||||||
# Recursively check all files and folders in /app/default
|
for src in $(find /app/default -mindepth 1); do
|
||||||
while IFS= read -r src; do
|
|
||||||
relpath="${src#/app/default/}"
|
relpath="${src#/app/default/}"
|
||||||
target="/app/config/$relpath"
|
target="/app/config/$relpath"
|
||||||
if [ ! -e "$target" ]; then
|
if [ ! -e "$target" ]; then
|
||||||
echo "[→] Copying: $relpath"
|
echo "[→] Copying: $relpath"
|
||||||
if [ -d "$src" ]; then
|
if [ -d "$src" ]; then
|
||||||
cp -r "$src" "$target"
|
mkdir -p "$target"
|
||||||
|
cp -r "$src/" "$target/"
|
||||||
else
|
else
|
||||||
|
mkdir -p "$(dirname "$target")"
|
||||||
cp "$src" "$target"
|
cp "$src" "$target"
|
||||||
fi
|
fi
|
||||||
files_copied=true
|
files_copied=true
|
||||||
fi
|
fi
|
||||||
done < <(find /app/default -mindepth 1)
|
done
|
||||||
|
|
||||||
if [ "$files_copied" = true ]; then
|
if [ "$files_copied" = true ]; then
|
||||||
echo "[✓] Default configuration files/folders copied successfully."
|
echo "[✓] Default configuration files/folders copied successfully."
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from src.py.builder.gallery_builder import update_gallery, update_hero
|
from src.py.builder.gallery_builder import update_gallery, update_hero
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
|
testpaths = tests
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==8.4.2
|
||||||
|
pytest-cov==7.1.0
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
line-length = 100
|
||||||
|
|
||||||
|
[lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B"]
|
||||||
|
ignore = ["E501"]
|
||||||
|
|
||||||
|
[lint.isort]
|
||||||
|
# See nvidia-stock-bot's ruff.toml for why this is pinned explicitly rather
|
||||||
|
# than left to auto-detection.
|
||||||
|
known-first-party = ["src", "build", "gallery"]
|
||||||
|
|
||||||
|
[lint.per-file-ignores]
|
||||||
|
# Real findings (unused var, unused loop var) but app-logic changes,
|
||||||
|
# left for the user to decide rather than auto-fixed.
|
||||||
|
"src/py/webui/webui.py" = ["F841", "B007"]
|
||||||
@@ -32,6 +32,14 @@ window.addEventListener("DOMContentLoaded", () => {
|
|||||||
img.classList.add("loaded");
|
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) {
|
if (img.complete && img.naturalHeight !== 0) {
|
||||||
onLoad(); // already loaded
|
onLoad(); // already loaded
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
|
||||||
from shutil import copyfile
|
from shutil import copyfile
|
||||||
|
|
||||||
|
|
||||||
def generate_css_variables(colors_dict, output_path):
|
def generate_css_variables(colors_dict, output_path):
|
||||||
"""Generate css variables for theme colors"""
|
"""Generate css variables for theme colors"""
|
||||||
css_lines = [":root {"]
|
css_lines = [":root {"]
|
||||||
@@ -13,6 +13,7 @@ def generate_css_variables(colors_dict, output_path):
|
|||||||
f.write("\n".join(css_lines))
|
f.write("\n".join(css_lines))
|
||||||
logging.info(f"[✓] CSS variables written to {output_path}")
|
logging.info(f"[✓] CSS variables written to {output_path}")
|
||||||
|
|
||||||
|
|
||||||
def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
|
def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
|
||||||
"""Generate css variables fonts"""
|
"""Generate css variables fonts"""
|
||||||
font_files = list(fonts_dir.glob("*"))
|
font_files = list(fonts_dir.glob("*"))
|
||||||
@@ -35,7 +36,7 @@ def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
|
|||||||
|
|
||||||
css_lines = []
|
css_lines = []
|
||||||
for font_name, sources in font_faces.items():
|
for font_name, sources in font_faces.items():
|
||||||
css_lines.append(f"@font-face {{")
|
css_lines.append("@font-face {")
|
||||||
css_lines.append(f" font-family: '{font_name}';")
|
css_lines.append(f" font-family: '{font_name}';")
|
||||||
srcs = [f"url('../fonts/{file}') format('{fmt}')" for file, fmt in sorted(sources)]
|
srcs = [f"url('../fonts/{file}') format('{fmt}')" for file, fmt in sorted(sources)]
|
||||||
css_lines.append(f" src: {', '.join(srcs)};")
|
css_lines.append(f" src: {', '.join(srcs)};")
|
||||||
@@ -58,6 +59,7 @@ def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
|
|||||||
logging.info(f"[✓] Generated fonts CSS: {output_path}")
|
logging.info(f"[✓] Generated fonts CSS: {output_path}")
|
||||||
return preload_links
|
return preload_links
|
||||||
|
|
||||||
|
|
||||||
def generate_google_fonts_link(fonts):
|
def generate_google_fonts_link(fonts):
|
||||||
"""Generate src link for Google fonts"""
|
"""Generate src link for Google fonts"""
|
||||||
if not fonts:
|
if not fonts:
|
||||||
@@ -70,5 +72,9 @@ def generate_google_fonts_link(fonts):
|
|||||||
families.append(f"{family}:wght@{';'.join(weights)}")
|
families.append(f"{family}:wght@{';'.join(weights)}")
|
||||||
else:
|
else:
|
||||||
families.append(family)
|
families.append(family)
|
||||||
href = "https://fonts.googleapis.com/css2?" + "&".join(f"family={f}" for f in families) + "&display=swap"
|
href = (
|
||||||
|
"https://fonts.googleapis.com/css2?"
|
||||||
|
+ "&".join(f"family={f}" for f in families)
|
||||||
|
+ "&display=swap"
|
||||||
|
)
|
||||||
return f'<link href="{href}" rel="stylesheet">'
|
return f'<link href="{href}" rel="stylesheet">'
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import yaml
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
# YAML file paths
|
# YAML file paths
|
||||||
GALLERY_YAML = "config/gallery.yaml"
|
GALLERY_YAML = "config/gallery.yaml"
|
||||||
|
|
||||||
@@ -9,31 +10,37 @@ GALLERY_YAML = "config/gallery.yaml"
|
|||||||
GALLERY_DIR = Path("config/photos/gallery")
|
GALLERY_DIR = Path("config/photos/gallery")
|
||||||
HERO_DIR = Path("config/photos/hero")
|
HERO_DIR = Path("config/photos/hero")
|
||||||
|
|
||||||
|
|
||||||
def load_yaml(path):
|
def load_yaml(path):
|
||||||
"""Load gallery config .yaml file"""
|
"""Load gallery config .yaml file"""
|
||||||
print(f"[→] Loading {path}...")
|
print(f"[→] Loading {path}...")
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
print(f"[✗] File not found: {path}")
|
print(f"[✗] File not found: {path}")
|
||||||
return {}
|
return {}
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
data = yaml.safe_load(f) or {}
|
data = yaml.safe_load(f) or {}
|
||||||
images = data.get("images", []) or []
|
images = data.get("images", []) or []
|
||||||
print(f"[✓] Loaded {len(images)} image(s) from {path}")
|
print(f"[✓] Loaded {len(images)} image(s) from {path}")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
def save_yaml(data, path):
|
def save_yaml(data, path):
|
||||||
"""Save modified gallery config .yaml file"""
|
"""Save modified gallery config .yaml file"""
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
yaml.dump(data, f, sort_keys=False, allow_unicode=True)
|
yaml.dump(data, f, sort_keys=False, allow_unicode=True)
|
||||||
print(f"[✓] Saved updated YAML to {path}")
|
print(f"[✓] Saved updated YAML to {path}")
|
||||||
|
|
||||||
|
|
||||||
def get_all_image_paths(directory):
|
def get_all_image_paths(directory):
|
||||||
"""Get the path to record for builded site"""
|
"""Get the path to record for builded site"""
|
||||||
return sorted([
|
return sorted(
|
||||||
|
[
|
||||||
str(p.relative_to(directory.parent)).replace("\\", "/")
|
str(p.relative_to(directory.parent)).replace("\\", "/")
|
||||||
for p in directory.rglob("*")
|
for p in directory.rglob("*")
|
||||||
if p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"]
|
if p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"]
|
||||||
])
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def update_gallery():
|
def update_gallery():
|
||||||
"""Update the gallery photo list"""
|
"""Update the gallery photo list"""
|
||||||
@@ -50,11 +57,7 @@ def update_gallery():
|
|||||||
known_images = {img["src"] for img in gallery_images}
|
known_images = {img["src"] for img in gallery_images}
|
||||||
|
|
||||||
# Add new images
|
# Add new images
|
||||||
new_images = [
|
new_images = [{"src": path, "tags": []} for path in all_images if path not in known_images]
|
||||||
{"src": path, "tags": []}
|
|
||||||
for path in all_images
|
|
||||||
if path not in known_images
|
|
||||||
]
|
|
||||||
if new_images:
|
if new_images:
|
||||||
gallery_images.extend(new_images)
|
gallery_images.extend(new_images)
|
||||||
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (gallery)")
|
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (gallery)")
|
||||||
@@ -74,6 +77,7 @@ def update_gallery():
|
|||||||
if not new_images and not deleted_images:
|
if not new_images and not deleted_images:
|
||||||
print("[✓] No changes to gallery.yaml (gallery)")
|
print("[✓] No changes to gallery.yaml (gallery)")
|
||||||
|
|
||||||
|
|
||||||
def update_hero():
|
def update_hero():
|
||||||
"""Update the hero photo list"""
|
"""Update the hero photo list"""
|
||||||
print("\n=== Updating gallery.yaml (hero section) ===")
|
print("\n=== Updating gallery.yaml (hero section) ===")
|
||||||
@@ -89,11 +93,7 @@ def update_hero():
|
|||||||
known_images = {img["src"] for img in hero_images}
|
known_images = {img["src"] for img in hero_images}
|
||||||
|
|
||||||
# Add new images
|
# Add new images
|
||||||
new_images = [
|
new_images = [{"src": path} for path in all_images if path not in known_images]
|
||||||
{"src": path}
|
|
||||||
for path in all_images
|
|
||||||
if path not in known_images
|
|
||||||
]
|
|
||||||
if new_images:
|
if new_images:
|
||||||
hero_images.extend(new_images)
|
hero_images.extend(new_images)
|
||||||
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (hero)")
|
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (hero)")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
def render_template(template_path, context):
|
def render_template(template_path, context):
|
||||||
"""Render html templates"""
|
"""Render html templates"""
|
||||||
with open(template_path, encoding="utf-8") as f:
|
with open(template_path, encoding="utf-8") as f:
|
||||||
@@ -11,6 +12,7 @@ def render_template(template_path, context):
|
|||||||
content = content.replace(placeholder, str(value) if value is not None else "")
|
content = content.replace(placeholder, str(value) if value is not None else "")
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
def render_gallery_images(images):
|
def render_gallery_images(images):
|
||||||
"""Render the photo gallery"""
|
"""Render the photo gallery"""
|
||||||
html = ""
|
html = ""
|
||||||
@@ -20,11 +22,12 @@ def render_gallery_images(images):
|
|||||||
html += f"""
|
html += f"""
|
||||||
<div class="section" data-tags="{tags}">
|
<div class="section" data-tags="{tags}">
|
||||||
<div class="tags">{tag_html}</div>
|
<div class="tags">{tag_html}</div>
|
||||||
<img class="fade-in-img lazyload" data-src="/img/{img['src']}" alt="{img.get('alt', '')}" loading="lazy">
|
<img class="fade-in-img lazyload" data-src="/img/{img["src"]}" alt="{img.get("alt", "")}" loading="lazy">
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
return html
|
return html
|
||||||
|
|
||||||
|
|
||||||
def generate_gallery_json_from_images(images, output_dir):
|
def generate_gallery_json_from_images(images, output_dir):
|
||||||
"""Generte the hero carrousel photo list"""
|
"""Generte the hero carrousel photo list"""
|
||||||
try:
|
try:
|
||||||
@@ -37,6 +40,7 @@ def generate_gallery_json_from_images(images, output_dir):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"[✗] Error generating gallery JSON: {e}")
|
logging.error(f"[✗] Error generating gallery JSON: {e}")
|
||||||
|
|
||||||
|
|
||||||
def generate_robots_txt(canonical_url, allowed_paths, output_dir):
|
def generate_robots_txt(canonical_url, allowed_paths, output_dir):
|
||||||
"""Generate the robot.txt"""
|
"""Generate the robot.txt"""
|
||||||
robots_lines = ["User-agent: *"]
|
robots_lines = ["User-agent: *"]
|
||||||
@@ -65,10 +69,11 @@ def generate_robots_txt(canonical_url, allowed_paths, output_dir):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"[✗] Failed to write robots.txt: {e}")
|
logging.error(f"[✗] Failed to write robots.txt: {e}")
|
||||||
|
|
||||||
|
|
||||||
def generate_sitemap_xml(canonical_url, allowed_paths, output_dir):
|
def generate_sitemap_xml(canonical_url, allowed_paths, output_dir):
|
||||||
"""Generate the sitemap"""
|
"""Generate the sitemap"""
|
||||||
urlset_start = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
urlset_start = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||||
urlset_end = '</urlset>\n'
|
urlset_end = "</urlset>\n"
|
||||||
urls = ""
|
urls = ""
|
||||||
for path in allowed_paths:
|
for path in allowed_paths:
|
||||||
loc = canonical_url.rstrip("/") + path
|
loc = canonical_url.rstrip("/") + path
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PIL import Image, features
|
|
||||||
from shutil import copyfile
|
from shutil import copyfile
|
||||||
|
|
||||||
|
from PIL import Image, features
|
||||||
|
|
||||||
|
|
||||||
def convert_and_resize_image(input_path, output_path, resize=True, max_width=1140):
|
def convert_and_resize_image(input_path, output_path, resize=True, max_width=1140):
|
||||||
"""Convert an image to WebP (or JPEG fallback) and optionally resize it."""
|
"""Convert an image to WebP (or JPEG fallback) and optionally resize it."""
|
||||||
try:
|
try:
|
||||||
@@ -11,6 +13,7 @@ def convert_and_resize_image(input_path, output_path, resize=True, max_width=114
|
|||||||
return
|
return
|
||||||
|
|
||||||
img = Image.open(input_path)
|
img = Image.open(input_path)
|
||||||
|
icc_profile = img.info.get("icc_profile")
|
||||||
if img.mode != "RGB":
|
if img.mode != "RGB":
|
||||||
img = img.convert("RGB")
|
img = img.convert("RGB")
|
||||||
|
|
||||||
@@ -27,12 +30,16 @@ def convert_and_resize_image(input_path, output_path, resize=True, max_width=114
|
|||||||
if fmt == "JPEG":
|
if fmt == "JPEG":
|
||||||
output_path = output_path.with_suffix(".jpg")
|
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}")
|
logging.info(f"[✓] Processed image: {input_path} → {output_path}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"[✗] Error processing image {input_path}: {e}")
|
logging.error(f"[✗] Error processing image {input_path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def process_images(images, resize_images, img_dir, build_dir):
|
def process_images(images, resize_images, img_dir, build_dir):
|
||||||
"""Process a list of image references and update paths to optimized versions."""
|
"""Process a list of image references and update paths to optimized versions."""
|
||||||
for img in images:
|
for img in images:
|
||||||
@@ -48,6 +55,7 @@ def process_images(images, resize_images, img_dir, build_dir):
|
|||||||
if jpg_path.exists():
|
if jpg_path.exists():
|
||||||
img["src"] = str(Path(img["src"]).with_suffix(".jpg"))
|
img["src"] = str(Path(img["src"]).with_suffix(".jpg"))
|
||||||
|
|
||||||
|
|
||||||
def copy_original_images(images, img_dir, build_dir):
|
def copy_original_images(images, img_dir, build_dir):
|
||||||
"""Copy original image files without processing."""
|
"""Copy original image files without processing."""
|
||||||
for img in images:
|
for img in images:
|
||||||
@@ -66,6 +74,7 @@ def copy_original_images(images, img_dir, build_dir):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"[✗] Error copying {src_path}: {e}")
|
logging.error(f"[✗] Error copying {src_path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def get_favicon_path(theme_vars, theme_dir):
|
def get_favicon_path(theme_vars, theme_dir):
|
||||||
"""Retrieve the favicon path from theme variables, ensuring it exists."""
|
"""Retrieve the favicon path from theme variables, ensuring it exists."""
|
||||||
fav_path = theme_vars.get("favicon", {}).get("path")
|
fav_path = theme_vars.get("favicon", {}).get("path")
|
||||||
@@ -83,6 +92,7 @@ def get_favicon_path(theme_vars, theme_dir):
|
|||||||
|
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def generate_favicons_from_logo(theme_vars, theme_dir, output_dir):
|
def generate_favicons_from_logo(theme_vars, theme_dir, output_dir):
|
||||||
"""Generate multiple PNG favicons from a single source image."""
|
"""Generate multiple PNG favicons from a single source image."""
|
||||||
logo_path = get_favicon_path(theme_vars, theme_dir)
|
logo_path = get_favicon_path(theme_vars, theme_dir)
|
||||||
@@ -93,9 +103,13 @@ def generate_favicons_from_logo(theme_vars, theme_dir, output_dir):
|
|||||||
try:
|
try:
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
specs = [
|
specs = [
|
||||||
(32, "favicon-32.png"), (96, "favicon-96.png"), (128, "favicon-128.png"),
|
(32, "favicon-32.png"),
|
||||||
(192, "favicon-192.png"), (196, "favicon-196.png"),
|
(96, "favicon-96.png"),
|
||||||
(152, "favicon-152.png"), (180, "favicon-180.png")
|
(128, "favicon-128.png"),
|
||||||
|
(192, "favicon-192.png"),
|
||||||
|
(196, "favicon-196.png"),
|
||||||
|
(152, "favicon-152.png"),
|
||||||
|
(180, "favicon-180.png"),
|
||||||
]
|
]
|
||||||
img = Image.open(logo_path).convert("RGBA")
|
img = Image.open(logo_path).convert("RGBA")
|
||||||
for size, name in specs:
|
for size, name in specs:
|
||||||
@@ -106,6 +120,7 @@ def generate_favicons_from_logo(theme_vars, theme_dir, output_dir):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"[✗] Error generating PNG favicons: {e}")
|
logging.error(f"[✗] Error generating PNG favicons: {e}")
|
||||||
|
|
||||||
|
|
||||||
def generate_favicon_ico(theme_vars, theme_dir, output_path):
|
def generate_favicon_ico(theme_vars, theme_dir, output_path):
|
||||||
"""Generate a multi-size favicon.ico from a source image."""
|
"""Generate a multi-size favicon.ico from a source image."""
|
||||||
logo_path = get_favicon_path(theme_vars, theme_dir)
|
logo_path = get_favicon_path(theme_vars, theme_dir)
|
||||||
|
|||||||
@@ -2,14 +2,27 @@ import logging
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from shutil import copyfile
|
from shutil import copyfile
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from .utils import ensure_dir, copy_assets, load_yaml, load_theme_config
|
|
||||||
from .css_generator import generate_css_variables, generate_fonts_css, generate_google_fonts_link
|
from .css_generator import generate_css_variables, generate_fonts_css, generate_google_fonts_link
|
||||||
from .image_processor import process_images, copy_original_images, convert_and_resize_image, generate_favicons_from_logo, generate_favicon_ico
|
from .html_generator import (
|
||||||
from .html_generator import render_template, render_gallery_images, generate_gallery_json_from_images, generate_robots_txt, generate_sitemap_xml
|
generate_gallery_json_from_images,
|
||||||
|
generate_robots_txt,
|
||||||
|
generate_sitemap_xml,
|
||||||
|
render_gallery_images,
|
||||||
|
render_template,
|
||||||
|
)
|
||||||
|
from .image_processor import (
|
||||||
|
copy_original_images,
|
||||||
|
generate_favicon_ico,
|
||||||
|
generate_favicons_from_logo,
|
||||||
|
process_images,
|
||||||
|
)
|
||||||
|
from .utils import copy_assets, ensure_dir, load_theme_config, load_yaml
|
||||||
|
|
||||||
# Configure logging to display only the messages
|
# Configure logging to display only the messages
|
||||||
logging.basicConfig(level=logging.INFO, format='%(message)s')
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
|
|
||||||
# Define key directories used throughout the script
|
# Define key directories used throughout the script
|
||||||
SRC_DIR = Path.cwd()
|
SRC_DIR = Path.cwd()
|
||||||
@@ -22,9 +35,10 @@ GALLERY_FILE = SRC_DIR / "config/gallery.yaml"
|
|||||||
SITE_FILE = SRC_DIR / "config/site.yaml"
|
SITE_FILE = SRC_DIR / "config/site.yaml"
|
||||||
THEMES_DIR = SRC_DIR / "config/themes"
|
THEMES_DIR = SRC_DIR / "config/themes"
|
||||||
VERSION_FILE = SRC_DIR / "VERSION"
|
VERSION_FILE = SRC_DIR / "VERSION"
|
||||||
with open(VERSION_FILE, "r") as vf:
|
with open(VERSION_FILE) as vf:
|
||||||
build_version = vf.read().strip()
|
build_version = vf.read().strip()
|
||||||
|
|
||||||
|
|
||||||
def build():
|
def build():
|
||||||
logging.info("\n")
|
logging.info("\n")
|
||||||
logging.info("=" * 24)
|
logging.info("=" * 24)
|
||||||
@@ -57,9 +71,13 @@ def build():
|
|||||||
logging.info(f"[✓] Theme CSS found, copied to build folder: {dest_theme_css}")
|
logging.info(f"[✓] Theme CSS found, copied to build folder: {dest_theme_css}")
|
||||||
else:
|
else:
|
||||||
theme_css = ""
|
theme_css = ""
|
||||||
logging.warning(f"[~] No theme.css found in {theme_css_path}, skipping theme CSS injection.")
|
logging.warning(
|
||||||
|
f"[~] No theme.css found in {theme_css_path}, skipping theme CSS injection."
|
||||||
|
)
|
||||||
|
|
||||||
preload_links = generate_fonts_css(fonts_dir, BUILD_DIR / "style" / "fonts.css", fonts_cfg=theme_vars.get("fonts"))
|
preload_links = generate_fonts_css(
|
||||||
|
fonts_dir, BUILD_DIR / "style" / "fonts.css", fonts_cfg=theme_vars.get("fonts")
|
||||||
|
)
|
||||||
generate_css_variables(theme_vars.get("colors", {}), BUILD_DIR / "style" / "colors.css")
|
generate_css_variables(theme_vars.get("colors", {}), BUILD_DIR / "style" / "colors.css")
|
||||||
generate_favicons_from_logo(theme_vars, theme_dir, BUILD_DIR / "img" / "favicon")
|
generate_favicons_from_logo(theme_vars, theme_dir, BUILD_DIR / "img" / "favicon")
|
||||||
generate_favicon_ico(theme_vars, theme_dir, BUILD_DIR / "favicon.ico")
|
generate_favicon_ico(theme_vars, theme_dir, BUILD_DIR / "favicon.ico")
|
||||||
@@ -94,7 +112,7 @@ def build():
|
|||||||
|
|
||||||
# Adding Google fonts if existing
|
# Adding Google fonts if existing
|
||||||
google_fonts_link = generate_google_fonts_link(theme_vars.get("google_fonts", []))
|
google_fonts_link = generate_google_fonts_link(theme_vars.get("google_fonts", []))
|
||||||
logging.info(f"[✓] Google Fonts link generated")
|
logging.info("[✓] Google Fonts link generated")
|
||||||
|
|
||||||
# Generating thumbnail
|
# Generating thumbnail
|
||||||
thumbnail_path = site_vars.get("social", {}).get("thumbnail")
|
thumbnail_path = site_vars.get("social", {}).get("thumbnail")
|
||||||
@@ -128,7 +146,9 @@ def build():
|
|||||||
# Render the home page
|
# Render the home page
|
||||||
head = render_template(TEMPLATE_DIR / "head.html", head_vars)
|
head = render_template(TEMPLATE_DIR / "head.html", head_vars)
|
||||||
hero = render_template(TEMPLATE_DIR / "hero.html", {**site_vars["hero"], **head_vars})
|
hero = render_template(TEMPLATE_DIR / "hero.html", {**site_vars["hero"], **head_vars})
|
||||||
footer = render_template(TEMPLATE_DIR / "footer.html", {**site_vars.get("footer", {}), **head_vars})
|
footer = render_template(
|
||||||
|
TEMPLATE_DIR / "footer.html", {**site_vars.get("footer", {}), **head_vars}
|
||||||
|
)
|
||||||
gallery_html = render_gallery_images(gallery_images)
|
gallery_html = render_gallery_images(gallery_images)
|
||||||
gallery = render_template(TEMPLATE_DIR / "gallery.html", {"gallery_images": gallery_html})
|
gallery = render_template(TEMPLATE_DIR / "gallery.html", {"gallery_images": gallery_html})
|
||||||
|
|
||||||
@@ -185,7 +205,8 @@ def build():
|
|||||||
generate_robots_txt(canonical_url, allowed_pages, BUILD_DIR)
|
generate_robots_txt(canonical_url, allowed_pages, BUILD_DIR)
|
||||||
generate_sitemap_xml(canonical_url, allowed_pages, BUILD_DIR)
|
generate_sitemap_xml(canonical_url, allowed_pages, BUILD_DIR)
|
||||||
else:
|
else:
|
||||||
logging.warning("[~] No canonical URL found in site.yaml info section, skipping robots.txt and sitemap.xml generation.")
|
logging.warning(
|
||||||
|
"[~] No canonical URL found in site.yaml info section, skipping robots.txt and sitemap.xml generation."
|
||||||
|
)
|
||||||
|
|
||||||
logging.info("✅ Build complete.")
|
logging.info("✅ Build complete.")
|
||||||
|
|
||||||
+10
-4
@@ -1,26 +1,30 @@
|
|||||||
import yaml
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from shutil import copytree, rmtree, copyfile
|
from shutil import copytree, rmtree
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
def load_yaml(path):
|
def load_yaml(path):
|
||||||
"""Load gallery and site .yaml conf"""
|
"""Load gallery and site .yaml conf"""
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
logging.warning(f"[!] YAML file not found: {path}")
|
logging.warning(f"[!] YAML file not found: {path}")
|
||||||
return {}
|
return {}
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
return yaml.safe_load(f)
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
|
||||||
def load_theme_config(theme_name, themes_dir):
|
def load_theme_config(theme_name, themes_dir):
|
||||||
"""Load theme.yaml"""
|
"""Load theme.yaml"""
|
||||||
theme_dir = themes_dir / theme_name
|
theme_dir = themes_dir / theme_name
|
||||||
theme_config_path = theme_dir / "theme.yaml"
|
theme_config_path = theme_dir / "theme.yaml"
|
||||||
if not theme_config_path.exists():
|
if not theme_config_path.exists():
|
||||||
raise FileNotFoundError(f"[✗] Theme config not found: {theme_config_path}")
|
raise FileNotFoundError(f"[✗] Theme config not found: {theme_config_path}")
|
||||||
with open(theme_config_path, "r", encoding="utf-8") as f:
|
with open(theme_config_path, encoding="utf-8") as f:
|
||||||
theme_vars = yaml.safe_load(f)
|
theme_vars = yaml.safe_load(f)
|
||||||
return theme_vars, theme_dir
|
return theme_vars, theme_dir
|
||||||
|
|
||||||
|
|
||||||
def clear_dir(path: Path):
|
def clear_dir(path: Path):
|
||||||
"""Clear the output dir"""
|
"""Clear the output dir"""
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
@@ -32,6 +36,7 @@ def clear_dir(path: Path):
|
|||||||
elif child.is_dir():
|
elif child.is_dir():
|
||||||
rmtree(child)
|
rmtree(child)
|
||||||
|
|
||||||
|
|
||||||
def ensure_dir(path: Path):
|
def ensure_dir(path: Path):
|
||||||
"""Create the output dir if it does not exist"""
|
"""Create the output dir if it does not exist"""
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
@@ -39,6 +44,7 @@ def ensure_dir(path: Path):
|
|||||||
else:
|
else:
|
||||||
clear_dir(path)
|
clear_dir(path)
|
||||||
|
|
||||||
|
|
||||||
def copy_assets(js_dir, style_dir, build_dir):
|
def copy_assets(js_dir, style_dir, build_dir):
|
||||||
"""Copy public assets to output dir"""
|
"""Copy public assets to output dir"""
|
||||||
for folder in [js_dir, style_dir]:
|
for folder in [js_dir, style_dir]:
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from flask import Blueprint, request, current_app
|
|
||||||
|
from flask import Blueprint, current_app, request
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from src.py.builder.gallery_builder import update_gallery, update_hero
|
from src.py.builder.gallery_builder import update_gallery, update_hero
|
||||||
|
|
||||||
# --- Create Flask blueprint for upload routes ---
|
# --- Create Flask blueprint for upload routes ---
|
||||||
@@ -10,10 +12,12 @@ upload_bp = Blueprint("upload", __name__)
|
|||||||
# --- Allowed file types ---
|
# --- Allowed file types ---
|
||||||
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "webp"}
|
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "webp"}
|
||||||
|
|
||||||
|
|
||||||
def allowed_file(filename: str) -> bool:
|
def allowed_file(filename: str) -> bool:
|
||||||
"""Check if the uploaded file has an allowed extension."""
|
"""Check if the uploaded file has an allowed extension."""
|
||||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
def save_uploaded_file(file, folder: Path):
|
def save_uploaded_file(file, folder: Path):
|
||||||
"""Save an uploaded file to the specified folder."""
|
"""Save an uploaded file to the specified folder."""
|
||||||
folder.mkdir(parents=True, exist_ok=True) # Create folder if not exists
|
folder.mkdir(parents=True, exist_ok=True) # Create folder if not exists
|
||||||
@@ -22,6 +26,7 @@ def save_uploaded_file(file, folder: Path):
|
|||||||
logging.info(f"[✓] Uploaded {filename} to {folder}")
|
logging.info(f"[✓] Uploaded {filename} to {folder}")
|
||||||
return filename
|
return filename
|
||||||
|
|
||||||
|
|
||||||
@upload_bp.route("/api/<section>/upload", methods=["POST"])
|
@upload_bp.route("/api/<section>/upload", methods=["POST"])
|
||||||
def upload_photo(section: str):
|
def upload_photo(section: str):
|
||||||
"""
|
"""
|
||||||
@@ -63,4 +68,3 @@ def upload_photo(section: str):
|
|||||||
return {"status": "ok", "uploaded": uploaded}
|
return {"status": "ok", "uploaded": uploaded}
|
||||||
|
|
||||||
return {"error": "No valid files uploaded"}, 400
|
return {"error": "No valid files uploaded"}, 400
|
||||||
|
|
||||||
|
|||||||
+112
-41
@@ -1,16 +1,27 @@
|
|||||||
# --- Imports ---
|
# --- Imports ---
|
||||||
import logging
|
import logging
|
||||||
import yaml
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import zipfile
|
import zipfile
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
from flask import (
|
from flask import (
|
||||||
Flask, jsonify, request, send_from_directory, render_template,
|
Flask,
|
||||||
send_file, after_this_request
|
after_this_request,
|
||||||
|
jsonify,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
send_file,
|
||||||
|
send_from_directory,
|
||||||
)
|
)
|
||||||
|
|
||||||
from src.py.builder.gallery_builder import (
|
from src.py.builder.gallery_builder import (
|
||||||
GALLERY_YAML, load_yaml, save_yaml, update_gallery, update_hero
|
GALLERY_YAML,
|
||||||
|
load_yaml,
|
||||||
|
save_yaml,
|
||||||
|
update_gallery,
|
||||||
|
update_hero,
|
||||||
)
|
)
|
||||||
from src.py.webui.upload import upload_bp
|
from src.py.webui.upload import upload_bp
|
||||||
|
|
||||||
@@ -19,16 +30,11 @@ logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|||||||
|
|
||||||
# --- Flask app setup ---
|
# --- Flask app setup ---
|
||||||
VERSION_FILE = Path(__file__).resolve().parents[3] / "VERSION"
|
VERSION_FILE = Path(__file__).resolve().parents[3] / "VERSION"
|
||||||
with open(VERSION_FILE, "r") as vf:
|
with open(VERSION_FILE) as vf:
|
||||||
lumeex_version = vf.read().strip()
|
lumeex_version = vf.read().strip()
|
||||||
|
|
||||||
WEBUI_PATH = Path(__file__).parents[2] / "webui" # Path to static/templates
|
WEBUI_PATH = Path(__file__).parents[2] / "webui" # Path to static/templates
|
||||||
app = Flask(
|
app = Flask(__name__, template_folder=WEBUI_PATH, static_folder=WEBUI_PATH, static_url_path="")
|
||||||
__name__,
|
|
||||||
template_folder=WEBUI_PATH,
|
|
||||||
static_folder=WEBUI_PATH,
|
|
||||||
static_url_path=""
|
|
||||||
)
|
|
||||||
|
|
||||||
WEBUI_PORT = int(os.getenv("WEBUI_PORT", 5000))
|
WEBUI_PORT = int(os.getenv("WEBUI_PORT", 5000))
|
||||||
|
|
||||||
@@ -40,26 +46,34 @@ app.config["PHOTOS_DIR"] = PHOTOS_DIR
|
|||||||
# --- Register upload blueprint ---
|
# --- Register upload blueprint ---
|
||||||
app.register_blueprint(upload_bp)
|
app.register_blueprint(upload_bp)
|
||||||
|
|
||||||
|
|
||||||
# --- Theme editor helper functions ---
|
# --- Theme editor helper functions ---
|
||||||
def get_theme_name():
|
def get_theme_name():
|
||||||
"""Get current theme name from site.yaml."""
|
"""Get current theme name from site.yaml."""
|
||||||
site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
|
site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
|
||||||
with open(site_yaml_path, "r") as f:
|
with open(site_yaml_path) as f:
|
||||||
site_yaml = yaml.safe_load(f)
|
site_yaml = yaml.safe_load(f)
|
||||||
return site_yaml.get("build", {}).get("theme", "modern")
|
return site_yaml.get("build", {}).get("theme", "modern")
|
||||||
|
|
||||||
|
|
||||||
def get_theme_yaml(theme_name):
|
def get_theme_yaml(theme_name):
|
||||||
"""Load theme.yaml for a given theme."""
|
"""Load theme.yaml for a given theme."""
|
||||||
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
|
theme_yaml_path = (
|
||||||
with open(theme_yaml_path, "r") as f:
|
Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
|
||||||
|
)
|
||||||
|
with open(theme_yaml_path) as f:
|
||||||
return yaml.safe_load(f)
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
|
||||||
def save_theme_yaml(theme_name, theme_yaml):
|
def save_theme_yaml(theme_name, theme_yaml):
|
||||||
"""Save theme.yaml for a given theme."""
|
"""Save theme.yaml for a given theme."""
|
||||||
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
|
theme_yaml_path = (
|
||||||
|
Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
|
||||||
|
)
|
||||||
with open(theme_yaml_path, "w") as f:
|
with open(theme_yaml_path, "w") as f:
|
||||||
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
|
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
|
||||||
|
|
||||||
|
|
||||||
def get_local_fonts(theme_name):
|
def get_local_fonts(theme_name):
|
||||||
"""List local font files for a theme."""
|
"""List local font files for a theme."""
|
||||||
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
|
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
|
||||||
@@ -67,21 +81,23 @@ def get_local_fonts(theme_name):
|
|||||||
return []
|
return []
|
||||||
return [f.name for f in fonts_dir.glob("*") if f.is_file() and f.suffix in [".woff", ".woff2"]]
|
return [f.name for f in fonts_dir.glob("*") if f.is_file() and f.suffix in [".woff", ".woff2"]]
|
||||||
|
|
||||||
|
|
||||||
# --- ROUTES ---
|
# --- ROUTES ---
|
||||||
|
|
||||||
|
|
||||||
# --- Main page ---
|
# --- Main page ---
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
return render_template("index.html")
|
return render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
PREVIEW_PORT = int(os.getenv("PREVIEW_PORT", 3000))
|
PREVIEW_PORT = int(os.getenv("PREVIEW_PORT", 3000))
|
||||||
|
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_version():
|
def inject_version():
|
||||||
return dict(
|
return dict(lumeex_version=lumeex_version, preview_port=PREVIEW_PORT)
|
||||||
lumeex_version=lumeex_version,
|
|
||||||
preview_port=PREVIEW_PORT
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Gallery & Hero API ---
|
# --- Gallery & Hero API ---
|
||||||
@app.route("/gallery-editor")
|
@app.route("/gallery-editor")
|
||||||
@@ -89,18 +105,21 @@ def gallery_editor():
|
|||||||
"""Render gallery editor page."""
|
"""Render gallery editor page."""
|
||||||
return render_template("gallery-editor/index.html")
|
return render_template("gallery-editor/index.html")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/gallery", methods=["GET"])
|
@app.route("/api/gallery", methods=["GET"])
|
||||||
def get_gallery():
|
def get_gallery():
|
||||||
"""Get gallery images."""
|
"""Get gallery images."""
|
||||||
data = load_yaml(GALLERY_YAML)
|
data = load_yaml(GALLERY_YAML)
|
||||||
return jsonify(data.get("gallery", {}).get("images", []))
|
return jsonify(data.get("gallery", {}).get("images", []))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/hero", methods=["GET"])
|
@app.route("/api/hero", methods=["GET"])
|
||||||
def get_hero():
|
def get_hero():
|
||||||
"""Get hero images."""
|
"""Get hero images."""
|
||||||
data = load_yaml(GALLERY_YAML)
|
data = load_yaml(GALLERY_YAML)
|
||||||
return jsonify(data.get("hero", {}).get("images", []))
|
return jsonify(data.get("hero", {}).get("images", []))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/gallery/update", methods=["POST"])
|
@app.route("/api/gallery/update", methods=["POST"])
|
||||||
def update_gallery_api():
|
def update_gallery_api():
|
||||||
"""Update gallery images."""
|
"""Update gallery images."""
|
||||||
@@ -110,6 +129,7 @@ def update_gallery_api():
|
|||||||
save_yaml(data, GALLERY_YAML)
|
save_yaml(data, GALLERY_YAML)
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/hero/update", methods=["POST"])
|
@app.route("/api/hero/update", methods=["POST"])
|
||||||
def update_hero_api():
|
def update_hero_api():
|
||||||
"""Update hero images."""
|
"""Update hero images."""
|
||||||
@@ -119,18 +139,21 @@ def update_hero_api():
|
|||||||
save_yaml(data, GALLERY_YAML)
|
save_yaml(data, GALLERY_YAML)
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/gallery/refresh", methods=["POST"])
|
@app.route("/api/gallery/refresh", methods=["POST"])
|
||||||
def refresh_gallery():
|
def refresh_gallery():
|
||||||
"""Refresh gallery images from disk."""
|
"""Refresh gallery images from disk."""
|
||||||
update_gallery()
|
update_gallery()
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/hero/refresh", methods=["POST"])
|
@app.route("/api/hero/refresh", methods=["POST"])
|
||||||
def refresh_hero():
|
def refresh_hero():
|
||||||
"""Refresh hero images from disk."""
|
"""Refresh hero images from disk."""
|
||||||
update_hero()
|
update_hero()
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
# --- Gallery & Hero photo deletion ---
|
# --- Gallery & Hero photo deletion ---
|
||||||
@app.route("/api/gallery/delete", methods=["POST"])
|
@app.route("/api/gallery/delete", methods=["POST"])
|
||||||
def delete_gallery_photo():
|
def delete_gallery_photo():
|
||||||
@@ -143,6 +166,7 @@ def delete_gallery_photo():
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
return {"error": "❌ File not found"}, 404
|
return {"error": "❌ File not found"}, 404
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/hero/delete", methods=["POST"])
|
@app.route("/api/hero/delete", methods=["POST"])
|
||||||
def delete_hero_photo():
|
def delete_hero_photo():
|
||||||
"""Delete a hero photo."""
|
"""Delete a hero photo."""
|
||||||
@@ -154,6 +178,7 @@ def delete_hero_photo():
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
return {"error": "❌ File not found"}, 404
|
return {"error": "❌ File not found"}, 404
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/gallery/delete_all", methods=["POST"])
|
@app.route("/api/gallery/delete_all", methods=["POST"])
|
||||||
def delete_all_gallery_photos():
|
def delete_all_gallery_photos():
|
||||||
"""Delete all gallery photos."""
|
"""Delete all gallery photos."""
|
||||||
@@ -168,6 +193,7 @@ def delete_all_gallery_photos():
|
|||||||
save_yaml(data, GALLERY_YAML)
|
save_yaml(data, GALLERY_YAML)
|
||||||
return jsonify({"status": "ok", "deleted": deleted})
|
return jsonify({"status": "ok", "deleted": deleted})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/hero/delete_all", methods=["POST"])
|
@app.route("/api/hero/delete_all", methods=["POST"])
|
||||||
def delete_all_hero_photos():
|
def delete_all_hero_photos():
|
||||||
"""Delete all hero photos."""
|
"""Delete all hero photos."""
|
||||||
@@ -182,36 +208,41 @@ def delete_all_hero_photos():
|
|||||||
save_yaml(data, GALLERY_YAML)
|
save_yaml(data, GALLERY_YAML)
|
||||||
return jsonify({"status": "ok", "deleted": deleted})
|
return jsonify({"status": "ok", "deleted": deleted})
|
||||||
|
|
||||||
|
|
||||||
# --- Serve photos ---
|
# --- Serve photos ---
|
||||||
@app.route("/photos/<section>/<path:filename>")
|
@app.route("/photos/<section>/<path:filename>")
|
||||||
def photos(section, filename):
|
def photos(section, filename):
|
||||||
"""Serve a photo from a section."""
|
"""Serve a photo from a section."""
|
||||||
return send_from_directory(PHOTOS_DIR / section, filename)
|
return send_from_directory(PHOTOS_DIR / section, filename)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/photos/<path:filename>")
|
@app.route("/photos/<path:filename>")
|
||||||
def serve_photo(filename):
|
def serve_photo(filename):
|
||||||
"""Serve a photo from the photos directory."""
|
"""Serve a photo from the photos directory."""
|
||||||
photos_dir = Path(__file__).resolve().parents[3] / "config" / "photos"
|
photos_dir = Path(__file__).resolve().parents[3] / "config" / "photos"
|
||||||
return send_from_directory(photos_dir, filename)
|
return send_from_directory(photos_dir, filename)
|
||||||
|
|
||||||
|
|
||||||
# --- Site info page & API ---
|
# --- Site info page & API ---
|
||||||
@app.route("/site-info")
|
@app.route("/site-info")
|
||||||
def site_info():
|
def site_info():
|
||||||
"""Render site info editor page."""
|
"""Render site info editor page."""
|
||||||
return render_template("site-info/index.html")
|
return render_template("site-info/index.html")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/site-info", methods=["GET"])
|
@app.route("/api/site-info", methods=["GET"])
|
||||||
def get_site_info():
|
def get_site_info():
|
||||||
"""Get site info YAML as JSON."""
|
"""Get site info YAML as JSON."""
|
||||||
with open(SITE_YAML, "r") as f:
|
with open(SITE_YAML) as f:
|
||||||
data = yaml.safe_load(f)
|
data = yaml.safe_load(f)
|
||||||
return jsonify(data)
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/site-info", methods=["POST"])
|
@app.route("/api/site-info", methods=["POST"])
|
||||||
def update_site_info():
|
def update_site_info():
|
||||||
"""Update site info YAML."""
|
"""Update site info YAML."""
|
||||||
new_data = request.json
|
new_data = request.json
|
||||||
with open(SITE_YAML, "r") as f:
|
with open(SITE_YAML) as f:
|
||||||
old_data = yaml.safe_load(f) or {}
|
old_data = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
def deep_merge(old, new):
|
def deep_merge(old, new):
|
||||||
@@ -227,6 +258,7 @@ def update_site_info():
|
|||||||
yaml.safe_dump(merged, f, sort_keys=False, allow_unicode=True)
|
yaml.safe_dump(merged, f, sort_keys=False, allow_unicode=True)
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
# --- Theme management ---
|
# --- Theme management ---
|
||||||
@app.route("/api/themes")
|
@app.route("/api/themes")
|
||||||
def list_themes():
|
def list_themes():
|
||||||
@@ -235,6 +267,7 @@ def list_themes():
|
|||||||
themes = [d.name for d in themes_dir.iterdir() if d.is_dir()]
|
themes = [d.name for d in themes_dir.iterdir() if d.is_dir()]
|
||||||
return jsonify(themes)
|
return jsonify(themes)
|
||||||
|
|
||||||
|
|
||||||
# --- Thumbnail upload/remove ---
|
# --- Thumbnail upload/remove ---
|
||||||
@app.route("/api/thumbnail/upload", methods=["POST"])
|
@app.route("/api/thumbnail/upload", methods=["POST"])
|
||||||
def upload_thumbnail():
|
def upload_thumbnail():
|
||||||
@@ -245,13 +278,14 @@ def upload_thumbnail():
|
|||||||
return {"error": "❌ No file provided"}, 400
|
return {"error": "❌ No file provided"}, 400
|
||||||
filename = "thumbnail.png"
|
filename = "thumbnail.png"
|
||||||
file.save(PHOTOS_DIR / filename)
|
file.save(PHOTOS_DIR / filename)
|
||||||
with open(SITE_YAML, "r") as f:
|
with open(SITE_YAML) as f:
|
||||||
data = yaml.safe_load(f)
|
data = yaml.safe_load(f)
|
||||||
data.setdefault("social", {})["thumbnail"] = filename
|
data.setdefault("social", {})["thumbnail"] = filename
|
||||||
with open(SITE_YAML, "w") as f:
|
with open(SITE_YAML, "w") as f:
|
||||||
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
|
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
|
||||||
return jsonify({"status": "ok", "filename": filename})
|
return jsonify({"status": "ok", "filename": filename})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/thumbnail/remove", methods=["POST"])
|
@app.route("/api/thumbnail/remove", methods=["POST"])
|
||||||
def remove_thumbnail():
|
def remove_thumbnail():
|
||||||
"""Remove thumbnail image and update site.yaml."""
|
"""Remove thumbnail image and update site.yaml."""
|
||||||
@@ -259,7 +293,7 @@ def remove_thumbnail():
|
|||||||
thumbnail_path = PHOTOS_DIR / "thumbnail.png"
|
thumbnail_path = PHOTOS_DIR / "thumbnail.png"
|
||||||
if thumbnail_path.exists():
|
if thumbnail_path.exists():
|
||||||
thumbnail_path.unlink()
|
thumbnail_path.unlink()
|
||||||
with open(SITE_YAML, "r") as f:
|
with open(SITE_YAML) as f:
|
||||||
data = yaml.safe_load(f)
|
data = yaml.safe_load(f)
|
||||||
if "social" in data and "thumbnail" in data["social"]:
|
if "social" in data and "thumbnail" in data["social"]:
|
||||||
data["social"]["thumbnail"] = ""
|
data["social"]["thumbnail"] = ""
|
||||||
@@ -267,6 +301,7 @@ def remove_thumbnail():
|
|||||||
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
|
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
# --- Theme upload ---
|
# --- Theme upload ---
|
||||||
@app.route("/api/theme/upload", methods=["POST"])
|
@app.route("/api/theme/upload", methods=["POST"])
|
||||||
def upload_theme():
|
def upload_theme():
|
||||||
@@ -286,6 +321,7 @@ def upload_theme():
|
|||||||
file.save(dest_path)
|
file.save(dest_path)
|
||||||
return jsonify({"status": "ok", "theme": folder_name})
|
return jsonify({"status": "ok", "theme": folder_name})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/theme/remove", methods=["POST"])
|
@app.route("/api/theme/remove", methods=["POST"])
|
||||||
def remove_theme():
|
def remove_theme():
|
||||||
"""Remove a custom theme folder."""
|
"""Remove a custom theme folder."""
|
||||||
@@ -302,15 +338,18 @@ def remove_theme():
|
|||||||
return jsonify({"error": "❌ Cannot remove default theme"}), 400
|
return jsonify({"error": "❌ Cannot remove default theme"}), 400
|
||||||
# Remove folder and all contents
|
# Remove folder and all contents
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
shutil.rmtree(theme_folder)
|
shutil.rmtree(theme_folder)
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
# --- Theme editor page & API ---
|
# --- Theme editor page & API ---
|
||||||
@app.route("/theme-editor")
|
@app.route("/theme-editor")
|
||||||
def theme_editor():
|
def theme_editor():
|
||||||
"""Render theme editor page."""
|
"""Render theme editor page."""
|
||||||
return render_template("theme-editor/index.html")
|
return render_template("theme-editor/index.html")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/theme-info", methods=["GET", "POST"])
|
@app.route("/api/theme-info", methods=["GET", "POST"])
|
||||||
def api_theme_info():
|
def api_theme_info():
|
||||||
"""Get or update theme.yaml for current theme."""
|
"""Get or update theme.yaml for current theme."""
|
||||||
@@ -318,11 +357,9 @@ def api_theme_info():
|
|||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
theme_yaml = get_theme_yaml(theme_name)
|
theme_yaml = get_theme_yaml(theme_name)
|
||||||
google_fonts = theme_yaml.get("google_fonts", [])
|
google_fonts = theme_yaml.get("google_fonts", [])
|
||||||
return jsonify({
|
return jsonify(
|
||||||
"theme_name": theme_name,
|
{"theme_name": theme_name, "theme_yaml": theme_yaml, "google_fonts": google_fonts}
|
||||||
"theme_yaml": theme_yaml,
|
)
|
||||||
"google_fonts": google_fonts
|
|
||||||
})
|
|
||||||
else:
|
else:
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
theme_yaml = data.get("theme_yaml")
|
theme_yaml = data.get("theme_yaml")
|
||||||
@@ -330,20 +367,24 @@ def api_theme_info():
|
|||||||
save_theme_yaml(theme_name, theme_yaml)
|
save_theme_yaml(theme_name, theme_yaml)
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/theme-google-fonts", methods=["POST"])
|
@app.route("/api/theme-google-fonts", methods=["POST"])
|
||||||
def update_theme_google_fonts():
|
def update_theme_google_fonts():
|
||||||
"""Update only google_fonts in theme.yaml for current theme."""
|
"""Update only google_fonts in theme.yaml for current theme."""
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
theme_name = data.get("theme_name")
|
theme_name = data.get("theme_name")
|
||||||
google_fonts = data.get("google_fonts", [])
|
google_fonts = data.get("google_fonts", [])
|
||||||
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
|
theme_yaml_path = (
|
||||||
with open(theme_yaml_path, "r") as f:
|
Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
|
||||||
|
)
|
||||||
|
with open(theme_yaml_path) as f:
|
||||||
theme_yaml = yaml.safe_load(f)
|
theme_yaml = yaml.safe_load(f)
|
||||||
theme_yaml["google_fonts"] = google_fonts
|
theme_yaml["google_fonts"] = google_fonts
|
||||||
with open(theme_yaml_path, "w") as f:
|
with open(theme_yaml_path, "w") as f:
|
||||||
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
|
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/local-fonts")
|
@app.route("/api/local-fonts")
|
||||||
def api_local_fonts():
|
def api_local_fonts():
|
||||||
"""List local fonts for a theme."""
|
"""List local fonts for a theme."""
|
||||||
@@ -351,6 +392,7 @@ def api_local_fonts():
|
|||||||
fonts = get_local_fonts(theme_name)
|
fonts = get_local_fonts(theme_name)
|
||||||
return jsonify(fonts)
|
return jsonify(fonts)
|
||||||
|
|
||||||
|
|
||||||
# --- Favicon upload/remove ---
|
# --- Favicon upload/remove ---
|
||||||
@app.route("/api/favicon/upload", methods=["POST"])
|
@app.route("/api/favicon/upload", methods=["POST"])
|
||||||
def upload_favicon():
|
def upload_favicon():
|
||||||
@@ -366,13 +408,14 @@ def upload_favicon():
|
|||||||
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name
|
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name
|
||||||
file.save(theme_dir / filename)
|
file.save(theme_dir / filename)
|
||||||
theme_yaml_path = theme_dir / "theme.yaml"
|
theme_yaml_path = theme_dir / "theme.yaml"
|
||||||
with open(theme_yaml_path, "r") as f:
|
with open(theme_yaml_path) as f:
|
||||||
theme_yaml = yaml.safe_load(f)
|
theme_yaml = yaml.safe_load(f)
|
||||||
theme_yaml.setdefault("favicon", {})["path"] = filename
|
theme_yaml.setdefault("favicon", {})["path"] = filename
|
||||||
with open(theme_yaml_path, "w") as f:
|
with open(theme_yaml_path, "w") as f:
|
||||||
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
|
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
|
||||||
return jsonify({"status": "ok", "filename": filename})
|
return jsonify({"status": "ok", "filename": filename})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/favicon/remove", methods=["POST"])
|
@app.route("/api/favicon/remove", methods=["POST"])
|
||||||
def remove_favicon():
|
def remove_favicon():
|
||||||
"""Remove favicon for a theme."""
|
"""Remove favicon for a theme."""
|
||||||
@@ -386,7 +429,7 @@ def remove_favicon():
|
|||||||
if favicon_path.exists():
|
if favicon_path.exists():
|
||||||
favicon_path.unlink()
|
favicon_path.unlink()
|
||||||
theme_yaml_path = theme_dir / "theme.yaml"
|
theme_yaml_path = theme_dir / "theme.yaml"
|
||||||
with open(theme_yaml_path, "r") as f:
|
with open(theme_yaml_path) as f:
|
||||||
theme_yaml = yaml.safe_load(f)
|
theme_yaml = yaml.safe_load(f)
|
||||||
if "favicon" in theme_yaml:
|
if "favicon" in theme_yaml:
|
||||||
theme_yaml["favicon"]["path"] = ""
|
theme_yaml["favicon"]["path"] = ""
|
||||||
@@ -394,6 +437,7 @@ def remove_favicon():
|
|||||||
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
|
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
# --- Serve theme assets ---
|
# --- Serve theme assets ---
|
||||||
@app.route("/themes/<theme>/<filename>")
|
@app.route("/themes/<theme>/<filename>")
|
||||||
def serve_theme_asset(theme, filename):
|
def serve_theme_asset(theme, filename):
|
||||||
@@ -401,6 +445,7 @@ def serve_theme_asset(theme, filename):
|
|||||||
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme
|
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme
|
||||||
return send_from_directory(theme_dir, filename)
|
return send_from_directory(theme_dir, filename)
|
||||||
|
|
||||||
|
|
||||||
# --- Font upload/remove ---
|
# --- Font upload/remove ---
|
||||||
@app.route("/api/font/upload", methods=["POST"])
|
@app.route("/api/font/upload", methods=["POST"])
|
||||||
def upload_font():
|
def upload_font():
|
||||||
@@ -415,7 +460,9 @@ def upload_font():
|
|||||||
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
|
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
|
||||||
fonts_dir.mkdir(parents=True, exist_ok=True)
|
fonts_dir.mkdir(parents=True, exist_ok=True)
|
||||||
file.save(fonts_dir / file.filename)
|
file.save(fonts_dir / file.filename)
|
||||||
return jsonify({"status": "ok", "filename": file.filename})
|
font_basename = Path(file.filename).stem
|
||||||
|
return jsonify({"status": "ok", "filename": font_basename})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/font/remove", methods=["POST"])
|
@app.route("/api/font/remove", methods=["POST"])
|
||||||
def remove_font():
|
def remove_font():
|
||||||
@@ -432,6 +479,7 @@ def remove_font():
|
|||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
return jsonify({"error": "❌ Font not found"}), 404
|
return jsonify({"error": "❌ Font not found"}), 404
|
||||||
|
|
||||||
|
|
||||||
# --- Build & Download ZIP ---
|
# --- Build & Download ZIP ---
|
||||||
@app.route("/api/build", methods=["POST"])
|
@app.route("/api/build", methods=["POST"])
|
||||||
def trigger_build():
|
def trigger_build():
|
||||||
@@ -445,7 +493,7 @@ def trigger_build():
|
|||||||
if not site_yaml_path.exists():
|
if not site_yaml_path.exists():
|
||||||
return jsonify({"status": "error", "message": "❌ site.yaml not found"}), 400
|
return jsonify({"status": "error", "message": "❌ site.yaml not found"}), 400
|
||||||
|
|
||||||
with open(site_yaml_path, "r") as f:
|
with open(site_yaml_path) as f:
|
||||||
site_data = yaml.safe_load(f) or {}
|
site_data = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
# Dynamically check all main sections and nested keys
|
# Dynamically check all main sections and nested keys
|
||||||
@@ -453,24 +501,45 @@ def trigger_build():
|
|||||||
for section in main_sections:
|
for section in main_sections:
|
||||||
value = site_data.get(section)
|
value = site_data.get(section)
|
||||||
if not value:
|
if not value:
|
||||||
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
|
return jsonify(
|
||||||
|
{"status": "error", "message": f"❌ Site info are not set: missing {section}"}
|
||||||
|
), 400
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
for k, v in value.items():
|
for k, v in value.items():
|
||||||
if v is None or v == "" or (isinstance(v, list) and not v):
|
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
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": f"❌ Site info are not set: missing {section}.{k}",
|
||||||
|
}
|
||||||
|
), 400
|
||||||
elif isinstance(value, list):
|
elif isinstance(value, list):
|
||||||
if not value:
|
if not value:
|
||||||
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
|
return jsonify(
|
||||||
|
{"status": "error", "message": f"❌ Site info are not set: missing {section}"}
|
||||||
|
), 400
|
||||||
for idx, item in enumerate(value):
|
for idx, item in enumerate(value):
|
||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
for k, v in item.items():
|
for k, v in item.items():
|
||||||
if v is None or v == "" or (isinstance(v, list) and not v):
|
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
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": f"❌ Site info are not set: missing {section}[{idx}].{k}",
|
||||||
|
}
|
||||||
|
), 400
|
||||||
elif item is None or item == "":
|
elif item is None or item == "":
|
||||||
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}[{idx}]"}), 400
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": f"❌ Site info are not set: missing {section}[{idx}]",
|
||||||
|
}
|
||||||
|
), 400
|
||||||
else:
|
else:
|
||||||
if value is None or value == "":
|
if value is None or value == "":
|
||||||
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
|
return jsonify(
|
||||||
|
{"status": "error", "message": f"❌ Site info are not set: missing {section}"}
|
||||||
|
), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(["python3", "build.py"], check=True)
|
subprocess.run(["python3", "build.py"], check=True)
|
||||||
@@ -478,6 +547,7 @@ def trigger_build():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"status": "error", "message": f"❌ {str(e)}"}), 500
|
return jsonify({"status": "error", "message": f"❌ {str(e)}"}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route("/download-output-zip", methods=["POST"])
|
@app.route("/download-output-zip", methods=["POST"])
|
||||||
def download_output_zip():
|
def download_output_zip():
|
||||||
"""
|
"""
|
||||||
@@ -504,6 +574,7 @@ def download_output_zip():
|
|||||||
|
|
||||||
return send_file(zip_path, as_attachment=True)
|
return send_file(zip_path, as_attachment=True)
|
||||||
|
|
||||||
|
|
||||||
# --- Run server ---
|
# --- Run server ---
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
logging.info("[~] Starting WebUI at http://0.0.0.0:5000")
|
logging.info("[~] Starting WebUI at http://0.0.0.0:5000")
|
||||||
|
|||||||
+76
-162
@@ -1,19 +1,29 @@
|
|||||||
// --- Arrays to store gallery and hero images ---
|
|
||||||
let galleryImages = [];
|
let galleryImages = [];
|
||||||
let heroImages = [];
|
let heroImages = [];
|
||||||
let allTags = []; // global tag list
|
let allTags = [];
|
||||||
let showOnlyUntagged = false;
|
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 ---
|
// --- Load images from server on page load ---
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
try {
|
try {
|
||||||
const galleryRes = await fetch('/api/gallery');
|
galleryImages = await (await fetch('/api/gallery')).json();
|
||||||
galleryImages = await galleryRes.json();
|
|
||||||
updateAllTags();
|
updateAllTags();
|
||||||
renderGallery();
|
renderGallery();
|
||||||
|
heroImages = await (await fetch('/api/hero')).json();
|
||||||
const heroRes = await fetch('/api/hero');
|
|
||||||
heroImages = await heroRes.json();
|
|
||||||
renderHero();
|
renderHero();
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -25,25 +35,38 @@ async function loadData() {
|
|||||||
function updateAllTags() {
|
function updateAllTags() {
|
||||||
allTags = [];
|
allTags = [];
|
||||||
galleryImages.forEach(img => {
|
galleryImages.forEach(img => {
|
||||||
if (img.tags) img.tags.forEach(t => {
|
(img.tags || []).forEach(t => {
|
||||||
if (!allTags.includes(t)) allTags.push(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 ---
|
// --- Render gallery images with tags and delete buttons ---
|
||||||
function renderGallery() {
|
function renderGallery() {
|
||||||
const container = document.getElementById('gallery');
|
const container = document.getElementById('gallery');
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
let imagesToShow = showOnlyUntagged
|
||||||
// Filter images if toggle is on
|
? galleryImages.filter(img => !img.tags || img.tags.length === 0)
|
||||||
let imagesToShow = galleryImages;
|
: galleryImages;
|
||||||
if (showOnlyUntagged) {
|
|
||||||
imagesToShow = galleryImages.filter(img => !img.tags || img.tags.length === 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
imagesToShow.forEach((img) => {
|
imagesToShow.forEach((img) => {
|
||||||
const i = galleryImages.indexOf(img); // Use real index for tag/delete actions
|
const i = galleryImages.indexOf(img);
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'photo flex-item flex-column';
|
div.className = 'photo flex-item flex-column';
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
@@ -59,62 +82,17 @@ function renderGallery() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
|
|
||||||
renderTags(i, img.tags || []);
|
renderTags(i, img.tags || []);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update gallery count (top)
|
updateCountAndButtons('gallery', imagesToShow.length);
|
||||||
const galleryCount = document.getElementById('gallery-count');
|
applyFadeInImages(container);
|
||||||
if (galleryCount) {
|
|
||||||
galleryCount.innerHTML = `<p>${imagesToShow.length} photos</p>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update gallery count (bottom)
|
|
||||||
const galleryCountBottom = document.getElementById('gallery-count-bottom');
|
|
||||||
if (galleryCountBottom) {
|
|
||||||
galleryCountBottom.innerHTML = `<p>${imagesToShow.length} photos</p>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show/hide Remove All button (top)
|
|
||||||
const removeAllBtn = document.getElementById('remove-all-gallery');
|
|
||||||
if (removeAllBtn) {
|
|
||||||
removeAllBtn.style.display = imagesToShow.length > 0 ? 'inline-block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show/hide bottom upload row
|
|
||||||
const bottomGalleryUpload = document.getElementById('bottom-gallery-upload');
|
|
||||||
if (bottomGalleryUpload) {
|
|
||||||
bottomGalleryUpload.style.display = imagesToShow.length > 0 ? 'flex' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show/hide Remove All button (bottom)
|
|
||||||
const removeAllBtnBottom = document.getElementById('remove-all-gallery-bottom');
|
|
||||||
if (removeAllBtnBottom) {
|
|
||||||
removeAllBtnBottom.style.display = imagesToShow.length > 0 ? 'inline-block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fade-in effect for loaded images
|
|
||||||
const fadeImages = document.querySelectorAll("img.fade-in-img");
|
|
||||||
fadeImages.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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Render tags for a single image ---
|
// --- Render tags for a single image ---
|
||||||
function renderTags(imgIndex, tags) {
|
function renderTags(imgIndex, tags) {
|
||||||
const tagsDisplay = document.querySelector(`.tags-display[data-index="${imgIndex}"]`);
|
const tagsDisplay = document.querySelector(`.tags-display[data-index="${imgIndex}"]`);
|
||||||
const inputContainer = document.querySelector(`.tag-input[data-index="${imgIndex}"]`);
|
const inputContainer = document.querySelector(`.tag-input[data-index="${imgIndex}"]`);
|
||||||
|
|
||||||
tagsDisplay.innerHTML = '';
|
tagsDisplay.innerHTML = '';
|
||||||
inputContainer.innerHTML = '';
|
inputContainer.innerHTML = '';
|
||||||
|
|
||||||
@@ -122,7 +100,6 @@ function renderTags(imgIndex, tags) {
|
|||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = 'tag';
|
span.className = 'tag';
|
||||||
span.textContent = tag;
|
span.textContent = tag;
|
||||||
|
|
||||||
const remove = document.createElement('span');
|
const remove = document.createElement('span');
|
||||||
remove.className = 'remove-tag';
|
remove.className = 'remove-tag';
|
||||||
remove.textContent = '×';
|
remove.textContent = '×';
|
||||||
@@ -131,7 +108,6 @@ function renderTags(imgIndex, tags) {
|
|||||||
updateTags(imgIndex, tags);
|
updateTags(imgIndex, tags);
|
||||||
renderTags(imgIndex, tags);
|
renderTags(imgIndex, tags);
|
||||||
};
|
};
|
||||||
|
|
||||||
span.appendChild(remove);
|
span.appendChild(remove);
|
||||||
tagsDisplay.appendChild(span);
|
tagsDisplay.appendChild(span);
|
||||||
});
|
});
|
||||||
@@ -141,11 +117,10 @@ function renderTags(imgIndex, tags) {
|
|||||||
input.placeholder = 'Add tag...';
|
input.placeholder = 'Add tag...';
|
||||||
inputContainer.appendChild(input);
|
inputContainer.appendChild(input);
|
||||||
|
|
||||||
// --- Validate button ---
|
|
||||||
const validateBtn = document.createElement('button');
|
const validateBtn = document.createElement('button');
|
||||||
validateBtn.textContent = '✔️';
|
validateBtn.textContent = '✔️';
|
||||||
validateBtn.className = 'validate-tag-btn';
|
validateBtn.className = 'validate-tag-btn';
|
||||||
validateBtn.style.display = 'none'; // hidden by default
|
validateBtn.style.display = 'none';
|
||||||
validateBtn.style.marginLeft = '4px';
|
validateBtn.style.marginLeft = '4px';
|
||||||
inputContainer.appendChild(validateBtn);
|
inputContainer.appendChild(validateBtn);
|
||||||
|
|
||||||
@@ -165,30 +140,20 @@ function renderTags(imgIndex, tags) {
|
|||||||
|
|
||||||
const updateSuggestions = () => {
|
const updateSuggestions = () => {
|
||||||
const value = input.value.toLowerCase();
|
const value = input.value.toLowerCase();
|
||||||
|
|
||||||
const allTagsFlat = galleryImages.flatMap(img => img.tags || []);
|
const allTagsFlat = galleryImages.flatMap(img => img.tags || []);
|
||||||
const tagCount = {};
|
const tagCount = {};
|
||||||
allTagsFlat.forEach(t => tagCount[t] = (tagCount[t] || 0) + 1);
|
allTagsFlat.forEach(t => tagCount[t] = (tagCount[t] || 0) + 1);
|
||||||
|
const allTagsSorted = Object.keys(tagCount).sort((a, b) => tagCount[b] - tagCount[a]);
|
||||||
const allTagsSorted = Object.keys(tagCount)
|
|
||||||
.sort((a, b) => tagCount[b] - tagCount[a]);
|
|
||||||
|
|
||||||
const suggestions = allTagsSorted.filter(t => t.toLowerCase().startsWith(value) && !tags.includes(t));
|
const suggestions = allTagsSorted.filter(t => t.toLowerCase().startsWith(value) && !tags.includes(t));
|
||||||
|
|
||||||
suggestionBox.innerHTML = '';
|
suggestionBox.innerHTML = '';
|
||||||
selectedIndex = -1;
|
selectedIndex = -1;
|
||||||
|
|
||||||
if (suggestions.length) {
|
if (suggestions.length) {
|
||||||
suggestionBox.style.display = 'block';
|
suggestionBox.style.display = 'block';
|
||||||
suggestions.forEach((s, idx) => {
|
suggestions.forEach((s, idx) => {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.style.fontStyle = 'italic';
|
li.style.fontStyle = 'italic';
|
||||||
li.style.textAlign = 'left';
|
li.style.textAlign = 'left';
|
||||||
|
li.innerHTML = `<b>${s.substring(0, input.value.length)}</b>${s.substring(input.value.length)}`;
|
||||||
const boldPart = `<b>${s.substring(0, input.value.length)}</b>`;
|
|
||||||
const rest = s.substring(input.value.length);
|
|
||||||
li.innerHTML = boldPart + rest;
|
|
||||||
|
|
||||||
li.addEventListener('mousedown', (e) => {
|
li.addEventListener('mousedown', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
addTag(s);
|
addTag(s);
|
||||||
@@ -196,7 +161,6 @@ function renderTags(imgIndex, tags) {
|
|||||||
input.focus();
|
input.focus();
|
||||||
updateSuggestions();
|
updateSuggestions();
|
||||||
});
|
});
|
||||||
|
|
||||||
li.onmouseover = () => selectedIndex = idx;
|
li.onmouseover = () => selectedIndex = idx;
|
||||||
suggestionBox.appendChild(li);
|
suggestionBox.appendChild(li);
|
||||||
});
|
});
|
||||||
@@ -213,7 +177,6 @@ function renderTags(imgIndex, tags) {
|
|||||||
updateSuggestions();
|
updateSuggestions();
|
||||||
validateBtn.style.display = input.value.trim() ? 'inline-block' : 'none';
|
validateBtn.style.display = input.value.trim() ? 'inline-block' : 'none';
|
||||||
});
|
});
|
||||||
|
|
||||||
input.addEventListener('keydown', (e) => {
|
input.addEventListener('keydown', (e) => {
|
||||||
const items = suggestionBox.querySelectorAll('li');
|
const items = suggestionBox.querySelectorAll('li');
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowDown') {
|
||||||
@@ -244,27 +207,22 @@ function renderTags(imgIndex, tags) {
|
|||||||
validateBtn.style.display = 'none';
|
validateBtn.style.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
input.addEventListener('blur', () => {
|
input.addEventListener('blur', () => {
|
||||||
setTimeout(() => {
|
|
||||||
suggestionBox.style.display = 'none';
|
suggestionBox.style.display = 'none';
|
||||||
input.value = '';
|
input.value = '';
|
||||||
validateBtn.style.display = 'none';
|
validateBtn.style.display = 'none';
|
||||||
}, 150);
|
|
||||||
});
|
});
|
||||||
|
validateBtn.addEventListener('mousedown', (e) => {
|
||||||
// --- Validate button action ---
|
e.preventDefault();
|
||||||
validateBtn.onclick = () => {
|
|
||||||
if (input.value.trim()) {
|
if (input.value.trim()) {
|
||||||
addTag(input.value.trim());
|
addTag(input.value.trim());
|
||||||
input.value = '';
|
input.value = '';
|
||||||
updateSuggestions();
|
updateSuggestions();
|
||||||
validateBtn.style.display = 'none';
|
validateBtn.style.display = 'none';
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
input.focus();
|
|
||||||
updateSuggestions();
|
updateSuggestions();
|
||||||
|
if (!input.value.trim()) suggestionBox.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Update tags in galleryImages array ---
|
// --- Update tags in galleryImages array ---
|
||||||
@@ -292,36 +250,8 @@ function renderHero() {
|
|||||||
`;
|
`;
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
});
|
});
|
||||||
|
updateCountAndButtons('hero', heroImages.length);
|
||||||
// Update hero count (top)
|
applyFadeInImages(container);
|
||||||
const heroCount = document.getElementById('hero-count');
|
|
||||||
if (heroCount) {
|
|
||||||
heroCount.innerHTML = `<p>${heroImages.length} photos</p>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update hero count (bottom)
|
|
||||||
const heroCountBottom = document.getElementById('hero-count-bottom');
|
|
||||||
if (heroCountBottom) {
|
|
||||||
heroCountBottom.innerHTML = `<p>${heroImages.length} photos</p>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show/hide Remove All button (top)
|
|
||||||
const removeAllBtn = document.getElementById('remove-all-hero');
|
|
||||||
if (removeAllBtn) {
|
|
||||||
removeAllBtn.style.display = heroImages.length > 0 ? 'inline-block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show/hide bottom upload row
|
|
||||||
const bottomHeroUpload = document.getElementById('bottom-hero-upload');
|
|
||||||
if (bottomHeroUpload) {
|
|
||||||
bottomHeroUpload.style.display = heroImages.length > 0 ? 'flex' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show/hide Remove All button (bottom)
|
|
||||||
const removeAllBtnBottom = document.getElementById('remove-all-hero-bottom');
|
|
||||||
if (removeAllBtnBottom) {
|
|
||||||
removeAllBtnBottom.style.display = heroImages.length > 0 ? 'inline-block' : 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Save gallery to server ---
|
// --- Save gallery to server ---
|
||||||
@@ -367,33 +297,27 @@ async function refreshHero() {
|
|||||||
function showToast(message, type = "success", duration = 3000) {
|
function showToast(message, type = "success", duration = 3000) {
|
||||||
const container = document.getElementById("toast-container");
|
const container = document.getElementById("toast-container");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const toast = document.createElement("div");
|
const toast = document.createElement("div");
|
||||||
toast.className = `toast ${type}`;
|
toast.className = `toast ${type}`;
|
||||||
toast.textContent = message;
|
toast.textContent = message;
|
||||||
container.appendChild(toast);
|
container.appendChild(toast);
|
||||||
|
|
||||||
requestAnimationFrame(() => toast.classList.add("show"));
|
requestAnimationFrame(() => toast.classList.add("show"));
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toast.classList.remove("show");
|
toast.classList.remove("show");
|
||||||
toast.addEventListener("transitionend", () => toast.remove());
|
toast.addEventListener("transitionend", () => toast.remove());
|
||||||
}, duration);
|
}, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
let pendingDelete = null; // { type: 'gallery'|'hero'|'gallery-all'|'hero-all', index: number|null }
|
let pendingDelete = null;
|
||||||
|
|
||||||
// --- Show delete confirmation modal ---
|
// --- Show delete confirmation modal ---
|
||||||
function showDeleteModal(type, index = null) {
|
function showDeleteModal(type, index = null) {
|
||||||
pendingDelete = { type, index };
|
pendingDelete = { type, index };
|
||||||
const modalText = document.getElementById('delete-modal-text');
|
const modalText = document.getElementById('delete-modal-text');
|
||||||
if (type === 'gallery-all') {
|
modalText.textContent =
|
||||||
modalText.textContent = "Are you sure you want to delete ALL gallery images?";
|
type === 'gallery-all' ? "Are you sure you want to delete ALL gallery images?"
|
||||||
} else if (type === 'hero-all') {
|
: type === 'hero-all' ? "Are you sure you want to delete ALL hero images?"
|
||||||
modalText.textContent = "Are you sure you want to delete ALL hero images?";
|
: "Are you sure you want to delete this image?";
|
||||||
} else {
|
|
||||||
modalText.textContent = "Are you sure you want to delete this image?";
|
|
||||||
}
|
|
||||||
document.getElementById('delete-modal').style.display = 'flex';
|
document.getElementById('delete-modal').style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,15 +330,10 @@ function hideDeleteModal() {
|
|||||||
// --- Confirm deletion ---
|
// --- Confirm deletion ---
|
||||||
async function confirmDelete() {
|
async function confirmDelete() {
|
||||||
if (!pendingDelete) return;
|
if (!pendingDelete) return;
|
||||||
if (pendingDelete.type === 'gallery') {
|
if (pendingDelete.type === 'gallery') await actuallyDeleteGalleryImage(pendingDelete.index);
|
||||||
await actuallyDeleteGalleryImage(pendingDelete.index);
|
else if (pendingDelete.type === 'hero') await actuallyDeleteHeroImage(pendingDelete.index);
|
||||||
} else if (pendingDelete.type === 'hero') {
|
else if (pendingDelete.type === 'gallery-all') await actuallyDeleteAllGalleryImages();
|
||||||
await actuallyDeleteHeroImage(pendingDelete.index);
|
else if (pendingDelete.type === 'hero-all') await actuallyDeleteAllHeroImages();
|
||||||
} else if (pendingDelete.type === 'gallery-all') {
|
|
||||||
await actuallyDeleteAllGalleryImages();
|
|
||||||
} else if (pendingDelete.type === 'hero-all') {
|
|
||||||
await actuallyDeleteAllHeroImages();
|
|
||||||
}
|
|
||||||
hideDeleteModal();
|
hideDeleteModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,41 +415,36 @@ async function actuallyDeleteAllHeroImages() {
|
|||||||
|
|
||||||
// --- Modal event listeners and bulk delete buttons ---
|
// --- Modal event listeners and bulk delete buttons ---
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
document.getElementById('delete-modal-close').onclick = hideDeleteModal;
|
['delete-modal-close', 'delete-modal-cancel'].forEach(id => {
|
||||||
document.getElementById('delete-modal-cancel').onclick = hideDeleteModal;
|
const el = document.getElementById(id);
|
||||||
document.getElementById('delete-modal-confirm').onclick = confirmDelete;
|
if (el) el.onclick = hideDeleteModal;
|
||||||
|
});
|
||||||
|
const confirmBtn = document.getElementById('delete-modal-confirm');
|
||||||
|
if (confirmBtn) confirmBtn.onclick = confirmDelete;
|
||||||
|
|
||||||
// --- Radio toggle for gallery filter ---
|
// Gallery filter radios
|
||||||
const showAllRadio = document.getElementById('show-all-radio');
|
const showAllRadio = document.getElementById('show-all-radio');
|
||||||
const showUntaggedRadio = document.getElementById('show-untagged-radio');
|
const showUntaggedRadio = document.getElementById('show-untagged-radio');
|
||||||
|
if (showAllRadio) showAllRadio.addEventListener('change', () => {
|
||||||
if (showAllRadio && showUntaggedRadio) {
|
|
||||||
showAllRadio.addEventListener('change', () => {
|
|
||||||
if (showAllRadio.checked) {
|
|
||||||
showOnlyUntagged = false;
|
showOnlyUntagged = false;
|
||||||
renderGallery();
|
renderGallery();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
showUntaggedRadio.addEventListener('change', () => {
|
if (showUntaggedRadio) showUntaggedRadio.addEventListener('change', () => {
|
||||||
if (showUntaggedRadio.checked) {
|
|
||||||
showOnlyUntagged = true;
|
showOnlyUntagged = true;
|
||||||
renderGallery();
|
renderGallery();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Bulk delete buttons
|
// Bulk delete buttons
|
||||||
const removeAllGalleryBtn = document.getElementById('remove-all-gallery');
|
[
|
||||||
const removeAllGalleryBtnBottom = document.getElementById('remove-all-gallery-bottom');
|
['remove-all-gallery', 'gallery-all'],
|
||||||
const removeAllHeroBtn = document.getElementById('remove-all-hero');
|
['remove-all-gallery-bottom', 'gallery-all'],
|
||||||
const removeAllHeroBtnBottom = document.getElementById('remove-all-hero-bottom');
|
['remove-all-hero', 'hero-all'],
|
||||||
if (removeAllGalleryBtn) removeAllGalleryBtn.onclick = () => showDeleteModal('gallery-all');
|
['remove-all-hero-bottom', 'hero-all']
|
||||||
if (removeAllGalleryBtnBottom) removeAllGalleryBtnBottom.onclick = () => showDeleteModal('gallery-all');
|
].forEach(([btnId, type]) => {
|
||||||
if (removeAllHeroBtn) removeAllHeroBtn.onclick = () => showDeleteModal('hero-all');
|
const btn = document.getElementById(btnId);
|
||||||
if (removeAllHeroBtnBottom) removeAllHeroBtnBottom.onclick = () => showDeleteModal('hero-all');
|
if (btn) btn.onclick = () => showDeleteModal(type);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// --- Initialize ---
|
// --- Initialize ---
|
||||||
loadData();
|
loadData();
|
||||||
+110
-222
@@ -12,7 +12,6 @@ function showToast(message, type = "success", duration = 3000) {
|
|||||||
}, duration);
|
}, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Loader helpers ---
|
|
||||||
function showLoader(text = "Uploading...") {
|
function showLoader(text = "Uploading...") {
|
||||||
const loader = document.getElementById("global-loader");
|
const loader = document.getElementById("global-loader");
|
||||||
if (loader) {
|
if (loader) {
|
||||||
@@ -27,77 +26,65 @@ function hideLoader() {
|
|||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
// --- Section Forms ---
|
// --- Section Forms ---
|
||||||
const infoForm = document.getElementById("info-form");
|
const forms = {
|
||||||
const socialForm = document.getElementById("social-form");
|
info: document.getElementById("info-form"),
|
||||||
const menuForm = document.getElementById("menu-form");
|
social: document.getElementById("social-form"),
|
||||||
const footerForm = document.getElementById("footer-form");
|
menu: document.getElementById("menu-form"),
|
||||||
const legalsForm = document.getElementById("legals-form");
|
footer: document.getElementById("footer-form"),
|
||||||
const buildForm = document.getElementById("build-form");
|
legals: document.getElementById("legals-form"),
|
||||||
|
build: document.getElementById("build-form")
|
||||||
|
};
|
||||||
|
|
||||||
// --- Menu logic ---
|
// --- Menu logic ---
|
||||||
const menuList = document.getElementById("menu-items-list");
|
const menuList = document.getElementById("menu-items-list");
|
||||||
const addMenuBtn = document.getElementById("add-menu-item");
|
const addMenuBtn = document.getElementById("add-menu-item");
|
||||||
let menuItems = [];
|
let menuItems = [];
|
||||||
|
|
||||||
function renderMenuItems() {
|
function renderMenuItems() {
|
||||||
menuList.innerHTML = "";
|
menuList.innerHTML = "";
|
||||||
menuItems.forEach((item, idx) => {
|
menuItems.forEach((item, idx) => {
|
||||||
const div = document.createElement("div");
|
menuList.innerHTML += `
|
||||||
div.style.display = "flex";
|
<div style="display:flex;gap:8px;margin-bottom:6px;">
|
||||||
div.style.gap = "8px";
|
|
||||||
div.style.marginBottom = "6px";
|
|
||||||
div.innerHTML = `
|
|
||||||
<input type="text" placeholder="Label" value="${item.label || ""}" style="flex:1;" data-idx="${idx}" data-type="label" required>
|
<input type="text" placeholder="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>
|
<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>
|
<button type="button" class="remove-menu-item" data-idx="${idx}">🗑</button>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
menuList.appendChild(div);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMenuItemsFromInputs() {
|
function updateMenuItemsFromInputs() {
|
||||||
const inputs = menuList.querySelectorAll("input");
|
const inputs = menuList.querySelectorAll("input");
|
||||||
const items = [];
|
menuItems = [];
|
||||||
for (let i = 0; i < inputs.length; i += 2) {
|
for (let i = 0; i < inputs.length; i += 2) {
|
||||||
const label = inputs[i].value.trim();
|
const label = inputs[i].value.trim();
|
||||||
const href = inputs[i + 1].value.trim();
|
const href = inputs[i + 1].value.trim();
|
||||||
if (label || href) items.push({ label, href });
|
if (label || href) menuItems.push({ label, href });
|
||||||
}
|
}
|
||||||
menuItems = items;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Intellectual property paragraphs logic ---
|
// --- Intellectual property paragraphs logic ---
|
||||||
const ipList = document.getElementById("ip-list");
|
const ipList = document.getElementById("ip-list");
|
||||||
const addIpBtn = document.getElementById("add-ip-paragraph");
|
const addIpBtn = document.getElementById("add-ip-paragraph");
|
||||||
let ipParagraphs = [];
|
let ipParagraphs = [];
|
||||||
|
|
||||||
function renderIpParagraphs() {
|
function renderIpParagraphs() {
|
||||||
ipList.innerHTML = "";
|
ipList.innerHTML = "";
|
||||||
ipParagraphs.forEach((item, idx) => {
|
ipParagraphs.forEach((item, idx) => {
|
||||||
const div = document.createElement("div");
|
ipList.innerHTML += `
|
||||||
div.style.display = "flex";
|
<div style="display:flex;gap:8px;margin-bottom:6px;">
|
||||||
div.style.gap = "8px";
|
|
||||||
div.style.marginBottom = "6px";
|
|
||||||
div.innerHTML = `
|
|
||||||
<textarea placeholder="Paragraph" required style="flex:1;" data-idx="${idx}">${item.paragraph || ""}</textarea>
|
<textarea placeholder="Paragraph" required style="flex:1;" data-idx="${idx}">${item.paragraph || ""}</textarea>
|
||||||
<button type="button" class="remove-ip-paragraph" data-idx="${idx}">🗑</button>
|
<button type="button" class="remove-ip-paragraph" data-idx="${idx}">🗑</button>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
ipList.appendChild(div);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateIpParagraphsFromInputs() {
|
function updateIpParagraphsFromInputs() {
|
||||||
const textareas = ipList.querySelectorAll("textarea");
|
ipParagraphs = Array.from(ipList.querySelectorAll("textarea"))
|
||||||
ipParagraphs = Array.from(textareas).map(textarea => ({
|
.map(textarea => ({ paragraph: textarea.value.trim() }))
|
||||||
paragraph: textarea.value.trim()
|
.filter(item => item.paragraph !== "");
|
||||||
})).filter(item => item.paragraph !== "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Build options ---
|
// --- Build options & Theme select ---
|
||||||
const convertImagesCheckbox = document.getElementById("convert-images-checkbox");
|
const convertImagesCheckbox = document.getElementById("convert-images-checkbox");
|
||||||
const resizeImagesCheckbox = document.getElementById("resize-images-checkbox");
|
const resizeImagesCheckbox = document.getElementById("resize-images-checkbox");
|
||||||
|
|
||||||
// --- Theme select ---
|
|
||||||
const themeSelect = document.getElementById("theme-select");
|
const themeSelect = document.getElementById("theme-select");
|
||||||
|
|
||||||
// --- Thumbnail upload and modal logic ---
|
// --- Thumbnail upload and modal logic ---
|
||||||
@@ -107,40 +94,30 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const thumbnailPreview = document.getElementById("thumbnail-preview");
|
const thumbnailPreview = document.getElementById("thumbnail-preview");
|
||||||
const removeThumbnailBtn = document.getElementById("remove-thumbnail-btn");
|
const removeThumbnailBtn = document.getElementById("remove-thumbnail-btn");
|
||||||
|
|
||||||
// --- Modal elements for delete confirmation ---
|
// --- Modal helpers ---
|
||||||
const deleteModal = document.getElementById("delete-modal");
|
function setupModal(modal, closeBtn, confirmBtn, cancelBtn, onConfirm) {
|
||||||
const deleteModalClose = document.getElementById("delete-modal-close");
|
if (!modal) return;
|
||||||
const deleteModalConfirm = document.getElementById("delete-modal-confirm");
|
if (closeBtn) closeBtn.onclick = () => modal.style.display = "none";
|
||||||
const deleteModalCancel = document.getElementById("delete-modal-cancel");
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Modal elements for theme deletion ---
|
// --- Thumbnail preview logic ---
|
||||||
const deleteThemeModal = document.getElementById("delete-theme-modal");
|
|
||||||
const deleteThemeModalClose = document.getElementById("delete-theme-modal-close");
|
|
||||||
const deleteThemeModalConfirm = document.getElementById("delete-theme-modal-confirm");
|
|
||||||
const deleteThemeModalCancel = document.getElementById("delete-theme-modal-cancel");
|
|
||||||
const deleteThemeModalText = document.getElementById("delete-theme-modal-text");
|
|
||||||
let themeToDelete = null;
|
|
||||||
|
|
||||||
// --- Show/hide thumbnail preview, remove button, and choose button ---
|
|
||||||
function updateThumbnailPreview(src) {
|
function updateThumbnailPreview(src) {
|
||||||
if (thumbnailPreview) {
|
if (thumbnailPreview) {
|
||||||
thumbnailPreview.src = src || "";
|
thumbnailPreview.src = src || "";
|
||||||
thumbnailPreview.style.display = src ? "block" : "none";
|
thumbnailPreview.style.display = src ? "block" : "none";
|
||||||
}
|
}
|
||||||
if (removeThumbnailBtn) {
|
if (removeThumbnailBtn) removeThumbnailBtn.style.display = src ? "inline-block" : "none";
|
||||||
removeThumbnailBtn.style.display = src ? "inline-block" : "none";
|
if (chooseThumbnailBtn) chooseThumbnailBtn.style.display = src ? "none" : "inline-block";
|
||||||
}
|
|
||||||
if (chooseThumbnailBtn) {
|
|
||||||
chooseThumbnailBtn.style.display = src ? "none" : "inline-block";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Choose thumbnail button triggers file input ---
|
|
||||||
if (chooseThumbnailBtn && thumbnailUpload) {
|
if (chooseThumbnailBtn && thumbnailUpload) {
|
||||||
chooseThumbnailBtn.addEventListener("click", () => thumbnailUpload.click());
|
chooseThumbnailBtn.addEventListener("click", () => thumbnailUpload.click());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Handle thumbnail upload and refresh preview (with cache busting) ---
|
|
||||||
if (thumbnailUpload) {
|
if (thumbnailUpload) {
|
||||||
thumbnailUpload.addEventListener("change", async (e) => {
|
thumbnailUpload.addEventListener("change", async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
@@ -161,25 +138,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
updateSectionStatus("social");
|
updateSectionStatus("social");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Remove thumbnail button triggers modal ---
|
|
||||||
if (removeThumbnailBtn) {
|
if (removeThumbnailBtn) {
|
||||||
removeThumbnailBtn.addEventListener("click", () => {
|
removeThumbnailBtn.addEventListener("click", () => {
|
||||||
deleteModal.style.display = "flex";
|
document.getElementById("delete-modal").style.display = "flex";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
setupModal(
|
||||||
// --- Modal logic for thumbnail deletion ---
|
document.getElementById("delete-modal"),
|
||||||
if (deleteModal && deleteModalClose && deleteModalConfirm && deleteModalCancel) {
|
document.getElementById("delete-modal-close"),
|
||||||
deleteModalClose.onclick = deleteModalCancel.onclick = () => {
|
document.getElementById("delete-modal-confirm"),
|
||||||
deleteModal.style.display = "none";
|
document.getElementById("delete-modal-cancel"),
|
||||||
};
|
async () => {
|
||||||
window.onclick = function(event) {
|
|
||||||
if (event.target === deleteModal) {
|
|
||||||
deleteModal.style.display = "none";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
deleteModalConfirm.onclick = async () => {
|
|
||||||
const res = await fetch("/api/thumbnail/remove", { method: "POST" });
|
const res = await fetch("/api/thumbnail/remove", { method: "POST" });
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (result.status === "ok") {
|
if (result.status === "ok") {
|
||||||
@@ -189,41 +158,28 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
} else {
|
} else {
|
||||||
showToast("❌ Error removing thumbnail", "error");
|
showToast("❌ Error removing thumbnail", "error");
|
||||||
}
|
}
|
||||||
deleteModal.style.display = "none";
|
document.getElementById("delete-modal").style.display = "none";
|
||||||
updateSectionStatus("social");
|
updateSectionStatus("social");
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// --- Theme upload logic (custom theme folder) ---
|
// --- Theme upload logic ---
|
||||||
const themeUpload = document.getElementById("theme-upload");
|
const themeUpload = document.getElementById("theme-upload");
|
||||||
const chooseThemeBtn = document.getElementById("choose-theme-btn");
|
const chooseThemeBtn = document.getElementById("choose-theme-btn");
|
||||||
if (chooseThemeBtn && themeUpload) {
|
if (chooseThemeBtn && themeUpload) {
|
||||||
chooseThemeBtn.addEventListener("click", () => themeUpload.click());
|
chooseThemeBtn.addEventListener("click", () => themeUpload.click());
|
||||||
themeUpload.addEventListener("change", async (e) => {
|
themeUpload.addEventListener("change", async (e) => {
|
||||||
const files = Array.from(e.target.files);
|
const files = Array.from(e.target.files);
|
||||||
if (files.length === 0) return;
|
if (!files.length) return;
|
||||||
showLoader("Uploading theme...");
|
showLoader("Uploading theme...");
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
files.forEach(file => {
|
files.forEach(file => formData.append("files", file, file.webkitRelativePath || file.name));
|
||||||
formData.append("files", file, file.webkitRelativePath || file.name);
|
|
||||||
});
|
|
||||||
const res = await fetch("/api/theme/upload", { method: "POST", body: formData });
|
const res = await fetch("/api/theme/upload", { method: "POST", body: formData });
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
hideLoader();
|
hideLoader();
|
||||||
if (result.status === "ok") {
|
if (result.status === "ok") {
|
||||||
showToast("✅ Theme uploaded!", "success");
|
showToast("✅ Theme uploaded!", "success");
|
||||||
// Refresh theme select after upload
|
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
showToast("❌ Error uploading theme", "error");
|
showToast("❌ Error uploading theme", "error");
|
||||||
}
|
}
|
||||||
@@ -231,8 +187,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Remove theme button triggers modal ---
|
// --- Remove theme logic ---
|
||||||
const removeThemeBtn = document.getElementById("remove-theme-btn");
|
const removeThemeBtn = document.getElementById("remove-theme-btn");
|
||||||
|
let themeToDelete = null;
|
||||||
if (removeThemeBtn && themeSelect) {
|
if (removeThemeBtn && themeSelect) {
|
||||||
removeThemeBtn.addEventListener("click", () => {
|
removeThemeBtn.addEventListener("click", () => {
|
||||||
const theme = themeSelect.value;
|
const theme = themeSelect.value;
|
||||||
@@ -242,24 +199,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
themeToDelete = theme;
|
themeToDelete = theme;
|
||||||
deleteThemeModalText.textContent = `Are you sure you want to remove theme "${theme}"?`;
|
document.getElementById("delete-theme-modal-text").textContent = `Are you sure you want to remove theme "${theme}"?`;
|
||||||
deleteThemeModal.style.display = "flex";
|
document.getElementById("delete-theme-modal").style.display = "flex";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
setupModal(
|
||||||
// --- Modal logic for theme deletion ---
|
document.getElementById("delete-theme-modal"),
|
||||||
if (deleteThemeModal && deleteThemeModalClose && deleteThemeModalConfirm && deleteThemeModalCancel) {
|
document.getElementById("delete-theme-modal-close"),
|
||||||
deleteThemeModalClose.onclick = deleteThemeModalCancel.onclick = () => {
|
document.getElementById("delete-theme-modal-confirm"),
|
||||||
deleteThemeModal.style.display = "none";
|
document.getElementById("delete-theme-modal-cancel"),
|
||||||
themeToDelete = null;
|
async () => {
|
||||||
};
|
|
||||||
window.onclick = function(event) {
|
|
||||||
if (event.target === deleteThemeModal) {
|
|
||||||
deleteThemeModal.style.display = "none";
|
|
||||||
themeToDelete = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
deleteThemeModalConfirm.onclick = async () => {
|
|
||||||
if (!themeToDelete) return;
|
if (!themeToDelete) return;
|
||||||
showLoader("Removing theme...");
|
showLoader("Removing theme...");
|
||||||
const res = await fetch("/api/theme/remove", {
|
const res = await fetch("/api/theme/remove", {
|
||||||
@@ -271,7 +220,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
hideLoader();
|
hideLoader();
|
||||||
if (result.status === "ok") {
|
if (result.status === "ok") {
|
||||||
showToast("✅ Theme removed!", "success");
|
showToast("✅ Theme removed!", "success");
|
||||||
// Refresh theme select
|
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")
|
fetch("/api/themes")
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(themes => {
|
.then(themes => {
|
||||||
@@ -282,17 +242,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
option.textContent = theme;
|
option.textContent = theme;
|
||||||
themeSelect.appendChild(option);
|
themeSelect.appendChild(option);
|
||||||
});
|
});
|
||||||
|
loadConfigAndUpdateBuildStatus();
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
showToast(result.error || "❌ Error removing theme", "error");
|
|
||||||
}
|
|
||||||
deleteThemeModal.style.display = "none";
|
|
||||||
themeToDelete = null;
|
|
||||||
updateSectionStatus("build");
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Fetch theme list and populate select, then load config and update build status ---
|
// --- Config loading ---
|
||||||
let loadedConfig = {};
|
let loadedConfig = {};
|
||||||
function loadConfigAndUpdateBuildStatus() {
|
function loadConfigAndUpdateBuildStatus() {
|
||||||
fetch("/api/site-info")
|
fetch("/api/site-info")
|
||||||
@@ -300,17 +254,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
loadedConfig = data;
|
loadedConfig = data;
|
||||||
// Info
|
// Info
|
||||||
if (infoForm) {
|
if (forms.info) {
|
||||||
infoForm.elements["info.title"].value = data.info?.title || "";
|
forms.info.elements["info.title"].value = data.info?.title || "";
|
||||||
infoForm.elements["info.subtitle"].value = data.info?.subtitle || "";
|
forms.info.elements["info.subtitle"].value = data.info?.subtitle || "";
|
||||||
infoForm.elements["info.description"].value = data.info?.description || "";
|
forms.info.elements["info.description"].value = data.info?.description || "";
|
||||||
infoForm.elements["info.canonical"].value = data.info?.canonical || "";
|
forms.info.elements["info.canonical"].value = data.info?.canonical || "";
|
||||||
infoForm.elements["info.keywords"].value = Array.isArray(data.info?.keywords) ? data.info.keywords.join(", ") : (data.info?.keywords || "");
|
forms.info.elements["info.keywords"].value = Array.isArray(data.info?.keywords) ? data.info.keywords.join(", ") : (data.info?.keywords || "");
|
||||||
infoForm.elements["info.author"].value = data.info?.author || "";
|
forms.info.elements["info.author"].value = data.info?.author || "";
|
||||||
}
|
}
|
||||||
// Social
|
// Social
|
||||||
if (socialForm) {
|
if (forms.social) {
|
||||||
socialForm.elements["social.instagram_url"].value = data.social?.instagram_url || "";
|
forms.social.elements["social.instagram_url"].value = data.social?.instagram_url || "";
|
||||||
if (thumbnailInput) thumbnailInput.value = data.social?.thumbnail || "";
|
if (thumbnailInput) thumbnailInput.value = data.social?.thumbnail || "";
|
||||||
updateThumbnailPreview(data.social?.thumbnail ? `/photos/${data.social.thumbnail}?t=${Date.now()}` : "");
|
updateThumbnailPreview(data.social?.thumbnail ? `/photos/${data.social.thumbnail}?t=${Date.now()}` : "");
|
||||||
}
|
}
|
||||||
@@ -318,66 +272,37 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
menuItems = Array.isArray(data.menu?.items) ? data.menu.items : [];
|
menuItems = Array.isArray(data.menu?.items) ? data.menu.items : [];
|
||||||
renderMenuItems();
|
renderMenuItems();
|
||||||
// Footer
|
// Footer
|
||||||
if (footerForm) {
|
if (forms.footer) {
|
||||||
footerForm.elements["footer.copyright"].value = data.footer?.copyright || "";
|
forms.footer.elements["footer.copyright"].value = data.footer?.copyright || "";
|
||||||
footerForm.elements["footer.legal_label"].value = data.footer?.legal_label || "";
|
forms.footer.elements["footer.legal_label"].value = data.footer?.legal_label || "";
|
||||||
}
|
}
|
||||||
// Legals
|
// Legals
|
||||||
ipParagraphs = Array.isArray(data.legals?.intellectual_property)
|
ipParagraphs = Array.isArray(data.legals?.intellectual_property)
|
||||||
? data.legals.intellectual_property
|
? data.legals.intellectual_property
|
||||||
: [];
|
: [];
|
||||||
renderIpParagraphs();
|
renderIpParagraphs();
|
||||||
if (legalsForm) {
|
if (forms.legals) {
|
||||||
legalsForm.elements["legals.hoster_name"].value = data.legals?.hoster_name || "";
|
forms.legals.elements["legals.hoster_name"].value = data.legals?.hoster_name || "";
|
||||||
legalsForm.elements["legals.hoster_address"].value = data.legals?.hoster_address || "";
|
forms.legals.elements["legals.hoster_address"].value = data.legals?.hoster_address || "";
|
||||||
legalsForm.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || "";
|
forms.legals.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || "";
|
||||||
}
|
}
|
||||||
// Build
|
// Build
|
||||||
if (themeSelect) {
|
if (themeSelect) themeSelect.value = data.build?.theme || "";
|
||||||
themeSelect.value = data.build?.theme || "";
|
if (convertImagesCheckbox) convertImagesCheckbox.checked = !!data.build?.convert_images;
|
||||||
}
|
if (resizeImagesCheckbox) resizeImagesCheckbox.checked = !!data.build?.resize_images;
|
||||||
if (convertImagesCheckbox) {
|
|
||||||
convertImagesCheckbox.checked = !!data.build?.convert_images;
|
|
||||||
}
|
|
||||||
if (resizeImagesCheckbox) {
|
|
||||||
resizeImagesCheckbox.checked = !!data.build?.resize_images;
|
|
||||||
}
|
|
||||||
// Initial status update for all sections except build
|
|
||||||
["info", "social", "menu", "footer", "legals"].forEach(updateSectionStatus);
|
["info", "social", "menu", "footer", "legals"].forEach(updateSectionStatus);
|
||||||
// For build, update status after theme select is set
|
|
||||||
updateSectionStatus("build");
|
updateSectionStatus("build");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (themeSelect) refreshThemes();
|
||||||
|
else loadConfigAndUpdateBuildStatus();
|
||||||
|
|
||||||
if (themeSelect) {
|
// --- Add/remove menu items ---
|
||||||
fetch("/api/themes")
|
if (addMenuBtn) addMenuBtn.addEventListener("click", () => {
|
||||||
.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);
|
|
||||||
});
|
|
||||||
// Now load config and update build status after theme select is ready
|
|
||||||
loadConfigAndUpdateBuildStatus();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// If no theme select, just load config
|
|
||||||
loadConfigAndUpdateBuildStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Add menu item ---
|
|
||||||
if (addMenuBtn) {
|
|
||||||
addMenuBtn.addEventListener("click", () => {
|
|
||||||
menuItems.push({ label: "", href: "" });
|
menuItems.push({ label: "", href: "" });
|
||||||
renderMenuItems();
|
renderMenuItems();
|
||||||
updateSectionStatus("menu");
|
updateSectionStatus("menu");
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// --- Remove menu item ---
|
|
||||||
menuList.addEventListener("click", (e) => {
|
menuList.addEventListener("click", (e) => {
|
||||||
if (e.target.classList.contains("remove-menu-item")) {
|
if (e.target.classList.contains("remove-menu-item")) {
|
||||||
const idx = parseInt(e.target.getAttribute("data-idx"));
|
const idx = parseInt(e.target.getAttribute("data-idx"));
|
||||||
@@ -386,23 +311,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
updateSectionStatus("menu");
|
updateSectionStatus("menu");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Update menuItems on input change ---
|
|
||||||
menuList.addEventListener("input", () => {
|
menuList.addEventListener("input", () => {
|
||||||
updateMenuItemsFromInputs();
|
updateMenuItemsFromInputs();
|
||||||
updateSectionStatus("menu");
|
updateSectionStatus("menu");
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Add paragraph ---
|
// --- Add/remove IP paragraphs ---
|
||||||
if (addIpBtn) {
|
if (addIpBtn) addIpBtn.addEventListener("click", () => {
|
||||||
addIpBtn.addEventListener("click", () => {
|
|
||||||
ipParagraphs.push({ paragraph: "" });
|
ipParagraphs.push({ paragraph: "" });
|
||||||
renderIpParagraphs();
|
renderIpParagraphs();
|
||||||
updateSectionStatus("legals");
|
updateSectionStatus("legals");
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// --- Remove paragraph ---
|
|
||||||
ipList.addEventListener("click", (e) => {
|
ipList.addEventListener("click", (e) => {
|
||||||
if (e.target.classList.contains("remove-ip-paragraph")) {
|
if (e.target.classList.contains("remove-ip-paragraph")) {
|
||||||
const idx = parseInt(e.target.getAttribute("data-idx"));
|
const idx = parseInt(e.target.getAttribute("data-idx"));
|
||||||
@@ -411,8 +330,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
updateSectionStatus("legals");
|
updateSectionStatus("legals");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Update ipParagraphs on input change ---
|
|
||||||
ipList.addEventListener("input", () => {
|
ipList.addEventListener("input", () => {
|
||||||
updateIpParagraphsFromInputs();
|
updateIpParagraphsFromInputs();
|
||||||
updateSectionStatus("legals");
|
updateSectionStatus("legals");
|
||||||
@@ -423,16 +340,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
switch (section) {
|
switch (section) {
|
||||||
case "info":
|
case "info":
|
||||||
return {
|
return {
|
||||||
title: infoForm.elements["info.title"].value,
|
title: forms.info.elements["info.title"].value,
|
||||||
subtitle: infoForm.elements["info.subtitle"].value,
|
subtitle: forms.info.elements["info.subtitle"].value,
|
||||||
description: infoForm.elements["info.description"].value,
|
description: forms.info.elements["info.description"].value,
|
||||||
canonical: infoForm.elements["info.canonical"].value,
|
canonical: forms.info.elements["info.canonical"].value,
|
||||||
keywords: infoForm.elements["info.keywords"].value.split(",").map(i => i.trim()).filter(Boolean),
|
keywords: forms.info.elements["info.keywords"].value.split(",").map(i => i.trim()).filter(Boolean),
|
||||||
author: infoForm.elements["info.author"].value
|
author: forms.info.elements["info.author"].value
|
||||||
};
|
};
|
||||||
case "social":
|
case "social":
|
||||||
return {
|
return {
|
||||||
instagram_url: socialForm.elements["social.instagram_url"].value,
|
instagram_url: forms.social.elements["social.instagram_url"].value,
|
||||||
thumbnail: thumbnailInput ? thumbnailInput.value : ""
|
thumbnail: thumbnailInput ? thumbnailInput.value : ""
|
||||||
};
|
};
|
||||||
case "menu":
|
case "menu":
|
||||||
@@ -440,15 +357,15 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return { items: menuItems };
|
return { items: menuItems };
|
||||||
case "footer":
|
case "footer":
|
||||||
return {
|
return {
|
||||||
copyright: footerForm.elements["footer.copyright"].value,
|
copyright: forms.footer.elements["footer.copyright"].value,
|
||||||
legal_label: footerForm.elements["footer.legal_label"].value
|
legal_label: forms.footer.elements["footer.legal_label"].value
|
||||||
};
|
};
|
||||||
case "legals":
|
case "legals":
|
||||||
updateIpParagraphsFromInputs();
|
updateIpParagraphsFromInputs();
|
||||||
return {
|
return {
|
||||||
hoster_name: legalsForm.elements["legals.hoster_name"].value,
|
hoster_name: forms.legals.elements["legals.hoster_name"].value,
|
||||||
hoster_address: legalsForm.elements["legals.hoster_address"].value,
|
hoster_address: forms.legals.elements["legals.hoster_address"].value,
|
||||||
hoster_contact: legalsForm.elements["legals.hoster_contact"].value,
|
hoster_contact: forms.legals.elements["legals.hoster_contact"].value,
|
||||||
intellectual_property: ipParagraphs
|
intellectual_property: ipParagraphs
|
||||||
};
|
};
|
||||||
case "build":
|
case "build":
|
||||||
@@ -533,7 +450,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
values.intellectual_property.every(ip => ip.paragraph)
|
values.intellectual_property.every(ip => ip.paragraph)
|
||||||
);
|
);
|
||||||
case "build":
|
case "build":
|
||||||
return !!values.theme; // Only check theme is present
|
return !!values.theme;
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -561,46 +478,22 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Listen for changes in each section ---
|
// --- Listen for changes in each section ---
|
||||||
[
|
Object.entries(forms).forEach(([section, form]) => {
|
||||||
{ form: infoForm, section: "info" },
|
|
||||||
{ form: socialForm, section: "social" },
|
|
||||||
{ form: menuForm, section: "menu" },
|
|
||||||
{ form: footerForm, section: "footer" },
|
|
||||||
{ form: legalsForm, section: "legals" },
|
|
||||||
{ form: buildForm, section: "build" }
|
|
||||||
].forEach(({ form, section }) => {
|
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
form.addEventListener("input", () => updateSectionStatus(section));
|
form.addEventListener("input", () => updateSectionStatus(section));
|
||||||
form.addEventListener("change", () => updateSectionStatus(section));
|
form.addEventListener("change", () => updateSectionStatus(section));
|
||||||
});
|
|
||||||
|
|
||||||
// --- Save section handler (form submit) ---
|
|
||||||
[
|
|
||||||
{ form: infoForm, section: "info" },
|
|
||||||
{ form: socialForm, section: "social" },
|
|
||||||
{ form: menuForm, section: "menu" },
|
|
||||||
{ form: footerForm, section: "footer" },
|
|
||||||
{ form: legalsForm, section: "legals" },
|
|
||||||
{ form: buildForm, section: "build" }
|
|
||||||
].forEach(({ form, section }) => {
|
|
||||||
if (!form) return;
|
|
||||||
form.addEventListener("submit", async (e) => {
|
form.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Native browser validation
|
|
||||||
if (!form.reportValidity()) {
|
if (!form.reportValidity()) {
|
||||||
showToast("❌ Please fill all required fields before saving.", "error");
|
showToast("❌ Please fill all required fields before saving.", "error");
|
||||||
updateSectionStatus(section);
|
updateSectionStatus(section);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Social section: check thumbnail
|
if (section === "social" && (!thumbnailInput || !thumbnailInput.value)) {
|
||||||
if (section === "social") {
|
|
||||||
if (!thumbnailInput || !thumbnailInput.value) {
|
|
||||||
showToast("❌ Thumbnail is required.", "error");
|
showToast("❌ Thumbnail is required.", "error");
|
||||||
updateSectionStatus(section);
|
updateSectionStatus(section);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Menu section: check all menu items
|
|
||||||
if (section === "menu") {
|
if (section === "menu") {
|
||||||
updateMenuItemsFromInputs();
|
updateMenuItemsFromInputs();
|
||||||
if (!menuItems.length || !menuItems.every(item => item.label && item.href)) {
|
if (!menuItems.length || !menuItems.every(item => item.label && item.href)) {
|
||||||
@@ -609,7 +502,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Legals section: check all paragraphs
|
|
||||||
if (section === "legals") {
|
if (section === "legals") {
|
||||||
updateIpParagraphsFromInputs();
|
updateIpParagraphsFromInputs();
|
||||||
if (!ipParagraphs.length || !ipParagraphs.every(ip => ip.paragraph)) {
|
if (!ipParagraphs.length || !ipParagraphs.every(ip => ip.paragraph)) {
|
||||||
@@ -618,10 +510,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Build payload for this section only
|
|
||||||
let payload = {};
|
let payload = {};
|
||||||
payload[section] = getSectionValues(section);
|
payload[section] = getSectionValues(section);
|
||||||
|
|
||||||
const res = await fetch("/api/site-info", {
|
const res = await fetch("/api/site-info", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -630,7 +520,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (result.status === "ok") {
|
if (result.status === "ok") {
|
||||||
showToast("✅ Section saved!", "success");
|
showToast("✅ Section saved!", "success");
|
||||||
// Reload config for this section
|
|
||||||
fetch("/api/site-info")
|
fetch("/api/site-info")
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -642,5 +531,4 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -70,9 +70,11 @@ function setupColorPicker(colorId, btnId, textId, initial) {
|
|||||||
function setFontDropdown(selectId, value, options) {
|
function setFontDropdown(selectId, value, options) {
|
||||||
const select = document.getElementById(selectId);
|
const select = document.getElementById(selectId);
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
select.innerHTML = options.map(opt =>
|
select.innerHTML = options.map(opt => {
|
||||||
`<option value="${opt}"${opt === value ? " selected" : ""}>${opt}</option>`
|
// Remove extension if present
|
||||||
).join("");
|
const base = opt.replace(/\.(woff2?|ttf|otf)$/, "");
|
||||||
|
return `<option value="${base}"${base === value ? " selected" : ""}>${base}</option>`;
|
||||||
|
}).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFallbackDropdown(selectId, value) {
|
function setFallbackDropdown(selectId, value) {
|
||||||
|
|||||||
+17
-87
@@ -11,107 +11,37 @@ function hideLoader() {
|
|||||||
if (loader) loader.classList.remove("active");
|
if (loader) loader.classList.remove("active");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Generic upload handler ---
|
||||||
// --- Upload gallery images ---
|
function setupUpload(inputId, apiUrl, loaderText, successMsg, refreshFn) {
|
||||||
const galleryInput = document.getElementById('upload-gallery');
|
const input = document.getElementById(inputId);
|
||||||
if (galleryInput) {
|
if (!input) return;
|
||||||
galleryInput.addEventListener('change', async (e) => {
|
input.addEventListener('change', async (e) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
showLoader("Uploading photos...");
|
showLoader(loaderText);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
for (const file of files) formData.append('files', file);
|
for (const file of files) formData.append('files', file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/gallery/upload', { method: 'POST', body: formData });
|
const res = await fetch(apiUrl, { method: 'POST', body: formData });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
hideLoader();
|
hideLoader();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast(`✅ ${data.uploaded.length} gallery image(s) uploaded!`, "success");
|
showToast(`✅ ${data.uploaded.length} ${successMsg}`, "success");
|
||||||
if (typeof refreshGallery === "function") refreshGallery();
|
if (typeof refreshFn === "function") refreshFn();
|
||||||
} else showToast('Error: ' + data.error, "error");
|
} else showToast('Error: ' + data.error, "error");
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
hideLoader();
|
hideLoader();
|
||||||
console.error(err);
|
console.error(err);
|
||||||
showToast('Server error!', "error");
|
showToast('Server error!', "error");
|
||||||
} finally { e.target.value = ''; }
|
} finally {
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Upload hero images ---
|
// --- Setup all upload inputs ---
|
||||||
const heroInput = document.getElementById('upload-hero');
|
setupUpload('upload-gallery', '/api/gallery/upload', "Uploading photos...", "gallery image(s) uploaded!", refreshGallery);
|
||||||
if (heroInput) {
|
setupUpload('upload-hero', '/api/hero/upload', "Uploading hero photos...", "hero image(s) uploaded!", refreshHero);
|
||||||
heroInput.addEventListener('change', async (e) => {
|
setupUpload('upload-gallery-bottom', '/api/gallery/upload', "Uploading photos...", "gallery image(s) uploaded!", refreshGallery);
|
||||||
const files = e.target.files;
|
setupUpload('upload-hero-bottom', '/api/hero/upload', "Uploading hero photos...", "hero image(s) uploaded!", refreshHero);
|
||||||
if (!files.length) return;
|
|
||||||
showLoader("Uploading hero photos...");
|
|
||||||
const formData = new FormData();
|
|
||||||
for (const file of files) formData.append('files', file);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/hero/upload', { method: 'POST', body: formData });
|
|
||||||
const data = await res.json();
|
|
||||||
hideLoader();
|
|
||||||
if (res.ok) {
|
|
||||||
showToast(`✅ ${data.uploaded.length} hero image(s) uploaded!`, "success");
|
|
||||||
if (typeof refreshHero === "function") refreshHero();
|
|
||||||
} else showToast('Error: ' + data.error, "error");
|
|
||||||
} catch(err) {
|
|
||||||
hideLoader();
|
|
||||||
console.error(err);
|
|
||||||
showToast('Server error!', "error");
|
|
||||||
} finally { e.target.value = ''; }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Upload gallery images (bottom button) ---
|
|
||||||
const galleryInputBottom = document.getElementById('upload-gallery-bottom');
|
|
||||||
if (galleryInputBottom) {
|
|
||||||
galleryInputBottom.addEventListener('change', async (e) => {
|
|
||||||
const files = e.target.files;
|
|
||||||
if (!files.length) return;
|
|
||||||
showLoader("Uploading photos...");
|
|
||||||
const formData = new FormData();
|
|
||||||
for (const file of files) formData.append('files', file);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/gallery/upload', { method: 'POST', body: formData });
|
|
||||||
const data = await res.json();
|
|
||||||
hideLoader();
|
|
||||||
if (res.ok) {
|
|
||||||
showToast(`✅ ${data.uploaded.length} gallery image(s) uploaded!`, "success");
|
|
||||||
if (typeof refreshGallery === "function") refreshGallery();
|
|
||||||
} else showToast('Error: ' + data.error, "error");
|
|
||||||
} catch(err) {
|
|
||||||
hideLoader();
|
|
||||||
console.error(err);
|
|
||||||
showToast('Server error!', "error");
|
|
||||||
} finally { e.target.value = ''; }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Upload hero images (bottom button) ---
|
|
||||||
const heroInputBottom = document.getElementById('upload-hero-bottom');
|
|
||||||
if (heroInputBottom) {
|
|
||||||
heroInputBottom.addEventListener('change', async (e) => {
|
|
||||||
const files = e.target.files;
|
|
||||||
if (!files.length) return;
|
|
||||||
showLoader("Uploading hero photos...");
|
|
||||||
const formData = new FormData();
|
|
||||||
for (const file of files) formData.append('files', file);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/hero/upload', { method: 'POST', body: formData });
|
|
||||||
const data = await res.json();
|
|
||||||
hideLoader();
|
|
||||||
if (res.ok) {
|
|
||||||
showToast(`✅ ${data.uploaded.length} hero image(s) uploaded!`, "success");
|
|
||||||
if (typeof refreshHero === "function") refreshHero();
|
|
||||||
} else showToast('Error: ' + data.error, "error");
|
|
||||||
} catch(err) {
|
|
||||||
hideLoader();
|
|
||||||
console.error(err);
|
|
||||||
showToast('Server error!', "error");
|
|
||||||
} finally { e.target.value = ''; }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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'\nfavicon:\n path: favicon.png\ngoogle_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\nsocial:\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()
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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)
|
||||||
|
raise AssertionError("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()
|
||||||
@@ -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">')
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
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"
|
||||||
@@ -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"?>')
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
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()
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
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()
|
||||||
Reference in New Issue
Block a user