Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d37b65315 | ||
|
|
e08c1477a3 | ||
|
|
42fb9cde06 | ||
|
|
d50cfc931f | ||
|
|
8385453cfd | ||
|
|
f96ce2f8c9 | ||
|
|
c8f42b429a | ||
|
|
f22634e9d9 | ||
|
|
edd39febd1 | ||
|
|
33766f0b0b |
@@ -0,0 +1,2 @@
|
||||
# Shell scripts run inside Linux containers: keep LF even on Windows checkouts
|
||||
*.sh text eol=lf
|
||||
+100
-4
@@ -18,6 +18,21 @@ jobs:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Scan for secrets
|
||||
run: |
|
||||
# docker cp, not a build COPY: a repo's own .dockerignore (e.g. one that
|
||||
# excludes .git for prod builds) would otherwise silently give an empty,
|
||||
# falsely-clean scan.
|
||||
CID=$(docker create zricethezav/gitleaks:v8.30.1 detect --source=/repo --no-banner -v)
|
||||
docker cp . "$CID:/repo"
|
||||
docker start -a "$CID"
|
||||
STATUS=$?
|
||||
docker rm "$CID" > /dev/null
|
||||
exit $STATUS
|
||||
|
||||
- name: Lint Dockerfile with hadolint
|
||||
run: docker run --rm -i hadolint/hadolint:v2.15.1-alpine hadolint --failure-threshold error - < Dockerfile
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build -t lumeex:ci . 2>&1 | tee build.log
|
||||
@@ -46,7 +61,10 @@ jobs:
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
docker build --target test -t lumeex:test .
|
||||
docker run --rm lumeex:test pytest -v
|
||||
docker run --rm lumeex:test pytest -v --cov=. --cov-report=term-missing --cov-fail-under=90
|
||||
|
||||
- name: Lint with ruff
|
||||
run: docker build --target lint -t lumeex:lint .
|
||||
|
||||
- name: Check deprecation warnings
|
||||
run: |
|
||||
@@ -56,12 +74,53 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Scan with Trivy (critical - blocking)
|
||||
id: trivy_critical
|
||||
continue-on-error: true
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e DOCKER_HOST=tcp://dockerhost:2375 \
|
||||
--add-host=dockerhost:host-gateway \
|
||||
aquasec/trivy:0.74.0 image --exit-code 1 --severity CRITICAL lumeex:ci
|
||||
|
||||
- name: Handle CRITICAL findings
|
||||
if: steps.trivy_critical.outcome == 'failure'
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" != "schedule" ]; then
|
||||
echo "::error::CRITICAL vulnerabilities found, failing the build."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Scheduled scan found CRITICAL vulnerabilities — attempting an automatic apk upgrade + rescan."
|
||||
|
||||
sed -i '/^FROM .* AS base$/a RUN apk upgrade --no-cache' Dockerfile
|
||||
docker build -t lumeex:remediated .
|
||||
|
||||
if docker run --rm \
|
||||
-e DOCKER_HOST=tcp://dockerhost:2375 \
|
||||
--add-host=dockerhost:host-gateway \
|
||||
aquasec/trivy:0.74.0 image --exit-code 1 --severity CRITICAL lumeex:remediated; then
|
||||
echo "apk upgrade clears the CRITICAL finding(s) — opening a PR for review."
|
||||
|
||||
BRANCH="auto/cve-fix-$(date +%Y%m%d)-$(echo "${{ github.sha }}" | cut -c1-7)"
|
||||
git config user.name "lumeex-ci"
|
||||
git config user.email "[email protected]"
|
||||
git checkout -b "$BRANCH"
|
||||
git add Dockerfile
|
||||
git commit -m "Auto-remediate CRITICAL CVE via apk upgrade"
|
||||
git config --unset-all http.https://git.djeex.fr/.extraheader || true
|
||||
git push "https://Djeex:${{ secrets.CI_PUSH_TOKEN }}@git.djeex.fr/Djeex/lumeex.git" "HEAD:$BRANCH"
|
||||
|
||||
PR_JSON=$(curl -s -X POST \
|
||||
-H "Authorization: token ${{ secrets.CI_PUSH_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg head "$BRANCH" '{title: "🔒 Auto: remediate CRITICAL CVE via apk upgrade", head: $head, base: "main", body: "Opened automatically by the scheduled CVE scan. An `apk upgrade --no-cache` cleared the CRITICAL Trivy finding(s) in a rebuild — review the diff and merge to publish the fix."}')" \
|
||||
"https://git.djeex.fr/api/v1/repos/Djeex/lumeex/pulls")
|
||||
echo "PR API response: $(echo "$PR_JSON" | jq -r '.html_url // .message // "unknown"')"
|
||||
else
|
||||
echo "::error::apk upgrade does not clear the CRITICAL finding(s) — no automatic fix available, needs manual review."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Scan with Trivy (high - informative)
|
||||
run: |
|
||||
docker run --rm \
|
||||
@@ -126,6 +185,16 @@ jobs:
|
||||
docker push "$IMAGE:$MINOR_TAG"
|
||||
docker push "$IMAGE:$VERSION"
|
||||
|
||||
GHCR_IMAGE=ghcr.io/djeex/lumeex
|
||||
echo "${{ secrets.GH_TOKEN }}" | docker login ghcr.io -u Djeex --password-stdin
|
||||
|
||||
docker tag lumeex:ci "$GHCR_IMAGE:latest"
|
||||
docker tag lumeex:ci "$GHCR_IMAGE:$MINOR_TAG"
|
||||
docker tag lumeex:ci "$GHCR_IMAGE:$VERSION"
|
||||
docker push "$GHCR_IMAGE:latest"
|
||||
docker push "$GHCR_IMAGE:$MINOR_TAG"
|
||||
docker push "$GHCR_IMAGE:$VERSION"
|
||||
|
||||
TRIGGER_MSG=$(git log -1 --format=%s "${{ github.sha }}")
|
||||
PR_NUM=$(echo "$TRIGGER_MSG" | grep -oE '#[0-9]+' | head -1 | tr -d '#' || true)
|
||||
|
||||
@@ -152,7 +221,7 @@ jobs:
|
||||
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 }}")
|
||||
COMMIT_LIST=$(git log --no-merges --format="- %s ([%h](${REPO_URL}/commit/%H))" "$BASE_REF".."${{ github.sha }}")
|
||||
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
SOURCE_LINE="[${SHORT_SHA}](${REPO_URL}/commit/${{ github.sha }})"
|
||||
@@ -162,10 +231,8 @@ jobs:
|
||||
|
||||
BODY=$(cat <<EOF
|
||||
## Changelog
|
||||
---
|
||||
|
||||
### ${CATEGORY}
|
||||
---
|
||||
${CHANGE_TITLE}
|
||||
|
||||
**Source:** ${SOURCE_LINE}
|
||||
@@ -187,3 +254,32 @@ jobs:
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$JSON_PAYLOAD" \
|
||||
"https://git.djeex.fr/api/v1/repos/Djeex/lumeex/releases"
|
||||
|
||||
MIRROR_NOTICE="_This github repo is a mirror of https://git.djeex.fr/Djeex/lumeex. You'll find full package, PR, history and release note there._"
|
||||
GH_BODY=$(printf '%s\n\n%s' "$MIRROR_NOTICE" "$BODY")
|
||||
|
||||
GH_JSON_PAYLOAD=$(jq -n \
|
||||
--arg tag "$VERSION" \
|
||||
--arg name "$VERSION" \
|
||||
--arg body "$GH_BODY" \
|
||||
--arg sha "${{ github.sha }}" \
|
||||
'{tag_name: $tag, name: $name, target_commitish: $sha, body: $body}')
|
||||
|
||||
GH_STATUS=0
|
||||
for i in 1 2 3 4 5; do
|
||||
GH_STATUS=$(curl -s -o /tmp/gh_release.json -w "%{http_code}" -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.GH_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$GH_JSON_PAYLOAD" \
|
||||
"https://api.github.com/repos/Djeex/lumeex/releases")
|
||||
if [ "$GH_STATUS" = "201" ]; then
|
||||
break
|
||||
fi
|
||||
echo "GitHub Release attempt $i failed (HTTP $GH_STATUS) — mirror may not have synced this commit yet, retrying in 15s..."
|
||||
sleep 15
|
||||
done
|
||||
echo "GitHub Release API response: $GH_STATUS"
|
||||
if [ "$GH_STATUS" != "201" ]; then
|
||||
cat /tmp/gh_release.json 2>/dev/null || true
|
||||
fi
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
!.env
|
||||
!.sh
|
||||
!.gitignore
|
||||
!.gitattributes
|
||||
!.gitea
|
||||
!.gitea/**
|
||||
output/
|
||||
|
||||
+9
-2
@@ -1,4 +1,4 @@
|
||||
FROM python:3.14.7-alpine AS builder
|
||||
FROM python:3.15.0-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -7,7 +7,7 @@ RUN apk add --no-cache gcc musl-dev jpeg-dev zlib-dev
|
||||
COPY requirements.txt .
|
||||
RUN pip wheel --no-cache-dir --wheel-dir=/wheels -r requirements.txt
|
||||
|
||||
FROM python:3.14.7-alpine AS base
|
||||
FROM python:3.15.0-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -27,6 +27,13 @@ RUN pip install --no-cache-dir -r requirements-dev.txt
|
||||
COPY ./tests/ ./tests/
|
||||
COPY ./demo/ ./demo/
|
||||
|
||||
FROM base AS lint
|
||||
|
||||
RUN pip install --no-cache-dir ruff==0.16.4
|
||||
COPY ruff.toml /app/ruff.toml
|
||||
COPY ./tests/ ./tests/
|
||||
RUN ruff check . && ruff format --check .
|
||||
|
||||
FROM base
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
|
||||
from src.py.builder.site_builder import build
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
build()
|
||||
build()
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
import logging
|
||||
|
||||
from src.py.builder.gallery_builder import update_gallery, update_hero
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
update_gallery()
|
||||
update_hero()
|
||||
update_hero()
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest==8.4.2
|
||||
pytest==9.1.1
|
||||
pytest-cov==7.1.0
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
line-length = 100
|
||||
|
||||
[lint]
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[lint.isort]
|
||||
# See nvidia-stock-bot's ruff.toml for why this is pinned explicitly rather
|
||||
# than left to auto-detection.
|
||||
known-first-party = ["src", "build", "gallery"]
|
||||
|
||||
[lint.per-file-ignores]
|
||||
# Real findings (unused var, unused loop var) but app-logic changes,
|
||||
# left for the user to decide rather than auto-fixed.
|
||||
"src/py/webui/webui.py" = ["F841", "B007"]
|
||||
@@ -1,6 +1,10 @@
|
||||
// js for Lumeex
|
||||
// https://git.djeex.fr/Djeex/lumeex
|
||||
|
||||
// The site is always served from the domain root (see the /img/ and
|
||||
// /data/ paths below); the photo menu builds its permalinks from this.
|
||||
const siteRoot = `${window.location.origin}/`;
|
||||
|
||||
// Fade in effect for elements with class 'appear'
|
||||
const setupIntersectionObserver = () => {
|
||||
document.querySelectorAll('.appear').forEach(parent => {
|
||||
@@ -163,6 +167,187 @@ const disableRightClickAndDrag = () => {
|
||||
document.addEventListener('dragstart', (e) => e.preventDefault());
|
||||
};
|
||||
|
||||
// Photo menu: right click (or long press on touch screens) on a gallery
|
||||
// photo opens a small menu to copy its permalink or share it. The
|
||||
// permalink points at photo/<id>/, a tiny page generated per photo that
|
||||
// carries the photo's own preview tags for social networks and sends
|
||||
// visitors back to the gallery with ?photo=<id>. The id is a hash of the
|
||||
// original photo file, so the link survives reordering and rebuilds.
|
||||
const PHOTO_MENU_TEXT = {
|
||||
en: { copy: 'Copy link', share: 'Share…', copied: 'Link copied', copyFailed: 'Could not copy the link' },
|
||||
fr: { copy: 'Copier le lien', share: 'Partager…', copied: 'Lien copié', copyFailed: 'Impossible de copier le lien' },
|
||||
};
|
||||
|
||||
const PHOTO_MENU_ICONS = {
|
||||
copy: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.5-1.5"/></svg>',
|
||||
share: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12"/><path d="m7 8 5-5 5 5"/><path d="M5 13v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6"/></svg>',
|
||||
};
|
||||
|
||||
const setupPhotoMenu = () => {
|
||||
const lang = (navigator.language || 'en').toLowerCase().startsWith('fr') ? 'fr' : 'en';
|
||||
const text = PHOTO_MENU_TEXT[lang];
|
||||
const canShare = typeof navigator.share === 'function';
|
||||
let menu = null;
|
||||
let toast = null;
|
||||
let toastTimer = null;
|
||||
let currentUrl = '';
|
||||
let pressTimer = null;
|
||||
let pressStart = null;
|
||||
let openScrollY = 0;
|
||||
|
||||
const photoAt = (el) => el.closest && el.closest('.section[data-photo-id] img');
|
||||
const permalinkFor = (img) => `${siteRoot}photo/${img.closest('.section').dataset.photoId}/`;
|
||||
|
||||
const showToast = (message) => {
|
||||
if (!toast) {
|
||||
toast = document.createElement('div');
|
||||
toast.className = 'photo-toast';
|
||||
toast.setAttribute('role', 'status');
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
toast.textContent = message;
|
||||
toast.classList.add('visible');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => toast.classList.remove('visible'), 2000);
|
||||
};
|
||||
|
||||
const copyText = async (value) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return true;
|
||||
} catch {
|
||||
// Clipboard API missing (plain http) or refused: fall back to the
|
||||
// legacy selection-based copy.
|
||||
const area = document.createElement('textarea');
|
||||
area.value = value;
|
||||
area.setAttribute('readonly', '');
|
||||
area.style.position = 'fixed';
|
||||
area.style.opacity = '0';
|
||||
document.body.appendChild(area);
|
||||
area.select();
|
||||
let ok = false;
|
||||
try { ok = document.execCommand('copy'); } catch { ok = false; }
|
||||
area.remove();
|
||||
return ok;
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (!menu || menu.hidden) return;
|
||||
menu.hidden = true;
|
||||
};
|
||||
|
||||
const buildMenu = () => {
|
||||
menu = document.createElement('div');
|
||||
menu.className = 'photo-menu';
|
||||
menu.setAttribute('role', 'menu');
|
||||
menu.tabIndex = -1;
|
||||
menu.hidden = true;
|
||||
const actions = canShare ? ['copy', 'share'] : ['copy'];
|
||||
actions.forEach((action) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.setAttribute('role', 'menuitem');
|
||||
button.dataset.action = action;
|
||||
button.innerHTML = PHOTO_MENU_ICONS[action];
|
||||
button.append(text[action]);
|
||||
menu.appendChild(button);
|
||||
});
|
||||
menu.addEventListener('click', async (e) => {
|
||||
const button = e.target.closest('button');
|
||||
if (!button) return;
|
||||
const url = currentUrl;
|
||||
close();
|
||||
if (button.dataset.action === 'copy') {
|
||||
showToast((await copyText(url)) ? text.copied : text.copyFailed);
|
||||
} else {
|
||||
// AbortError just means the visitor dismissed the share sheet.
|
||||
navigator.share({ title: document.title, url }).catch(() => {});
|
||||
}
|
||||
});
|
||||
menu.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||
e.preventDefault();
|
||||
const items = Array.from(menu.querySelectorAll('button'));
|
||||
const i = items.indexOf(document.activeElement);
|
||||
const next = i === -1
|
||||
? (e.key === 'ArrowDown' ? 0 : items.length - 1)
|
||||
: (i + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length;
|
||||
items[next].focus();
|
||||
});
|
||||
document.body.appendChild(menu);
|
||||
};
|
||||
|
||||
const open = (img, x, y) => {
|
||||
if (!menu) buildMenu();
|
||||
currentUrl = permalinkFor(img);
|
||||
openScrollY = window.scrollY;
|
||||
menu.hidden = false;
|
||||
// Keep the whole menu inside the viewport near the pointer.
|
||||
const margin = 8;
|
||||
const left = Math.min(x, window.innerWidth - menu.offsetWidth - margin);
|
||||
const top = Math.min(y, window.innerHeight - menu.offsetHeight - margin);
|
||||
menu.style.left = `${Math.max(margin, left)}px`;
|
||||
menu.style.top = `${Math.max(margin, top)}px`;
|
||||
// Focus the menu itself (not its first item) so Escape and the arrow
|
||||
// keys work without a focus ring showing after a mouse right click.
|
||||
menu.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
document.addEventListener('contextmenu', (e) => {
|
||||
const img = photoAt(e.target);
|
||||
if (img) open(img, e.clientX, e.clientY);
|
||||
else close();
|
||||
});
|
||||
|
||||
// iOS never fires contextmenu on a long press, so time it by hand.
|
||||
const cancelPress = () => {
|
||||
clearTimeout(pressTimer);
|
||||
pressTimer = null;
|
||||
};
|
||||
document.addEventListener('pointerdown', (e) => {
|
||||
if (menu && !menu.hidden && !menu.contains(e.target)) close();
|
||||
if (e.pointerType === 'mouse') return;
|
||||
const img = photoAt(e.target);
|
||||
if (!img) return;
|
||||
pressStart = { x: e.clientX, y: e.clientY };
|
||||
cancelPress();
|
||||
pressTimer = setTimeout(() => open(img, pressStart.x, pressStart.y), 500);
|
||||
});
|
||||
document.addEventListener('pointermove', (e) => {
|
||||
if (pressTimer && Math.hypot(e.clientX - pressStart.x, e.clientY - pressStart.y) > 10) cancelPress();
|
||||
});
|
||||
['pointerup', 'pointercancel'].forEach((type) => document.addEventListener(type, cancelPress));
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') close();
|
||||
});
|
||||
// Only a real scroll closes the menu: lazy-loaded photos shifting the
|
||||
// layout also fire small scroll events (scroll anchoring).
|
||||
window.addEventListener('scroll', () => {
|
||||
if (Math.abs(window.scrollY - openScrollY) > 40) close();
|
||||
}, { passive: true });
|
||||
window.addEventListener('resize', close);
|
||||
window.addEventListener('blur', close);
|
||||
};
|
||||
|
||||
// Permalink landing: ?photo=<id> moves that photo to the top of the
|
||||
// gallery (after the shuffle) and scrolls to it.
|
||||
const openLinkedPhoto = () => {
|
||||
const id = new URLSearchParams(window.location.search).get('photo');
|
||||
if (!id) return;
|
||||
const gallery = document.querySelector('#gallery');
|
||||
const section = gallery && Array.from(gallery.querySelectorAll('.section[data-photo-id]'))
|
||||
.find((el) => el.dataset.photoId === id);
|
||||
if (!section) return;
|
||||
gallery.prepend(section);
|
||||
section.classList.add('photo-linked');
|
||||
// Wait for the loader to fade out so the scroll lands on the final layout.
|
||||
window.addEventListener('load', () => {
|
||||
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
};
|
||||
|
||||
// Scroll-to-top button functionality
|
||||
const setupScrollToTopButton = () => {
|
||||
const scrollBtn = document.getElementById("scrollToTop");
|
||||
@@ -190,9 +375,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
setupIntersectionObserver();
|
||||
setupLoader();
|
||||
shuffleGallery();
|
||||
openLinkedPhoto();
|
||||
randomizeHeroBackground();
|
||||
setupTagFilter();
|
||||
disableRightClickAndDrag();
|
||||
setupPhotoMenu();
|
||||
setupScrollToTopButton();
|
||||
fixNavSeparators();
|
||||
});
|
||||
|
||||
@@ -331,6 +331,126 @@ h2 {
|
||||
margin: 5px 0 60px 0;
|
||||
}
|
||||
|
||||
/* Photo menu (right click / long press on a gallery photo) */
|
||||
|
||||
.section[data-photo-id] img {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.photo-menu {
|
||||
position: fixed;
|
||||
z-index: 1100;
|
||||
min-width: 200px;
|
||||
padding: 6px;
|
||||
background: var(--color-background);
|
||||
border: 1px solid rgb(0 0 0 / 7%);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 12px 32px rgb(0 0 0 / 16%), 0 2px 6px rgb(0 0 0 / 6%);
|
||||
font-family: var(--font-primary), Arial, sans-serif;
|
||||
transform-origin: top left;
|
||||
animation: photo-menu-in 0.14s ease-out;
|
||||
}
|
||||
|
||||
.photo-menu:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.photo-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.photo-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
color: var(--color-text-dark);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.photo-menu button:hover,
|
||||
.photo-menu button:focus-visible {
|
||||
background: rgb(245 245 245);
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.photo-menu button:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.photo-menu button:focus-visible {
|
||||
outline: 2px solid var(--color-secondary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.photo-menu svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: none;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.photo-toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 24px;
|
||||
z-index: 1100;
|
||||
padding: 9px 18px;
|
||||
border-radius: 30px;
|
||||
background: var(--color-primary-dark);
|
||||
color: var(--color-background);
|
||||
font-family: var(--font-primary), Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 6px 18px rgb(0 0 0 / 18%);
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 8px);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.photo-toast.visible {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.section.photo-linked img {
|
||||
animation: photo-linked 2.4s ease-out 0.6s;
|
||||
}
|
||||
|
||||
@keyframes photo-menu-in {
|
||||
from { opacity: 0; transform: scale(0.96); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes photo-linked {
|
||||
0%, 60% { box-shadow: 0 0 0 3px var(--color-background), 0 0 0 6px var(--color-secondary); }
|
||||
100% { box-shadow: 0 0 0 3px transparent, 0 0 0 6px transparent; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.photo-menu, .section.photo-linked img {
|
||||
animation: none;
|
||||
}
|
||||
.photo-toast {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.text-block {
|
||||
padding:10px;
|
||||
margin:10px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from shutil import copyfile
|
||||
|
||||
|
||||
def generate_css_variables(colors_dict, output_path):
|
||||
"""Generate css variables for theme colors"""
|
||||
css_lines = [":root {"]
|
||||
@@ -13,6 +13,7 @@ def generate_css_variables(colors_dict, output_path):
|
||||
f.write("\n".join(css_lines))
|
||||
logging.info(f"[✓] CSS variables written to {output_path}")
|
||||
|
||||
|
||||
def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
|
||||
"""Generate css variables fonts"""
|
||||
font_files = list(fonts_dir.glob("*"))
|
||||
@@ -35,7 +36,7 @@ def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
|
||||
|
||||
css_lines = []
|
||||
for font_name, sources in font_faces.items():
|
||||
css_lines.append(f"@font-face {{")
|
||||
css_lines.append("@font-face {")
|
||||
css_lines.append(f" font-family: '{font_name}';")
|
||||
srcs = [f"url('../fonts/{file}') format('{fmt}')" for file, fmt in sorted(sources)]
|
||||
css_lines.append(f" src: {', '.join(srcs)};")
|
||||
@@ -58,6 +59,7 @@ def generate_fonts_css(fonts_dir, output_path, fonts_cfg=None):
|
||||
logging.info(f"[✓] Generated fonts CSS: {output_path}")
|
||||
return preload_links
|
||||
|
||||
|
||||
def generate_google_fonts_link(fonts):
|
||||
"""Generate src link for Google fonts"""
|
||||
if not fonts:
|
||||
@@ -70,5 +72,9 @@ def generate_google_fonts_link(fonts):
|
||||
families.append(f"{family}:wght@{';'.join(weights)}")
|
||||
else:
|
||||
families.append(family)
|
||||
href = "https://fonts.googleapis.com/css2?" + "&".join(f"family={f}" for f in families) + "&display=swap"
|
||||
href = (
|
||||
"https://fonts.googleapis.com/css2?"
|
||||
+ "&".join(f"family={f}" for f in families)
|
||||
+ "&display=swap"
|
||||
)
|
||||
return f'<link href="{href}" rel="stylesheet">'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import yaml
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
# YAML file paths
|
||||
GALLERY_YAML = "config/gallery.yaml"
|
||||
|
||||
@@ -9,31 +10,37 @@ GALLERY_YAML = "config/gallery.yaml"
|
||||
GALLERY_DIR = Path("config/photos/gallery")
|
||||
HERO_DIR = Path("config/photos/hero")
|
||||
|
||||
|
||||
def load_yaml(path):
|
||||
"""Load gallery config .yaml file"""
|
||||
print(f"[→] Loading {path}...")
|
||||
if not os.path.exists(path):
|
||||
print(f"[✗] File not found: {path}")
|
||||
return {}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
images = data.get("images", []) or []
|
||||
print(f"[✓] Loaded {len(images)} image(s) from {path}")
|
||||
return data
|
||||
|
||||
|
||||
def save_yaml(data, path):
|
||||
"""Save modified gallery config .yaml file"""
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, sort_keys=False, allow_unicode=True)
|
||||
print(f"[✓] Saved updated YAML to {path}")
|
||||
|
||||
|
||||
def get_all_image_paths(directory):
|
||||
"""Get the path to record for builded site"""
|
||||
return sorted([
|
||||
str(p.relative_to(directory.parent)).replace("\\", "/")
|
||||
for p in directory.rglob("*")
|
||||
if p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"]
|
||||
])
|
||||
return sorted(
|
||||
[
|
||||
str(p.relative_to(directory.parent)).replace("\\", "/")
|
||||
for p in directory.rglob("*")
|
||||
if p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def update_gallery():
|
||||
"""Update the gallery photo list"""
|
||||
@@ -50,11 +57,7 @@ def update_gallery():
|
||||
known_images = {img["src"] for img in gallery_images}
|
||||
|
||||
# Add new images
|
||||
new_images = [
|
||||
{"src": path, "tags": []}
|
||||
for path in all_images
|
||||
if path not in known_images
|
||||
]
|
||||
new_images = [{"src": path, "tags": []} for path in all_images if path not in known_images]
|
||||
if new_images:
|
||||
gallery_images.extend(new_images)
|
||||
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (gallery)")
|
||||
@@ -74,6 +77,7 @@ def update_gallery():
|
||||
if not new_images and not deleted_images:
|
||||
print("[✓] No changes to gallery.yaml (gallery)")
|
||||
|
||||
|
||||
def update_hero():
|
||||
"""Update the hero photo list"""
|
||||
print("\n=== Updating gallery.yaml (hero section) ===")
|
||||
@@ -89,11 +93,7 @@ def update_hero():
|
||||
known_images = {img["src"] for img in hero_images}
|
||||
|
||||
# Add new images
|
||||
new_images = [
|
||||
{"src": path}
|
||||
for path in all_images
|
||||
if path not in known_images
|
||||
]
|
||||
new_images = [{"src": path} for path in all_images if path not in known_images]
|
||||
if new_images:
|
||||
hero_images.extend(new_images)
|
||||
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (hero)")
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import json
|
||||
import logging
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def render_template(template_path, context):
|
||||
"""Render html templates"""
|
||||
@@ -11,20 +14,25 @@ def render_template(template_path, context):
|
||||
content = content.replace(placeholder, str(value) if value is not None else "")
|
||||
return content
|
||||
|
||||
|
||||
def render_gallery_images(images):
|
||||
"""Render the photo gallery"""
|
||||
html = ""
|
||||
for img in images:
|
||||
tags = " ".join(img.get("tags", []))
|
||||
tag_html = "".join(f'<span class="tag">#{t}</span>' for t in img.get("tags", []))
|
||||
# data-photo-id is what js/lumeex.js's photo menu builds the permalink from
|
||||
photo_id = img.get("photo_id")
|
||||
photo_id_attr = f' data-photo-id="{photo_id}"' if photo_id else ""
|
||||
html += f"""
|
||||
<div class="section" data-tags="{tags}">
|
||||
<div class="section" data-tags="{tags}"{photo_id_attr}>
|
||||
<div class="tags">{tag_html}</div>
|
||||
<img class="fade-in-img lazyload" data-src="/img/{img['src']}" alt="{img.get('alt', '')}" loading="lazy">
|
||||
<img class="fade-in-img lazyload" data-src="/img/{img["src"]}" alt="{img.get("alt", "")}" loading="lazy">
|
||||
</div>
|
||||
"""
|
||||
return html
|
||||
|
||||
|
||||
def generate_gallery_json_from_images(images, output_dir):
|
||||
"""Generte the hero carrousel photo list"""
|
||||
try:
|
||||
@@ -37,6 +45,7 @@ def generate_gallery_json_from_images(images, output_dir):
|
||||
except Exception as e:
|
||||
logging.error(f"[✗] Error generating gallery JSON: {e}")
|
||||
|
||||
|
||||
def generate_robots_txt(canonical_url, allowed_paths, output_dir):
|
||||
"""Generate the robot.txt"""
|
||||
robots_lines = ["User-agent: *"]
|
||||
@@ -65,10 +74,11 @@ def generate_robots_txt(canonical_url, allowed_paths, output_dir):
|
||||
except Exception as e:
|
||||
logging.error(f"[✗] Failed to write robots.txt: {e}")
|
||||
|
||||
|
||||
def generate_sitemap_xml(canonical_url, allowed_paths, output_dir):
|
||||
"""Generate the sitemap"""
|
||||
urlset_start = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||
urlset_end = '</urlset>\n'
|
||||
urlset_end = "</urlset>\n"
|
||||
urls = ""
|
||||
for path in allowed_paths:
|
||||
loc = canonical_url.rstrip("/") + path
|
||||
@@ -78,3 +88,46 @@ def generate_sitemap_xml(canonical_url, allowed_paths, output_dir):
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(sitemap_content)
|
||||
logging.info(f"[✓] sitemap.xml generated at {output_path}")
|
||||
|
||||
|
||||
def render_photo_page(
|
||||
photo_id, site_title, description, alt, page_url, image_url, image_size, signature
|
||||
):
|
||||
"""
|
||||
photo/<id>/index.html: the page a copied/shared photo link points at.
|
||||
Link-preview crawlers read its og:/twitter: tags (without running
|
||||
scripts) and show this photo; visitors are sent on to the gallery with
|
||||
?photo=<id>. Kept out of search results with noindex rather than
|
||||
robots.txt, since preview crawlers such as Twitterbot honour robots.txt
|
||||
and would then never read the tags.
|
||||
"""
|
||||
title = f"{alt} - {site_title}" if alt else site_title
|
||||
gallery_href = f"/?photo={quote(photo_id)}"
|
||||
width, height = image_size
|
||||
t = escape(title)
|
||||
return f"""<!DOCTYPE html>
|
||||
{signature}
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{t}</title>
|
||||
<meta name="robots" content="noindex, follow">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
||||
<meta name="description" content="{escape(description)}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="{escape(site_title)}" />
|
||||
<meta property="og:title" content="{t}" />
|
||||
<meta property="og:description" content="{escape(description)}" />
|
||||
<meta property="og:url" content="{escape(page_url)}" />
|
||||
<meta property="og:image" content="{escape(image_url)}" />
|
||||
<meta property="og:image:width" content="{width}" />
|
||||
<meta property="og:image:height" content="{height}" />
|
||||
<meta property="og:image:alt" content="{escape(alt)}" />
|
||||
<script>location.replace({json.dumps(gallery_href)});</script>
|
||||
</head>
|
||||
<body>
|
||||
<p><a href="{escape(gallery_href)}">{t}</a></p>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from PIL import Image, features
|
||||
from shutil import copyfile
|
||||
|
||||
from PIL import Image, features
|
||||
|
||||
SHARE_PREVIEW_MAX_WIDTH = 1200
|
||||
|
||||
|
||||
def convert_and_resize_image(input_path, output_path, resize=True, max_width=1140):
|
||||
"""Convert an image to WebP (or JPEG fallback) and optionally resize it."""
|
||||
try:
|
||||
@@ -37,6 +43,7 @@ def convert_and_resize_image(input_path, output_path, resize=True, max_width=114
|
||||
except Exception as e:
|
||||
logging.error(f"[✗] Error processing image {input_path}: {e}")
|
||||
|
||||
|
||||
def process_images(images, resize_images, img_dir, build_dir):
|
||||
"""Process a list of image references and update paths to optimized versions."""
|
||||
for img in images:
|
||||
@@ -52,6 +59,65 @@ def process_images(images, resize_images, img_dir, build_dir):
|
||||
if jpg_path.exists():
|
||||
img["src"] = str(Path(img["src"]).with_suffix(".jpg"))
|
||||
|
||||
|
||||
def compute_photo_ids(images, img_dir):
|
||||
"""
|
||||
Set each image's permalink id (img["photo_id"]): the SHA-256 of its
|
||||
original file, so the same photo keeps the same link whatever its
|
||||
position or filename, across rebuilds. lumeex-web computes the same
|
||||
value. Duplicates of the same file get "-2", "-3"... in list order.
|
||||
Must run before process_images, which rewrites img["src"].
|
||||
"""
|
||||
seen = {}
|
||||
for img in images:
|
||||
src_path = img_dir / img["src"]
|
||||
if not src_path.exists():
|
||||
continue
|
||||
digest = hashlib.sha256(src_path.read_bytes()).hexdigest()
|
||||
seen[digest] = seen.get(digest, 0) + 1
|
||||
img["photo_id"] = digest if seen[digest] == 1 else f"{digest}-{seen[digest]}"
|
||||
|
||||
|
||||
def _to_srgb(img, icc_profile):
|
||||
"""Convert to sRGB when Pillow was built with LittleCMS; unchanged otherwise."""
|
||||
try:
|
||||
from PIL import ImageCms
|
||||
except ImportError:
|
||||
return img
|
||||
try:
|
||||
src_profile = ImageCms.ImageCmsProfile(io.BytesIO(icc_profile))
|
||||
return ImageCms.profileToProfile(img, src_profile, ImageCms.createProfile("sRGB"))
|
||||
except ImageCms.PyCMSError as e:
|
||||
logging.warning(f"[~] Could not convert embedded profile to sRGB: {e}")
|
||||
return img
|
||||
|
||||
|
||||
def generate_share_preview(input_path, output_path, max_width=SHARE_PREVIEW_MAX_WIDTH):
|
||||
"""
|
||||
Link-preview image for one gallery photo (photo/<id>/ pages): a plain
|
||||
sRGB JPEG no wider than max_width, since WebP and wide-gamut profiles
|
||||
aren't handled by every messaging app. Returns (width, height), or None
|
||||
on failure.
|
||||
"""
|
||||
try:
|
||||
img = Image.open(input_path)
|
||||
icc_profile = img.info.get("icc_profile")
|
||||
img = img.convert("RGB")
|
||||
if icc_profile:
|
||||
img = _to_srgb(img, icc_profile)
|
||||
width, height = img.size
|
||||
if width > max_width:
|
||||
height = int((max_width / width) * height)
|
||||
width = max_width
|
||||
img = img.resize((width, height), Image.LANCZOS)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(output_path, "JPEG", quality=85)
|
||||
return width, height
|
||||
except Exception as e:
|
||||
logging.error(f"[✗] Error generating share preview for {input_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def copy_original_images(images, img_dir, build_dir):
|
||||
"""Copy original image files without processing."""
|
||||
for img in images:
|
||||
@@ -70,6 +136,7 @@ def copy_original_images(images, img_dir, build_dir):
|
||||
except Exception as e:
|
||||
logging.error(f"[✗] Error copying {src_path}: {e}")
|
||||
|
||||
|
||||
def get_favicon_path(theme_vars, theme_dir):
|
||||
"""Retrieve the favicon path from theme variables, ensuring it exists."""
|
||||
fav_path = theme_vars.get("favicon", {}).get("path")
|
||||
@@ -87,6 +154,7 @@ def get_favicon_path(theme_vars, theme_dir):
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def generate_favicons_from_logo(theme_vars, theme_dir, output_dir):
|
||||
"""Generate multiple PNG favicons from a single source image."""
|
||||
logo_path = get_favicon_path(theme_vars, theme_dir)
|
||||
@@ -97,9 +165,13 @@ def generate_favicons_from_logo(theme_vars, theme_dir, output_dir):
|
||||
try:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
specs = [
|
||||
(32, "favicon-32.png"), (96, "favicon-96.png"), (128, "favicon-128.png"),
|
||||
(192, "favicon-192.png"), (196, "favicon-196.png"),
|
||||
(152, "favicon-152.png"), (180, "favicon-180.png")
|
||||
(32, "favicon-32.png"),
|
||||
(96, "favicon-96.png"),
|
||||
(128, "favicon-128.png"),
|
||||
(192, "favicon-192.png"),
|
||||
(196, "favicon-196.png"),
|
||||
(152, "favicon-152.png"),
|
||||
(180, "favicon-180.png"),
|
||||
]
|
||||
img = Image.open(logo_path).convert("RGBA")
|
||||
for size, name in specs:
|
||||
@@ -110,6 +182,7 @@ def generate_favicons_from_logo(theme_vars, theme_dir, output_dir):
|
||||
except Exception as e:
|
||||
logging.error(f"[✗] Error generating PNG favicons: {e}")
|
||||
|
||||
|
||||
def generate_favicon_ico(theme_vars, theme_dir, output_path):
|
||||
"""Generate a multi-size favicon.ico from a source image."""
|
||||
logo_path = get_favicon_path(theme_vars, theme_dir)
|
||||
|
||||
@@ -2,14 +2,30 @@ import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from shutil import copyfile
|
||||
|
||||
from PIL import Image
|
||||
from .utils import ensure_dir, copy_assets, load_yaml, load_theme_config
|
||||
|
||||
from .css_generator import generate_css_variables, generate_fonts_css, generate_google_fonts_link
|
||||
from .image_processor import process_images, copy_original_images, convert_and_resize_image, generate_favicons_from_logo, generate_favicon_ico
|
||||
from .html_generator import render_template, render_gallery_images, generate_gallery_json_from_images, generate_robots_txt, generate_sitemap_xml
|
||||
from .html_generator import (
|
||||
generate_gallery_json_from_images,
|
||||
generate_robots_txt,
|
||||
generate_sitemap_xml,
|
||||
render_gallery_images,
|
||||
render_photo_page,
|
||||
render_template,
|
||||
)
|
||||
from .image_processor import (
|
||||
compute_photo_ids,
|
||||
copy_original_images,
|
||||
generate_favicon_ico,
|
||||
generate_favicons_from_logo,
|
||||
generate_share_preview,
|
||||
process_images,
|
||||
)
|
||||
from .utils import copy_assets, ensure_dir, load_theme_config, load_yaml
|
||||
|
||||
# Configure logging to display only the messages
|
||||
logging.basicConfig(level=logging.INFO, format='%(message)s')
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
|
||||
# Define key directories used throughout the script
|
||||
SRC_DIR = Path.cwd()
|
||||
@@ -22,9 +38,10 @@ GALLERY_FILE = SRC_DIR / "config/gallery.yaml"
|
||||
SITE_FILE = SRC_DIR / "config/site.yaml"
|
||||
THEMES_DIR = SRC_DIR / "config/themes"
|
||||
VERSION_FILE = SRC_DIR / "VERSION"
|
||||
with open(VERSION_FILE, "r") as vf:
|
||||
with open(VERSION_FILE) as vf:
|
||||
build_version = vf.read().strip()
|
||||
|
||||
|
||||
def build():
|
||||
logging.info("\n")
|
||||
logging.info("=" * 24)
|
||||
@@ -33,7 +50,7 @@ def build():
|
||||
logging.info("\n === Starting build === ")
|
||||
ensure_dir(BUILD_DIR)
|
||||
copy_assets(JS_DIR, STYLE_DIR, BUILD_DIR)
|
||||
|
||||
|
||||
# Defining build vars
|
||||
build_date = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
build_date_version = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
@@ -57,9 +74,13 @@ def build():
|
||||
logging.info(f"[✓] Theme CSS found, copied to build folder: {dest_theme_css}")
|
||||
else:
|
||||
theme_css = ""
|
||||
logging.warning(f"[~] No theme.css found in {theme_css_path}, skipping theme CSS injection.")
|
||||
logging.warning(
|
||||
f"[~] No theme.css found in {theme_css_path}, skipping theme CSS injection."
|
||||
)
|
||||
|
||||
preload_links = generate_fonts_css(fonts_dir, BUILD_DIR / "style" / "fonts.css", fonts_cfg=theme_vars.get("fonts"))
|
||||
preload_links = generate_fonts_css(
|
||||
fonts_dir, BUILD_DIR / "style" / "fonts.css", fonts_cfg=theme_vars.get("fonts")
|
||||
)
|
||||
generate_css_variables(theme_vars.get("colors", {}), BUILD_DIR / "style" / "colors.css")
|
||||
generate_favicons_from_logo(theme_vars, theme_dir, BUILD_DIR / "img" / "favicon")
|
||||
generate_favicon_ico(theme_vars, theme_dir, BUILD_DIR / "favicon.ico")
|
||||
@@ -73,6 +94,13 @@ def build():
|
||||
hero_images = gallery_vars.get("hero", {}).get("images", [])
|
||||
gallery_images = gallery_vars.get("gallery", {}).get("images", [])
|
||||
|
||||
# Permalink ids for gallery photos (not the hero carousel), hashed from
|
||||
# the original files — before process_images rewrites their src
|
||||
compute_photo_ids(gallery_images, IMG_DIR)
|
||||
original_src_by_photo_id = {
|
||||
img["photo_id"]: img["src"] for img in gallery_images if "photo_id" in img
|
||||
}
|
||||
|
||||
if convert_images:
|
||||
process_images(hero_images, resize_images, IMG_DIR, BUILD_DIR)
|
||||
process_images(gallery_images, resize_images, IMG_DIR, BUILD_DIR)
|
||||
@@ -94,7 +122,7 @@ def build():
|
||||
|
||||
# Adding Google fonts if existing
|
||||
google_fonts_link = generate_google_fonts_link(theme_vars.get("google_fonts", []))
|
||||
logging.info(f"[✓] Google Fonts link generated")
|
||||
logging.info("[✓] Google Fonts link generated")
|
||||
|
||||
# Generating thumbnail
|
||||
thumbnail_path = site_vars.get("social", {}).get("thumbnail")
|
||||
@@ -124,11 +152,13 @@ def build():
|
||||
head_vars["theme_css"] = theme_css
|
||||
head_vars["build_date"] = build_date
|
||||
head_vars["canonical"] = canonical_home
|
||||
|
||||
|
||||
# Render the home page
|
||||
head = render_template(TEMPLATE_DIR / "head.html", head_vars)
|
||||
hero = render_template(TEMPLATE_DIR / "hero.html", {**site_vars["hero"], **head_vars})
|
||||
footer = render_template(TEMPLATE_DIR / "footer.html", {**site_vars.get("footer", {}), **head_vars})
|
||||
footer = render_template(
|
||||
TEMPLATE_DIR / "footer.html", {**site_vars.get("footer", {}), **head_vars}
|
||||
)
|
||||
gallery_html = render_gallery_images(gallery_images)
|
||||
gallery = render_template(TEMPLATE_DIR / "gallery.html", {"gallery_images": gallery_html})
|
||||
|
||||
@@ -171,6 +201,34 @@ def build():
|
||||
else:
|
||||
logging.warning("[~] No legals section found in site.yaml")
|
||||
|
||||
# One link-preview page + preview image per gallery photo. og:image and
|
||||
# og:url must be absolute for crawlers, hence the canonical URL.
|
||||
site_info = site_vars.get("info", {})
|
||||
share_pages = 0
|
||||
for img in gallery_images:
|
||||
photo_id = img.get("photo_id")
|
||||
if not photo_id:
|
||||
continue
|
||||
preview_path = BUILD_DIR / "img" / "share" / f"{photo_id}.jpg"
|
||||
size = generate_share_preview(IMG_DIR / original_src_by_photo_id[photo_id], preview_path)
|
||||
if not size:
|
||||
continue
|
||||
page = render_photo_page(
|
||||
photo_id,
|
||||
site_title=site_info.get("title", ""),
|
||||
description=site_info.get("description", ""),
|
||||
alt=img.get("alt", ""),
|
||||
page_url=f"{canonical_url}/photo/{photo_id}/",
|
||||
image_url=f"{canonical_url}/img/share/{photo_id}.jpg",
|
||||
image_size=size,
|
||||
signature=signature,
|
||||
)
|
||||
page_path = BUILD_DIR / "photo" / photo_id / "index.html"
|
||||
page_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
page_path.write_text(page, encoding="utf-8")
|
||||
share_pages += 1
|
||||
logging.info(f"[✓] Photo share pages generated: {share_pages}")
|
||||
|
||||
# Hero carrousel generator
|
||||
if hero_images:
|
||||
generate_gallery_json_from_images(hero_images, BUILD_DIR)
|
||||
@@ -185,7 +243,8 @@ def build():
|
||||
generate_robots_txt(canonical_url, allowed_pages, BUILD_DIR)
|
||||
generate_sitemap_xml(canonical_url, allowed_pages, BUILD_DIR)
|
||||
else:
|
||||
logging.warning("[~] No canonical URL found in site.yaml info section, skipping robots.txt and sitemap.xml generation.")
|
||||
logging.warning(
|
||||
"[~] No canonical URL found in site.yaml info section, skipping robots.txt and sitemap.xml generation."
|
||||
)
|
||||
|
||||
logging.info("✅ Build complete.")
|
||||
|
||||
+10
-4
@@ -1,26 +1,30 @@
|
||||
import yaml
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from shutil import copytree, rmtree, copyfile
|
||||
from shutil import copytree, rmtree
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def load_yaml(path):
|
||||
"""Load gallery and site .yaml conf"""
|
||||
if not path.exists():
|
||||
logging.warning(f"[!] YAML file not found: {path}")
|
||||
return {}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def load_theme_config(theme_name, themes_dir):
|
||||
"""Load theme.yaml"""
|
||||
theme_dir = themes_dir / theme_name
|
||||
theme_config_path = theme_dir / "theme.yaml"
|
||||
if not theme_config_path.exists():
|
||||
raise FileNotFoundError(f"[✗] Theme config not found: {theme_config_path}")
|
||||
with open(theme_config_path, "r", encoding="utf-8") as f:
|
||||
with open(theme_config_path, encoding="utf-8") as f:
|
||||
theme_vars = yaml.safe_load(f)
|
||||
return theme_vars, theme_dir
|
||||
|
||||
|
||||
def clear_dir(path: Path):
|
||||
"""Clear the output dir"""
|
||||
if not path.exists():
|
||||
@@ -32,6 +36,7 @@ def clear_dir(path: Path):
|
||||
elif child.is_dir():
|
||||
rmtree(child)
|
||||
|
||||
|
||||
def ensure_dir(path: Path):
|
||||
"""Create the output dir if it does not exist"""
|
||||
if not path.exists():
|
||||
@@ -39,6 +44,7 @@ def ensure_dir(path: Path):
|
||||
else:
|
||||
clear_dir(path)
|
||||
|
||||
|
||||
def copy_assets(js_dir, style_dir, build_dir):
|
||||
"""Copy public assets to output dir"""
|
||||
for folder in [js_dir, style_dir]:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, request, current_app
|
||||
|
||||
from flask import Blueprint, current_app, request
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from src.py.builder.gallery_builder import update_gallery, update_hero
|
||||
|
||||
# --- Create Flask blueprint for upload routes ---
|
||||
@@ -10,10 +12,12 @@ upload_bp = Blueprint("upload", __name__)
|
||||
# --- Allowed file types ---
|
||||
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "webp"}
|
||||
|
||||
|
||||
def allowed_file(filename: str) -> bool:
|
||||
"""Check if the uploaded file has an allowed extension."""
|
||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
def save_uploaded_file(file, folder: Path):
|
||||
"""Save an uploaded file to the specified folder."""
|
||||
folder.mkdir(parents=True, exist_ok=True) # Create folder if not exists
|
||||
@@ -22,6 +26,7 @@ def save_uploaded_file(file, folder: Path):
|
||||
logging.info(f"[✓] Uploaded {filename} to {folder}")
|
||||
return filename
|
||||
|
||||
|
||||
@upload_bp.route("/api/<section>/upload", methods=["POST"])
|
||||
def upload_photo(section: str):
|
||||
"""
|
||||
@@ -35,7 +40,7 @@ def upload_photo(section: str):
|
||||
# Check if files are provided
|
||||
if "files" not in request.files:
|
||||
return {"error": "No files provided"}, 400
|
||||
|
||||
|
||||
files = request.files.getlist("files")
|
||||
if not files:
|
||||
return {"error": "No selected files"}, 400
|
||||
@@ -63,4 +68,3 @@ def upload_photo(section: str):
|
||||
return {"status": "ok", "uploaded": uploaded}
|
||||
|
||||
return {"error": "No valid files uploaded"}, 400
|
||||
|
||||
|
||||
+111
-41
@@ -1,16 +1,27 @@
|
||||
# --- Imports ---
|
||||
import logging
|
||||
import yaml
|
||||
import os
|
||||
import subprocess
|
||||
import zipfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from flask import (
|
||||
Flask, jsonify, request, send_from_directory, render_template,
|
||||
send_file, after_this_request
|
||||
Flask,
|
||||
after_this_request,
|
||||
jsonify,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
send_from_directory,
|
||||
)
|
||||
|
||||
from src.py.builder.gallery_builder import (
|
||||
GALLERY_YAML, load_yaml, save_yaml, update_gallery, update_hero
|
||||
GALLERY_YAML,
|
||||
load_yaml,
|
||||
save_yaml,
|
||||
update_gallery,
|
||||
update_hero,
|
||||
)
|
||||
from src.py.webui.upload import upload_bp
|
||||
|
||||
@@ -19,16 +30,11 @@ logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
|
||||
# --- Flask app setup ---
|
||||
VERSION_FILE = Path(__file__).resolve().parents[3] / "VERSION"
|
||||
with open(VERSION_FILE, "r") as vf:
|
||||
with open(VERSION_FILE) as vf:
|
||||
lumeex_version = vf.read().strip()
|
||||
|
||||
WEBUI_PATH = Path(__file__).parents[2] / "webui" # Path to static/templates
|
||||
app = Flask(
|
||||
__name__,
|
||||
template_folder=WEBUI_PATH,
|
||||
static_folder=WEBUI_PATH,
|
||||
static_url_path=""
|
||||
)
|
||||
app = Flask(__name__, template_folder=WEBUI_PATH, static_folder=WEBUI_PATH, static_url_path="")
|
||||
|
||||
WEBUI_PORT = int(os.getenv("WEBUI_PORT", 5000))
|
||||
|
||||
@@ -40,26 +46,34 @@ app.config["PHOTOS_DIR"] = PHOTOS_DIR
|
||||
# --- Register upload blueprint ---
|
||||
app.register_blueprint(upload_bp)
|
||||
|
||||
|
||||
# --- Theme editor helper functions ---
|
||||
def get_theme_name():
|
||||
"""Get current theme name from site.yaml."""
|
||||
site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
|
||||
with open(site_yaml_path, "r") as f:
|
||||
with open(site_yaml_path) as f:
|
||||
site_yaml = yaml.safe_load(f)
|
||||
return site_yaml.get("build", {}).get("theme", "modern")
|
||||
|
||||
|
||||
def get_theme_yaml(theme_name):
|
||||
"""Load theme.yaml for a given theme."""
|
||||
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
|
||||
with open(theme_yaml_path, "r") as f:
|
||||
theme_yaml_path = (
|
||||
Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
|
||||
)
|
||||
with open(theme_yaml_path) as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def save_theme_yaml(theme_name, theme_yaml):
|
||||
"""Save theme.yaml for a given theme."""
|
||||
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
|
||||
theme_yaml_path = (
|
||||
Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
|
||||
)
|
||||
with open(theme_yaml_path, "w") as f:
|
||||
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
|
||||
|
||||
|
||||
def get_local_fonts(theme_name):
|
||||
"""List local font files for a theme."""
|
||||
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
|
||||
@@ -67,21 +81,23 @@ def get_local_fonts(theme_name):
|
||||
return []
|
||||
return [f.name for f in fonts_dir.glob("*") if f.is_file() and f.suffix in [".woff", ".woff2"]]
|
||||
|
||||
|
||||
# --- ROUTES ---
|
||||
|
||||
|
||||
# --- Main page ---
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
PREVIEW_PORT = int(os.getenv("PREVIEW_PORT", 3000))
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_version():
|
||||
return dict(
|
||||
lumeex_version=lumeex_version,
|
||||
preview_port=PREVIEW_PORT
|
||||
)
|
||||
return dict(lumeex_version=lumeex_version, preview_port=PREVIEW_PORT)
|
||||
|
||||
|
||||
# --- Gallery & Hero API ---
|
||||
@app.route("/gallery-editor")
|
||||
@@ -89,18 +105,21 @@ def gallery_editor():
|
||||
"""Render gallery editor page."""
|
||||
return render_template("gallery-editor/index.html")
|
||||
|
||||
|
||||
@app.route("/api/gallery", methods=["GET"])
|
||||
def get_gallery():
|
||||
"""Get gallery images."""
|
||||
data = load_yaml(GALLERY_YAML)
|
||||
return jsonify(data.get("gallery", {}).get("images", []))
|
||||
|
||||
|
||||
@app.route("/api/hero", methods=["GET"])
|
||||
def get_hero():
|
||||
"""Get hero images."""
|
||||
data = load_yaml(GALLERY_YAML)
|
||||
return jsonify(data.get("hero", {}).get("images", []))
|
||||
|
||||
|
||||
@app.route("/api/gallery/update", methods=["POST"])
|
||||
def update_gallery_api():
|
||||
"""Update gallery images."""
|
||||
@@ -110,6 +129,7 @@ def update_gallery_api():
|
||||
save_yaml(data, GALLERY_YAML)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@app.route("/api/hero/update", methods=["POST"])
|
||||
def update_hero_api():
|
||||
"""Update hero images."""
|
||||
@@ -119,18 +139,21 @@ def update_hero_api():
|
||||
save_yaml(data, GALLERY_YAML)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@app.route("/api/gallery/refresh", methods=["POST"])
|
||||
def refresh_gallery():
|
||||
"""Refresh gallery images from disk."""
|
||||
update_gallery()
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@app.route("/api/hero/refresh", methods=["POST"])
|
||||
def refresh_hero():
|
||||
"""Refresh hero images from disk."""
|
||||
update_hero()
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
# --- Gallery & Hero photo deletion ---
|
||||
@app.route("/api/gallery/delete", methods=["POST"])
|
||||
def delete_gallery_photo():
|
||||
@@ -143,6 +166,7 @@ def delete_gallery_photo():
|
||||
return {"status": "ok"}
|
||||
return {"error": "❌ File not found"}, 404
|
||||
|
||||
|
||||
@app.route("/api/hero/delete", methods=["POST"])
|
||||
def delete_hero_photo():
|
||||
"""Delete a hero photo."""
|
||||
@@ -154,6 +178,7 @@ def delete_hero_photo():
|
||||
return {"status": "ok"}
|
||||
return {"error": "❌ File not found"}, 404
|
||||
|
||||
|
||||
@app.route("/api/gallery/delete_all", methods=["POST"])
|
||||
def delete_all_gallery_photos():
|
||||
"""Delete all gallery photos."""
|
||||
@@ -168,6 +193,7 @@ def delete_all_gallery_photos():
|
||||
save_yaml(data, GALLERY_YAML)
|
||||
return jsonify({"status": "ok", "deleted": deleted})
|
||||
|
||||
|
||||
@app.route("/api/hero/delete_all", methods=["POST"])
|
||||
def delete_all_hero_photos():
|
||||
"""Delete all hero photos."""
|
||||
@@ -182,36 +208,41 @@ def delete_all_hero_photos():
|
||||
save_yaml(data, GALLERY_YAML)
|
||||
return jsonify({"status": "ok", "deleted": deleted})
|
||||
|
||||
|
||||
# --- Serve photos ---
|
||||
@app.route("/photos/<section>/<path:filename>")
|
||||
def photos(section, filename):
|
||||
"""Serve a photo from a section."""
|
||||
return send_from_directory(PHOTOS_DIR / section, filename)
|
||||
|
||||
|
||||
@app.route("/photos/<path:filename>")
|
||||
def serve_photo(filename):
|
||||
"""Serve a photo from the photos directory."""
|
||||
photos_dir = Path(__file__).resolve().parents[3] / "config" / "photos"
|
||||
return send_from_directory(photos_dir, filename)
|
||||
|
||||
|
||||
# --- Site info page & API ---
|
||||
@app.route("/site-info")
|
||||
def site_info():
|
||||
"""Render site info editor page."""
|
||||
return render_template("site-info/index.html")
|
||||
|
||||
|
||||
@app.route("/api/site-info", methods=["GET"])
|
||||
def get_site_info():
|
||||
"""Get site info YAML as JSON."""
|
||||
with open(SITE_YAML, "r") as f:
|
||||
with open(SITE_YAML) as f:
|
||||
data = yaml.safe_load(f)
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@app.route("/api/site-info", methods=["POST"])
|
||||
def update_site_info():
|
||||
"""Update site info YAML."""
|
||||
new_data = request.json
|
||||
with open(SITE_YAML, "r") as f:
|
||||
with open(SITE_YAML) as f:
|
||||
old_data = yaml.safe_load(f) or {}
|
||||
|
||||
def deep_merge(old, new):
|
||||
@@ -227,6 +258,7 @@ def update_site_info():
|
||||
yaml.safe_dump(merged, f, sort_keys=False, allow_unicode=True)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
# --- Theme management ---
|
||||
@app.route("/api/themes")
|
||||
def list_themes():
|
||||
@@ -235,6 +267,7 @@ def list_themes():
|
||||
themes = [d.name for d in themes_dir.iterdir() if d.is_dir()]
|
||||
return jsonify(themes)
|
||||
|
||||
|
||||
# --- Thumbnail upload/remove ---
|
||||
@app.route("/api/thumbnail/upload", methods=["POST"])
|
||||
def upload_thumbnail():
|
||||
@@ -245,13 +278,14 @@ def upload_thumbnail():
|
||||
return {"error": "❌ No file provided"}, 400
|
||||
filename = "thumbnail.png"
|
||||
file.save(PHOTOS_DIR / filename)
|
||||
with open(SITE_YAML, "r") as f:
|
||||
with open(SITE_YAML) as f:
|
||||
data = yaml.safe_load(f)
|
||||
data.setdefault("social", {})["thumbnail"] = filename
|
||||
with open(SITE_YAML, "w") as f:
|
||||
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
|
||||
return jsonify({"status": "ok", "filename": filename})
|
||||
|
||||
|
||||
@app.route("/api/thumbnail/remove", methods=["POST"])
|
||||
def remove_thumbnail():
|
||||
"""Remove thumbnail image and update site.yaml."""
|
||||
@@ -259,7 +293,7 @@ def remove_thumbnail():
|
||||
thumbnail_path = PHOTOS_DIR / "thumbnail.png"
|
||||
if thumbnail_path.exists():
|
||||
thumbnail_path.unlink()
|
||||
with open(SITE_YAML, "r") as f:
|
||||
with open(SITE_YAML) as f:
|
||||
data = yaml.safe_load(f)
|
||||
if "social" in data and "thumbnail" in data["social"]:
|
||||
data["social"]["thumbnail"] = ""
|
||||
@@ -267,6 +301,7 @@ def remove_thumbnail():
|
||||
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
# --- Theme upload ---
|
||||
@app.route("/api/theme/upload", methods=["POST"])
|
||||
def upload_theme():
|
||||
@@ -286,6 +321,7 @@ def upload_theme():
|
||||
file.save(dest_path)
|
||||
return jsonify({"status": "ok", "theme": folder_name})
|
||||
|
||||
|
||||
@app.route("/api/theme/remove", methods=["POST"])
|
||||
def remove_theme():
|
||||
"""Remove a custom theme folder."""
|
||||
@@ -302,15 +338,18 @@ def remove_theme():
|
||||
return jsonify({"error": "❌ Cannot remove default theme"}), 400
|
||||
# Remove folder and all contents
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(theme_folder)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
# --- Theme editor page & API ---
|
||||
@app.route("/theme-editor")
|
||||
def theme_editor():
|
||||
"""Render theme editor page."""
|
||||
return render_template("theme-editor/index.html")
|
||||
|
||||
|
||||
@app.route("/api/theme-info", methods=["GET", "POST"])
|
||||
def api_theme_info():
|
||||
"""Get or update theme.yaml for current theme."""
|
||||
@@ -318,11 +357,9 @@ def api_theme_info():
|
||||
if request.method == "GET":
|
||||
theme_yaml = get_theme_yaml(theme_name)
|
||||
google_fonts = theme_yaml.get("google_fonts", [])
|
||||
return jsonify({
|
||||
"theme_name": theme_name,
|
||||
"theme_yaml": theme_yaml,
|
||||
"google_fonts": google_fonts
|
||||
})
|
||||
return jsonify(
|
||||
{"theme_name": theme_name, "theme_yaml": theme_yaml, "google_fonts": google_fonts}
|
||||
)
|
||||
else:
|
||||
data = request.get_json()
|
||||
theme_yaml = data.get("theme_yaml")
|
||||
@@ -330,20 +367,24 @@ def api_theme_info():
|
||||
save_theme_yaml(theme_name, theme_yaml)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@app.route("/api/theme-google-fonts", methods=["POST"])
|
||||
def update_theme_google_fonts():
|
||||
"""Update only google_fonts in theme.yaml for current theme."""
|
||||
data = request.get_json()
|
||||
theme_name = data.get("theme_name")
|
||||
google_fonts = data.get("google_fonts", [])
|
||||
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
|
||||
with open(theme_yaml_path, "r") as f:
|
||||
theme_yaml_path = (
|
||||
Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
|
||||
)
|
||||
with open(theme_yaml_path) as f:
|
||||
theme_yaml = yaml.safe_load(f)
|
||||
theme_yaml["google_fonts"] = google_fonts
|
||||
with open(theme_yaml_path, "w") as f:
|
||||
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@app.route("/api/local-fonts")
|
||||
def api_local_fonts():
|
||||
"""List local fonts for a theme."""
|
||||
@@ -351,6 +392,7 @@ def api_local_fonts():
|
||||
fonts = get_local_fonts(theme_name)
|
||||
return jsonify(fonts)
|
||||
|
||||
|
||||
# --- Favicon upload/remove ---
|
||||
@app.route("/api/favicon/upload", methods=["POST"])
|
||||
def upload_favicon():
|
||||
@@ -366,13 +408,14 @@ def upload_favicon():
|
||||
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name
|
||||
file.save(theme_dir / filename)
|
||||
theme_yaml_path = theme_dir / "theme.yaml"
|
||||
with open(theme_yaml_path, "r") as f:
|
||||
with open(theme_yaml_path) as f:
|
||||
theme_yaml = yaml.safe_load(f)
|
||||
theme_yaml.setdefault("favicon", {})["path"] = filename
|
||||
with open(theme_yaml_path, "w") as f:
|
||||
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
|
||||
return jsonify({"status": "ok", "filename": filename})
|
||||
|
||||
|
||||
@app.route("/api/favicon/remove", methods=["POST"])
|
||||
def remove_favicon():
|
||||
"""Remove favicon for a theme."""
|
||||
@@ -386,7 +429,7 @@ def remove_favicon():
|
||||
if favicon_path.exists():
|
||||
favicon_path.unlink()
|
||||
theme_yaml_path = theme_dir / "theme.yaml"
|
||||
with open(theme_yaml_path, "r") as f:
|
||||
with open(theme_yaml_path) as f:
|
||||
theme_yaml = yaml.safe_load(f)
|
||||
if "favicon" in theme_yaml:
|
||||
theme_yaml["favicon"]["path"] = ""
|
||||
@@ -394,6 +437,7 @@ def remove_favicon():
|
||||
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
# --- Serve theme assets ---
|
||||
@app.route("/themes/<theme>/<filename>")
|
||||
def serve_theme_asset(theme, filename):
|
||||
@@ -401,6 +445,7 @@ def serve_theme_asset(theme, filename):
|
||||
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme
|
||||
return send_from_directory(theme_dir, filename)
|
||||
|
||||
|
||||
# --- Font upload/remove ---
|
||||
@app.route("/api/font/upload", methods=["POST"])
|
||||
def upload_font():
|
||||
@@ -418,6 +463,7 @@ def upload_font():
|
||||
font_basename = Path(file.filename).stem
|
||||
return jsonify({"status": "ok", "filename": font_basename})
|
||||
|
||||
|
||||
@app.route("/api/font/remove", methods=["POST"])
|
||||
def remove_font():
|
||||
"""Remove a font file for a theme."""
|
||||
@@ -433,6 +479,7 @@ def remove_font():
|
||||
return jsonify({"status": "ok"})
|
||||
return jsonify({"error": "❌ Font not found"}), 404
|
||||
|
||||
|
||||
# --- Build & Download ZIP ---
|
||||
@app.route("/api/build", methods=["POST"])
|
||||
def trigger_build():
|
||||
@@ -446,7 +493,7 @@ def trigger_build():
|
||||
if not site_yaml_path.exists():
|
||||
return jsonify({"status": "error", "message": "❌ site.yaml not found"}), 400
|
||||
|
||||
with open(site_yaml_path, "r") as f:
|
||||
with open(site_yaml_path) as f:
|
||||
site_data = yaml.safe_load(f) or {}
|
||||
|
||||
# Dynamically check all main sections and nested keys
|
||||
@@ -454,24 +501,45 @@ def trigger_build():
|
||||
for section in main_sections:
|
||||
value = site_data.get(section)
|
||||
if not value:
|
||||
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
|
||||
return jsonify(
|
||||
{"status": "error", "message": f"❌ Site info are not set: missing {section}"}
|
||||
), 400
|
||||
if isinstance(value, dict):
|
||||
for k, v in value.items():
|
||||
if v is None or v == "" or (isinstance(v, list) and not v):
|
||||
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}.{k}"}), 400
|
||||
return jsonify(
|
||||
{
|
||||
"status": "error",
|
||||
"message": f"❌ Site info are not set: missing {section}.{k}",
|
||||
}
|
||||
), 400
|
||||
elif isinstance(value, list):
|
||||
if not value:
|
||||
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
|
||||
return jsonify(
|
||||
{"status": "error", "message": f"❌ Site info are not set: missing {section}"}
|
||||
), 400
|
||||
for idx, item in enumerate(value):
|
||||
if isinstance(item, dict):
|
||||
for k, v in item.items():
|
||||
if v is None or v == "" or (isinstance(v, list) and not v):
|
||||
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}[{idx}].{k}"}), 400
|
||||
return jsonify(
|
||||
{
|
||||
"status": "error",
|
||||
"message": f"❌ Site info are not set: missing {section}[{idx}].{k}",
|
||||
}
|
||||
), 400
|
||||
elif item is None or item == "":
|
||||
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}[{idx}]"}), 400
|
||||
return jsonify(
|
||||
{
|
||||
"status": "error",
|
||||
"message": f"❌ Site info are not set: missing {section}[{idx}]",
|
||||
}
|
||||
), 400
|
||||
else:
|
||||
if value is None or value == "":
|
||||
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
|
||||
return jsonify(
|
||||
{"status": "error", "message": f"❌ Site info are not set: missing {section}"}
|
||||
), 400
|
||||
|
||||
try:
|
||||
subprocess.run(["python3", "build.py"], check=True)
|
||||
@@ -479,6 +547,7 @@ def trigger_build():
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "message": f"❌ {str(e)}"}), 500
|
||||
|
||||
|
||||
@app.route("/download-output-zip", methods=["POST"])
|
||||
def download_output_zip():
|
||||
"""
|
||||
@@ -505,8 +574,9 @@ def download_output_zip():
|
||||
|
||||
return send_file(zip_path, as_attachment=True)
|
||||
|
||||
|
||||
# --- Run server ---
|
||||
if __name__ == "__main__":
|
||||
logging.info("[~] Starting WebUI at http://0.0.0.0:5000")
|
||||
logging.info(f"[i] WebUI host port is set to {WEBUI_PORT}")
|
||||
app.run(host="0.0.0.0", port=5000, debug=True)
|
||||
app.run(host="0.0.0.0", port=5000, debug=True)
|
||||
|
||||
+2
-5
@@ -50,9 +50,7 @@ def app_env(tmp_path, monkeypatch, make_image):
|
||||
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",
|
||||
"colors:\n browser_color: '#ffffff'\nfavicon:\n path: favicon.png\ngoogle_fonts: []\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
make_image(theme_dir / "favicon.png", size=(32, 32), fmt="PNG")
|
||||
@@ -61,8 +59,7 @@ def app_env(tmp_path, monkeypatch, make_image):
|
||||
"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",
|
||||
"info:\n title: Test\n canonical: https://example.com\nsocial:\n thumbnail: ''\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from pathlib import Path
|
||||
|
||||
from src.py.builder import utils
|
||||
|
||||
|
||||
@@ -30,7 +28,7 @@ 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"
|
||||
raise AssertionError("expected FileNotFoundError")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import yaml
|
||||
|
||||
from src.py.builder import gallery_builder as gb
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,55 @@ def test_render_gallery_images_with_and_without_tags():
|
||||
assert 'alt=""' in html
|
||||
|
||||
|
||||
def test_render_gallery_images_adds_photo_id_when_present():
|
||||
html = hg.render_gallery_images(
|
||||
[{"src": "gallery/a.jpg", "photo_id": "abc123"}, {"src": "gallery/b.jpg"}]
|
||||
)
|
||||
|
||||
assert html.count("data-photo-id=") == 1
|
||||
assert 'data-photo-id="abc123"' in html
|
||||
|
||||
|
||||
def _photo_page(**overrides):
|
||||
kwargs = {
|
||||
"photo_id": "ab12",
|
||||
"site_title": "My photos",
|
||||
"description": "A gallery",
|
||||
"alt": "Sunset",
|
||||
"page_url": "https://example.com/photo/ab12/",
|
||||
"image_url": "https://example.com/img/share/ab12.jpg",
|
||||
"image_size": (1200, 800),
|
||||
"signature": "<!-- sig -->",
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return hg.render_photo_page(**kwargs)
|
||||
|
||||
|
||||
def test_render_photo_page_preview_tags_and_noindex():
|
||||
page = _photo_page()
|
||||
|
||||
assert '<meta name="robots" content="noindex, follow">' in page
|
||||
assert '<meta property="og:image" content="https://example.com/img/share/ab12.jpg" />' in page
|
||||
assert '<meta property="og:url" content="https://example.com/photo/ab12/" />' in page
|
||||
assert '<meta property="og:image:width" content="1200" />' in page
|
||||
assert "<title>Sunset - My photos</title>" in page
|
||||
assert 'rel="canonical"' not in page
|
||||
|
||||
|
||||
def test_render_photo_page_redirects_to_gallery():
|
||||
page = _photo_page()
|
||||
|
||||
assert 'location.replace("/?photo=ab12")' in page
|
||||
assert '<a href="/?photo=ab12">' in page
|
||||
|
||||
|
||||
def test_render_photo_page_escapes_text():
|
||||
page = _photo_page(alt="", site_title='"><script>alert(1)</script>')
|
||||
|
||||
assert "<script>alert(1)" not in page
|
||||
assert "<title>"><script>alert(1)</script></title>" in page
|
||||
|
||||
|
||||
def test_generate_gallery_json_from_images(tmp_path):
|
||||
images = [{"src": "hero/a.jpg"}, {"src": "hero/b.jpg"}]
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from src.py.builder import image_processor as ip
|
||||
@@ -101,8 +99,13 @@ def test_generate_favicons_from_logo_creates_all_sizes(tmp_path, make_image):
|
||||
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",
|
||||
"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()
|
||||
@@ -132,3 +135,79 @@ 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()
|
||||
|
||||
|
||||
def test_compute_photo_ids_hashes_original_files(tmp_path, make_image):
|
||||
import hashlib
|
||||
|
||||
img_dir = tmp_path / "photos"
|
||||
a = make_image(img_dir / "gallery" / "a.jpg", color=(1, 2, 3))
|
||||
make_image(img_dir / "gallery" / "b.jpg", color=(200, 10, 10))
|
||||
images = [{"src": "gallery/a.jpg"}, {"src": "gallery/b.jpg"}, {"src": "gallery/missing.jpg"}]
|
||||
|
||||
ip.compute_photo_ids(images, img_dir)
|
||||
|
||||
assert images[0]["photo_id"] == hashlib.sha256(a.read_bytes()).hexdigest()
|
||||
assert len(images[1]["photo_id"]) == 64
|
||||
assert images[0]["photo_id"] != images[1]["photo_id"]
|
||||
assert "photo_id" not in images[2]
|
||||
|
||||
|
||||
def test_compute_photo_ids_is_independent_of_order_and_name(tmp_path, make_image):
|
||||
img_dir = tmp_path / "photos"
|
||||
make_image(img_dir / "gallery" / "a.jpg", color=(1, 2, 3))
|
||||
(img_dir / "gallery" / "renamed.jpg").write_bytes((img_dir / "gallery" / "a.jpg").read_bytes())
|
||||
make_image(img_dir / "gallery" / "b.jpg", color=(9, 9, 9))
|
||||
first = [{"src": "gallery/a.jpg"}, {"src": "gallery/b.jpg"}]
|
||||
second = [{"src": "gallery/b.jpg"}, {"src": "gallery/renamed.jpg"}]
|
||||
|
||||
ip.compute_photo_ids(first, img_dir)
|
||||
ip.compute_photo_ids(second, img_dir)
|
||||
|
||||
assert first[0]["photo_id"] == second[1]["photo_id"]
|
||||
assert first[1]["photo_id"] == second[0]["photo_id"]
|
||||
|
||||
|
||||
def test_compute_photo_ids_suffixes_duplicates(tmp_path, make_image):
|
||||
img_dir = tmp_path / "photos"
|
||||
make_image(img_dir / "gallery" / "a.jpg")
|
||||
images = [{"src": "gallery/a.jpg"}, {"src": "gallery/a.jpg"}, {"src": "gallery/a.jpg"}]
|
||||
|
||||
ip.compute_photo_ids(images, img_dir)
|
||||
|
||||
base = images[0]["photo_id"]
|
||||
assert images[1]["photo_id"] == f"{base}-2"
|
||||
assert images[2]["photo_id"] == f"{base}-3"
|
||||
|
||||
|
||||
def test_generate_share_preview_resizes_to_jpeg(tmp_path, make_image):
|
||||
input_path = make_image(tmp_path / "in.png", size=(2400, 1600), fmt="PNG")
|
||||
output_path = tmp_path / "share" / "x.jpg"
|
||||
|
||||
size = ip.generate_share_preview(input_path, output_path)
|
||||
|
||||
assert size == (1200, 800)
|
||||
with Image.open(output_path) as img:
|
||||
assert img.format == "JPEG"
|
||||
assert img.size == (1200, 800)
|
||||
|
||||
|
||||
def test_generate_share_preview_converts_embedded_profile_to_srgb(tmp_path):
|
||||
from PIL import ImageCms
|
||||
|
||||
input_path = tmp_path / "in.jpg"
|
||||
srgb_bytes = ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes()
|
||||
Image.new("RGB", (300, 200), (10, 200, 30)).save(input_path, "JPEG", icc_profile=srgb_bytes)
|
||||
output_path = tmp_path / "out.jpg"
|
||||
|
||||
size = ip.generate_share_preview(input_path, output_path)
|
||||
|
||||
assert size == (300, 200)
|
||||
with Image.open(output_path) as img:
|
||||
assert img.mode == "RGB"
|
||||
|
||||
|
||||
def test_generate_share_preview_failure_returns_none(tmp_path):
|
||||
bad = tmp_path / "bad.jpg"
|
||||
bad.write_bytes(b"not an image")
|
||||
assert ip.generate_share_preview(bad, tmp_path / "out.jpg") is None
|
||||
|
||||
@@ -36,6 +36,35 @@ def test_build_end_to_end_with_demo_content(tmp_path, monkeypatch, demo_root):
|
||||
assert all(p.suffix in (".webp", ".jpg") for p in processed)
|
||||
|
||||
|
||||
def test_build_generates_photo_share_pages(tmp_path, monkeypatch, demo_root):
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
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()
|
||||
|
||||
gallery = yaml.safe_load((demo_root / "gallery.yaml").read_text(encoding="utf-8"))
|
||||
sources = [img["src"] for img in gallery["gallery"]["images"]]
|
||||
index_html = (output_dir / "index.html").read_text(encoding="utf-8")
|
||||
ids = re.findall(r'data-photo-id="([^"]+)"', index_html)
|
||||
assert len(ids) == len(sources)
|
||||
first_hash = hashlib.sha256((demo_root / "photos" / sources[0]).read_bytes()).hexdigest()
|
||||
assert ids[0] == first_hash
|
||||
|
||||
page = (output_dir / "photo" / first_hash / "index.html").read_text(encoding="utf-8")
|
||||
assert f'content="https://lumeex.djeex.fr/img/share/{first_hash}.jpg"' in page
|
||||
assert 'content="noindex, follow"' in page
|
||||
assert (output_dir / "img" / "share" / f"{first_hash}.jpg").exists()
|
||||
assert len(list((output_dir / "photo").iterdir())) == len(set(ids))
|
||||
assert "/photo/" not in (output_dir / "sitemap.xml").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -102,7 +102,13 @@ def test_site_info_get_and_post(client, app_env):
|
||||
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"}})
|
||||
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"
|
||||
@@ -271,9 +277,7 @@ def test_trigger_build_success_invokes_subprocess(client, app_env, monkeypatch):
|
||||
)
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
webui.subprocess, "run", lambda *a, **k: calls.append((a, k))
|
||||
)
|
||||
monkeypatch.setattr(webui.subprocess, "run", lambda *a, **k: calls.append((a, k)))
|
||||
|
||||
resp = client.post("/api/build")
|
||||
assert resp.get_json() == {"status": "ok"}
|
||||
|
||||
Reference in New Issue
Block a user