Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
5fb6ef18d1 | ||
|
|
1119647884 | ||
|
|
2cc0a213c9 | ||
|
|
24113a4aa8 | ||
|
|
be8ce4f62c | ||
|
|
edca473e29 | ||
|
|
eac90820d2 | ||
|
|
191fa82711 | ||
|
|
b17652e471 | ||
|
|
65fd62e342 | ||
|
|
6b03ee30fa | ||
|
|
fd45ebbd53 | ||
|
|
795f5fbd13 | ||
|
|
f8bebb9c95 | ||
|
|
3198755576 | ||
|
|
f98f2d598f | ||
|
|
e8718e71ab | ||
|
|
5b65e5efe3 | ||
|
|
021e0c7974 | ||
|
|
debbf07280 |
@@ -0,0 +1,189 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: "0 6 * * 1"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
docker build -t lumeex:ci . 2>&1 | tee build.log
|
||||||
|
if grep -q "Building wheel for" build.log; then
|
||||||
|
echo "::warning::A dependency was built from source — check Python/Alpine compatibility"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Smoke test (syntax check)
|
||||||
|
run: |
|
||||||
|
docker run --rm --entrypoint python lumeex:ci -c "
|
||||||
|
import ast
|
||||||
|
ok = True
|
||||||
|
for f in ('build.py', 'gallery.py'):
|
||||||
|
with open(f) as fh:
|
||||||
|
source = fh.read()
|
||||||
|
try:
|
||||||
|
ast.parse(source)
|
||||||
|
except SyntaxError as e:
|
||||||
|
print(f'::error::Syntax error in {f}: {e}')
|
||||||
|
ok = False
|
||||||
|
if not ok:
|
||||||
|
exit(1)
|
||||||
|
print('OK: syntax is valid')
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: |
|
||||||
|
docker build --target test -t lumeex:test .
|
||||||
|
docker run --rm lumeex:test pytest -v
|
||||||
|
|
||||||
|
- name: Check deprecation warnings
|
||||||
|
run: |
|
||||||
|
docker run --rm --entrypoint python lumeex:ci -W error::DeprecationWarning -c "import src.py.webui.webui" 2>&1 | tee deprecation.log || true
|
||||||
|
if grep -qi "deprecat" deprecation.log; then
|
||||||
|
echo "::warning::Deprecation warning detected, check logs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Scan with Trivy (critical - blocking)
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-e DOCKER_HOST=tcp://dockerhost:2375 \
|
||||||
|
--add-host=dockerhost:host-gateway \
|
||||||
|
aquasec/trivy:0.74.0 image --exit-code 1 --severity CRITICAL lumeex:ci
|
||||||
|
|
||||||
|
- name: Scan with Trivy (high - informative)
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-e DOCKER_HOST=tcp://dockerhost:2375 \
|
||||||
|
--add-host=dockerhost:host-gateway \
|
||||||
|
aquasec/trivy:0.74.0 image --exit-code 0 --severity HIGH lumeex:ci
|
||||||
|
|
||||||
|
- name: Publish tagged image
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
run: |
|
||||||
|
BEFORE="${{ github.event.before }}"
|
||||||
|
if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$BEFORE" 2>/dev/null; then
|
||||||
|
BASE_REF="$BEFORE"
|
||||||
|
else
|
||||||
|
BASE_REF="HEAD~1"
|
||||||
|
fi
|
||||||
|
CHANGED=$(git diff --name-only "$BASE_REF" "${{ github.sha }}")
|
||||||
|
echo "Changed files:"
|
||||||
|
echo "$CHANGED"
|
||||||
|
|
||||||
|
if ! echo "$CHANGED" | grep -qE '^(Dockerfile|requirements\.txt|VERSION|build\.py|gallery\.py)$|^(src|config)/|^docker/\.sh/entrypoint\.sh$'; then
|
||||||
|
echo "No container-relevant file changed, skipping publish."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if echo "$CHANGED" | grep -qE '^VERSION$'; then
|
||||||
|
echo "VERSION was manually edited in this push, using it as-is."
|
||||||
|
else
|
||||||
|
echo "VERSION untouched but container files changed, auto-bumping the build number (Z)."
|
||||||
|
OLD_VERSION=$(tr -d '[:space:]' < VERSION)
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<< "$OLD_VERSION"
|
||||||
|
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||||
|
echo "$NEW_VERSION" > VERSION
|
||||||
|
|
||||||
|
git config user.name "lumeex-ci"
|
||||||
|
git config user.email "[email protected]"
|
||||||
|
git add VERSION
|
||||||
|
git commit -m "Bump build version to $NEW_VERSION [skip ci]"
|
||||||
|
|
||||||
|
# Belt and suspenders: actions/checkout can leave its own ephemeral
|
||||||
|
# credential injected as an extraheader, which would silently override
|
||||||
|
# the URL-embedded token below. persist-credentials:false on checkout
|
||||||
|
# should already prevent this, but strip it here too just in case.
|
||||||
|
git config --unset-all http.https://git.djeex.fr/.extraheader || true
|
||||||
|
|
||||||
|
git push "https://Djeex:${{ secrets.CI_PUSH_TOKEN }}@git.djeex.fr/Djeex/lumeex.git" HEAD:main
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION=$(tr -d '[:space:]' < VERSION)
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
|
||||||
|
MINOR_TAG="${MAJOR}.${MINOR}"
|
||||||
|
|
||||||
|
IMAGE=git.djeex.fr/djeex/lumeex
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.djeex.fr -u Djeex --password-stdin
|
||||||
|
|
||||||
|
# Retag the already-built, already-scanned image — never rebuild for publish,
|
||||||
|
# so what ships is byte-for-byte what Trivy just scanned.
|
||||||
|
docker tag lumeex:ci "$IMAGE:latest"
|
||||||
|
docker tag lumeex:ci "$IMAGE:$MINOR_TAG"
|
||||||
|
docker tag lumeex:ci "$IMAGE:$VERSION"
|
||||||
|
docker push "$IMAGE:latest"
|
||||||
|
docker push "$IMAGE:$MINOR_TAG"
|
||||||
|
docker push "$IMAGE:$VERSION"
|
||||||
|
|
||||||
|
TRIGGER_MSG=$(git log -1 --format=%s "${{ github.sha }}")
|
||||||
|
PR_NUM=$(echo "$TRIGGER_MSG" | grep -oE '#[0-9]+' | head -1 | tr -d '#' || true)
|
||||||
|
|
||||||
|
CATEGORY="🔧 Maintenance"
|
||||||
|
CHANGE_TITLE="$TRIGGER_MSG"
|
||||||
|
|
||||||
|
if [ -n "$PR_NUM" ]; then
|
||||||
|
PR_JSON=$(curl -s -H "Authorization: token ${{ secrets.CI_PUSH_TOKEN }}" \
|
||||||
|
"https://git.djeex.fr/api/v1/repos/Djeex/lumeex/pulls/$PR_NUM")
|
||||||
|
PR_TITLE=$(echo "$PR_JSON" | jq -r '.title // empty' 2>/dev/null || true)
|
||||||
|
LABELS=$(echo "$PR_JSON" | jq -r '.labels[]?.name' 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -n "$PR_TITLE" ]; then
|
||||||
|
CHANGE_TITLE="$PR_TITLE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if echo "$LABELS" | grep -qx 'bug'; then
|
||||||
|
CATEGORY="⚠️ Hotfix"
|
||||||
|
elif echo "$LABELS" | grep -qx 'major'; then
|
||||||
|
CATEGORY="💥 Breaking change"
|
||||||
|
elif echo "$LABELS" | grep -qx 'minor'; then
|
||||||
|
CATEGORY="✨ Update"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
REPO_URL="https://git.djeex.fr/Djeex/lumeex"
|
||||||
|
COMMIT_LIST=$(git log --no-merges --format="- [%h](${REPO_URL}/commit/%H) %s" "$BASE_REF".."${{ github.sha }}")
|
||||||
|
|
||||||
|
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||||
|
SOURCE_LINE="[${SHORT_SHA}](${REPO_URL}/commit/${{ github.sha }})"
|
||||||
|
if [ -n "$PR_NUM" ]; then
|
||||||
|
SOURCE_LINE="[#${PR_NUM}](${REPO_URL}/pulls/${PR_NUM}) · ${SOURCE_LINE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
BODY=$(cat <<EOF
|
||||||
|
## Changelog
|
||||||
|
---
|
||||||
|
|
||||||
|
### ${CATEGORY}
|
||||||
|
---
|
||||||
|
${CHANGE_TITLE}
|
||||||
|
|
||||||
|
**Source:** ${SOURCE_LINE}
|
||||||
|
**Image:** \`${IMAGE}:${VERSION}\`
|
||||||
|
|
||||||
|
**Commits:**
|
||||||
|
${COMMIT_LIST}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
JSON_PAYLOAD=$(jq -n \
|
||||||
|
--arg tag "$VERSION" \
|
||||||
|
--arg name "$VERSION" \
|
||||||
|
--arg body "$BODY" \
|
||||||
|
'{tag_name: $tag, name: $name, target_commitish: "main", body: $body}')
|
||||||
|
|
||||||
|
curl -s -o /dev/null -w "Release API response: %{http_code}\n" -X POST \
|
||||||
|
-H "Authorization: token ${{ secrets.CI_PUSH_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$JSON_PAYLOAD" \
|
||||||
|
"https://git.djeex.fr/api/v1/repos/Djeex/lumeex/releases"
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
.*
|
.*
|
||||||
|
!.env
|
||||||
!.sh
|
!.sh
|
||||||
!.gitignore
|
!.gitignore
|
||||||
|
!.gitea
|
||||||
|
!.gitea/**
|
||||||
output/
|
output/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
htmlcov/
|
||||||
|
site_output.zip
|
||||||
+19
-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,13 @@ 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
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
+7
-10
@@ -1,4 +1,3 @@
|
|||||||
# Please change this by your settings.
|
|
||||||
info:
|
info:
|
||||||
title:
|
title:
|
||||||
subtitle:
|
subtitle:
|
||||||
@@ -9,7 +8,7 @@ info:
|
|||||||
|
|
||||||
social:
|
social:
|
||||||
instagram_url:
|
instagram_url:
|
||||||
thumbnail:
|
thumbnail: ''
|
||||||
|
|
||||||
menu:
|
menu:
|
||||||
items:
|
items:
|
||||||
@@ -18,19 +17,17 @@ menu:
|
|||||||
|
|
||||||
footer:
|
footer:
|
||||||
copyright: Copyright © 2025
|
copyright: Copyright © 2025
|
||||||
legal_link: '/legals/'
|
legal_link: /legals/
|
||||||
legal_label: Legal notice
|
legal_label: Legal notice
|
||||||
|
|
||||||
# Build parameters
|
|
||||||
build:
|
build:
|
||||||
theme: modern # choose a theme in config/theme folder
|
theme: modern
|
||||||
convert_images: true # true to enable image conversion
|
convert_images: true
|
||||||
resize_images: true # true to enable image resizing
|
resize_images: true
|
||||||
|
|
||||||
# Change this by your legals
|
|
||||||
legals:
|
legals:
|
||||||
hoster_name:
|
hoster_name:
|
||||||
hoster_adress:
|
hoster_address:
|
||||||
hoster_contact:
|
hoster_contact:
|
||||||
intellectual_property:
|
intellectual_property:
|
||||||
- paragraph: ""
|
- paragraph: ''
|
||||||
|
|||||||
@@ -35,16 +35,13 @@ img, tag {
|
|||||||
|
|
||||||
#footer {
|
#footer {
|
||||||
box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5);
|
box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5);
|
||||||
margin: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|
||||||
#footer {
|
#footer {
|
||||||
box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5);
|
box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5);
|
||||||
border-radius: 15px 15px 0 0;
|
|
||||||
max-width: 1140px;
|
max-width: 1140px;
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
@@ -53,7 +50,7 @@ img, tag {
|
|||||||
|
|
||||||
.hero-background {
|
.hero-background {
|
||||||
max-width: 90%;
|
max-width: 90%;
|
||||||
margin: auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-button {
|
.back-button {
|
||||||
|
|||||||
@@ -1,32 +1,31 @@
|
|||||||
#-----------------------------------#
|
|
||||||
# Modern theme for Lumeex #
|
|
||||||
# https://git.djeex.fr/Djeex/lumeex #
|
|
||||||
#-----------------------------------#
|
|
||||||
colors:
|
colors:
|
||||||
primary: '#0065a1'
|
primary: '#0065A1'
|
||||||
primary_dark: '#005384'
|
primary_dark: '#005384'
|
||||||
secondary: '#00b0f0'
|
secondary: '#00B0F0'
|
||||||
accent: '#ffc700'
|
accent: '#FFC700'
|
||||||
text_dark: '#616161'
|
text_dark: '#616161'
|
||||||
background: '#ffffff'
|
background: '#FFFFFF'
|
||||||
browser_color: '#ffffff'
|
browser_color: '#FFFFFF'
|
||||||
|
|
||||||
favicon:
|
favicon:
|
||||||
path: favicon.png
|
path: favicon.png
|
||||||
google_fonts:
|
|
||||||
- family: Lato
|
|
||||||
weights:
|
|
||||||
- '200'
|
|
||||||
- '400'
|
|
||||||
- '700'
|
|
||||||
- family: Montserrat
|
|
||||||
weights:
|
|
||||||
- '200'
|
|
||||||
- '400'
|
|
||||||
- '700'
|
|
||||||
fonts:
|
fonts:
|
||||||
primary:
|
primary:
|
||||||
name: Lato
|
|
||||||
fallback: sans-serif
|
fallback: sans-serif
|
||||||
|
name: Lato
|
||||||
secondary:
|
secondary:
|
||||||
name: Montserrat
|
|
||||||
fallback: serif
|
fallback: serif
|
||||||
|
name: Montserrat
|
||||||
|
|
||||||
|
google_fonts:
|
||||||
|
- family: 'Lato'
|
||||||
|
weights:
|
||||||
|
- '200'
|
||||||
|
- '400'
|
||||||
|
- '700'
|
||||||
|
- family: Montserrat
|
||||||
|
weights:
|
||||||
|
- '200'
|
||||||
|
- '400'
|
||||||
|
- '700'
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
#-----------------------------------#
|
|
||||||
# Typewriter theme for Lumeex #
|
|
||||||
# https://git.djeex.fr/Djeex/lumeex #
|
|
||||||
#-----------------------------------#
|
|
||||||
colors:
|
colors:
|
||||||
primary: '#0065a1'
|
accent: '#FFC700'
|
||||||
|
background: '#FFFFFF'
|
||||||
|
browser_color: '#FFFFFF'
|
||||||
|
primary: '#0065A1'
|
||||||
primary_dark: '#005384'
|
primary_dark: '#005384'
|
||||||
secondary: '#00b0f0'
|
secondary: '#00B0F0'
|
||||||
accent: '#ffc700'
|
|
||||||
text_dark: '#616161'
|
text_dark: '#616161'
|
||||||
background: '#ffffff'
|
|
||||||
browser_color: '#ffffff'
|
|
||||||
favicon:
|
favicon:
|
||||||
path: favicon.png
|
path: favicon.png
|
||||||
fonts:
|
fonts:
|
||||||
primary:
|
primary:
|
||||||
name: Trixie
|
name: trixie
|
||||||
fallback: sans-serif
|
fallback: sans-serif
|
||||||
secondary:
|
secondary:
|
||||||
name: Trixie
|
name: trixie
|
||||||
fallback: serif
|
fallback: serif
|
||||||
@@ -1,32 +1,31 @@
|
|||||||
#-----------------------------------#
|
|
||||||
# Modern theme for Lumeex #
|
|
||||||
# https://git.djeex.fr/Djeex/lumeex #
|
|
||||||
#-----------------------------------#
|
|
||||||
colors:
|
colors:
|
||||||
primary: '#0065a1'
|
primary: '#0065A1'
|
||||||
primary_dark: '#005384'
|
primary_dark: '#005384'
|
||||||
secondary: '#00b0f0'
|
secondary: '#00B0F0'
|
||||||
accent: '#ffc700'
|
accent: '#FFC700'
|
||||||
text_dark: '#616161'
|
text_dark: '#616161'
|
||||||
background: '#fff'
|
background: '#FFFFFF'
|
||||||
browser_color: '#fff'
|
browser_color: '#FFFFFF'
|
||||||
|
|
||||||
favicon:
|
favicon:
|
||||||
path: favicon.png
|
path: favicon.png
|
||||||
google_fonts:
|
|
||||||
- family: Lato
|
|
||||||
weights:
|
|
||||||
- '200'
|
|
||||||
- '400'
|
|
||||||
- '700'
|
|
||||||
- family: Montserrat
|
|
||||||
weights:
|
|
||||||
- '200'
|
|
||||||
- '400'
|
|
||||||
- '700'
|
|
||||||
fonts:
|
fonts:
|
||||||
primary:
|
primary:
|
||||||
name: Lato
|
|
||||||
fallback: sans-serif
|
fallback: sans-serif
|
||||||
|
name: Lato
|
||||||
secondary:
|
secondary:
|
||||||
name: Montserrat
|
|
||||||
fallback: serif
|
fallback: serif
|
||||||
|
name: Montserrat
|
||||||
|
|
||||||
|
google_fonts:
|
||||||
|
- family: ''
|
||||||
|
weights:
|
||||||
|
- '200'
|
||||||
|
- '400'
|
||||||
|
- '700'
|
||||||
|
- family: Montserrat
|
||||||
|
weights:
|
||||||
|
- '200'
|
||||||
|
- '400'
|
||||||
|
- '700'
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
#-----------------------------------#
|
|
||||||
# Typewriter theme for Lumeex #
|
|
||||||
# https://git.djeex.fr/Djeex/lumeex #
|
|
||||||
#-----------------------------------#
|
|
||||||
colors:
|
colors:
|
||||||
primary: '#0065a1'
|
accent: '#FFC700'
|
||||||
|
background: '#FFFFFF'
|
||||||
|
browser_color: '#FFFFFF'
|
||||||
|
primary: '#0065A1'
|
||||||
primary_dark: '#005384'
|
primary_dark: '#005384'
|
||||||
secondary: '#00b0f0'
|
secondary: '#00B0F0'
|
||||||
accent: '#ffc700'
|
|
||||||
text_dark: '#616161'
|
text_dark: '#616161'
|
||||||
background: '#fff'
|
|
||||||
browser_color: '#fff'
|
|
||||||
favicon:
|
favicon:
|
||||||
path: favicon.png
|
path: favicon.png
|
||||||
|
|
||||||
fonts:
|
fonts:
|
||||||
primary:
|
primary:
|
||||||
name: Trixie
|
name: trixie.woff
|
||||||
fallback: sans-serif
|
fallback: sans-serif
|
||||||
secondary:
|
secondary:
|
||||||
name: Trixie
|
name: trixie.woff
|
||||||
fallback: serif
|
fallback: serif
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
PREVIEW_PORT=3000
|
||||||
|
WEBUI_PORT=5000
|
||||||
+24
-19
@@ -1,33 +1,36 @@
|
|||||||
#!/bin/bash
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
CYAN="\033[1;36m"
|
CYAN="\033[1;36m"
|
||||||
NC="\033[0m"
|
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 need to be copied..."
|
echo "[~] Checking if default config files/folders need to be copied..."
|
||||||
files_copied=false
|
files_copied=false
|
||||||
|
|
||||||
for file in /app/default/*; do
|
for src in $(find /app/default -mindepth 1); do
|
||||||
filename=$(basename "$file")
|
relpath="${src#/app/default/}"
|
||||||
target="/app/config/$filename"
|
target="/app/config/$relpath"
|
||||||
|
|
||||||
if [ ! -e "$target" ]; then
|
if [ ! -e "$target" ]; then
|
||||||
echo "Copying default config file: $filename"
|
echo "[→] Copying: $relpath"
|
||||||
cp -r "$file" "$target"
|
if [ -d "$src" ]; then
|
||||||
|
mkdir -p "$target"
|
||||||
|
cp -r "$src/" "$target/"
|
||||||
|
else
|
||||||
|
mkdir -p "$(dirname "$target")"
|
||||||
|
cp "$src" "$target"
|
||||||
|
fi
|
||||||
files_copied=true
|
files_copied=true
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ "$files_copied" = true ]; then
|
if [ "$files_copied" = true ]; then
|
||||||
echo "Default configuration files copied successfully."
|
echo "[✓] Default configuration files/folders copied successfully."
|
||||||
else
|
else
|
||||||
echo "No default files needed to be copied."
|
echo "[✓] No default files/folders needed to be copied."
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,11 +46,13 @@ start_server() {
|
|||||||
cat /tmp/build_logs_fifo >&2 &
|
cat /tmp/build_logs_fifo >&2 &
|
||||||
cat /tmp/build_logs_fifo2 >&2 &
|
cat /tmp/build_logs_fifo2 >&2 &
|
||||||
|
|
||||||
echo "Starting preview HTTP server on port 3000..."
|
PREVIEW_PORT="${PREVIEW_PORT:-3000}"
|
||||||
|
echo "[~]Starting preview HTTP server on port 3000..."
|
||||||
|
echo "[i] Preview host port is set to: ${PREVIEW_PORT}"
|
||||||
python3 -u -m http.server 3000 -d /app/output &
|
python3 -u -m http.server 3000 -d /app/output &
|
||||||
SERVER_PID=$!
|
SERVER_PID=$!
|
||||||
|
|
||||||
echo "Starting Lumeex Flask webui..."
|
echo "[~] Starting Lumeex Flask webui..."
|
||||||
python3 -u -m src.py.webui.webui &
|
python3 -u -m src.py.webui.webui &
|
||||||
WEBUI_PID=$!
|
WEBUI_PID=$!
|
||||||
|
|
||||||
@@ -72,15 +77,15 @@ fi
|
|||||||
|
|
||||||
case "$1" in
|
case "$1" in
|
||||||
build)
|
build)
|
||||||
echo "Running build.py..."
|
echo "[~] Running build.py..."
|
||||||
python3 -u /app/build.py 2>&1 | tee /tmp/build_logs_fifo
|
python3 -u /app/build.py 2>&1 | tee /tmp/build_logs_fifo
|
||||||
;;
|
;;
|
||||||
gallery)
|
gallery)
|
||||||
echo "Running gallery.py..."
|
echo "[~] Running gallery.py..."
|
||||||
python3 -u /app/gallery.py 2>&1 | tee /tmp/build_logs_fifo2
|
python3 -u /app/gallery.py 2>&1 | tee /tmp/build_logs_fifo2
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown command: $1"
|
echo "[!] Unknown command: $1"
|
||||||
exec "$@"
|
exec "$@"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ services:
|
|||||||
lumeex:
|
lumeex:
|
||||||
container_name: lmx
|
container_name: lmx
|
||||||
build: ..
|
build: ..
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- PREVIEW_PORT=${PREVIEW_PORT:-3000} # port for preview server - set it in .env file
|
||||||
|
- WEBUI_PORT=${WEBUI_PORT:-5000} # port for webui server - set it in .env file
|
||||||
volumes:
|
volumes:
|
||||||
- ../config:/app/config # mount config directory
|
- ../config:/app/config # mount config directory
|
||||||
- ../output:/app/output # mount output directory
|
- ../output:/app/output # mount output directory
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "${PREVIEW_PORT:-3000}:3000"
|
||||||
- "5000:5000"
|
- "${WEBUI_PORT:-5000}:5000"
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -9,7 +9,6 @@ window.addEventListener("DOMContentLoaded", () => {
|
|||||||
entries.forEach(entry => {
|
entries.forEach(entry => {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
const img = entry.target;
|
const img = entry.target;
|
||||||
console.log("Lazy-loading image:", img.dataset.src);
|
|
||||||
img.src = img.dataset.src;
|
img.src = img.dataset.src;
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
img.classList.add("loaded");
|
img.classList.add("loaded");
|
||||||
@@ -33,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 {
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ html,body {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height:1.5;
|
line-height:1.5;
|
||||||
color:var(--color-primary-dark);
|
color:var(--color-primary-dark);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@@ -179,8 +181,8 @@ h2 {
|
|||||||
/* animation */
|
/* animation */
|
||||||
|
|
||||||
.appear {
|
.appear {
|
||||||
-webkit-transition: all 0.3s;
|
-webkit-transition: all 1s;
|
||||||
transition: all 0.3s;
|
transition: all 1s;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
-webkit-transform: translateY(20px);
|
-webkit-transform: translateY(20px);
|
||||||
transform: translateY(20px);
|
transform: translateY(20px);
|
||||||
@@ -237,12 +239,23 @@ h2 {
|
|||||||
/* Hero */
|
/* Hero */
|
||||||
|
|
||||||
#hero {
|
#hero {
|
||||||
height: 100%;
|
min-height: 100vh;
|
||||||
width: 100%;
|
flex: 1 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#hero .section {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
#hero .content-wrapper, #hero .section {
|
#hero .content-wrapper, #hero .section {
|
||||||
height:100%;
|
height:100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-background {
|
.hero-background {
|
||||||
height: 66%;
|
height: 66%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -303,7 +316,6 @@ h2 {
|
|||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.gallery {
|
.gallery {
|
||||||
padding-top: 15px;
|
padding-top: 15px;
|
||||||
}
|
}
|
||||||
@@ -341,6 +353,11 @@ h2 {
|
|||||||
|
|
||||||
/* Footer */
|
/* Footer */
|
||||||
|
|
||||||
|
#footer {
|
||||||
|
margin: auto 0 0 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.navigation {
|
.navigation {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding-top: 40px;
|
padding-top: 40px;
|
||||||
@@ -470,8 +487,7 @@ h2 {
|
|||||||
|
|
||||||
#legals.content-wrapper {
|
#legals.content-wrapper {
|
||||||
max-width: 90%;
|
max-width: 90%;
|
||||||
margin: auto;
|
margin: 50px auto;
|
||||||
margin-top: 50px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.legals-content {
|
.legals-content {
|
||||||
|
|||||||
@@ -11,6 +11,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,7 +28,10 @@ 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:
|
||||||
|
|||||||
+26
-5
@@ -30,6 +30,8 @@ app = Flask(
|
|||||||
static_url_path=""
|
static_url_path=""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
WEBUI_PORT = int(os.getenv("WEBUI_PORT", 5000))
|
||||||
|
|
||||||
# --- Config paths ---
|
# --- Config paths ---
|
||||||
SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
|
SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
|
||||||
PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos"
|
PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos"
|
||||||
@@ -72,9 +74,14 @@ def get_local_fonts(theme_name):
|
|||||||
def index():
|
def index():
|
||||||
return render_template("index.html")
|
return render_template("index.html")
|
||||||
|
|
||||||
|
PREVIEW_PORT = int(os.getenv("PREVIEW_PORT", 3000))
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_version():
|
def inject_version():
|
||||||
return dict(lumeex_version=lumeex_version)
|
return dict(
|
||||||
|
lumeex_version=lumeex_version,
|
||||||
|
preview_port=PREVIEW_PORT
|
||||||
|
)
|
||||||
|
|
||||||
# --- Gallery & Hero API ---
|
# --- Gallery & Hero API ---
|
||||||
@app.route("/gallery-editor")
|
@app.route("/gallery-editor")
|
||||||
@@ -203,9 +210,21 @@ def get_site_info():
|
|||||||
@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."""
|
||||||
data = request.json
|
new_data = request.json
|
||||||
|
with open(SITE_YAML, "r") as f:
|
||||||
|
old_data = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
def deep_merge(old, new):
|
||||||
|
for k, v in new.items():
|
||||||
|
if isinstance(v, dict) and isinstance(old.get(k), dict):
|
||||||
|
old[k] = deep_merge(old[k], v)
|
||||||
|
else:
|
||||||
|
old[k] = v
|
||||||
|
return old
|
||||||
|
|
||||||
|
merged = deep_merge(old_data, new_data)
|
||||||
with open(SITE_YAML, "w") as f:
|
with open(SITE_YAML, "w") as f:
|
||||||
yaml.safe_dump(data, 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 ---
|
||||||
@@ -396,7 +415,8 @@ 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():
|
||||||
@@ -487,5 +507,6 @@ def download_output_zip():
|
|||||||
|
|
||||||
# --- 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")
|
||||||
|
logging.info(f"[i] WebUI host port is set to {WEBUI_PORT}")
|
||||||
app.run(host="0.0.0.0", port=5000, debug=True)
|
app.run(host="0.0.0.0", port=5000, debug=True)
|
||||||
@@ -18,7 +18,16 @@
|
|||||||
<button id="remove-all-hero" class="up-btn">🗑 Delete all</button>
|
<button id="remove-all-hero" class="up-btn">🗑 Delete all</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="file" id="upload-hero" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
|
<input type="file" id="upload-hero" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
|
||||||
|
<span id="hero-count" class="photo-count"></span>
|
||||||
<div id="hero"></div>
|
<div id="hero"></div>
|
||||||
|
<input type="file" id="upload-hero-bottom" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
|
||||||
|
<div id="bottom-hero-upload" class="upload-actions-row bottom-action-row">
|
||||||
|
<span id="hero-count-bottom" class="photo-count"></span>
|
||||||
|
<label for="upload-hero-bottom" class="up-btn">
|
||||||
|
📸 Upload photos
|
||||||
|
</label>
|
||||||
|
<button id="remove-all-hero-bottom" class="up-btn bottom-remove-btn">🗑 Delete all</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gallery Upload Section -->
|
<!-- Gallery Upload Section -->
|
||||||
@@ -31,8 +40,27 @@
|
|||||||
</label>
|
</label>
|
||||||
<button id="remove-all-gallery" class="up-btn">🗑 Delete all</button>
|
<button id="remove-all-gallery" class="up-btn">🗑 Delete all</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filter-row">
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="gallery-filter" id="show-all-radio" checked>
|
||||||
|
All
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="gallery-filter" id="show-untagged-radio">
|
||||||
|
Untagged
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<span id="gallery-count" class="photo-count"></span>
|
||||||
<input type="file" id="upload-gallery" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
|
<input type="file" id="upload-gallery" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
|
||||||
<div id="gallery"></div>
|
<div id="gallery"></div>
|
||||||
|
<input type="file" id="upload-gallery-bottom" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
|
||||||
|
<div id="bottom-gallery-upload" class="upload-actions-row bottom-action-row">
|
||||||
|
<span id="gallery-count-bottom" class="photo-count"></span>
|
||||||
|
<label for="upload-gallery-bottom" class="up-btn">
|
||||||
|
📸 Upload photos
|
||||||
|
</label>
|
||||||
|
<button id="remove-all-gallery-bottom" class="up-btn bottom-remove-btn">🗑 Delete all</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
|
|||||||
@@ -92,6 +92,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preview Site button
|
||||||
|
const previewBtn = document.getElementById("preview-site-btn");
|
||||||
|
if (previewBtn) {
|
||||||
|
const previewPort = previewBtn.getAttribute("data-preview-port") || "3000";
|
||||||
|
previewBtn.onclick = () => {
|
||||||
|
const host = window.location.hostname;
|
||||||
|
const protocol = window.location.protocol;
|
||||||
|
const url = `${protocol}//${host}:${previewPort}/`;
|
||||||
|
window.open(url, "_blank");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Modal close logic
|
// Modal close logic
|
||||||
if (buildModal && buildModalClose) {
|
if (buildModal && buildModalClose) {
|
||||||
buildModalClose.onclick = () => {
|
buildModalClose.onclick = () => {
|
||||||
|
|||||||
@@ -1,18 +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;
|
||||||
|
|
||||||
|
// --- 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);
|
||||||
@@ -24,22 +35,43 @@ 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 = '';
|
||||||
galleryImages.forEach((img, i) => {
|
let imagesToShow = showOnlyUntagged
|
||||||
|
? galleryImages.filter(img => !img.tags || img.tags.length === 0)
|
||||||
|
: galleryImages;
|
||||||
|
|
||||||
|
imagesToShow.forEach((img) => {
|
||||||
|
const i = galleryImages.indexOf(img);
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'photo flex-item flex-column';
|
div.className = 'photo flex-item flex-column';
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="flex-item">
|
<div class="flex-item">
|
||||||
<img src="/photos/${img.src}">
|
<img class="fade-in-img" src="/photos/${img.src}">
|
||||||
</div>
|
</div>
|
||||||
<div class="tags-display" data-index="${i}"></div>
|
<div class="tags-display" data-index="${i}"></div>
|
||||||
<div class="flex-item flex-full">
|
<div class="flex-item flex-full">
|
||||||
@@ -50,22 +82,17 @@ function renderGallery() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
|
|
||||||
renderTags(i, img.tags || []);
|
renderTags(i, img.tags || []);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show/hide Remove All button
|
updateCountAndButtons('gallery', imagesToShow.length);
|
||||||
const removeAllBtn = document.getElementById('remove-all-gallery');
|
applyFadeInImages(container);
|
||||||
if (removeAllBtn) {
|
|
||||||
removeAllBtn.style.display = galleryImages.length > 0 ? 'inline-block' : 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 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 = '';
|
||||||
|
|
||||||
@@ -73,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 = '×';
|
||||||
@@ -82,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);
|
||||||
});
|
});
|
||||||
@@ -92,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);
|
||||||
|
|
||||||
@@ -116,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);
|
||||||
@@ -147,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);
|
||||||
});
|
});
|
||||||
@@ -164,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') {
|
||||||
@@ -195,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 ---
|
||||||
@@ -233,7 +240,7 @@ function renderHero() {
|
|||||||
div.className = 'photo flex-item flex-column';
|
div.className = 'photo flex-item flex-column';
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="flex-item">
|
<div class="flex-item">
|
||||||
<img src="/photos/${img.src}">
|
<img class="fade-in-img" src="/photos/${img.src}">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-item flex-full">
|
<div class="flex-item flex-full">
|
||||||
<div class="flex-item flex-end">
|
<div class="flex-item flex-end">
|
||||||
@@ -243,12 +250,8 @@ function renderHero() {
|
|||||||
`;
|
`;
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
});
|
});
|
||||||
|
updateCountAndButtons('hero', heroImages.length);
|
||||||
// Show/hide Remove All button
|
applyFadeInImages(container);
|
||||||
const removeAllBtn = document.getElementById('remove-all-hero');
|
|
||||||
if (removeAllBtn) {
|
|
||||||
removeAllBtn.style.display = heroImages.length > 0 ? 'inline-block' : 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Save gallery to server ---
|
// --- Save gallery to server ---
|
||||||
@@ -294,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';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,15 +415,35 @@ 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;
|
||||||
|
|
||||||
|
// Gallery filter radios
|
||||||
|
const showAllRadio = document.getElementById('show-all-radio');
|
||||||
|
const showUntaggedRadio = document.getElementById('show-untagged-radio');
|
||||||
|
if (showAllRadio) showAllRadio.addEventListener('change', () => {
|
||||||
|
showOnlyUntagged = false;
|
||||||
|
renderGallery();
|
||||||
|
});
|
||||||
|
if (showUntaggedRadio) showUntaggedRadio.addEventListener('change', () => {
|
||||||
|
showOnlyUntagged = true;
|
||||||
|
renderGallery();
|
||||||
|
});
|
||||||
|
|
||||||
// Bulk delete buttons
|
// Bulk delete buttons
|
||||||
const removeAllGalleryBtn = document.getElementById('remove-all-gallery');
|
[
|
||||||
const removeAllHeroBtn = document.getElementById('remove-all-hero');
|
['remove-all-gallery', 'gallery-all'],
|
||||||
if (removeAllGalleryBtn) removeAllGalleryBtn.onclick = () => showDeleteModal('gallery-all');
|
['remove-all-gallery-bottom', 'gallery-all'],
|
||||||
if (removeAllHeroBtn) removeAllHeroBtn.onclick = () => showDeleteModal('hero-all');
|
['remove-all-hero', 'hero-all'],
|
||||||
|
['remove-all-hero-bottom', 'hero-all']
|
||||||
|
].forEach(([btnId, type]) => {
|
||||||
|
const btn = document.getElementById(btnId);
|
||||||
|
if (btn) btn.onclick = () => showDeleteModal(type);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Initialize ---
|
// --- Initialize ---
|
||||||
|
|||||||
+316
-238
@@ -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) {
|
||||||
@@ -26,119 +25,99 @@ function hideLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
// Form and menu logic
|
// --- Section Forms ---
|
||||||
const form = document.getElementById("site-info-form");
|
const forms = {
|
||||||
|
info: document.getElementById("info-form"),
|
||||||
|
social: document.getElementById("social-form"),
|
||||||
|
menu: document.getElementById("menu-form"),
|
||||||
|
footer: document.getElementById("footer-form"),
|
||||||
|
legals: document.getElementById("legals-form"),
|
||||||
|
build: document.getElementById("build-form")
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Menu logic ---
|
||||||
const menuList = document.getElementById("menu-items-list");
|
const menuList = document.getElementById("menu-items-list");
|
||||||
const addMenuBtn = document.getElementById("add-menu-item");
|
const addMenuBtn = document.getElementById("add-menu-item");
|
||||||
|
|
||||||
let menuItems = [];
|
let menuItems = [];
|
||||||
|
|
||||||
// Render menu items
|
|
||||||
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";
|
<input type="text" placeholder="Label" value="${item.label || ""}" style="flex:1;" data-idx="${idx}" data-type="label" required>
|
||||||
div.style.marginBottom = "6px";
|
<input type="text" placeholder="?tag=tag1,tag2" value="${item.href || ""}" style="flex:2;" data-idx="${idx}" data-type="href" required>
|
||||||
div.innerHTML = `
|
<button type="button" class="remove-menu-item" data-idx="${idx}">🗑</button>
|
||||||
<input type="text" placeholder="Label" value="${item.label || ""}" style="flex:1;" data-idx="${idx}" data-type="label" required>
|
</div>
|
||||||
<input type="text" placeholder="?tag=tag1,tag2" value="${item.href || ""}" style="flex:2;" data-idx="${idx}" data-type="href" required>
|
|
||||||
<button type="button" class="remove-menu-item" data-idx="${idx}">🗑</button>
|
|
||||||
`;
|
`;
|
||||||
menuList.appendChild(div);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update menu items from inputs
|
|
||||||
function updateMenuItemsFromInputs() {
|
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 = [];
|
||||||
|
|
||||||
// Render IP paragraphs
|
|
||||||
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";
|
<textarea placeholder="Paragraph" required style="flex:1;" data-idx="${idx}">${item.paragraph || ""}</textarea>
|
||||||
div.style.marginBottom = "6px";
|
<button type="button" class="remove-ip-paragraph" data-idx="${idx}">🗑</button>
|
||||||
div.innerHTML = `
|
</div>
|
||||||
<textarea placeholder="Paragraph" required style="flex:1;" data-idx="${idx}">${item.paragraph || ""}</textarea>
|
|
||||||
<button type="button" class="remove-ip-paragraph" data-idx="${idx}">🗑</button>
|
|
||||||
`;
|
`;
|
||||||
ipList.appendChild(div);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update IP paragraphs from textareas
|
|
||||||
function updateIpParagraphsFromInputs() {
|
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 ---
|
||||||
const thumbnailInput = form?.elements["social.thumbnail"];
|
const thumbnailInput = document.getElementById("social-thumbnail");
|
||||||
const thumbnailUpload = document.getElementById("thumbnail-upload");
|
const thumbnailUpload = document.getElementById("thumbnail-upload");
|
||||||
const chooseThumbnailBtn = document.getElementById("choose-thumbnail-btn");
|
const chooseThumbnailBtn = document.getElementById("choose-thumbnail-btn");
|
||||||
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];
|
||||||
@@ -156,27 +135,20 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
} else {
|
} else {
|
||||||
showToast("❌ Error uploading thumbnail", "error");
|
showToast("❌ Error uploading thumbnail", "error");
|
||||||
}
|
}
|
||||||
|
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") {
|
||||||
@@ -186,48 +158,38 @@ 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");
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// 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");
|
||||||
}
|
}
|
||||||
|
updateSectionStatus("build");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -237,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", {
|
||||||
@@ -266,28 +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();
|
||||||
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(result.error || "❌ Error removing theme", "error");
|
showToast(result.error || "❌ Error removing theme", "error");
|
||||||
}
|
}
|
||||||
deleteThemeModal.style.display = "none";
|
document.getElementById("delete-theme-modal").style.display = "none";
|
||||||
themeToDelete = null;
|
themeToDelete = null;
|
||||||
};
|
updateSectionStatus("build");
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Fetch theme list and populate select
|
// --- Theme select refresh ---
|
||||||
if (themeSelect) {
|
function refreshThemes() {
|
||||||
fetch("/api/themes")
|
fetch("/api/themes")
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(themes => {
|
.then(themes => {
|
||||||
@@ -298,148 +242,276 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
option.textContent = theme;
|
option.textContent = theme;
|
||||||
themeSelect.appendChild(option);
|
themeSelect.appendChild(option);
|
||||||
});
|
});
|
||||||
// Set selected value after loading config
|
loadConfigAndUpdateBuildStatus();
|
||||||
fetch("/api/site-info")
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
themeSelect.value = data.build?.theme || "";
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load config from server and populate form
|
// --- Config loading ---
|
||||||
if (form) {
|
let loadedConfig = {};
|
||||||
|
function loadConfigAndUpdateBuildStatus() {
|
||||||
fetch("/api/site-info")
|
fetch("/api/site-info")
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
loadedConfig = data;
|
||||||
|
// Info
|
||||||
|
if (forms.info) {
|
||||||
|
forms.info.elements["info.title"].value = data.info?.title || "";
|
||||||
|
forms.info.elements["info.subtitle"].value = data.info?.subtitle || "";
|
||||||
|
forms.info.elements["info.description"].value = data.info?.description || "";
|
||||||
|
forms.info.elements["info.canonical"].value = data.info?.canonical || "";
|
||||||
|
forms.info.elements["info.keywords"].value = Array.isArray(data.info?.keywords) ? data.info.keywords.join(", ") : (data.info?.keywords || "");
|
||||||
|
forms.info.elements["info.author"].value = data.info?.author || "";
|
||||||
|
}
|
||||||
|
// Social
|
||||||
|
if (forms.social) {
|
||||||
|
forms.social.elements["social.instagram_url"].value = data.social?.instagram_url || "";
|
||||||
|
if (thumbnailInput) thumbnailInput.value = data.social?.thumbnail || "";
|
||||||
|
updateThumbnailPreview(data.social?.thumbnail ? `/photos/${data.social.thumbnail}?t=${Date.now()}` : "");
|
||||||
|
}
|
||||||
|
// Menu
|
||||||
|
menuItems = Array.isArray(data.menu?.items) ? data.menu.items : [];
|
||||||
|
renderMenuItems();
|
||||||
|
// Footer
|
||||||
|
if (forms.footer) {
|
||||||
|
forms.footer.elements["footer.copyright"].value = data.footer?.copyright || "";
|
||||||
|
forms.footer.elements["footer.legal_label"].value = data.footer?.legal_label || "";
|
||||||
|
}
|
||||||
|
// Legals
|
||||||
ipParagraphs = Array.isArray(data.legals?.intellectual_property)
|
ipParagraphs = Array.isArray(data.legals?.intellectual_property)
|
||||||
? data.legals.intellectual_property
|
? data.legals.intellectual_property
|
||||||
: [];
|
: [];
|
||||||
renderIpParagraphs();
|
renderIpParagraphs();
|
||||||
menuItems = Array.isArray(data.menu?.items) ? data.menu.items : [];
|
if (forms.legals) {
|
||||||
renderMenuItems();
|
forms.legals.elements["legals.hoster_name"].value = data.legals?.hoster_name || "";
|
||||||
form.elements["info.title"].value = data.info?.title || "";
|
forms.legals.elements["legals.hoster_address"].value = data.legals?.hoster_address || "";
|
||||||
form.elements["info.subtitle"].value = data.info?.subtitle || "";
|
forms.legals.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || "";
|
||||||
form.elements["info.description"].value = data.info?.description || "";
|
|
||||||
form.elements["info.canonical"].value = data.info?.canonical || "";
|
|
||||||
form.elements["info.keywords"].value = Array.isArray(data.info?.keywords) ? data.info.keywords.join(", ") : (data.info?.keywords || "");
|
|
||||||
form.elements["info.author"].value = data.info?.author || "";
|
|
||||||
form.elements["social.instagram_url"].value = data.social?.instagram_url || "";
|
|
||||||
if (thumbnailInput) thumbnailInput.value = data.social?.thumbnail || "";
|
|
||||||
updateThumbnailPreview(data.social?.thumbnail ? `/photos/${data.social.thumbnail}?t=${Date.now()}` : "");
|
|
||||||
form.elements["footer.copyright"].value = data.footer?.copyright || "";
|
|
||||||
form.elements["footer.legal_label"].value = data.footer?.legal_label || "";
|
|
||||||
if (themeSelect) {
|
|
||||||
themeSelect.value = data.build?.theme || "";
|
|
||||||
}
|
|
||||||
form.elements["legals.hoster_name"].value = data.legals?.hoster_name || "";
|
|
||||||
form.elements["legals.hoster_address"].value = data.legals?.hoster_address || "";
|
|
||||||
form.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || "";
|
|
||||||
// Build checkboxes
|
|
||||||
if (convertImagesCheckbox) {
|
|
||||||
convertImagesCheckbox.checked = !!data.build?.convert_images;
|
|
||||||
}
|
|
||||||
if (resizeImagesCheckbox) {
|
|
||||||
resizeImagesCheckbox.checked = !!data.build?.resize_images;
|
|
||||||
}
|
}
|
||||||
|
// Build
|
||||||
|
if (themeSelect) themeSelect.value = data.build?.theme || "";
|
||||||
|
if (convertImagesCheckbox) convertImagesCheckbox.checked = !!data.build?.convert_images;
|
||||||
|
if (resizeImagesCheckbox) resizeImagesCheckbox.checked = !!data.build?.resize_images;
|
||||||
|
["info", "social", "menu", "footer", "legals"].forEach(updateSectionStatus);
|
||||||
|
updateSectionStatus("build");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (themeSelect) refreshThemes();
|
||||||
|
else loadConfigAndUpdateBuildStatus();
|
||||||
|
|
||||||
// Add menu item
|
// --- Add/remove menu items ---
|
||||||
if (addMenuBtn) {
|
if (addMenuBtn) addMenuBtn.addEventListener("click", () => {
|
||||||
addMenuBtn.addEventListener("click", () => {
|
menuItems.push({ label: "", href: "" });
|
||||||
menuItems.push({ label: "", href: "" });
|
renderMenuItems();
|
||||||
renderMenuItems();
|
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"));
|
||||||
menuItems.splice(idx, 1);
|
menuItems.splice(idx, 1);
|
||||||
renderMenuItems();
|
renderMenuItems();
|
||||||
|
updateSectionStatus("menu");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update menuItems on input change
|
|
||||||
menuList.addEventListener("input", () => {
|
menuList.addEventListener("input", () => {
|
||||||
updateMenuItemsFromInputs();
|
updateMenuItemsFromInputs();
|
||||||
|
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");
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// 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"));
|
||||||
ipParagraphs.splice(idx, 1);
|
ipParagraphs.splice(idx, 1);
|
||||||
renderIpParagraphs();
|
renderIpParagraphs();
|
||||||
|
updateSectionStatus("legals");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update ipParagraphs on input change
|
|
||||||
ipList.addEventListener("input", () => {
|
ipList.addEventListener("input", () => {
|
||||||
updateIpParagraphsFromInputs();
|
updateIpParagraphsFromInputs();
|
||||||
|
updateSectionStatus("legals");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save config to server
|
// --- Section value helpers ---
|
||||||
if (form) {
|
function getSectionValues(section) {
|
||||||
|
switch (section) {
|
||||||
|
case "info":
|
||||||
|
return {
|
||||||
|
title: forms.info.elements["info.title"].value,
|
||||||
|
subtitle: forms.info.elements["info.subtitle"].value,
|
||||||
|
description: forms.info.elements["info.description"].value,
|
||||||
|
canonical: forms.info.elements["info.canonical"].value,
|
||||||
|
keywords: forms.info.elements["info.keywords"].value.split(",").map(i => i.trim()).filter(Boolean),
|
||||||
|
author: forms.info.elements["info.author"].value
|
||||||
|
};
|
||||||
|
case "social":
|
||||||
|
return {
|
||||||
|
instagram_url: forms.social.elements["social.instagram_url"].value,
|
||||||
|
thumbnail: thumbnailInput ? thumbnailInput.value : ""
|
||||||
|
};
|
||||||
|
case "menu":
|
||||||
|
updateMenuItemsFromInputs();
|
||||||
|
return { items: menuItems };
|
||||||
|
case "footer":
|
||||||
|
return {
|
||||||
|
copyright: forms.footer.elements["footer.copyright"].value,
|
||||||
|
legal_label: forms.footer.elements["footer.legal_label"].value
|
||||||
|
};
|
||||||
|
case "legals":
|
||||||
|
updateIpParagraphsFromInputs();
|
||||||
|
return {
|
||||||
|
hoster_name: forms.legals.elements["legals.hoster_name"].value,
|
||||||
|
hoster_address: forms.legals.elements["legals.hoster_address"].value,
|
||||||
|
hoster_contact: forms.legals.elements["legals.hoster_contact"].value,
|
||||||
|
intellectual_property: ipParagraphs
|
||||||
|
};
|
||||||
|
case "build":
|
||||||
|
return {
|
||||||
|
theme: themeSelect ? themeSelect.value : "",
|
||||||
|
convert_images: !!(convertImagesCheckbox && convertImagesCheckbox.checked),
|
||||||
|
resize_images: !!(resizeImagesCheckbox && resizeImagesCheckbox.checked)
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSectionSaved(section) {
|
||||||
|
const values = getSectionValues(section);
|
||||||
|
const config = loadedConfig[section] || {};
|
||||||
|
function normalizeMenuItems(items) {
|
||||||
|
return (items || []).map(item => ({
|
||||||
|
label: item.label || "",
|
||||||
|
href: item.href || ""
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
switch (section) {
|
||||||
|
case "info":
|
||||||
|
return Object.keys(values).every(
|
||||||
|
key => values[key] && (
|
||||||
|
key === "keywords"
|
||||||
|
? Array.isArray(config.keywords) && values.keywords.join(",") === config.keywords.join(",")
|
||||||
|
: values[key] === (config[key] || "")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
case "social":
|
||||||
|
return values.instagram_url && values.thumbnail &&
|
||||||
|
values.instagram_url === (config.instagram_url || "") &&
|
||||||
|
values.thumbnail === (config.thumbnail || "");
|
||||||
|
case "menu":
|
||||||
|
return JSON.stringify(normalizeMenuItems(values.items)) === JSON.stringify(normalizeMenuItems(config.items));
|
||||||
|
case "footer":
|
||||||
|
return values.copyright && values.legal_label &&
|
||||||
|
values.copyright === (config.copyright || "") &&
|
||||||
|
values.legal_label === (config.legal_label || "");
|
||||||
|
case "legals":
|
||||||
|
return values.hoster_name && values.hoster_address && values.hoster_contact &&
|
||||||
|
values.hoster_name === (config.hoster_name || "") &&
|
||||||
|
values.hoster_address === (config.hoster_address || "") &&
|
||||||
|
values.hoster_contact === (config.hoster_contact || "") &&
|
||||||
|
JSON.stringify(values.intellectual_property) === JSON.stringify(config.intellectual_property || []);
|
||||||
|
case "build":
|
||||||
|
return values.theme === (config.theme || "") &&
|
||||||
|
!!values.convert_images === !!config.convert_images &&
|
||||||
|
!!values.resize_images === !!config.resize_images;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSectionComplete(section) {
|
||||||
|
const values = getSectionValues(section);
|
||||||
|
switch (section) {
|
||||||
|
case "info":
|
||||||
|
return (
|
||||||
|
values.title &&
|
||||||
|
values.subtitle &&
|
||||||
|
values.description &&
|
||||||
|
values.canonical &&
|
||||||
|
values.keywords.length > 0 &&
|
||||||
|
values.author
|
||||||
|
);
|
||||||
|
case "social":
|
||||||
|
return values.instagram_url && values.thumbnail;
|
||||||
|
case "menu":
|
||||||
|
return Array.isArray(values.items) && values.items.every(item => item.label && item.href);
|
||||||
|
case "footer":
|
||||||
|
return values.copyright && values.legal_label;
|
||||||
|
case "legals":
|
||||||
|
return (
|
||||||
|
values.hoster_name &&
|
||||||
|
values.hoster_address &&
|
||||||
|
values.hoster_contact &&
|
||||||
|
Array.isArray(values.intellectual_property) &&
|
||||||
|
values.intellectual_property.length > 0 &&
|
||||||
|
values.intellectual_property.every(ip => ip.paragraph)
|
||||||
|
);
|
||||||
|
case "build":
|
||||||
|
return !!values.theme;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSectionStatus(section) {
|
||||||
|
const statusEl = document.querySelector(`#${section}-section .section-status`);
|
||||||
|
if (!statusEl) return;
|
||||||
|
if (!isSectionComplete(section)) {
|
||||||
|
statusEl.innerHTML = "⚠️ Section not yet saved. Please fill required fields";
|
||||||
|
statusEl.style.color = "#ffc700";
|
||||||
|
statusEl.style.display = "";
|
||||||
|
statusEl.style.fontStyle = "normal";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isSectionSaved(section)) {
|
||||||
|
statusEl.innerHTML = "";
|
||||||
|
statusEl.style.display = "none";
|
||||||
|
} else {
|
||||||
|
statusEl.innerHTML = "⚠️ Section not yet saved";
|
||||||
|
statusEl.style.color = "#ffc700";
|
||||||
|
statusEl.style.display = "";
|
||||||
|
statusEl.style.fontStyle = "normal";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Listen for changes in each section ---
|
||||||
|
Object.entries(forms).forEach(([section, form]) => {
|
||||||
|
if (!form) return;
|
||||||
|
form.addEventListener("input", () => updateSectionStatus(section));
|
||||||
|
form.addEventListener("change", () => updateSectionStatus(section));
|
||||||
form.addEventListener("submit", async (e) => {
|
form.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
updateMenuItemsFromInputs();
|
if (!form.reportValidity()) {
|
||||||
updateIpParagraphsFromInputs();
|
showToast("❌ Please fill all required fields before saving.", "error");
|
||||||
|
updateSectionStatus(section);
|
||||||
// Check if thumbnail is set before saving (uploaded or present in input)
|
|
||||||
if (!thumbnailInput || !thumbnailInput.value) {
|
|
||||||
showLoader("Saving...");
|
|
||||||
showToast("❌ Thumbnail is required.", "error");
|
|
||||||
hideLoader();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (section === "social" && (!thumbnailInput || !thumbnailInput.value)) {
|
||||||
const build = {
|
showToast("❌ Thumbnail is required.", "error");
|
||||||
theme: themeSelect ? themeSelect.value : "",
|
updateSectionStatus(section);
|
||||||
convert_images: !!(convertImagesCheckbox && convertImagesCheckbox.checked),
|
return;
|
||||||
resize_images: !!(resizeImagesCheckbox && resizeImagesCheckbox.checked)
|
}
|
||||||
};
|
if (section === "menu") {
|
||||||
|
updateMenuItemsFromInputs();
|
||||||
const payload = {
|
if (!menuItems.length || !menuItems.every(item => item.label && item.href)) {
|
||||||
info: {
|
showToast("❌ Please fill all menu item fields.", "error");
|
||||||
title: form.elements["info.title"].value,
|
updateSectionStatus(section);
|
||||||
subtitle: form.elements["info.subtitle"].value,
|
return;
|
||||||
description: form.elements["info.description"].value,
|
|
||||||
canonical: form.elements["info.canonical"].value,
|
|
||||||
keywords: form.elements["info.keywords"].value.split(",").map(i => i.trim()).filter(Boolean),
|
|
||||||
author: form.elements["info.author"].value
|
|
||||||
},
|
|
||||||
social: {
|
|
||||||
instagram_url: form.elements["social.instagram_url"].value,
|
|
||||||
thumbnail: thumbnailInput ? thumbnailInput.value : ""
|
|
||||||
},
|
|
||||||
menu: {
|
|
||||||
items: menuItems
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
copyright: form.elements["footer.copyright"].value,
|
|
||||||
legal_label: form.elements["footer.legal_label"].value
|
|
||||||
},
|
|
||||||
build,
|
|
||||||
legals: {
|
|
||||||
hoster_name: form.elements["legals.hoster_name"].value,
|
|
||||||
hoster_address: form.elements["legals.hoster_address"].value,
|
|
||||||
hoster_contact: form.elements["legals.hoster_contact"].value,
|
|
||||||
intellectual_property: ipParagraphs
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
// --- REMOVE loader for save ---
|
if (section === "legals") {
|
||||||
// showLoader("Saving...");
|
updateIpParagraphsFromInputs();
|
||||||
|
if (!ipParagraphs.length || !ipParagraphs.every(ip => ip.paragraph)) {
|
||||||
|
showToast("❌ Please fill all intellectual property paragraphs.", "error");
|
||||||
|
updateSectionStatus(section);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let payload = {};
|
||||||
|
payload[section] = getSectionValues(section);
|
||||||
const res = await fetch("/api/site-info", {
|
const res = await fetch("/api/site-info", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -447,10 +519,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (result.status === "ok") {
|
if (result.status === "ok") {
|
||||||
showToast("✅ Site info saved!", "success");
|
showToast("✅ Section saved!", "success");
|
||||||
|
fetch("/api/site-info")
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
loadedConfig = data;
|
||||||
|
updateSectionStatus(section);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
showToast("❌ Error saving site info", "error");
|
showToast("❌ Error saving section", "error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
});
|
});
|
||||||
+217
-76
@@ -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) {
|
||||||
@@ -116,54 +118,173 @@ function renderLocalFonts(fonts) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Section helpers ---
|
||||||
|
function getSectionValues(section) {
|
||||||
|
switch (section) {
|
||||||
|
case "colors":
|
||||||
|
return {
|
||||||
|
primary: document.getElementById("color-primary-text").value,
|
||||||
|
primary_dark: document.getElementById("color-primary-dark-text").value,
|
||||||
|
secondary: document.getElementById("color-secondary-text").value,
|
||||||
|
accent: document.getElementById("color-accent-text").value,
|
||||||
|
text_dark: document.getElementById("color-text-dark-text").value,
|
||||||
|
background: document.getElementById("color-background-text").value,
|
||||||
|
browser_color: document.getElementById("color-browser-color-text").value
|
||||||
|
};
|
||||||
|
case "google-fonts":
|
||||||
|
const googleFontsFields = document.getElementById("google-fonts-fields");
|
||||||
|
const fonts = [];
|
||||||
|
if (googleFontsFields) {
|
||||||
|
googleFontsFields.querySelectorAll(".input-field").forEach(field => {
|
||||||
|
const family = field.querySelector('input[name^="google_fonts"][name$="[family]"]').value.trim();
|
||||||
|
const weights = field.querySelector('input[name^="google_fonts"][name$="[weights]"]').value
|
||||||
|
.split(",").map(w => w.trim()).filter(Boolean);
|
||||||
|
if (family) fonts.push({ family, weights });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fonts;
|
||||||
|
case "fonts":
|
||||||
|
return {
|
||||||
|
primary: {
|
||||||
|
name: document.getElementById("font-primary").value,
|
||||||
|
fallback: document.getElementById("font-primary-fallback").value
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
name: document.getElementById("font-secondary").value,
|
||||||
|
fallback: document.getElementById("font-secondary-fallback").value
|
||||||
|
}
|
||||||
|
};
|
||||||
|
case "favicon":
|
||||||
|
return {
|
||||||
|
path: document.getElementById("favicon-path").value
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSectionComplete(section) {
|
||||||
|
switch (section) {
|
||||||
|
case "colors":
|
||||||
|
const v = getSectionValues("colors");
|
||||||
|
return (
|
||||||
|
v.primary &&
|
||||||
|
v.primary_dark &&
|
||||||
|
v.secondary &&
|
||||||
|
v.accent &&
|
||||||
|
v.text_dark &&
|
||||||
|
v.background &&
|
||||||
|
v.browser_color
|
||||||
|
);
|
||||||
|
case "google-fonts":
|
||||||
|
const fonts = getSectionValues("google-fonts");
|
||||||
|
return fonts.every(f => f.family);
|
||||||
|
case "fonts":
|
||||||
|
const f = getSectionValues("fonts");
|
||||||
|
return f.primary.name && f.primary.fallback && f.secondary.name && f.secondary.fallback;
|
||||||
|
case "favicon":
|
||||||
|
const fav = getSectionValues("favicon");
|
||||||
|
return !!fav.path;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSectionSaved(section, loadedConfig) {
|
||||||
|
switch (section) {
|
||||||
|
case "colors":
|
||||||
|
const v = getSectionValues("colors");
|
||||||
|
const c = loadedConfig.colors || {};
|
||||||
|
return (
|
||||||
|
v.primary === c.primary &&
|
||||||
|
v.primary_dark === c.primary_dark &&
|
||||||
|
v.secondary === c.secondary &&
|
||||||
|
v.accent === c.accent &&
|
||||||
|
v.text_dark === c.text_dark &&
|
||||||
|
v.background === c.background &&
|
||||||
|
v.browser_color === c.browser_color
|
||||||
|
);
|
||||||
|
case "google-fonts":
|
||||||
|
const fonts = getSectionValues("google-fonts");
|
||||||
|
const cf = loadedConfig.google_fonts || [];
|
||||||
|
return JSON.stringify(fonts) === JSON.stringify(cf);
|
||||||
|
case "fonts":
|
||||||
|
const f = getSectionValues("fonts");
|
||||||
|
const cfnt = loadedConfig.fonts || {};
|
||||||
|
return (
|
||||||
|
f.primary.name === (cfnt.primary?.name || "") &&
|
||||||
|
f.primary.fallback === (cfnt.primary?.fallback || "") &&
|
||||||
|
f.secondary.name === (cfnt.secondary?.name || "") &&
|
||||||
|
f.secondary.fallback === (cfnt.secondary?.fallback || "")
|
||||||
|
);
|
||||||
|
case "favicon":
|
||||||
|
const fav = getSectionValues("favicon");
|
||||||
|
const cfav = loadedConfig.favicon || {};
|
||||||
|
return fav.path === (cfav.path || "");
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSectionStatus(section, loadedConfig) {
|
||||||
|
const statusEl = document.querySelector(`#${section}-form .section-status`);
|
||||||
|
if (!statusEl) return;
|
||||||
|
if (!isSectionComplete(section)) {
|
||||||
|
statusEl.innerHTML = "⚠️ Section not yet saved. Please fill required fields";
|
||||||
|
statusEl.style.color = "#ffc700";
|
||||||
|
statusEl.style.display = "";
|
||||||
|
statusEl.style.fontStyle = "normal";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isSectionSaved(section, loadedConfig)) {
|
||||||
|
statusEl.innerHTML = "";
|
||||||
|
statusEl.style.display = "none";
|
||||||
|
} else {
|
||||||
|
statusEl.innerHTML = "⚠️ Section not yet saved";
|
||||||
|
statusEl.style.color = "#ffc700";
|
||||||
|
statusEl.style.display = "";
|
||||||
|
statusEl.style.fontStyle = "normal";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", async () => {
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
const themeInfo = await fetchThemeInfo();
|
const themeInfo = await fetchThemeInfo();
|
||||||
const themeNameSpan = document.getElementById("current-theme");
|
const themeNameSpan = document.getElementById("current-theme");
|
||||||
if (themeNameSpan) themeNameSpan.textContent = themeInfo.theme_name;
|
if (themeNameSpan) themeNameSpan.textContent = themeInfo.theme_name;
|
||||||
|
|
||||||
const themeYaml = themeInfo.theme_yaml;
|
let loadedConfig = themeInfo.theme_yaml;
|
||||||
const googleFonts = themeYaml.google_fonts ? JSON.parse(JSON.stringify(themeYaml.google_fonts)) : [];
|
let googleFonts = loadedConfig.google_fonts ? JSON.parse(JSON.stringify(loadedConfig.google_fonts)) : [];
|
||||||
let localFonts = await fetchLocalFonts(themeInfo.theme_name);
|
let localFonts = await fetchLocalFonts(themeInfo.theme_name);
|
||||||
|
|
||||||
// Colors
|
// Colors
|
||||||
if (themeYaml.colors) {
|
if (loadedConfig.colors) {
|
||||||
setupColorPicker("color-primary", "color-primary-btn", "color-primary-text", themeYaml.colors.primary || "#0065a1");
|
setupColorPicker("color-primary", "color-primary-btn", "color-primary-text", loadedConfig.colors.primary || "#0065a1");
|
||||||
setupColorPicker("color-primary-dark", "color-primary-dark-btn", "color-primary-dark-text", themeYaml.colors.primary_dark || "#005384");
|
setupColorPicker("color-primary-dark", "color-primary-dark-btn", "color-primary-dark-text", loadedConfig.colors.primary_dark || "#005384");
|
||||||
setupColorPicker("color-secondary", "color-secondary-btn", "color-secondary-text", themeYaml.colors.secondary || "#00b0f0");
|
setupColorPicker("color-secondary", "color-secondary-btn", "color-secondary-text", loadedConfig.colors.secondary || "#00b0f0");
|
||||||
setupColorPicker("color-accent", "color-accent-btn", "color-accent-text", themeYaml.colors.accent || "#ffc700");
|
setupColorPicker("color-accent", "color-accent-btn", "color-accent-text", loadedConfig.colors.accent || "#ffc700");
|
||||||
setupColorPicker("color-text-dark", "color-text-dark-btn", "color-text-dark-text", themeYaml.colors.text_dark || "#616161");
|
setupColorPicker("color-text-dark", "color-text-dark-btn", "color-text-dark-text", loadedConfig.colors.text_dark || "#616161");
|
||||||
setupColorPicker("color-background", "color-background-btn", "color-background-text", themeYaml.colors.background || "#fff");
|
setupColorPicker("color-background", "color-background-btn", "color-background-text", loadedConfig.colors.background || "#fff");
|
||||||
setupColorPicker("color-browser-color", "color-browser-color-btn", "color-browser-color-text", themeYaml.colors.browser_color || "#fff");
|
setupColorPicker("color-browser-color", "color-browser-color-btn", "color-browser-color-text", loadedConfig.colors.browser_color || "#fff");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fonts
|
// Fonts
|
||||||
function refreshFontDropdowns() {
|
function refreshFontDropdowns() {
|
||||||
setFontDropdown("font-primary", document.getElementById("font-primary").value, [
|
setFontDropdown("font-primary", loadedConfig.fonts?.primary?.name || "Lato", [
|
||||||
...googleFonts.map(f => f.family),
|
...googleFonts.map(f => f.family),
|
||||||
...localFonts
|
...localFonts
|
||||||
]);
|
]);
|
||||||
setFontDropdown("font-secondary", document.getElementById("font-secondary").value, [
|
setFontDropdown("font-secondary", loadedConfig.fonts?.secondary?.name || "Montserrat", [
|
||||||
...googleFonts.map(f => f.family),
|
...googleFonts.map(f => f.family),
|
||||||
...localFonts
|
...localFonts
|
||||||
]);
|
]);
|
||||||
|
setFallbackDropdown("font-primary-fallback", loadedConfig.fonts?.primary?.fallback || "sans-serif");
|
||||||
|
setFallbackDropdown("font-secondary-fallback", loadedConfig.fonts?.secondary?.fallback || "serif");
|
||||||
}
|
}
|
||||||
if (themeYaml.fonts) {
|
refreshFontDropdowns();
|
||||||
setFontDropdown("font-primary", themeYaml.fonts.primary?.name || "Lato", [
|
|
||||||
...googleFonts.map(f => f.family),
|
|
||||||
...localFonts
|
|
||||||
]);
|
|
||||||
setFallbackDropdown("font-primary-fallback", themeYaml.fonts.primary?.fallback || "sans-serif");
|
|
||||||
setFontDropdown("font-secondary", themeYaml.fonts.secondary?.name || "Montserrat", [
|
|
||||||
...googleFonts.map(f => f.family),
|
|
||||||
...localFonts
|
|
||||||
]);
|
|
||||||
setFallbackDropdown("font-secondary-fallback", themeYaml.fonts.secondary?.fallback || "serif");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Font upload logic
|
// Font upload logic
|
||||||
const fontUploadInput = document.getElementById("font-upload");
|
const fontUploadInput = document.getElementById("font-upload");
|
||||||
const chooseFontBtn = document.getElementById("choose-font-btn");
|
const chooseFontBtn = document.getElementById("choose-font-btn");
|
||||||
const fontUploadStatus = document.getElementById("font-upload-status");
|
|
||||||
const localFontsList = document.getElementById("local-fonts-list");
|
const localFontsList = document.getElementById("local-fonts-list");
|
||||||
|
|
||||||
// Modal logic for font deletion
|
// Modal logic for font deletion
|
||||||
@@ -341,9 +462,9 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (themeYaml.favicon && themeYaml.favicon.path) {
|
if (loadedConfig.favicon && loadedConfig.favicon.path) {
|
||||||
faviconInput.value = themeYaml.favicon.path;
|
faviconInput.value = loadedConfig.favicon.path;
|
||||||
updateFaviconPreview(`/themes/${themeInfo.theme_name}/${themeYaml.favicon.path}?t=${Date.now()}`);
|
updateFaviconPreview(`/themes/${themeInfo.theme_name}/${loadedConfig.favicon.path}?t=${Date.now()}`);
|
||||||
} else {
|
} else {
|
||||||
updateFaviconPreview("");
|
updateFaviconPreview("");
|
||||||
}
|
}
|
||||||
@@ -367,6 +488,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
googleFonts.push(...updatedGoogleFonts);
|
googleFonts.push(...updatedGoogleFonts);
|
||||||
renderGoogleFonts(googleFonts);
|
renderGoogleFonts(googleFonts);
|
||||||
refreshFontDropdowns();
|
refreshFontDropdowns();
|
||||||
|
updateSectionStatus("google-fonts", loadedConfig);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,6 +518,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
googleFonts.push(...updatedGoogleFonts);
|
googleFonts.push(...updatedGoogleFonts);
|
||||||
renderGoogleFonts(googleFonts);
|
renderGoogleFonts(googleFonts);
|
||||||
refreshFontDropdowns();
|
refreshFontDropdowns();
|
||||||
|
updateSectionStatus("google-fonts", loadedConfig);
|
||||||
}
|
}
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
@@ -414,54 +537,72 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
googleFonts.push(...updatedGoogleFonts);
|
googleFonts.push(...updatedGoogleFonts);
|
||||||
renderGoogleFonts(googleFonts);
|
renderGoogleFonts(googleFonts);
|
||||||
refreshFontDropdowns();
|
refreshFontDropdowns();
|
||||||
|
updateSectionStatus("google-fonts", loadedConfig);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("theme-editor-form").addEventListener("submit", async (e) => {
|
// --- Section status listeners ---
|
||||||
e.preventDefault();
|
[
|
||||||
showLoader("Saving theme...");
|
{ form: document.getElementById("colors-form"), section: "colors" },
|
||||||
const data = {};
|
{ form: document.getElementById("google-fonts-form"), section: "google-fonts" },
|
||||||
data.colors = {
|
{ form: document.getElementById("fonts-form"), section: "fonts" },
|
||||||
primary: document.getElementById("color-primary-text").value,
|
{ form: document.getElementById("favicon-form"), section: "favicon" }
|
||||||
primary_dark: document.getElementById("color-primary-dark-text").value,
|
].forEach(({ form, section }) => {
|
||||||
secondary: document.getElementById("color-secondary-text").value,
|
if (!form) return;
|
||||||
accent: document.getElementById("color-accent-text").value,
|
form.addEventListener("input", () => updateSectionStatus(section, loadedConfig));
|
||||||
text_dark: document.getElementById("color-text-dark-text").value,
|
form.addEventListener("change", () => updateSectionStatus(section, loadedConfig));
|
||||||
background: document.getElementById("color-background-text").value,
|
|
||||||
browser_color: document.getElementById("color-browser-color-text").value
|
|
||||||
};
|
|
||||||
data.fonts = {
|
|
||||||
primary: {
|
|
||||||
name: document.getElementById("font-primary").value,
|
|
||||||
fallback: document.getElementById("font-primary-fallback").value
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
name: document.getElementById("font-secondary").value,
|
|
||||||
fallback: document.getElementById("font-secondary-fallback").value
|
|
||||||
}
|
|
||||||
};
|
|
||||||
data.favicon = {
|
|
||||||
path: faviconInput.value
|
|
||||||
};
|
|
||||||
data.google_fonts = [];
|
|
||||||
document.querySelectorAll("#google-fonts-fields .input-field").forEach(field => {
|
|
||||||
const family = field.querySelector('input[name^="google_fonts"][name$="[family]"]').value;
|
|
||||||
const weights = field.querySelector('input[name^="google_fonts"][name$="[weights]"]').value
|
|
||||||
.split(",").map(w => w.trim()).filter(w => w);
|
|
||||||
if (family) data.google_fonts.push({ family, weights });
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await fetch("/api/theme-info", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ theme_name: themeInfo.theme_name, theme_yaml: data })
|
|
||||||
});
|
|
||||||
hideLoader();
|
|
||||||
if (res.ok) {
|
|
||||||
showToast("✅ Theme saved!", "success");
|
|
||||||
} else {
|
|
||||||
showToast("Error saving theme.", "error");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Section save handlers ---
|
||||||
|
[
|
||||||
|
{ form: document.getElementById("colors-form"), section: "colors" },
|
||||||
|
{ form: document.getElementById("google-fonts-form"), section: "google-fonts" },
|
||||||
|
{ form: document.getElementById("fonts-form"), section: "fonts" },
|
||||||
|
{ form: document.getElementById("favicon-form"), section: "favicon" }
|
||||||
|
].forEach(({ form, section }) => {
|
||||||
|
if (!form) return;
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.reportValidity() || !isSectionComplete(section)) {
|
||||||
|
showToast("❌ Please fill all required fields before saving.", "error");
|
||||||
|
updateSectionStatus(section, loadedConfig);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Merge with loadedConfig to avoid overwriting other sections
|
||||||
|
let payload = { ...loadedConfig };
|
||||||
|
switch (section) {
|
||||||
|
case "colors":
|
||||||
|
payload.colors = getSectionValues("colors");
|
||||||
|
break;
|
||||||
|
case "google-fonts":
|
||||||
|
payload.google_fonts = getSectionValues("google-fonts");
|
||||||
|
break;
|
||||||
|
case "fonts":
|
||||||
|
payload.fonts = getSectionValues("fonts");
|
||||||
|
break;
|
||||||
|
case "favicon":
|
||||||
|
payload.favicon = getSectionValues("favicon");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
showLoader("Saving...");
|
||||||
|
const res = await fetch("/api/theme-info", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ theme_name: themeInfo.theme_name, theme_yaml: payload })
|
||||||
|
});
|
||||||
|
hideLoader();
|
||||||
|
if (res.ok) {
|
||||||
|
showToast("✅ Section saved!", "success");
|
||||||
|
const updatedThemeInfo = await fetchThemeInfo();
|
||||||
|
loadedConfig = updatedThemeInfo.theme_yaml;
|
||||||
|
updateSectionStatus(section, loadedConfig);
|
||||||
|
} else {
|
||||||
|
showToast("Error saving section.", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial status update
|
||||||
|
["colors", "google-fonts", "fonts", "favicon"].forEach(section => updateSectionStatus(section, loadedConfig));
|
||||||
});
|
});
|
||||||
+17
-34
@@ -11,54 +11,37 @@ function hideLoader() {
|
|||||||
if (loader) loader.classList.remove("active");
|
if (loader) loader.classList.remove("active");
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Upload gallery images ---
|
// --- Generic upload handler ---
|
||||||
const galleryInput = document.getElementById('upload-gallery');
|
function setupUpload(inputId, apiUrl, loaderText, successMsg, refreshFn) {
|
||||||
if (galleryInput) {
|
const input = document.getElementById(inputId);
|
||||||
galleryInput.addEventListener('change', async (e) => {
|
if (!input) return;
|
||||||
|
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 = ''; }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+190
-162
@@ -4,176 +4,204 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<h1>Edit Site Info</h1>
|
<h1>Edit Site Info</h1>
|
||||||
<form id="site-info-form">
|
|
||||||
<!-- Info Section -->
|
<!-- Info Section -->
|
||||||
<fieldset>
|
<form id="info-form" autocomplete="off">
|
||||||
<h2>Info</h2>
|
<fieldset id="info-section">
|
||||||
<p>Set the basic information for your site and SEO</p>
|
<h2>Info</h2>
|
||||||
<div class="fields">
|
<p class="section-status"></p>
|
||||||
<div class="input-field">
|
<p>Set the basic information for your site and SEO</p>
|
||||||
<label>Title</label>
|
<div class="fields">
|
||||||
<input type="text" name="info.title" placeholder="Your site title" required>
|
<div class="input-field">
|
||||||
</div>
|
<label>Title</label>
|
||||||
<div class="input-field">
|
<input type="text" name="info.title" placeholder="Your site title" required>
|
||||||
<label>Subtitle</label>
|
|
||||||
<input type="text" name="info.subtitle" placeholder="Your site subtitle" required>
|
|
||||||
</div>
|
|
||||||
<div class="input-field">
|
|
||||||
<label>Description</label>
|
|
||||||
<input type="text" name="info.description" placeholder="Your site description" required>
|
|
||||||
</div>
|
|
||||||
<div class="input-field">
|
|
||||||
<label>Canonical URL</label>
|
|
||||||
<input type="text" name="info.canonical" placeholder="https://yoursite.com" required>
|
|
||||||
</div>
|
|
||||||
<div class="input-field">
|
|
||||||
<label>Keywords (comma separated)</label>
|
|
||||||
<input type="text" name="info.keywords" placeholder="photo, gallery, photography" required>
|
|
||||||
</div>
|
|
||||||
<div class="input-field">
|
|
||||||
<label>Author</label>
|
|
||||||
<input type="text" name="info.author" placeholder="Your Name" required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<!-- Social Section -->
|
|
||||||
<fieldset>
|
|
||||||
<h2>Social</h2>
|
|
||||||
<p>Set your social media links and thumbnail for link sharing</p>
|
|
||||||
<div class="fields">
|
|
||||||
<div class="input-field">
|
|
||||||
<label>Instagram URL</label>
|
|
||||||
<input type="text" name="social.instagram_url" placeholder="https://instagram.com/yourprofile" required>
|
|
||||||
<label class="thumbnail-form-label">Thumbnail</label>
|
|
||||||
<input type="file" id="thumbnail-upload" accept="image/png,image/jpeg,image/webp" style="display:none;">
|
|
||||||
<button type="button" id="choose-thumbnail-btn" class="up-btn">📸 Upload a photo</button>
|
|
||||||
<div class="thumbnail-form">
|
|
||||||
<input type="hidden" name="social.thumbnail" id="social-thumbnail">
|
|
||||||
<img id="thumbnail-preview" src="" alt="Thumbnail preview" style="max-width:100px;display:none;">
|
|
||||||
<button type="button" id="remove-thumbnail-btn" class="remove-btn up-btn danger" style="display:none;">Remove</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<!-- Menu Section -->
|
|
||||||
<fieldset>
|
|
||||||
<h2>Menu</h2>
|
|
||||||
<p>Manage your site menu items. You can use tag combination to propose custom filters</p>
|
|
||||||
<div class="fields">
|
|
||||||
<div class="input-field" style="flex: 1 1 100%;">
|
|
||||||
<div id="menu-items-list"></div>
|
|
||||||
<button type="button" id="add-menu-item">+ Add menu item</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<!-- Footer Section -->
|
|
||||||
<fieldset>
|
|
||||||
<h2>Footer</h2>
|
|
||||||
<p>Set your copyright informations and legal link name</p>
|
|
||||||
<div class="fields">
|
|
||||||
<div class="input-field">
|
|
||||||
<label>Copyright</label>
|
|
||||||
<input type="text" name="footer.copyright" required>
|
|
||||||
</div>
|
|
||||||
<div class="input-field">
|
|
||||||
<label>Legal Label</label>
|
|
||||||
<input type="text" name="footer.legal_label" re>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<!-- Legals Section -->
|
|
||||||
<fieldset>
|
|
||||||
<h2>Legals</h2>
|
|
||||||
<p>Set your legal informations</p>
|
|
||||||
<div class="fields">
|
|
||||||
<div class="input-field">
|
|
||||||
<label>Hoster Name</label>
|
|
||||||
<input type="text" name="legals.hoster_name" placeholder="Name" required>
|
|
||||||
</div>
|
|
||||||
<div class="input-field">
|
|
||||||
<label>Hoster Address</label>
|
|
||||||
<input type="text" name="legals.hoster_address" placeholder="Street, Postal Code, City, Country" required>
|
|
||||||
</div>
|
|
||||||
<div class="input-field">
|
|
||||||
<label>Hoster Contact</label>
|
|
||||||
<input type="text" name="legals.hoster_contact" placeholder="Email or/and Phone" required>
|
|
||||||
</div>
|
|
||||||
<div class="input-field" style="flex: 1 1 100%;">
|
|
||||||
<label>Intellectual Property</label>
|
|
||||||
<div id="ip-list"></div>
|
|
||||||
<button type="button" id="add-ip-paragraph">+ Add paragraph</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<!-- Build Section -->
|
|
||||||
<fieldset>
|
|
||||||
<h2>Build</h2>
|
|
||||||
<p>Select a theme from the dropdown menu or add your custom theme folder</p>
|
|
||||||
<div class="fields">
|
|
||||||
<div class="input-field">
|
|
||||||
<label>Theme</label>
|
|
||||||
<select name="build.theme" id="theme-select" required></select>
|
|
||||||
<button type="button" id="remove-theme-btn" class="remove-btn up-btn danger">🗑 Remove selected theme</button>
|
|
||||||
<input type="file" id="theme-upload" webkitdirectory directory multiple style="display:none;">
|
|
||||||
<button type="button" id="choose-theme-btn" class="up-btn">📂 Upload custom theme folder</button>
|
|
||||||
<label class="thumbnail-form-label">Images processing</label>
|
|
||||||
<p>If checked, images will be converted for web and resized to fit the theme</p>
|
|
||||||
<label>
|
|
||||||
<input class="thumbnail-form-label" type="checkbox" name="build.convert_images" id="convert-images-checkbox">
|
|
||||||
Convert images
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" name="build.resize_images" id="resize-images-checkbox">
|
|
||||||
Resize images
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<button type="submit">Save</button>
|
|
||||||
</form>
|
|
||||||
<div class="section">
|
|
||||||
<h2>Steps</h2>
|
|
||||||
<p> Follow the steps to generate your static gallery</p>
|
|
||||||
<ul id="stepper">
|
|
||||||
<li><a href="/gallery-editor">Upload your photos</a></li>
|
|
||||||
<div></div>
|
|
||||||
<li><a class="step-active" href="/site-info">Configure site info</a></li>
|
|
||||||
<div></div>
|
|
||||||
<li><a href="/theme-editor">Customize your theme</a></li>
|
|
||||||
<div></div>
|
|
||||||
<li><button id="stepper-build">Generate your static site!</button></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="input-field">
|
||||||
|
<label>Subtitle</label>
|
||||||
|
<input type="text" name="info.subtitle" placeholder="Your site subtitle" required>
|
||||||
|
</div>
|
||||||
|
<div class="input-field">
|
||||||
|
<label>Description</label>
|
||||||
|
<input type="text" name="info.description" placeholder="Your site description" required>
|
||||||
|
</div>
|
||||||
|
<div class="input-field">
|
||||||
|
<label>Canonical URL</label>
|
||||||
|
<input type="text" name="info.canonical" placeholder="https://yoursite.com" required>
|
||||||
|
</div>
|
||||||
|
<div class="input-field">
|
||||||
|
<label>Keywords (comma separated)</label>
|
||||||
|
<input type="text" name="info.keywords" placeholder="photo, gallery, photography" required>
|
||||||
|
</div>
|
||||||
|
<div class="input-field">
|
||||||
|
<label>Author</label>
|
||||||
|
<input type="text" name="info.author" placeholder="Your Name" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="section-save-btn" data-section="info">Save</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Social Section -->
|
||||||
|
<form id="social-form" autocomplete="off">
|
||||||
|
<fieldset id="social-section">
|
||||||
|
<h2>Social</h2>
|
||||||
|
<p class="section-status"></p>
|
||||||
|
<p>Set your social media links and thumbnail for link sharing</p>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="input-field">
|
||||||
|
<label>Instagram URL</label>
|
||||||
|
<input type="text" name="social.instagram_url" placeholder="https://instagram.com/yourprofile" required>
|
||||||
|
<label class="thumbnail-form-label">Thumbnail</label>
|
||||||
|
<input type="file" id="thumbnail-upload" accept="image/png,image/jpeg,image/webp" style="display:none;">
|
||||||
|
<button type="button" id="choose-thumbnail-btn" class="up-btn">📸 Upload a photo</button>
|
||||||
|
<div class="thumbnail-form">
|
||||||
|
<input type="hidden" name="social.thumbnail" id="social-thumbnail" required>
|
||||||
|
<img id="thumbnail-preview" src="" alt="Thumbnail preview" style="max-width:100px;display:none;">
|
||||||
|
<button type="button" id="remove-thumbnail-btn" class="remove-btn up-btn danger" style="display:none;">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="section-save-btn" data-section="social">Save</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Menu Section -->
|
||||||
|
<form id="menu-form" autocomplete="off">
|
||||||
|
<fieldset id="menu-section">
|
||||||
|
<h2>Menu</h2>
|
||||||
|
<p class="section-status"></p>
|
||||||
|
<p>Manage your site menu items. You can use tag combination to propose custom filters</p>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="input-field" style="flex: 1 1 100%;">
|
||||||
|
<div id="menu-items-list"></div>
|
||||||
|
<button type="button" id="add-menu-item">+ Add menu item</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="section-save-btn" data-section="menu">Save</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Footer Section -->
|
||||||
|
<form id="footer-form" autocomplete="off">
|
||||||
|
<fieldset id="footer-section">
|
||||||
|
<h2>Footer</h2>
|
||||||
|
<p class="section-status"></p>
|
||||||
|
<p>Set your copyright informations and legal link name</p>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="input-field">
|
||||||
|
<label>Copyright</label>
|
||||||
|
<input type="text" name="footer.copyright" required>
|
||||||
|
</div>
|
||||||
|
<div class="input-field">
|
||||||
|
<label>Legal Label</label>
|
||||||
|
<input type="text" name="footer.legal_label" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="section-save-btn" data-section="footer">Save</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Legals Section -->
|
||||||
|
<form id="legals-form" autocomplete="off">
|
||||||
|
<fieldset id="legals-section">
|
||||||
|
<h2>Legals</h2>
|
||||||
|
<p class="section-status"></p>
|
||||||
|
<p>Set your legal informations</p>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="input-field">
|
||||||
|
<label>Hoster Name</label>
|
||||||
|
<input type="text" name="legals.hoster_name" placeholder="Name" required>
|
||||||
|
</div>
|
||||||
|
<div class="input-field">
|
||||||
|
<label>Hoster Address</label>
|
||||||
|
<input type="text" name="legals.hoster_address" placeholder="Street, Postal Code, City, Country" required>
|
||||||
|
</div>
|
||||||
|
<div class="input-field">
|
||||||
|
<label>Hoster Contact</label>
|
||||||
|
<input type="text" name="legals.hoster_contact" placeholder="Email or/and Phone" required>
|
||||||
|
</div>
|
||||||
|
<div class="input-field" style="flex: 1 1 100%;">
|
||||||
|
<label>Intellectual Property</label>
|
||||||
|
<div id="ip-list"></div>
|
||||||
|
<button type="button" id="add-ip-paragraph">+ Add paragraph</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="section-save-btn" data-section="legals">Save</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Build Section -->
|
||||||
|
<form id="build-form" autocomplete="off">
|
||||||
|
<fieldset id="build-section">
|
||||||
|
<h2>Build</h2>
|
||||||
|
<p class="section-status"></p>
|
||||||
|
<p>Select a theme from the dropdown menu or add your custom theme folder</p>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="input-field">
|
||||||
|
<label>Theme</label>
|
||||||
|
<select name="build.theme" id="theme-select"></select>
|
||||||
|
<button type="button" id="remove-theme-btn" class="remove-btn up-btn danger">🗑 Remove selected theme</button>
|
||||||
|
<input type="file" id="theme-upload" webkitdirectory directory multiple style="display:none;">
|
||||||
|
<button type="button" id="choose-theme-btn" class="up-btn">📂 Upload custom theme folder</button>
|
||||||
|
<label class="thumbnail-form-label">Images processing</label>
|
||||||
|
<p>If checked, images will be converted for web and resized to fit the theme</p>
|
||||||
|
<label>
|
||||||
|
<input class="thumbnail-form-label" type="checkbox" name="build.convert_images" id="convert-images-checkbox">
|
||||||
|
Convert images
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="build.resize_images" id="resize-images-checkbox">
|
||||||
|
Resize images
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="section-save-btn" data-section="build">Save</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Stepper -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Steps</h2>
|
||||||
|
<p>Follow the steps to generate your static gallery</p>
|
||||||
|
<ul id="stepper">
|
||||||
|
<li><a href="/gallery-editor">Upload your photos</a></li>
|
||||||
|
<div></div>
|
||||||
|
<li><a class="step-active" href="/site-info">Configure site info</a></li>
|
||||||
|
<div></div>
|
||||||
|
<li><a href="/theme-editor">Customize your theme</a></li>
|
||||||
|
<div></div>
|
||||||
|
<li><button id="stepper-build">Generate your static site!</button></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<!-- Delete thumbnail confirmation modal-->
|
<!-- Delete thumbnail confirmation modal-->
|
||||||
<div class="content-inner">
|
<div class="content-inner">
|
||||||
<div id="delete-modal" class="modal" style="display:none;">
|
<div id="delete-modal" class="modal" style="display:none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<span id="delete-modal-close" class="modal-close">×</span>
|
<span id="delete-modal-close" class="modal-close">×</span>
|
||||||
<h3>Confirm Deletion</h3>
|
<h3>Confirm Deletion</h3>
|
||||||
<p id="delete-modal-text">Are you sure you want to remove this thumbnail?</p>
|
<p id="delete-modal-text">Are you sure you want to remove this thumbnail?</p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button id="delete-modal-confirm" class="modal-btn danger">Remove</button>
|
<button id="delete-modal-confirm" class="modal-btn danger">Remove</button>
|
||||||
<button id="delete-modal-cancel" class="modal-btn">Cancel</button>
|
<button id="delete-modal-cancel" class="modal-btn">Cancel</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Delete theme confirmation modal -->
|
</div>
|
||||||
<div class="content-inner">
|
<!-- Delete theme confirmation modal -->
|
||||||
<div id="delete-theme-modal" class="modal" style="display:none;">
|
<div class="content-inner">
|
||||||
<div class="modal-content">
|
<div id="delete-theme-modal" class="modal" style="display:none;">
|
||||||
<span id="delete-theme-modal-close" class="modal-close">×</span>
|
<div class="modal-content">
|
||||||
<h3>Confirm Theme Deletion</h3>
|
<span id="delete-theme-modal-close" class="modal-close">×</span>
|
||||||
<p id="delete-theme-modal-text">Are you sure you want to remove this theme?</p>
|
<h3>Confirm Theme Deletion</h3>
|
||||||
<div class="modal-actions">
|
<p id="delete-theme-modal-text">Are you sure you want to remove this theme?</p>
|
||||||
<button id="delete-theme-modal-confirm" class="modal-btn danger">Remove</button>
|
<div class="modal-actions">
|
||||||
<button id="delete-theme-modal-cancel" class="modal-btn">Cancel</button>
|
<button id="delete-theme-modal-confirm" class="modal-btn danger">Remove</button>
|
||||||
</div>
|
<button id="delete-theme-modal-cancel" class="modal-btn">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
+99
-37
@@ -7,7 +7,7 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
margin:0px;
|
margin:0px;
|
||||||
width: 100vw;
|
min-width: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -236,11 +236,32 @@ h2 {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Filter Row --- */
|
||||||
|
.filter-row label {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row input[type="radio"] {
|
||||||
|
accent-color: #55c3ec;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Gallery & Hero Grid --- */
|
/* --- Gallery & Hero Grid --- */
|
||||||
#gallery, #hero {
|
#gallery, #hero {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
|
margin: 30px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#hero-count-bottom, #gallery-count-bottom {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-action-row {
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +337,20 @@ h2 {
|
|||||||
.toast.success { background-color: #28a7468c; }
|
.toast.success { background-color: #28a7468c; }
|
||||||
.toast.error { background-color: #dc3545; }
|
.toast.error { background-color: #dc3545; }
|
||||||
|
|
||||||
|
/* img fade in */
|
||||||
|
|
||||||
|
.fade-in-img {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.02);
|
||||||
|
transition: opacity 1.2s ease-out, transform 1.2s ease-out;
|
||||||
|
will-change: opacity, transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-img.loaded {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Tags --- */
|
/* --- Tags --- */
|
||||||
.tag-input {
|
.tag-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -366,15 +401,18 @@ h2 {
|
|||||||
z-index: 999;
|
z-index: 999;
|
||||||
display: none;
|
display: none;
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-input ul.suggestions li {
|
.tag-input ul.suggestions li {
|
||||||
padding: 6px 8px;
|
padding: 6px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-input ul.suggestions li:hover {
|
.tag-input ul.suggestions li:hover {
|
||||||
background-color: #007782;
|
background-color: #007782;
|
||||||
|
border-radius: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tags-display {
|
.tags-display {
|
||||||
@@ -406,7 +444,9 @@ h2 {
|
|||||||
background-color: #007782;
|
background-color: #007782;
|
||||||
color: white;
|
color: white;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
border-radius: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestions li {
|
.suggestions li {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -416,6 +456,14 @@ h2 {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-actions-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center; /* Center horizontally */
|
||||||
|
align-items: center; /* Center vertically */
|
||||||
|
}
|
||||||
|
|
||||||
.flex-column {
|
.flex-column {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -521,18 +569,23 @@ h2 {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Remove All Buttons --- */
|
/* --- Remove All Buttons --- */
|
||||||
#remove-all-hero, #remove-all-gallery {
|
#remove-all-hero, #remove-all-gallery, .bottom-remove-btn {
|
||||||
background: #2d2d2d;
|
background: #2d2d2d;
|
||||||
color: white;
|
color: white;
|
||||||
display: none;
|
display: none;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottom-remove-btn {
|
||||||
|
display: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
#remove-all-gallery:hover,
|
#remove-all-gallery:hover,
|
||||||
#remove-all-hero:hover {
|
#remove-all-hero:hover, .bottom-remove-btn:hover {
|
||||||
background: rgb(121, 26, 19);
|
background: rgb(121, 26, 19);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,6 +594,7 @@ h2 {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,9 +642,9 @@ label {
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site-info-form input, #theme-editor-form input,
|
form input,
|
||||||
#site-info-form textarea, #theme-editor-form textarea,
|
form textarea,
|
||||||
#site-info-form select, #theme-editor-form select {
|
form select {
|
||||||
background: #1f2223;
|
background: #1f2223;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: 1px solid #585858;
|
border: 1px solid #585858;
|
||||||
@@ -612,27 +666,24 @@ label {
|
|||||||
gap: 0 18px;
|
gap: 0 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-info {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form input::placeholder,
|
||||||
#site-info-form input::placeholder,
|
form textarea::placeholder {
|
||||||
#theme-editor-form input::placeholder,
|
|
||||||
#site-info-form textarea::placeholder,
|
|
||||||
#theme-editor-form textarea::placeholder {
|
|
||||||
color: #585858;
|
color: #585858;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site-info-form input:focus,
|
form input:focus,
|
||||||
#theme-editor-form input:focus,
|
form textarea:focus,
|
||||||
#site-info-form textarea:focus,
|
form select:focus {
|
||||||
#theme-editor-form textarea:focus,
|
|
||||||
#site-info-form select:focus,
|
|
||||||
#theme-editor-form select:focus {
|
|
||||||
border-color: #585858;
|
border-color: #585858;
|
||||||
background: #161616;
|
background: #161616;
|
||||||
}
|
}
|
||||||
#site-info-form textarea,
|
|
||||||
#theme-editor-form textarea {
|
form textarea {
|
||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
@@ -651,7 +702,7 @@ img#thumbnail-preview {
|
|||||||
border: 1px solid #585858;
|
border: 1px solid #585858;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site-info-form button[type="submit"], #theme-editor-form button[type="submit"] {
|
form button[type="submit"] {
|
||||||
background: linear-gradient(135deg, #26c4ff, #016074);
|
background: linear-gradient(135deg, #26c4ff, #016074);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -659,17 +710,18 @@ img#thumbnail-preview {
|
|||||||
border-radius: 30px;
|
border-radius: 30px;
|
||||||
padding: 12px 32px;
|
padding: 12px 32px;
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
margin: 0 0 45px 0;
|
margin: 16px 0 0 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 4px 16px rgba(38,196,255,0.15);
|
box-shadow: 0 4px 16px rgba(38,196,255,0.15);
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site-info-form button[type="submit"]:hover, #theme-editor-form button[type="submit"]:hover {
|
form button[type="submit"]:hover {
|
||||||
background: linear-gradient(135deg, #72d9ff, #26657e);
|
background: linear-gradient(135deg, #72d9ff, #26657e);
|
||||||
}
|
}
|
||||||
|
|
||||||
#site-info-form button[type="button"], #theme-editor-form button[type="button"] {
|
form button[type="button"] {
|
||||||
background: #00000000;
|
background: #00000000;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -682,34 +734,36 @@ img#thumbnail-preview {
|
|||||||
border: 1px solid #585858;
|
border: 1px solid #585858;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site-info-form button[type="button"]:hover, #theme-editor-form button[type="button"]:hover {
|
form button[type="button"]:hover{
|
||||||
background: #2d2d2d;
|
background: #2d2d2d;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#site-info-form button.remove-menu-item, #site-info-form button.remove-ip-paragraph, #theme-editor-form button.remove-menu-item, #theme-editor-form button.remove-ip-paragraph {
|
form button.remove-menu-item,
|
||||||
|
form button.remove-ip-paragraph {
|
||||||
margin-top: 0px;
|
margin-top: 0px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
border-radius: 30px;
|
border-radius: 30px;
|
||||||
background: #2d2d2d;
|
background: #2d2d2d;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site-info-form button.remove-menu-item:hover, #site-info-form button.remove-ip-paragraph:hover, #theme-editor-form button.remove-menu-item:hover, #theme-editor-form button.remove-ip-paragraph:hover {
|
form button.remove-menu-item:hover,
|
||||||
|
form button.remove-ip-paragraph:hover {
|
||||||
background: rgb(121, 26, 19);
|
background: rgb(121, 26, 19);
|
||||||
}
|
}
|
||||||
|
|
||||||
#site-info-form button.remove-btn, #theme-editor-form button.remove-btn {
|
form button.remove-btn {
|
||||||
|
|
||||||
border-radius: 30px;
|
border-radius: 30px;
|
||||||
background: #2d2d2d;
|
background: #2d2d2d;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site-info-form button.remove-btn:hover, #theme-editor-form button.remove-btn:hover {
|
form button.remove-btn:hover {
|
||||||
background: rgb(121, 26, 19);
|
background: rgb(121, 26, 19);
|
||||||
}
|
}
|
||||||
|
|
||||||
#site-info-form .thumbnail-form-label, #theme-editor-form .thumbnail-form-label {
|
form .thumbnail-form-label {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,6 +825,10 @@ fieldset p, .section p {
|
|||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-status {
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Stepper --- */
|
/* --- Stepper --- */
|
||||||
|
|
||||||
#stepper {
|
#stepper {
|
||||||
@@ -905,6 +963,7 @@ justify-content: center;
|
|||||||
|
|
||||||
#footer a {
|
#footer a {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-credit .lum-first::before {
|
.footer-credit .lum-first::before {
|
||||||
@@ -920,6 +979,7 @@ justify-content: center;
|
|||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-text {
|
.icon-text {
|
||||||
@@ -994,12 +1054,12 @@ justify-content: center;
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
#stepper {
|
form button[type="button"], form button[type="submit"], #stepper li {
|
||||||
flex-direction: column;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#stepper li {
|
#stepper {
|
||||||
width: 100%;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-container, .footer-links {
|
.footer-container, .footer-links {
|
||||||
@@ -1020,14 +1080,16 @@ justify-content: center;
|
|||||||
/* Hide the default arrow */
|
/* Hide the default arrow */
|
||||||
color: transparent;
|
color: transparent;
|
||||||
}
|
}
|
||||||
#site-info-form, #theme-editor-form {
|
|
||||||
padding: 18px 8px;
|
|
||||||
}
|
|
||||||
.input-field {
|
.input-field {
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#site-info-form button[type="submit"], #theme-editor-form button[type="submit"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
#color-picker .input-field{
|
#color-picker .input-field{
|
||||||
min-width: 170px;
|
min-width: 170px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,10 @@
|
|||||||
<span id="build-success-modal-close" class="modal-close">×</span>
|
<span id="build-success-modal-close" class="modal-close">×</span>
|
||||||
<h3>✅ Build completed!</h3>
|
<h3>✅ Build completed!</h3>
|
||||||
<p>Your files are available in the output folder.</p>
|
<p>Your files are available in the output folder.</p>
|
||||||
<button id="download-zip-btn" class="modal-btn">Download ZIP</button>
|
<div class="modal-actions-row">
|
||||||
|
<button id="preview-site-btn" class="modal-btn" data-preview-port="{{ preview_port }}">🔎 Preview Site</button>
|
||||||
|
<button id="download-zip-btn" class="modal-btn">📦 Download ZIP</button>
|
||||||
|
</div>
|
||||||
<div id="zip-loader" style="display:none;">Creating ZIP...</div>
|
<div id="zip-loader" style="display:none;">Creating ZIP...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +68,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a class="footer-link documentation" href="https://lumeex.djeex.fr"><span class="icon"><img src="/img/favicon.svg"></span><span class="icon-text">Documentation</span></a>
|
<a class="footer-link documentation" href="https://lumeex.djeex.fr"><span class="icon"><img src="/img/favicon.svg"></span><span class="icon-text">Documentation</span></a>
|
||||||
<a class="footer-link gitea" href="https://gitea.com/Djeex/lumeex"><span class="icon"><img src="/img/gitea.svg"></span><span class="icon-text">Giteex</span></a>
|
<a class="footer-link gitea" href="https://git.djeex.fr/Djeex/lumeex"><span class="icon"><img src="/img/gitea.svg"></span><span class="icon-text">Giteex</span></a>
|
||||||
<a class="footer-link github" href="https://github.com/Djeex/lumeex"><span class="icon"><img src="/img/github.svg"></span><span class="icon-text">Github</span></a>
|
<a class="footer-link github" href="https://github.com/Djeex/lumeex"><span class="icon"><img src="/img/github.svg"></span><span class="icon-text">Github</span></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+177
-160
@@ -4,176 +4,193 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<h1>Edit Theme</h1>
|
<h1>Edit Theme</h1>
|
||||||
<!-- Show current theme -->
|
<!-- Show current theme -->
|
||||||
<div class="theme-info">
|
<div class="theme-info">
|
||||||
<strong>Current theme:</strong> <span id="current-theme"></span>
|
<strong>Current theme:</strong> <span id="current-theme"></span>
|
||||||
</div>
|
</div>
|
||||||
<form id="theme-editor-form">
|
<div id="theme-editor-form">
|
||||||
<!-- Colors Section -->
|
<!-- Colors Section -->
|
||||||
<fieldset id="color-picker">
|
<form id="colors-form" autocomplete="off">
|
||||||
<h2>Colors</h2>
|
<fieldset id="color-picker">
|
||||||
<p>Set the color values for your theme</p>
|
<h2>Colors</h2>
|
||||||
<div class="fields">
|
<p class="section-status"></p>
|
||||||
<!-- Example for one color field, repeat for all -->
|
<p>Set the color values for your theme</p>
|
||||||
<div class="input-field">
|
<div class="fields">
|
||||||
<label>Primary</label>
|
<div class="input-field">
|
||||||
<div class="fields color-fields">
|
<label>Primary</label>
|
||||||
<button type="button" id="color-primary-btn" class="color-btn"></button>
|
<div class="fields color-fields">
|
||||||
<input type="color" name="colors.primary" id="color-primary" class="color-input">
|
<button type="button" id="color-primary-btn" class="color-btn"></button>
|
||||||
<input type="text" name="colors.primary_text" id="color-primary-text" style="width:100px;">
|
<input type="color" name="colors.primary" id="color-primary" class="color-input" required>
|
||||||
</div>
|
<input type="text" name="colors.primary_text" id="color-primary-text" style="width:100px;" required>
|
||||||
</div>
|
|
||||||
<div class="input-field">
|
|
||||||
<label>Primary Dark</label>
|
|
||||||
<div class="fields color-fields">
|
|
||||||
<button type="button" id="color-primary-dark-btn" class="color-btn"></button>
|
|
||||||
<input type="color" name="colors.primary_dark" id="color-primary-dark" class="color-input">
|
|
||||||
<input type="text" name="colors.primary_dark_text" id="color-primary-dark-text" style="width:100px;">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="input-field">
|
|
||||||
<label>Secondary</label>
|
|
||||||
<div class="fields color-fields">
|
|
||||||
<button type="button" id="color-secondary-btn" class="color-btn"></button>
|
|
||||||
<input type="color" name="colors.secondary" id="color-secondary" class="color-input">
|
|
||||||
<input type="text" name="colors.secondary_text" id="color-secondary-text" style="width:100px;">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="input-field">
|
|
||||||
<label>Accent</label>
|
|
||||||
<div class="fields color-fields">
|
|
||||||
<button type="button" id="color-accent-btn" class="color-btn"></button>
|
|
||||||
<input type="color" name="colors.accent" id="color-accent" class="color-input">
|
|
||||||
<input type="text" name="colors.accent_text" id="color-accent-text" style="width:100px;">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="input-field">
|
|
||||||
<label>Text Dark</label>
|
|
||||||
<div class="fields color-fields">
|
|
||||||
<button type="button" id="color-text-dark-btn" class="color-btn"></button>
|
|
||||||
<input type="color" name="colors.text_dark" id="color-text-dark" class="color-input">
|
|
||||||
<input type="text" name="colors.text_dark_text" id="color-text-dark-text" style="width:100px;">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="input-field">
|
|
||||||
<label>Background</label>
|
|
||||||
<div class="fields color-fields">
|
|
||||||
<button type="button" id="color-background-btn" class="color-btn"></button>
|
|
||||||
<input type="color" name="colors.background" id="color-background" class="color-input">
|
|
||||||
<input type="text" name="colors.background_text" id="color-background-text" style="width:100px;">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="input-field">
|
|
||||||
<label>Browser Color</label>
|
|
||||||
<div class="fields color-fields">
|
|
||||||
<button type="button" id="color-browser-color-btn" class="color-btn"></button>
|
|
||||||
<input type="color" name="colors.browser_color" id="color-browser-color" class="color-input">
|
|
||||||
<input type="text" name="colors.browser_color_text" id="color-browser-color-text" style="width:100px;">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
<div class="input-field">
|
||||||
<!-- Google Fonts Section -->
|
<label>Primary Dark</label>
|
||||||
<fieldset>
|
<div class="fields color-fields">
|
||||||
<h2>Google Fonts</h2>
|
<button type="button" id="color-primary-dark-btn" class="color-btn"></button>
|
||||||
<p>Add Google Fonts to your theme</p>
|
<input type="color" name="colors.primary_dark" id="color-primary-dark" class="color-input" required>
|
||||||
<div class="fields" id="google-fonts-fields">
|
<input type="text" name="colors.primary_dark_text" id="color-primary-dark-text" style="width:100px;" required>
|
||||||
<!-- JS will render font family and weights inputs here -->
|
|
||||||
</div>
|
|
||||||
<button type="button" id="add-google-font">Add Google Font</button>
|
|
||||||
</fieldset>
|
|
||||||
<!-- Custom Font Upload Section -->
|
|
||||||
<fieldset>
|
|
||||||
<h2>Upload Custom Font</h2>
|
|
||||||
<p>Supported formats: .woff, .woff2</p>
|
|
||||||
<input type="file" id="font-upload" accept=".woff,.woff2" style="display:none;">
|
|
||||||
<div id="local-fonts-list" class="font-list"></div>
|
|
||||||
<button type="button" id="choose-font-btn" class="up-btn">🖋️ Upload font</button>
|
|
||||||
</fieldset>
|
|
||||||
<!-- Fonts Section -->
|
|
||||||
<fieldset>
|
|
||||||
<h2>Fonts</h2>
|
|
||||||
<p>Select where to apply your fonts</p>
|
|
||||||
<div class="fields">
|
|
||||||
<div class="input-field">
|
|
||||||
<label>Primary Font</label>
|
|
||||||
<select name="fonts.primary.name" id="font-primary"></select>
|
|
||||||
<label>Fallback</label>
|
|
||||||
<select name="fonts.primary.fallback" id="font-primary-fallback">
|
|
||||||
<option value="sans-serif">sans-serif</option>
|
|
||||||
<option value="serif">serif</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="input-field">
|
|
||||||
<label>Secondary Font</label>
|
|
||||||
<select name="fonts.secondary.name" id="font-secondary"></select>
|
|
||||||
<label>Fallback</label>
|
|
||||||
<select name="fonts.secondary.fallback" id="font-secondary-fallback">
|
|
||||||
<option value="sans-serif">sans-serif</option>
|
|
||||||
<option value="serif">serif</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
<div class="input-field">
|
||||||
<!-- Favicon Section -->
|
<label>Secondary</label>
|
||||||
<fieldset>
|
<div class="fields color-fields">
|
||||||
<h2>Favicon</h2>
|
<button type="button" id="color-secondary-btn" class="color-btn"></button>
|
||||||
<p>Supported formats: .png, .jpg, .jpeg</p>
|
<input type="color" name="colors.secondary" id="color-secondary" class="color-input" required>
|
||||||
<div class="fields">
|
<input type="text" name="colors.secondary_text" id="color-secondary-text" style="width:100px;" required>
|
||||||
<div class="input-field">
|
</div>
|
||||||
<label>Favicon Path</label>
|
</div>
|
||||||
<input type="text" name="favicon.path" id="favicon-path" readonly>
|
<div class="input-field">
|
||||||
<input type="file" id="favicon-upload" accept=".png,.jpg,.jpeg,.ico" style="display:none;">
|
<label>Accent</label>
|
||||||
<button type="button" id="choose-favicon-btn" class="up-btn">🖼️ Upload favicon</button>
|
<div class="fields color-fields">
|
||||||
<div class="favicon-form">
|
<button type="button" id="color-accent-btn" class="color-btn"></button>
|
||||||
<img id="favicon-preview" src="" alt="Favicon preview" style="max-width:48px;display:none;">
|
<input type="color" name="colors.accent" id="color-accent" class="color-input" required>
|
||||||
<button type="button" id="remove-favicon-btn" class="remove-btn up-btn danger" style="display:none;">Remove</button>
|
<input type="text" name="colors.accent_text" id="color-accent-text" style="width:100px;" required>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-field">
|
||||||
|
<label>Text Dark</label>
|
||||||
|
<div class="fields color-fields">
|
||||||
|
<button type="button" id="color-text-dark-btn" class="color-btn"></button>
|
||||||
|
<input type="color" name="colors.text_dark" id="color-text-dark" class="color-input" required>
|
||||||
|
<input type="text" name="colors.text_dark_text" id="color-text-dark-text" style="width:100px;" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-field">
|
||||||
|
<label>Background</label>
|
||||||
|
<div class="fields color-fields">
|
||||||
|
<button type="button" id="color-background-btn" class="color-btn"></button>
|
||||||
|
<input type="color" name="colors.background" id="color-background" class="color-input" required>
|
||||||
|
<input type="text" name="colors.background_text" id="color-background-text" style="width:100px;" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-field">
|
||||||
|
<label>Browser Color</label>
|
||||||
|
<div class="fields color-fields">
|
||||||
|
<button type="button" id="color-browser-color-btn" class="color-btn"></button>
|
||||||
|
<input type="color" name="colors.browser_color" id="color-browser-color" class="color-input" required>
|
||||||
|
<input type="text" name="colors.browser_color_text" id="color-browser-color-text" style="width:100px;" required>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
|
||||||
<button type="submit">Save Theme</button>
|
|
||||||
</form>
|
|
||||||
<div class="section">
|
|
||||||
<h2>Steps</h2>
|
|
||||||
<p> Follow the steps to generate your static gallery</p>
|
|
||||||
<ul id="stepper">
|
|
||||||
<li><a href="/gallery-editor">Upload your photos</a></li>
|
|
||||||
<div></div>
|
|
||||||
<li><a href="/site-info">Configure site info</a></li>
|
|
||||||
<div></div>
|
|
||||||
<li><a class="step-active" href="/theme-editor">Customize your theme</a></li>
|
|
||||||
<div></div>
|
|
||||||
<li><button id="stepper-build">Generate your static site!</button></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Delete confirmation modal for favicon -->
|
|
||||||
<div id="delete-favicon-modal" class="modal" style="display:none;">
|
|
||||||
<div class="modal-content">
|
|
||||||
<span id="delete-favicon-modal-close" class="modal-close">×</span>
|
|
||||||
<h3>Confirm Deletion</h3>
|
|
||||||
<p id="delete-favicon-modal-text">Are you sure you want to remove this favicon?</p>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button id="delete-favicon-modal-confirm" class="modal-btn danger">Remove</button>
|
|
||||||
<button id="delete-favicon-modal-cancel" class="modal-btn">Cancel</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button type="submit" class="section-save-btn" data-section="colors">Save</button>
|
||||||
</div>
|
</fieldset>
|
||||||
<!-- Delete confirmation modal for font -->
|
</form>
|
||||||
<div id="delete-font-modal" class="modal" style="display:none;">
|
<!-- Google Fonts Section -->
|
||||||
<div class="modal-content">
|
<form id="google-fonts-form" autocomplete="off">
|
||||||
<span id="delete-font-modal-close" class="modal-close">×</span>
|
<fieldset>
|
||||||
<h3>Confirm Deletion</h3>
|
<h2>Google Fonts</h2>
|
||||||
<p id="delete-font-modal-text">Are you sure you want to remove this font?</p>
|
<p class="section-status"></p>
|
||||||
<div class="modal-actions">
|
<p>Add Google Fonts to your theme</p>
|
||||||
<button id="delete-font-modal-confirm" class="modal-btn danger">Remove</button>
|
<div class="fields" id="google-fonts-fields">
|
||||||
<button id="delete-font-modal-cancel" class="modal-btn">Cancel</button>
|
<!-- JS will render font family and weights inputs here -->
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" id="add-google-font">Add Google Font</button>
|
||||||
|
<button type="submit" class="section-save-btn" data-section="google-fonts">Save</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
<!-- Custom Font Upload Section -->
|
||||||
|
<form id="font-upload-form" autocomplete="off">
|
||||||
|
<fieldset>
|
||||||
|
<h2>Upload Custom Font</h2>
|
||||||
|
<p class="section-status"></p>
|
||||||
|
<p>Supported formats: .woff, .woff2</p>
|
||||||
|
<input type="file" id="font-upload" accept=".woff,.woff2" style="display:none;">
|
||||||
|
<div id="local-fonts-list" class="font-list"></div>
|
||||||
|
<button type="button" id="choose-font-btn" class="up-btn">🖋️ Upload font</button>
|
||||||
|
<!-- Save button not needed for upload-only section -->
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
<!-- Fonts Section -->
|
||||||
|
<form id="fonts-form" autocomplete="off">
|
||||||
|
<fieldset>
|
||||||
|
<h2>Fonts</h2>
|
||||||
|
<p class="section-status"></p>
|
||||||
|
<p>Select where to apply your fonts</p>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="input-field">
|
||||||
|
<label>Primary Font</label>
|
||||||
|
<select name="fonts.primary.name" id="font-primary" required></select>
|
||||||
|
<label>Fallback</label>
|
||||||
|
<select name="fonts.primary.fallback" id="font-primary-fallback" required>
|
||||||
|
<option value="sans-serif">sans-serif</option>
|
||||||
|
<option value="serif">serif</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="input-field">
|
||||||
|
<label>Secondary Font</label>
|
||||||
|
<select name="fonts.secondary.name" id="font-secondary" required></select>
|
||||||
|
<label>Fallback</label>
|
||||||
|
<select name="fonts.secondary.fallback" id="font-secondary-fallback" required>
|
||||||
|
<option value="sans-serif">sans-serif</option>
|
||||||
|
<option value="serif">serif</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="section-save-btn" data-section="fonts">Save</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
<!-- Favicon Section -->
|
||||||
|
<form id="favicon-form" autocomplete="off">
|
||||||
|
<fieldset>
|
||||||
|
<h2>Favicon</h2>
|
||||||
|
<p class="section-status"></p>
|
||||||
|
<p>Supported formats: .png, .jpg, .jpeg</p>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="input-field">
|
||||||
|
<label>Favicon Path</label>
|
||||||
|
<input type="text" name="favicon.path" id="favicon-path" readonly>
|
||||||
|
<input type="file" id="favicon-upload" accept=".png,.jpg,.jpeg,.ico" style="display:none;">
|
||||||
|
<button type="button" id="choose-favicon-btn" class="up-btn">🖼️ Upload favicon</button>
|
||||||
|
<div class="favicon-form">
|
||||||
|
<img id="favicon-preview" src="" alt="Favicon preview" style="max-width:48px;display:none;">
|
||||||
|
<button type="button" id="remove-favicon-btn" class="remove-btn up-btn danger" style="display:none;">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="section-save-btn" data-section="favicon">Save</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<!-- Stepper -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Steps</h2>
|
||||||
|
<p>Follow the steps to generate your static gallery</p>
|
||||||
|
<ul id="stepper">
|
||||||
|
<li><a href="/gallery-editor">Upload your photos</a></li>
|
||||||
|
<div></div>
|
||||||
|
<li><a href="/site-info">Configure site info</a></li>
|
||||||
|
<div></div>
|
||||||
|
<li><a class="step-active" href="/theme-editor">Customize your theme</a></li>
|
||||||
|
<div></div>
|
||||||
|
<li><button id="stepper-build">Generate your static site!</button></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<!-- Delete confirmation modal for favicon -->
|
||||||
|
<div id="delete-favicon-modal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span id="delete-favicon-modal-close" class="modal-close">×</span>
|
||||||
|
<h3>Confirm Deletion</h3>
|
||||||
|
<p id="delete-favicon-modal-text">Are you sure you want to remove this favicon?</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="delete-favicon-modal-confirm" class="modal-btn danger">Remove</button>
|
||||||
|
<button id="delete-favicon-modal-cancel" class="modal-btn">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Delete confirmation modal for font -->
|
||||||
|
<div id="delete-font-modal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span id="delete-font-modal-close" class="modal-close">×</span>
|
||||||
|
<h3>Confirm Deletion</h3>
|
||||||
|
<p id="delete-font-modal-text">Are you sure you want to remove this font?</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="delete-font-modal-confirm" class="modal-btn danger">Remove</button>
|
||||||
|
<button id="delete-font-modal-cancel" class="modal-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DEMO_ROOT = REPO_ROOT / "demo" / "config"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def repo_root():
|
||||||
|
return REPO_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def demo_root():
|
||||||
|
return DEMO_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def make_image():
|
||||||
|
"""Factory that writes a small real image to disk and returns its path."""
|
||||||
|
|
||||||
|
def _make(path: Path, size=(64, 48), color=(200, 100, 50), fmt="JPEG", **save_kwargs):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Image.new("RGB", size, color).save(path, fmt, **save_kwargs)
|
||||||
|
return path
|
||||||
|
|
||||||
|
return _make
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app_env(tmp_path, monkeypatch, make_image):
|
||||||
|
"""
|
||||||
|
Isolate the Flask webui from the real repo filesystem.
|
||||||
|
|
||||||
|
webui.py resolves most config paths from `Path(__file__).resolve().parents[3]`
|
||||||
|
(recomputed inline in most routes) or from module-level globals computed once
|
||||||
|
at import time (PHOTOS_DIR, SITE_YAML). We patch `__file__` so inline lookups
|
||||||
|
land in a throwaway tmp_path repo skeleton, patch the two import-time globals
|
||||||
|
directly, and chdir so the plain-relative paths used by gallery_builder
|
||||||
|
(GALLERY_YAML, GALLERY_DIR, HERO_DIR) resolve there too.
|
||||||
|
"""
|
||||||
|
import src.py.webui.webui as webui
|
||||||
|
|
||||||
|
root = tmp_path
|
||||||
|
(root / "config" / "photos" / "gallery").mkdir(parents=True)
|
||||||
|
(root / "config" / "photos" / "hero").mkdir(parents=True)
|
||||||
|
|
||||||
|
theme_dir = root / "config" / "themes" / "modern"
|
||||||
|
theme_dir.mkdir(parents=True)
|
||||||
|
(theme_dir / "theme.yaml").write_text(
|
||||||
|
"colors:\n browser_color: '#ffffff'\n"
|
||||||
|
"favicon:\n path: favicon.png\n"
|
||||||
|
"google_fonts: []\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
make_image(theme_dir / "favicon.png", size=(32, 32), fmt="PNG")
|
||||||
|
|
||||||
|
(root / "config" / "gallery.yaml").write_text(
|
||||||
|
"hero:\n images: []\ngallery:\n images: []\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(root / "config" / "site.yaml").write_text(
|
||||||
|
"info:\n title: Test\n canonical: https://example.com\n"
|
||||||
|
"social:\n thumbnail: ''\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_module_file = root / "src" / "py" / "webui" / "webui.py"
|
||||||
|
fake_module_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
monkeypatch.chdir(root)
|
||||||
|
monkeypatch.setattr(webui, "__file__", str(fake_module_file))
|
||||||
|
monkeypatch.setattr(webui, "PHOTOS_DIR", root / "config" / "photos")
|
||||||
|
monkeypatch.setattr(webui, "SITE_YAML", root / "config" / "site.yaml")
|
||||||
|
webui.app.config["PHOTOS_DIR"] = root / "config" / "photos"
|
||||||
|
webui.app.config["TESTING"] = True
|
||||||
|
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app_env):
|
||||||
|
import src.py.webui.webui as webui
|
||||||
|
|
||||||
|
return webui.app.test_client()
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.py.builder import utils
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_yaml_missing_file_returns_empty_dict(tmp_path, caplog):
|
||||||
|
result = utils.load_yaml(tmp_path / "missing.yaml")
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_yaml_parses_existing_file(tmp_path):
|
||||||
|
path = tmp_path / "data.yaml"
|
||||||
|
path.write_text("foo: bar\nnum: 1\n", encoding="utf-8")
|
||||||
|
assert utils.load_yaml(path) == {"foo": "bar", "num": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_theme_config_success(tmp_path):
|
||||||
|
themes_dir = tmp_path / "themes"
|
||||||
|
theme_dir = themes_dir / "modern"
|
||||||
|
theme_dir.mkdir(parents=True)
|
||||||
|
(theme_dir / "theme.yaml").write_text("colors:\n primary: '#000'\n", encoding="utf-8")
|
||||||
|
|
||||||
|
theme_vars, returned_dir = utils.load_theme_config("modern", themes_dir)
|
||||||
|
|
||||||
|
assert theme_vars == {"colors": {"primary": "#000"}}
|
||||||
|
assert returned_dir == theme_dir
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_theme_config_missing_raises(tmp_path):
|
||||||
|
themes_dir = tmp_path / "themes"
|
||||||
|
try:
|
||||||
|
utils.load_theme_config("missing", themes_dir)
|
||||||
|
assert False, "expected FileNotFoundError"
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_dir_creates_missing_dir(tmp_path):
|
||||||
|
target = tmp_path / "out"
|
||||||
|
utils.clear_dir(target)
|
||||||
|
assert target.is_dir()
|
||||||
|
assert list(target.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_dir_removes_existing_content(tmp_path):
|
||||||
|
target = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
(target / "file.txt").write_text("x", encoding="utf-8")
|
||||||
|
sub = target / "sub"
|
||||||
|
sub.mkdir()
|
||||||
|
(sub / "nested.txt").write_text("y", encoding="utf-8")
|
||||||
|
|
||||||
|
utils.clear_dir(target)
|
||||||
|
|
||||||
|
assert target.is_dir()
|
||||||
|
assert list(target.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_dir_creates_and_clears(tmp_path):
|
||||||
|
target = tmp_path / "out"
|
||||||
|
utils.ensure_dir(target)
|
||||||
|
assert target.is_dir()
|
||||||
|
|
||||||
|
(target / "stale.txt").write_text("x", encoding="utf-8")
|
||||||
|
utils.ensure_dir(target)
|
||||||
|
assert list(target.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_assets_copies_existing_folders(tmp_path):
|
||||||
|
js_dir = tmp_path / "js"
|
||||||
|
style_dir = tmp_path / "style"
|
||||||
|
build_dir = tmp_path / "output"
|
||||||
|
js_dir.mkdir()
|
||||||
|
style_dir.mkdir()
|
||||||
|
(js_dir / "app.js").write_text("console.log(1)", encoding="utf-8")
|
||||||
|
(style_dir / "style.css").write_text("body{}", encoding="utf-8")
|
||||||
|
|
||||||
|
utils.copy_assets(js_dir, style_dir, build_dir)
|
||||||
|
|
||||||
|
assert (build_dir / "js" / "app.js").exists()
|
||||||
|
assert (build_dir / "style" / "style.css").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_assets_skips_missing_folder(tmp_path, caplog):
|
||||||
|
js_dir = tmp_path / "missing_js"
|
||||||
|
style_dir = tmp_path / "style"
|
||||||
|
style_dir.mkdir()
|
||||||
|
build_dir = tmp_path / "output"
|
||||||
|
|
||||||
|
utils.copy_assets(js_dir, style_dir, build_dir)
|
||||||
|
|
||||||
|
assert not (build_dir / "missing_js").exists()
|
||||||
|
assert (build_dir / "style").exists()
|
||||||
@@ -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,113 @@
|
|||||||
|
import yaml
|
||||||
|
|
||||||
|
from src.py.builder import gallery_builder as gb
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_repo(tmp_path, monkeypatch):
|
||||||
|
(tmp_path / "config" / "photos" / "gallery").mkdir(parents=True)
|
||||||
|
(tmp_path / "config" / "photos" / "hero").mkdir(parents=True)
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_yaml_missing_file(tmp_path, monkeypatch):
|
||||||
|
_setup_repo(tmp_path, monkeypatch)
|
||||||
|
assert gb.load_yaml("config/gallery.yaml") == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_yaml_and_save_yaml_roundtrip(tmp_path, monkeypatch):
|
||||||
|
_setup_repo(tmp_path, monkeypatch)
|
||||||
|
data = {"gallery": {"images": [{"src": "gallery/a.jpg", "tags": ["x"]}]}}
|
||||||
|
gb.save_yaml(data, "config/gallery.yaml")
|
||||||
|
|
||||||
|
loaded = gb.load_yaml("config/gallery.yaml")
|
||||||
|
assert loaded == data
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_all_image_paths_filters_and_sorts(tmp_path, monkeypatch, make_image):
|
||||||
|
_setup_repo(tmp_path, monkeypatch)
|
||||||
|
gallery_dir = tmp_path / "config" / "photos" / "gallery"
|
||||||
|
make_image(gallery_dir / "b.jpg")
|
||||||
|
make_image(gallery_dir / "a.png", fmt="PNG")
|
||||||
|
(gallery_dir / "notes.txt").write_text("ignore me", encoding="utf-8")
|
||||||
|
|
||||||
|
paths = gb.get_all_image_paths(gallery_dir)
|
||||||
|
|
||||||
|
assert paths == ["gallery/a.png", "gallery/b.jpg"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_gallery_adds_new_and_removes_deleted(tmp_path, monkeypatch, make_image, capsys):
|
||||||
|
_setup_repo(tmp_path, monkeypatch)
|
||||||
|
gallery_dir = tmp_path / "config" / "photos" / "gallery"
|
||||||
|
make_image(gallery_dir / "kept.jpg")
|
||||||
|
make_image(gallery_dir / "new.jpg")
|
||||||
|
|
||||||
|
gb.save_yaml(
|
||||||
|
{
|
||||||
|
"gallery": {
|
||||||
|
"images": [
|
||||||
|
{"src": "gallery/kept.jpg", "tags": ["portrait"]},
|
||||||
|
{"src": "gallery/gone.jpg", "tags": ["stale"]},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
gb.GALLERY_YAML,
|
||||||
|
)
|
||||||
|
|
||||||
|
gb.update_gallery()
|
||||||
|
|
||||||
|
result = gb.load_yaml(gb.GALLERY_YAML)
|
||||||
|
images = {img["src"]: img for img in result["gallery"]["images"]}
|
||||||
|
assert set(images) == {"gallery/kept.jpg", "gallery/new.jpg"}
|
||||||
|
assert images["gallery/kept.jpg"]["tags"] == ["portrait"]
|
||||||
|
assert images["gallery/new.jpg"]["tags"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_gallery_no_changes(tmp_path, monkeypatch, make_image, capsys):
|
||||||
|
_setup_repo(tmp_path, monkeypatch)
|
||||||
|
gallery_dir = tmp_path / "config" / "photos" / "gallery"
|
||||||
|
make_image(gallery_dir / "only.jpg")
|
||||||
|
gb.save_yaml(
|
||||||
|
{"gallery": {"images": [{"src": "gallery/only.jpg", "tags": []}]}},
|
||||||
|
gb.GALLERY_YAML,
|
||||||
|
)
|
||||||
|
|
||||||
|
gb.update_gallery()
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "No changes to gallery.yaml (gallery)" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_hero_adds_new_and_removes_deleted(tmp_path, monkeypatch, make_image):
|
||||||
|
_setup_repo(tmp_path, monkeypatch)
|
||||||
|
hero_dir = tmp_path / "config" / "photos" / "hero"
|
||||||
|
make_image(hero_dir / "kept.jpg")
|
||||||
|
make_image(hero_dir / "new.jpg")
|
||||||
|
|
||||||
|
gb.save_yaml(
|
||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"images": [
|
||||||
|
{"src": "hero/kept.jpg"},
|
||||||
|
{"src": "hero/gone.jpg"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
gb.GALLERY_YAML,
|
||||||
|
)
|
||||||
|
|
||||||
|
gb.update_hero()
|
||||||
|
|
||||||
|
result = gb.load_yaml(gb.GALLERY_YAML)
|
||||||
|
srcs = {img["src"] for img in result["hero"]["images"]}
|
||||||
|
assert srcs == {"hero/kept.jpg", "hero/new.jpg"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_gallery_on_empty_yaml_initializes_section(tmp_path, monkeypatch, make_image):
|
||||||
|
_setup_repo(tmp_path, monkeypatch)
|
||||||
|
gallery_dir = tmp_path / "config" / "photos" / "gallery"
|
||||||
|
make_image(gallery_dir / "only.jpg")
|
||||||
|
|
||||||
|
gb.update_gallery()
|
||||||
|
|
||||||
|
result = gb.load_yaml(gb.GALLERY_YAML)
|
||||||
|
assert result["gallery"]["images"][0]["src"] == "gallery/only.jpg"
|
||||||
@@ -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,134 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from src.py.builder import image_processor as ip
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_and_resize_image_missing_input_logs_and_skips(tmp_path):
|
||||||
|
output_path = tmp_path / "out.webp"
|
||||||
|
ip.convert_and_resize_image(tmp_path / "missing.jpg", output_path)
|
||||||
|
assert not output_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_and_resize_image_resizes_down(tmp_path, make_image):
|
||||||
|
input_path = make_image(tmp_path / "in.jpg", size=(2000, 1000))
|
||||||
|
output_path = tmp_path / "out.webp"
|
||||||
|
|
||||||
|
ip.convert_and_resize_image(input_path, output_path, resize=True, max_width=1000)
|
||||||
|
|
||||||
|
actual_output = output_path if output_path.exists() else output_path.with_suffix(".jpg")
|
||||||
|
assert actual_output.exists()
|
||||||
|
with Image.open(actual_output) as img:
|
||||||
|
assert img.width == 1000
|
||||||
|
assert img.height == 500
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_and_resize_image_no_resize_keeps_dimensions(tmp_path, make_image):
|
||||||
|
input_path = make_image(tmp_path / "in.jpg", size=(500, 300))
|
||||||
|
output_path = tmp_path / "out.webp"
|
||||||
|
|
||||||
|
ip.convert_and_resize_image(input_path, output_path, resize=False)
|
||||||
|
|
||||||
|
actual_output = output_path if output_path.exists() else output_path.with_suffix(".jpg")
|
||||||
|
with Image.open(actual_output) as img:
|
||||||
|
assert img.size == (500, 300)
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_and_resize_image_preserves_icc_profile(tmp_path):
|
||||||
|
input_path = tmp_path / "in.jpg"
|
||||||
|
icc_bytes = b"fake-icc-profile-bytes"
|
||||||
|
Image.new("RGB", (100, 80), (10, 20, 30)).save(input_path, "JPEG", icc_profile=icc_bytes)
|
||||||
|
output_path = tmp_path / "out.webp"
|
||||||
|
|
||||||
|
ip.convert_and_resize_image(input_path, output_path, resize=False)
|
||||||
|
|
||||||
|
actual_output = output_path if output_path.exists() else output_path.with_suffix(".jpg")
|
||||||
|
with Image.open(actual_output) as img:
|
||||||
|
assert img.info.get("icc_profile") == icc_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_images_updates_src_to_processed_extension(tmp_path, make_image):
|
||||||
|
img_dir = tmp_path / "photos"
|
||||||
|
build_dir = tmp_path / "output"
|
||||||
|
make_image(img_dir / "gallery" / "a.jpg", size=(200, 200))
|
||||||
|
images = [{"src": "gallery/a.jpg", "tags": []}]
|
||||||
|
|
||||||
|
ip.process_images(images, resize_images=True, img_dir=img_dir, build_dir=build_dir)
|
||||||
|
|
||||||
|
assert images[0]["src"] in ("gallery/a.webp", "gallery/a.jpg")
|
||||||
|
produced = build_dir / "img" / images[0]["src"]
|
||||||
|
assert produced.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_original_images_copies_existing_and_skips_missing(tmp_path, make_image, caplog):
|
||||||
|
img_dir = tmp_path / "photos"
|
||||||
|
build_dir = tmp_path / "output"
|
||||||
|
make_image(img_dir / "gallery" / "present.jpg")
|
||||||
|
images = [{"src": "gallery/present.jpg"}, {"src": "gallery/absent.jpg"}]
|
||||||
|
|
||||||
|
ip.copy_original_images(images, img_dir, build_dir)
|
||||||
|
|
||||||
|
assert (build_dir / "img" / "gallery" / "present.jpg").exists()
|
||||||
|
assert not (build_dir / "img" / "gallery" / "absent.jpg").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_favicon_path_missing_config_returns_none(tmp_path):
|
||||||
|
assert ip.get_favicon_path({}, tmp_path) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_favicon_path_relative_and_absolute(tmp_path, make_image):
|
||||||
|
theme_dir = tmp_path / "theme"
|
||||||
|
icon_path = make_image(theme_dir / "favicon.png", fmt="PNG")
|
||||||
|
|
||||||
|
result = ip.get_favicon_path({"favicon": {"path": "favicon.png"}}, theme_dir)
|
||||||
|
assert result == icon_path
|
||||||
|
|
||||||
|
absolute_result = ip.get_favicon_path({"favicon": {"path": str(icon_path)}}, theme_dir)
|
||||||
|
assert absolute_result == icon_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_favicon_path_file_not_found(tmp_path):
|
||||||
|
result = ip.get_favicon_path({"favicon": {"path": "missing.png"}}, tmp_path)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_favicons_from_logo_creates_all_sizes(tmp_path, make_image):
|
||||||
|
theme_dir = tmp_path / "theme"
|
||||||
|
make_image(theme_dir / "favicon.png", size=(256, 256), fmt="PNG")
|
||||||
|
output_dir = tmp_path / "output" / "favicon"
|
||||||
|
|
||||||
|
ip.generate_favicons_from_logo({"favicon": {"path": "favicon.png"}}, theme_dir, output_dir)
|
||||||
|
|
||||||
|
expected = [
|
||||||
|
"favicon-32.png", "favicon-96.png", "favicon-128.png",
|
||||||
|
"favicon-192.png", "favicon-196.png", "favicon-152.png", "favicon-180.png",
|
||||||
|
]
|
||||||
|
for name in expected:
|
||||||
|
assert (output_dir / name).exists()
|
||||||
|
with Image.open(output_dir / "favicon-32.png") as img:
|
||||||
|
assert img.size == (32, 32)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_favicons_from_logo_no_favicon_skips(tmp_path):
|
||||||
|
output_dir = tmp_path / "output" / "favicon"
|
||||||
|
ip.generate_favicons_from_logo({}, tmp_path, output_dir)
|
||||||
|
assert not output_dir.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_favicon_ico_creates_file(tmp_path, make_image):
|
||||||
|
theme_dir = tmp_path / "theme"
|
||||||
|
make_image(theme_dir / "favicon.png", size=(64, 64), fmt="PNG")
|
||||||
|
output_path = tmp_path / "output" / "favicon.ico"
|
||||||
|
|
||||||
|
ip.generate_favicon_ico({"favicon": {"path": "favicon.png"}}, theme_dir, output_path)
|
||||||
|
|
||||||
|
assert output_path.exists()
|
||||||
|
with Image.open(output_path) as img:
|
||||||
|
assert img.format == "ICO"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_favicon_ico_no_favicon_skips(tmp_path):
|
||||||
|
output_path = tmp_path / "output" / "favicon.ico"
|
||||||
|
ip.generate_favicon_ico({}, tmp_path, output_path)
|
||||||
|
assert not output_path.exists()
|
||||||
@@ -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,312 @@
|
|||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_page(client):
|
||||||
|
resp = client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_gallery_and_hero_start_empty(client):
|
||||||
|
assert client.get("/api/gallery").get_json() == []
|
||||||
|
assert client.get("/api/hero").get_json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_gallery_and_hero(client, app_env):
|
||||||
|
images = [{"src": "gallery/a.jpg", "tags": ["x"]}]
|
||||||
|
resp = client.post("/api/gallery/update", json=images)
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
assert client.get("/api/gallery").get_json() == images
|
||||||
|
|
||||||
|
hero_images = [{"src": "hero/a.jpg"}]
|
||||||
|
resp = client.post("/api/hero/update", json=hero_images)
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
assert client.get("/api/hero").get_json() == hero_images
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_gallery_and_hero_pick_up_new_files(client, app_env, make_image):
|
||||||
|
make_image(app_env / "config" / "photos" / "gallery" / "new.jpg")
|
||||||
|
make_image(app_env / "config" / "photos" / "hero" / "new.jpg")
|
||||||
|
|
||||||
|
client.post("/api/gallery/refresh")
|
||||||
|
client.post("/api/hero/refresh")
|
||||||
|
|
||||||
|
gallery = client.get("/api/gallery").get_json()
|
||||||
|
hero = client.get("/api/hero").get_json()
|
||||||
|
assert gallery[0]["src"] == "gallery/new.jpg"
|
||||||
|
assert hero[0]["src"] == "hero/new.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_gallery_photo(client, app_env, make_image):
|
||||||
|
make_image(app_env / "config" / "photos" / "gallery" / "a.jpg")
|
||||||
|
|
||||||
|
resp = client.post("/api/gallery/delete", json={"src": "a.jpg"})
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
assert not (app_env / "config" / "photos" / "gallery" / "a.jpg").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_gallery_photo_not_found(client):
|
||||||
|
resp = client.post("/api/gallery/delete", json={"src": "missing.jpg"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_hero_photo(client, app_env, make_image):
|
||||||
|
make_image(app_env / "config" / "photos" / "hero" / "a.jpg")
|
||||||
|
|
||||||
|
resp = client.post("/api/hero/delete", json={"src": "a.jpg"})
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
assert not (app_env / "config" / "photos" / "hero" / "a.jpg").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_all_gallery_photos(client, app_env, make_image):
|
||||||
|
make_image(app_env / "config" / "photos" / "gallery" / "a.jpg")
|
||||||
|
make_image(app_env / "config" / "photos" / "gallery" / "b.jpg")
|
||||||
|
client.post("/api/gallery/update", json=[{"src": "gallery/a.jpg"}, {"src": "gallery/b.jpg"}])
|
||||||
|
|
||||||
|
resp = client.post("/api/gallery/delete_all")
|
||||||
|
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
assert body["deleted"] == 2
|
||||||
|
assert client.get("/api/gallery").get_json() == []
|
||||||
|
assert list((app_env / "config" / "photos" / "gallery").iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_all_hero_photos(client, app_env, make_image):
|
||||||
|
make_image(app_env / "config" / "photos" / "hero" / "a.jpg")
|
||||||
|
client.post("/api/hero/update", json=[{"src": "hero/a.jpg"}])
|
||||||
|
|
||||||
|
resp = client.post("/api/hero/delete_all")
|
||||||
|
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["deleted"] == 1
|
||||||
|
assert client.get("/api/hero").get_json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_photo_by_section(client, app_env, make_image):
|
||||||
|
make_image(app_env / "config" / "photos" / "gallery" / "a.jpg")
|
||||||
|
resp = client.get("/photos/gallery/a.jpg")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_photo_at_root(client, app_env, make_image):
|
||||||
|
make_image(app_env / "config" / "photos" / "thumbnail.png", fmt="PNG")
|
||||||
|
resp = client.get("/photos/thumbnail.png")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_site_info_get_and_post(client, app_env):
|
||||||
|
resp = client.get("/api/site-info")
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["info"]["title"] == "Test"
|
||||||
|
|
||||||
|
client.post("/api/site-info", json={"info": {"subtitle": "New subtitle"}, "social": {"instagram_url": "https://insta.example"}})
|
||||||
|
|
||||||
|
updated = client.get("/api/site-info").get_json()
|
||||||
|
assert updated["info"]["title"] == "Test"
|
||||||
|
assert updated["info"]["subtitle"] == "New subtitle"
|
||||||
|
assert updated["social"]["instagram_url"] == "https://insta.example"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_themes(client):
|
||||||
|
assert client.get("/api/themes").get_json() == ["modern"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_thumbnail_upload_and_remove(client, app_env):
|
||||||
|
data = {"file": (io.BytesIO(b"fake-png-bytes"), "thumb.png")}
|
||||||
|
resp = client.post("/api/thumbnail/upload", data=data, content_type="multipart/form-data")
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
assert (app_env / "config" / "photos" / "thumbnail.png").exists()
|
||||||
|
|
||||||
|
site_info = client.get("/api/site-info").get_json()
|
||||||
|
assert site_info["social"]["thumbnail"] == "thumbnail.png"
|
||||||
|
|
||||||
|
resp = client.post("/api/thumbnail/remove")
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
assert not (app_env / "config" / "photos" / "thumbnail.png").exists()
|
||||||
|
site_info = client.get("/api/site-info").get_json()
|
||||||
|
assert site_info["social"]["thumbnail"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_thumbnail_upload_missing_file(client):
|
||||||
|
resp = client.post("/api/thumbnail/upload", data={}, content_type="multipart/form-data")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_upload_and_remove(client, app_env):
|
||||||
|
data = {
|
||||||
|
"files": [
|
||||||
|
(io.BytesIO(b"colors: {}"), "custom/theme.yaml"),
|
||||||
|
(io.BytesIO(b"body{}"), "custom/theme.css"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
resp = client.post("/api/theme/upload", data=data, content_type="multipart/form-data")
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
assert body["theme"] == "custom"
|
||||||
|
assert (app_env / "config" / "themes" / "custom" / "theme.yaml").exists()
|
||||||
|
assert (app_env / "config" / "themes" / "custom" / "theme.css").exists()
|
||||||
|
|
||||||
|
resp = client.post("/api/theme/remove", json={"theme": "custom"})
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
assert not (app_env / "config" / "themes" / "custom").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_remove_protected_theme(client):
|
||||||
|
resp = client.post("/api/theme/remove", json={"theme": "modern"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_remove_missing_theme(client):
|
||||||
|
resp = client.post("/api/theme/remove", json={"theme": "does-not-exist"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_info_get_and_post(client, app_env):
|
||||||
|
resp = client.get("/api/theme-info")
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["theme_name"] == "modern"
|
||||||
|
assert body["theme_yaml"]["favicon"]["path"] == "favicon.png"
|
||||||
|
|
||||||
|
new_theme_yaml = dict(body["theme_yaml"])
|
||||||
|
new_theme_yaml["colors"] = {"primary": "#123456"}
|
||||||
|
resp = client.post(
|
||||||
|
"/api/theme-info",
|
||||||
|
json={"theme_name": "modern", "theme_yaml": new_theme_yaml},
|
||||||
|
)
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
|
||||||
|
saved = yaml.safe_load((app_env / "config" / "themes" / "modern" / "theme.yaml").read_text())
|
||||||
|
assert saved["colors"]["primary"] == "#123456"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_theme_google_fonts(client, app_env):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/theme-google-fonts",
|
||||||
|
json={"theme_name": "modern", "google_fonts": [{"family": "Lato"}]},
|
||||||
|
)
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
|
||||||
|
saved = yaml.safe_load((app_env / "config" / "themes" / "modern" / "theme.yaml").read_text())
|
||||||
|
assert saved["google_fonts"] == [{"family": "Lato"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_fonts_empty_then_populated(client, app_env):
|
||||||
|
assert client.get("/api/local-fonts?theme=modern").get_json() == []
|
||||||
|
|
||||||
|
fonts_dir = app_env / "config" / "themes" / "modern" / "fonts"
|
||||||
|
fonts_dir.mkdir(parents=True)
|
||||||
|
(fonts_dir / "trixie.woff2").write_bytes(b"fake")
|
||||||
|
|
||||||
|
assert client.get("/api/local-fonts?theme=modern").get_json() == ["trixie.woff2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_favicon_upload_invalid_extension(client):
|
||||||
|
data = {"theme": "modern", "file": (io.BytesIO(b"not-an-image"), "favicon.txt")}
|
||||||
|
resp = client.post("/api/favicon/upload", data=data, content_type="multipart/form-data")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_favicon_upload_and_remove(client, app_env):
|
||||||
|
data = {"theme": "modern", "file": (io.BytesIO(b"fake-png"), "favicon.png")}
|
||||||
|
resp = client.post("/api/favicon/upload", data=data, content_type="multipart/form-data")
|
||||||
|
assert resp.get_json()["status"] == "ok"
|
||||||
|
assert (app_env / "config" / "themes" / "modern" / "favicon.png").exists()
|
||||||
|
|
||||||
|
resp = client.post("/api/favicon/remove", json={"theme": "modern"})
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
assert not (app_env / "config" / "themes" / "modern" / "favicon.png").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_serve_theme_asset(client, app_env):
|
||||||
|
(app_env / "config" / "themes" / "modern" / "theme.css").write_text("body{}", encoding="utf-8")
|
||||||
|
resp = client.get("/themes/modern/theme.css")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_font_upload_invalid_extension(client):
|
||||||
|
data = {"theme": "modern", "file": (io.BytesIO(b"data"), "font.ttf")}
|
||||||
|
resp = client.post("/api/font/upload", data=data, content_type="multipart/form-data")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_font_upload_and_remove(client, app_env):
|
||||||
|
data = {"theme": "modern", "file": (io.BytesIO(b"data"), "trixie.woff2")}
|
||||||
|
resp = client.post("/api/font/upload", data=data, content_type="multipart/form-data")
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
assert body["filename"] == "trixie"
|
||||||
|
|
||||||
|
resp = client.post("/api/font/remove", json={"theme": "modern", "font": "trixie.woff2"})
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_font_remove_missing(client):
|
||||||
|
resp = client.post("/api/font/remove", json={"theme": "modern", "font": "missing.woff2"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_trigger_build_missing_site_yaml(client, app_env):
|
||||||
|
(app_env / "config" / "site.yaml").unlink()
|
||||||
|
resp = client.post("/api/build")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "site.yaml not found" in resp.get_json()["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_trigger_build_missing_required_field(client, app_env):
|
||||||
|
resp = client.post("/api/build")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "Site info are not set" in resp.get_json()["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_trigger_build_success_invokes_subprocess(client, app_env, monkeypatch):
|
||||||
|
import src.py.webui.webui as webui
|
||||||
|
|
||||||
|
(app_env / "config" / "site.yaml").write_text(
|
||||||
|
"info:\n title: Test\n canonical: https://example.com\nsocial:\n thumbnail: t.png\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
webui.subprocess, "run", lambda *a, **k: calls.append((a, k))
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post("/api/build")
|
||||||
|
assert resp.get_json() == {"status": "ok"}
|
||||||
|
assert calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_trigger_build_subprocess_failure_returns_500(client, app_env, monkeypatch):
|
||||||
|
import src.py.webui.webui as webui
|
||||||
|
|
||||||
|
(app_env / "config" / "site.yaml").write_text(
|
||||||
|
"info:\n title: Test\n canonical: https://example.com\nsocial:\n thumbnail: t.png\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _raise(*a, **k):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(webui.subprocess, "run", _raise)
|
||||||
|
|
||||||
|
resp = client.post("/api/build")
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert "boom" in resp.get_json()["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_output_zip(client, app_env):
|
||||||
|
output_dir = app_env / "output"
|
||||||
|
output_dir.mkdir()
|
||||||
|
(output_dir / "index.html").write_text("<html></html>", encoding="utf-8")
|
||||||
|
|
||||||
|
resp = client.post("/download-output-zip")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
zip_bytes = io.BytesIO(resp.data)
|
||||||
|
with zipfile.ZipFile(zip_bytes) as zf:
|
||||||
|
assert "index.html" in zf.namelist()
|
||||||
|
assert not (app_env / "site_output.zip").exists()
|
||||||
Reference in New Issue
Block a user