55 Commits
Author SHA1 Message Date
Djeex 88754bf907 Add a right-click copy link and share menu on gallery photos with stable per-photo permalinks
CI / build-and-scan (pull_request) Successful in 2m9s
2026-09-23 15:46:35 +02:00
Djeex 6c0a169528 Keep shell scripts LF on Windows checkouts and un-ignore .gitattributes 2026-09-23 15:46:35 +02:00
Djeex f96ce2f8c9 Update dependency pytest to v9 (#35)
CI / build-and-scan (push) Successful in 49s
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [pytest](https://github.com/pytest-dev/pytest) ([changelog](https://docs.pytest.org/en/stable/changelog.html)) | major | `==8.4.2` -> `==9.1.1` |

---

### Release Notes

<details>
<summary>pytest-dev/pytest (pytest)</summary>

### [`v9.1.1`](https://github.com/pytest-dev/pytest/releases/tag/9.1.1)

[Compare Source](https://github.com/pytest-dev/pytest/compare/9.1.0...9.1.1)

### pytest 9.1.1 (2026-06-19)

#### Bug fixes

-   [#&#8203;14220](https://github.com/pytest-dev/pytest/issues/14220): Fixed a logic bug in `pytest.RaisesGroup` which would might cause it to display incorrect "It matches <span class="title-ref">FooError()</span> which was paired with <span class="title-ref">BarError</span>" messages.
-   [#&#8203;14591](https://github.com/pytest-dev/pytest/issues/14591): Fixed a regression in pytest 9.1.0 which caused overriding a parametrized fixture with an indirect <span class="title-ref">[@&#8203;pytest](https://github.com/pytest).mark.parametrize</span> to fail with "duplicate parametrization of '\<fixture name>'".
-   [#&#8203;14606](https://github.com/pytest-dev/pytest/issues/14606): Fixed `list-item` typing errors from mypy in `@pytest.mark.parametrize <pytest.mark.parametrize ref>` `argvalues` parameter.
-   [#&#8203;14608](https://github.com/pytest-dev/pytest/issues/14608): Fixed a regression in pytest 9.1.0 where `conftest.py` files located in `<invocation dir>/test*` were no longer loaded as initial conftests when invoked without arguments.
    This could cause certain hooks (like `pytest_addoption`) in these files to not fire.

### [`v9.1.0`](https://github.com/pytest-dev/pytest/releases/tag/9.1.0)

[Compare Source](https://github.com/pytest-dev/pytest/compare/9.0.3...9.1.0)

### pytest 9.1.0 (2026-06-13)

#### Removals and backward incompatible breaking changes

-   [#&#8203;14533](https://github.com/pytest-dev/pytest/issues/14533): When using `--doctest-modules`, autouse fixtures with `module`, `package` or `session` scope that are defined inline in Python test modules (not plugins or conftests) will now possibly execute twice.

    If this is undesirable, move the fixture definition to a `conftest.py` file if possible.

    Technical explanation for those interested:
    When using <span class="title-ref">--doctest-modules</span>, pytest possibly collects Python modules twice, once as `pytest.Module` and once as a `DoctestModule` (depending on the configuration).
    Due to improvements in pytest's fixture implementation, if e.g. the `DoctestModule` collects a fixture, it is now visible to it only, and not to the `Module`.
    This means that both need to register the fixtures independently.

#### Deprecations (removal in next major release)

-   [#&#8203;10819](https://github.com/pytest-dev/pytest/issues/10819): Added a deprecation warning for class-scoped fixtures defined as instance methods (without `@classmethod`). Such fixtures set attributes on a different instance than the test methods use, leading to unexpected behavior. Use `@classmethod` decorator instead -- by `yastcher`.

    See `10819` and `14011`.

-   [#&#8203;12882](https://github.com/pytest-dev/pytest/issues/12882): Calling `request.getfixturevalue() <pytest.FixtureRequest.getfixturevalue>` during teardown to request a fixture that was not already requested is now deprecated and will become an error in pytest 10.

    See `dynamic-fixture-request-during-teardown` for details.

-   [#&#8203;13409](https://github.com/pytest-dev/pytest/issues/13409): Using non-`~collections.abc.Collection` iterables (such as generators, iterators, or custom iterable objects) for the `argvalues` parameter in `@pytest.mark.parametrize <pytest.mark.parametrize ref>` and `metafunc.parametrize <pytest.Metafunc.parametrize>` is now deprecated.

    These iterables get exhausted after the first iteration,
    leading to tests getting unexpectedly skipped in cases such as running `pytest.main()` multiple times,
    using class-level parametrize decorators,
    or collecting tests multiple times.

    See `parametrize-iterators` for details and suggestions.

-   [#&#8203;13946](https://github.com/pytest-dev/pytest/issues/13946): The private `config.inicfg` attribute is now deprecated.
    Use `config.getini() <pytest.Config.getini>` to access configuration values instead.

    See `config-inicfg` for more details.

-   [#&#8203;14004](https://github.com/pytest-dev/pytest/issues/14004): Passing `baseid` to `~pytest.FixtureDef` or `nodeid` strings to fixture registration APIs is now deprecated. These are internal pytest APIs that are used by some plugins.

    Use the `node` parameter instead for fixture scoping. This enables more robust node-based
    matching instead of string prefix matching.
    If you've used `nodeid=None`, pass `node=session` instead.

    This will be removed in pytest 10.

-   [#&#8203;14335](https://github.com/pytest-dev/pytest/issues/14335): The method of configuring hooks using markers, deprecated since pytest 7.2, is now scheduled to be removed in pytest 10.
    See `hook-markers` for more details.

-   [#&#8203;14434](https://github.com/pytest-dev/pytest/issues/14434): The `--pastebin` option is now deprecated.
    The same functionality is now available in an external plugin, `pytest-pastebin`.
    See `pastebin-deprecated` for more details.

-   [#&#8203;14513](https://github.com/pytest-dev/pytest/issues/14513): The private `FixtureDef.has_location` attribute is now deprecated and will be removed in pytest 10.
    See `fixturedef-has-location-deprecated` for details.

-   [#&#8203;1764](https://github.com/pytest-dev/pytest/issues/1764): `pytest.console_main` is now deprecated and will be removed in pytest 10.
    It was never intended for programmatic use; use `pytest.main` instead.

#### New features

-   [#&#8203;12376](https://github.com/pytest-dev/pytest/issues/12376): Added `pytest.register_fixture()` to register fixtures using an imperative interface.

    This is an advanced function intended for use by plugins.

    Normally, fixtures should be registered declaratively using the `@pytest.fixture <pytest.fixture>` decorator.
    Pytest looks for these fixture definitions during the collection phase and registers them automatically.
    For some plugin usecases the declarative interface can be cumbersome or unviable, in which case this imperative interface can be used.

-   [#&#8203;14023](https://github.com/pytest-dev/pytest/issues/14023): Added <span class="title-ref">--report-chars</span> long CLI option.

-   [#&#8203;14371](https://github.com/pytest-dev/pytest/issues/14371): Added `--max-warnings` command-line option and `max_warnings` configuration option to fail the test run when the number of warnings exceeds a given threshold -- by `miketheman`.

-   [#&#8203;6757](https://github.com/pytest-dev/pytest/issues/6757): Added the `assertion_text_diff_style` configuration option, allowing
    string equality failures to be rendered as separate `Left:` and `Right:`
    blocks instead of `ndiff` output.

-   [#&#8203;8395](https://github.com/pytest-dev/pytest/issues/8395): Added support for `~datetime.datetime` and `~datetime.timedelta` comparisons with `pytest.approx`. An explicit `abs` or `rel` tolerance as a `~datetime.timedelta` is required and relative tolerance is not supported for datetime comparisons -- by `hamza-mobeen`.

#### Improvements in existing functionality

-   [#&#8203;11225](https://github.com/pytest-dev/pytest/issues/11225): `pytest.warns` now shows "Regex pattern did not match" instead of "DID NOT WARN" when warnings were emitted but the `match` pattern did not match.

-   [#&#8203;11295](https://github.com/pytest-dev/pytest/issues/11295): Improved output of `--fixtures-per-test` by excluding internal-implementation fixtures generated by `@pytest.mark.parametrize` and similar.

-   [#&#8203;13241](https://github.com/pytest-dev/pytest/issues/13241): `pytest.raises`, `pytest.warns` and `pytest.deprecated_call` now uses `ParamSpec` for the type hint to the (old and not recommended) callable overload, instead of `Any`. This allows type checkers to raise errors when passing incorrect function parameters.
    `func` can now also be passed as a kwarg, which the type hint previously showed as possible but didn't accept.

-   [#&#8203;13862](https://github.com/pytest-dev/pytest/issues/13862): Improved the readability of "DID NOT RAISE" error messages by using the exception type's name instead of its <span class="title-ref">repr</span>.

-   [#&#8203;14026](https://github.com/pytest-dev/pytest/issues/14026): Added test coverage for compiled regex patterns in `pytest.raises` match parameter.

-   [#&#8203;14137](https://github.com/pytest-dev/pytest/issues/14137): <span class="title-ref">pytest.ScopeName</span> is now public to allow using it in function signatures.

-   [#&#8203;14342](https://github.com/pytest-dev/pytest/issues/14342): Marked `yield_fixture` as deprecated to type checkers using the `deprecated` decorator. Note it
    `has originally been deprecated <yield-fixture-deprecated>` in pytest 6.2 already.

-   [#&#8203;14373](https://github.com/pytest-dev/pytest/issues/14373): Added type annotations for `pytest.approx`.

-   [#&#8203;14430](https://github.com/pytest-dev/pytest/issues/14430): When using `--setup-show`, a space is now printed after the test name (and possibly used fixtures), to separate it from the test result.

-   [#&#8203;14441](https://github.com/pytest-dev/pytest/issues/14441): Reduced the default number of `gc.collect()` passes in the `unraisableexception` plugin from 5 to 1 on CPython, where reference counting makes a single pass sufficient. PyPy retains 5 passes due to object resurrection via `__del__`. This can noticeably speed up test suites that trigger many pytester runs.

-   [#&#8203;14461](https://github.com/pytest-dev/pytest/issues/14461): Improved assertion failure explanations for equality comparisons between mapping objects that are not `dict` instances.

-   [#&#8203;14513](https://github.com/pytest-dev/pytest/issues/14513): The order in which fixture definitions overriding each other are resolved is now determined first by their *visibility* in the collection tree rather than by the order in which they are registered.

    A fixture defined for a more specific node (e.g. a module or an item) now always takes precedence over one with the same name defined for a more general node (e.g. the session), even when the more general one was registered later.
    Fixtures with non-comparable visibility or the same visibility keep the existing behavior of "last registered wins".
    This change is supposed to only affect plugins which register multiple fixtures programmatically with the same name.

-   [#&#8203;14524](https://github.com/pytest-dev/pytest/issues/14524): Add official Python 3.15 support.

-   [#&#8203;1764](https://github.com/pytest-dev/pytest/issues/1764): Improved argparse program name to show `pytest`, `python -m pytest`, or `pytest.main()` based on how pytest was invoked, making help and error messages clearer.

-   [#&#8203;8265](https://github.com/pytest-dev/pytest/issues/8265): Emit a `PytestCollectionWarning` when a module-level `__getattr__` returns `None` for `pytestmark` instead of raising `AttributeError`.

    Previously this caused a cryptic `TypeError: got None instead of Mark` error.
    Now pytest issues a helpful warning and continues collecting the module normally.

#### Bug fixes

-   [#&#8203;13192](https://github.com/pytest-dev/pytest/issues/13192): Fixed <span class="title-ref">|</span> (pipe) not being treated as a regex meta-character that needs escaping in `pytest.raises(match=...) <pytest.raises>`.

-   [#&#8203;13484](https://github.com/pytest-dev/pytest/issues/13484): Fixed `-W` option values being duplicated in `Config.known_args_namespace`.

-   [#&#8203;13626](https://github.com/pytest-dev/pytest/issues/13626): Fixed function-scoped fixture values being kept alive after a test was interrupted by `KeyboardInterrupt` or early exit,
    allowing them to potentially be released more promptly.

-   [#&#8203;13784](https://github.com/pytest-dev/pytest/issues/13784): Fixed `capteesys` producing doubled output when used with `--capture=no` (`-s`).

-   [#&#8203;13817](https://github.com/pytest-dev/pytest/issues/13817): Fixed a secondary <span class="title-ref">AttributeError</span> masking the original error when an option argument fails to initialize.

-   [#&#8203;13884](https://github.com/pytest-dev/pytest/issues/13884): Fixed rare internal IndexError caused by <span class="title-ref">builtins.compile</span> being overridden in client code.

-   [#&#8203;13885](https://github.com/pytest-dev/pytest/issues/13885): Fixed autouse fixtures defined inside a `unittest.TestCase` class running even when the class is decorated with `unittest.skip` or `unittest.skipIf` -- regression since pytest 8.1.0.

-   [#&#8203;13917](https://github.com/pytest-dev/pytest/issues/13917): `unittest.SkipTest` is no longer considered an interactive exception, i.e. `pytest_exception_interact` is no longer called for it.

-   [#&#8203;13963](https://github.com/pytest-dev/pytest/issues/13963): Fixed subtests running with `pytest-xdist` when their contexts contain objects that are not JSON-serializable.

    Fixes [pytest-dev/pytest-xdist#1273](https://github.com/pytest-dev/pytest-xdist/issues/1273).

-   [#&#8203;14004](https://github.com/pytest-dev/pytest/issues/14004): Fixed conftest.py fixture scoping when `testpaths` points outside of the `rootdir <rootdir>`.

    Previously, fixtures from nested conftest.py files would incorrectly leak to sibling directories
    when using a relative `testpaths` like `../tests/sdk`.

    Conftest fixtures are now parsed during `Directory <pytest.Directory>` collection, using the `Directory` node for proper scoping.

-   [#&#8203;14050](https://github.com/pytest-dev/pytest/issues/14050): Display dictionary differences in assertion failures using the original key insertion order instead of sorted order.

-   [#&#8203;14080](https://github.com/pytest-dev/pytest/issues/14080): fix missing type annotations on `Pytester.makepyfile` and `Pytester.maketxtfile` methods.

-   [#&#8203;14114](https://github.com/pytest-dev/pytest/issues/14114): An exception from `pytest_fixture_post_finalizer` no longer prevents fixtures from being torn down, causing additional errors in the following tests.

-   [#&#8203;14161](https://github.com/pytest-dev/pytest/issues/14161): Fixed `monkeypatch.setattr() <pytest.MonkeyPatch.setattr>` leaving a stale entry on the undo stack when the underlying `setattr()` call fails (e.g. on immutable targets), causing an `AttributeError` crash during teardown.

-   [#&#8203;14214](https://github.com/pytest-dev/pytest/issues/14214): Fixed `-v` hint in `pytest.raises` match diff not working because assertion verbosity was not propagated.

-   [#&#8203;14234](https://github.com/pytest-dev/pytest/issues/14234): Allow `pytest.HIDDEN_PARAM <hidden-param>` in `@pytest.mark.parametrize(ids=...) <pytest.mark.parametrize ref>` typing.

-   [#&#8203;14248](https://github.com/pytest-dev/pytest/issues/14248): Fixed direct parametrization causing the static fixture closure (as reflected in `request.fixturenames <pytest.FixtureRequest.fixturenames>`) to omit fixtures that are requested transitively from overridden fixtures.

-   [#&#8203;14263](https://github.com/pytest-dev/pytest/issues/14263): Unraisable exceptions from finalizers are now collected during `pytest_unconfigure`, before pytest tears down the warning filters installed for the session. Previously the collection ran from a cleanup callback whose order relative to other plugins' cleanups was not guaranteed, so an active `error` filter could be removed before the exception surfaced and a late resource leak would pass silently. A `-W error` filter, or any filter matching `pytest.PytestUnraisableExceptionWarning`, now promotes these exceptions to failures regardless of plugin cleanup order.

-   [#&#8203;14377](https://github.com/pytest-dev/pytest/issues/14377): Fixed crash in <span class="title-ref">Config.get_terminal_writer</span> when an assertion fails with the `terminalreporter` plugin disabled.

-   [#&#8203;14381](https://github.com/pytest-dev/pytest/issues/14381): Fixed `-V` (short form of `--version`) to properly display the current version.

-   [#&#8203;14389](https://github.com/pytest-dev/pytest/issues/14389): Improved `pytest.raises(..., match=...) <pytest.raises>` failures to suppress the mismatched exception as a cause of the resulting `AssertionError`.

-   [#&#8203;14392](https://github.com/pytest-dev/pytest/issues/14392): Fixed a bug in `pytest.raises(match=...) <pytest.raises>` "fully escaped" detection, causing the regex diff display to be shown in some instances when the raw string diff display should be shown instead.

-   [#&#8203;14442](https://github.com/pytest-dev/pytest/issues/14442): Fixed a regression in pytest 9.0 where `--strict-markers` and `--strict-config` specified through `addopts` were silently ignored.

    Note that when targeting pytest >= 9.0, it's nicer to use `strict_markers` and `strict_config`, or `strict mode <strict mode>`.

-   [#&#8203;14456](https://github.com/pytest-dev/pytest/issues/14456): Fixed `pytest.approx` not recognizing types with `__array_interface__` as numpy-like arrays.

-   [#&#8203;14474](https://github.com/pytest-dev/pytest/issues/14474): Fixed a regression where `-k` and `-m` expressions containing both backslash characters in identifiers and string literal arguments would incorrectly raise a `SyntaxError` about escaping.

-   [#&#8203;14483](https://github.com/pytest-dev/pytest/issues/14483): Fixed JUnit XML report incorrectly escaping high Unicode codepoints (supplementary plane characters like emoji) in test failure messages. -- by `EternalRights`

-   [#&#8203;14492](https://github.com/pytest-dev/pytest/issues/14492): Fixed `Code.getargs()` incorrectly including local variable names in the returned argument tuple for functions with `*args` and/or `**kwargs`. The method was using `co_flags` bitmask values (`4` and `8`) directly as counts instead of converting them to `1` via `bool()`, and was not accounting for `co_kwonlyargcount` when `var=True`.

-   [#&#8203;3697](https://github.com/pytest-dev/pytest/issues/3697): Logging capture now works for non-propagating loggers.
    Previously only logs which reached the root logger were captured.
    This includes `caplog` and the "Captured log calls" test reporting.

-   [#&#8203;3850](https://github.com/pytest-dev/pytest/issues/3850): Fixed JUnit XML report: the `tests` attribute of the `<testsuite>` element now always matches the number of `<testcase>` elements in the file. In some cases (test passes but fails during teardown) the `tests` attribute would report an incorrect number of testcases in the XML file.

-   [#&#8203;5848](https://github.com/pytest-dev/pytest/issues/5848): `pytest_fixture_post_finalizer` is no longer called extra times for the same fixture teardown in some cases.

-   [#&#8203;719](https://github.com/pytest-dev/pytest/issues/719): Fixed `@pytest.mark.parametrize <pytest.mark.parametrize ref>` not unpacking single-element tuple values when using a string argnames with a trailing comma (e.g., `"arg,"`).

    The trailing comma form now correctly behaves like the tuple form `("arg",)`, treating argvalues as a list of tuples to unpack.

#### Improved documentation

-   [#&#8203;11022](https://github.com/pytest-dev/pytest/issues/11022): Document safer alternatives and scope guidance for monkeypatching standard library functions.
-   [#&#8203;11307](https://github.com/pytest-dev/pytest/issues/11307): Document that `@pytest.hookimpl(specname=...)` only works for function names starting with `pytest_`.
-   [#&#8203;13038](https://github.com/pytest-dev/pytest/issues/13038): Document that doctests do not support parametrized fixtures, including parametrized autouse fixtures.
-   [#&#8203;13155](https://github.com/pytest-dev/pytest/issues/13155): Clarified how the `request` fixture provides indirect parametrization values via `request.param`.
-   [#&#8203;13304](https://github.com/pytest-dev/pytest/issues/13304): Clarified in the documentation that hook implementations defined in `conftest.py` files are not available to other plugins during their `pytest_addoption()` execution, as conftest files are discovered and loaded after builtin and third-party plugins have been initialized. However, initial conftest files themselves can implement `pytest_addoption()` to add their own command-line options.
-   [#&#8203;13902](https://github.com/pytest-dev/pytest/issues/13902): Clarified how subtest progress markers are shown in the documentation.
-   [#&#8203;14012](https://github.com/pytest-dev/pytest/issues/14012): The `ini options ref` section of the API Reference now specified the type and default value of every configuration option.
-   [#&#8203;14148](https://github.com/pytest-dev/pytest/issues/14148): Documented a safe `pytestconfig.cache` access pattern when the
    `cacheprovider` plugin is disabled.
-   [#&#8203;14303](https://github.com/pytest-dev/pytest/issues/14303): The documentation is now built with Sphinx >= 9.
-   [#&#8203;14465](https://github.com/pytest-dev/pytest/issues/14465): Updated the hooks how-to page to link the `newhooks.py` file in `pytest-xdist` at tag `v3.8.0` instead of an unrelated 2017-era commit under the old layout. Pointing at a tag keeps the example in sync with the version users actually install, while remaining stable when the project's main branch moves on.

#### Miscellaneous internal changes

-   [#&#8203;14582](https://github.com/pytest-dev/pytest/issues/14582): Improved the recursion traceback test to exercise all requested traceback styles.

### [`v9.0.3`](https://github.com/pytest-dev/pytest/releases/tag/9.0.3)

[Compare Source](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3)

### pytest 9.0.3 (2026-04-07)

#### Bug fixes

-   [#&#8203;12444](https://github.com/pytest-dev/pytest/issues/12444): Fixed `pytest.approx` which now correctly takes into account `~collections.abc.Mapping` keys order to compare them.

-   [#&#8203;13634](https://github.com/pytest-dev/pytest/issues/13634): Blocking a `conftest.py` file using the `-p no:` option is now explicitly disallowed.

    Previously this resulted in an internal assertion failure during plugin loading.

    Pytest now raises a clear `UsageError` explaining that conftest files are not plugins and cannot be disabled via `-p`.

-   [#&#8203;13734](https://github.com/pytest-dev/pytest/issues/13734): Fixed crash when a test raises an exceptiongroup with `__tracebackhide__ = True`.

-   [#&#8203;14195](https://github.com/pytest-dev/pytest/issues/14195): Fixed an issue where non-string messages passed to <span class="title-ref">unittest.TestCase.subTest()</span> were not printed.

-   [#&#8203;14343](https://github.com/pytest-dev/pytest/issues/14343): Fixed use of insecure temporary directory (CVE-2025-71176).

#### Improved documentation

-   [#&#8203;13388](https://github.com/pytest-dev/pytest/issues/13388): Clarified documentation for `-p` vs `PYTEST_PLUGINS` plugin loading and fixed an incorrect `-p` example.
-   [#&#8203;13731](https://github.com/pytest-dev/pytest/issues/13731): Clarified that capture fixtures (e.g. `capsys` and `capfd`) take precedence over the `-s` / `--capture=no` command-line options in `Accessing captured output from a test function <accessing-captured-output>`.
-   [#&#8203;14088](https://github.com/pytest-dev/pytest/issues/14088): Clarified that the default `pytest_collection` hook sets `session.items` before it calls `pytest_collection_finish`, not after.
-   [#&#8203;14255](https://github.com/pytest-dev/pytest/issues/14255): TOML integer log levels must be quoted: Updating reference documentation.

#### Contributor-facing changes

-   [#&#8203;12689](https://github.com/pytest-dev/pytest/issues/12689): The test reports are now published to Codecov from GitHub Actions.
    The test statistics is visible [on the web interface](https://app.codecov.io/gh/pytest-dev/pytest/tests).

    \-- by `aleguy02`

### [`v9.0.2`](https://github.com/pytest-dev/pytest/releases/tag/9.0.2)

[Compare Source](https://github.com/pytest-dev/pytest/compare/9.0.1...9.0.2)

### pytest 9.0.2 (2025-12-06)

#### Bug fixes

-   [#&#8203;13896](https://github.com/pytest-dev/pytest/issues/13896): The terminal progress feature added in pytest 9.0.0 has been disabled by default, except on Windows, due to compatibility issues with some terminal emulators.

    You may enable it again by passing `-p terminalprogress`. We may enable it by default again once compatibility improves in the future.

    Additionally, when the environment variable `TERM` is `dumb`, the escape codes are no longer emitted, even if the plugin is enabled.

-   [#&#8203;13904](https://github.com/pytest-dev/pytest/issues/13904): Fixed the TOML type of the `tmp_path_retention_count` settings in the API reference from number to string.

-   [#&#8203;13946](https://github.com/pytest-dev/pytest/issues/13946): The private `config.inicfg` attribute was changed in a breaking manner in pytest 9.0.0.
    Due to its usage in the ecosystem, it is now restored to working order using a compatibility shim.
    It will be deprecated in pytest 9.1 and removed in pytest 10.

-   [#&#8203;13965](https://github.com/pytest-dev/pytest/issues/13965): Fixed quadratic-time behavior when handling `unittest` subtests in Python 3.10.

#### Improved documentation

-   [#&#8203;4492](https://github.com/pytest-dev/pytest/issues/4492): The API Reference now contains cross-reference-able documentation of `pytest's command-line flags <command-line-flags>`.

### [`v9.0.1`](https://github.com/pytest-dev/pytest/releases/tag/9.0.1)

[Compare Source](https://github.com/pytest-dev/pytest/compare/9.0.0...9.0.1)

### pytest 9.0.1 (2025-11-12)

#### Bug fixes

-   [#&#8203;13895](https://github.com/pytest-dev/pytest/issues/13895): Restore support for skipping tests via `raise unittest.SkipTest`.
-   [#&#8203;13896](https://github.com/pytest-dev/pytest/issues/13896): The terminal progress plugin added in pytest 9.0 is now automatically disabled when iTerm2 is detected, it generated desktop notifications instead of the desired functionality.
-   [#&#8203;13904](https://github.com/pytest-dev/pytest/issues/13904): Fixed the TOML type of the verbosity settings in the API reference from number to string.
-   [#&#8203;13910](https://github.com/pytest-dev/pytest/issues/13910): Fixed <span class="title-ref">UserWarning: Do not expect file_or_dir</span> on some earlier Python 3.12 and 3.13 point versions.

#### Packaging updates and notes for downstreams

-   [#&#8203;13933](https://github.com/pytest-dev/pytest/issues/13933): The tox configuration has been adjusted to make sure the desired
    version string can be passed into its `package_env` through
    the `SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYTEST` environment
    variable as a part of the release process -- by `webknjaz`.

#### Contributor-facing changes

-   [#&#8203;13891](https://github.com/pytest-dev/pytest/issues/13891), [#&#8203;13942](https://github.com/pytest-dev/pytest/issues/13942): The CI/CD part of the release automation is now capable of
    creating GitHub Releases without having a Git checkout on
    disk -- by `bluetech` and `webknjaz`.
-   [#&#8203;13933](https://github.com/pytest-dev/pytest/issues/13933): The tox configuration has been adjusted to make sure the desired
    version string can be passed into its `package_env` through
    the `SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYTEST` environment
    variable as a part of the release process -- by `webknjaz`.

### [`v9.0.0`](https://github.com/pytest-dev/pytest/releases/tag/9.0.0)

[Compare Source](https://github.com/pytest-dev/pytest/compare/8.4.2...9.0.0)

### pytest 9.0.0 (2025-11-05)

#### New features

-   [#&#8203;1367](https://github.com/pytest-dev/pytest/issues/1367): **Support for subtests** has been added.

    `subtests <subtests>` are an alternative to parametrization, useful in situations where the parametrization values are not all known at collection time.

    Example:

    ```python
    def contains_docstring(p: Path) -> bool:
        """Return True if the given Python file contains a top-level docstring."""
        ...

    def test_py_files_contain_docstring(subtests: pytest.Subtests) -> None:
        for path in Path.cwd().glob("*.py"):
            with subtests.test(path=str(path)):
                assert contains_docstring(path)
    ```

    Each assert failure or error is caught by the context manager and reported individually, giving a clear picture of all files that are missing a docstring.

    In addition, `unittest.TestCase.subTest` is now also supported.

    This feature was originally implemented as a separate plugin in [pytest-subtests](https://github.com/pytest-dev/pytest-subtests), but since then has been merged into the core.

    > \[!NOTE]
    > This feature is experimental and will likely evolve in future releases. By that we mean that we might change how subtests are reported on failure, but the functionality and how to use it are stable.

-   [#&#8203;13743](https://github.com/pytest-dev/pytest/issues/13743): Added support for **native TOML configuration files**.

    While pytest, since version 6, supports configuration in `pyproject.toml` files under `[tool.pytest.ini_options]`,
    it does so in an "INI compatibility mode", where all configuration values are treated as strings or list of strings.
    Now, pytest supports the native TOML data model.

    In `pyproject.toml`, the native TOML configuration is under the `[tool.pytest]` table.

    ```toml
    ```

### pyproject.toml

\[tool.pytest]
minversion = "9.0"
addopts = \["-ra", "-q"]
testpaths = \[
"tests",
"integration",
]

````

The `[tool.pytest.ini_options]` table remains supported, but both tables cannot be used at the same time.

If you prefer to use a separate configuration file, or don't use `pyproject.toml`, you can use `pytest.toml` or `.pytest.toml`:

``` toml

### pytest.toml or .pytest.toml
[pytest]
minversion = "9.0"
addopts = ["-ra", "-q"]
testpaths = [
    "tests",
    "integration",
]
````

The documentation now (sometimes) shows configuration snippets in both TOML and INI formats, in a tabbed interface.

See `config file formats` for full details.

-   [#&#8203;13823](https://github.com/pytest-dev/pytest/issues/13823): Added a **"strict mode"** enabled by the `strict` configuration option.

    When set to `true`, the `strict` option currently enables

    -   `strict_config`
    -   `strict_markers`
    -   `strict_parametrization_ids`
    -   `strict_xfail`

    The individual strictness options can be explicitly set to override the global `strict` setting.

    The previously-deprecated `--strict` command-line flag now enables strict mode.

    If pytest adds new strictness options in the future, they will also be enabled in strict mode.
    Therefore, you should only enable strict mode if you use a pinned/locked version of pytest,
    or if you want to proactively adopt new strictness options as they are added.

    See `strict mode` for more details.

-   [#&#8203;13737](https://github.com/pytest-dev/pytest/issues/13737): Added the `strict_parametrization_ids` configuration option.

    When set, pytest emits an error if it detects non-unique parameter set IDs,
    rather than automatically making the IDs unique by adding <span class="title-ref">0</span>, <span class="title-ref">1</span>, ... to them.
    This can be particularly useful for catching unintended duplicates.

-   [#&#8203;13072](https://github.com/pytest-dev/pytest/issues/13072): Added support for displaying test session **progress in the terminal tab** using the [OSC 9;4;](https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC) ANSI sequence.
    When pytest runs in a supported terminal emulator like ConEmu, Gnome Terminal, Ptyxis, Windows Terminal, Kitty or Ghostty,
    you'll see the progress in the terminal tab or window,
    allowing you to monitor pytest's progress at a glance.

    This feature is automatically enabled when running in a TTY. It is implemented as an internal plugin. If needed, it can be disabled as follows:

    -   On a user level, using `-p no:terminalprogress` on the command line or via an environment variable `PYTEST_ADDOPTS='-p no:terminalprogress'`.
    -   On a project configuration level, using `addopts = "-p no:terminalprogress"`.

-   [#&#8203;478](https://github.com/pytest-dev/pytest/issues/478): Support PEP420 (implicit namespace packages) as <span class="title-ref">--pyargs</span> target when `consider_namespace_packages` is <span class="title-ref">true</span> in the config.

    Previously, this option only impacted package imports, now it also impacts tests discovery.

-   [#&#8203;13678](https://github.com/pytest-dev/pytest/issues/13678): Added a new `faulthandler_exit_on_timeout` configuration option set to "false" by default to let <span class="title-ref">faulthandler</span> interrupt the <span class="title-ref">pytest</span> process after a timeout in case of deadlock.

    Previously, a <span class="title-ref">faulthandler</span> timeout would only dump the traceback of all threads to stderr, but would not interrupt the <span class="title-ref">pytest</span> process.

    \-- by `ogrisel`.

-   [#&#8203;13829](https://github.com/pytest-dev/pytest/issues/13829): Added support for configuration option aliases via the `aliases` parameter in `Parser.addini() <pytest.Parser.addini>`.

    Plugins can now register alternative names for configuration options,
    allowing for more flexibility in configuration naming and supporting backward compatibility when renaming options.
    The canonical name always takes precedence if both the canonical name and an alias are specified in the configuration file.

#### Improvements in existing functionality

-   [#&#8203;13330](https://github.com/pytest-dev/pytest/issues/13330): Having pytest configuration spread over more than one file (for example having both a `pytest.ini` file and `pyproject.toml` with a `[tool.pytest.ini_options]` table) will now print a warning to make it clearer to the user that only one of them is actually used.

    \-- by `sgaist`

-   [#&#8203;13574](https://github.com/pytest-dev/pytest/issues/13574): The single argument `--version` no longer loads the entire plugin infrastructure, making it faster and more reliable when displaying only the pytest version.

    Passing `--version` twice (e.g., `pytest --version --version`) retains the original behavior, showing both the pytest version and plugin information.

    > \[!NOTE]
    > Since `--version` is now processed early, it only takes effect when passed directly via the command line. It will not work if set through other mechanisms, such as `PYTEST_ADDOPTS` or `addopts`.

-   [#&#8203;13823](https://github.com/pytest-dev/pytest/issues/13823): Added `strict_xfail` as an alias to the `xfail_strict` option,
    `strict_config` as an alias to the `--strict-config` flag,
    and `strict_markers` as an alias to the `--strict-markers` flag.
    This makes all strictness options consistently have configuration options with the prefix `strict_`.

-   [#&#8203;13700](https://github.com/pytest-dev/pytest/issues/13700): <span class="title-ref">--junitxml</span> no longer prints the <span class="title-ref">generated xml file</span> summary at the end of the pytest session when <span class="title-ref">--quiet</span> is given.

-   [#&#8203;13732](https://github.com/pytest-dev/pytest/issues/13732): Previously, when filtering warnings, pytest would fail if the filter referenced a class that could not be imported. Now, this only outputs a message indicating the problem.

-   [#&#8203;13859](https://github.com/pytest-dev/pytest/issues/13859): Clarify the error message for <span class="title-ref">pytest.raises()</span> when a regex <span class="title-ref">match</span> fails.

-   [#&#8203;13861](https://github.com/pytest-dev/pytest/issues/13861): Better sentence structure in a test's expected error message. Previously, the error message would be "expected exception must be \<expected>, but got \<actual>". Now, it is "Expected \<expected>, but got \<actual>".

#### Removals and backward incompatible breaking changes

-   [#&#8203;12083](https://github.com/pytest-dev/pytest/issues/12083): Fixed a bug where an invocation such as <span class="title-ref">pytest a/ a/b</span> would cause only tests from <span class="title-ref">a/b</span> to run, and not other tests under <span class="title-ref">a/</span>.

    The fix entails a few breaking changes to how such overlapping arguments and duplicates are handled:

    1.  <span class="title-ref">pytest a/b a/</span> or <span class="title-ref">pytest a/ a/b</span> are equivalent to <span class="title-ref">pytest a</span>; if an argument overlaps another arguments, only the prefix remains.
    2.  <span class="title-ref">pytest x.py x.py</span> is equivalent to <span class="title-ref">pytest x.py</span>; previously such an invocation was taken as an explicit request to run the tests from the file twice.

    If you rely on these behaviors, consider using `--keep-duplicates <duplicate-paths>`, which retains its existing behavior (including the bug).

-   [#&#8203;13719](https://github.com/pytest-dev/pytest/issues/13719): Support for Python 3.9 is dropped following its end of life.

-   [#&#8203;13766](https://github.com/pytest-dev/pytest/issues/13766): Previously, pytest would assume it was running in a CI/CD environment if either of the environment variables <span class="title-ref">$CI</span> or <span class="title-ref">$BUILD_NUMBER</span> was defined;
    now, CI mode is only activated if at least one of those variables is defined and set to a *non-empty* value.

-   [#&#8203;13779](https://github.com/pytest-dev/pytest/issues/13779): **PytestRemovedIn9Warning deprecation warnings are now errors by default.**

    Following our plan to remove deprecated features with as little disruption as
    possible, all warnings of type `PytestRemovedIn9Warning` now generate errors
    instead of warning messages by default.

    **The affected features will be effectively removed in pytest 9.1**, so please consult the
    `deprecations` section in the docs for directions on how to update existing code.

    In the pytest `9.0.X` series, it is possible to change the errors back into warnings as a
    stopgap measure by adding this to your `pytest.ini` file:

    ```ini
    [pytest]
    filterwarnings =
        ignore::pytest.PytestRemovedIn9Warning
    ```

    But this will stop working when pytest `9.1` is released.

    **If you have concerns** about the removal of a specific feature, please add a
    comment to `13779`.

#### Deprecations (removal in next major release)

-   [#&#8203;13807](https://github.com/pytest-dev/pytest/issues/13807): `monkeypatch.syspath_prepend() <pytest.MonkeyPatch.syspath_prepend>` now issues a deprecation warning when the prepended path contains legacy namespace packages (those using `pkg_resources.declare_namespace()`).
    Users should migrate to native namespace packages (`420`).
    See `monkeypatch-fixup-namespace-packages` for details.

#### Bug fixes

-   [#&#8203;13445](https://github.com/pytest-dev/pytest/issues/13445): Made the type annotations of `pytest.skip` and friends more spec-complaint to have them work across more type checkers.

-   [#&#8203;13537](https://github.com/pytest-dev/pytest/issues/13537): Fixed a bug in which `ExceptionGroup` with only `Skipped` exceptions in teardown was not handled correctly and showed as error.

-   [#&#8203;13598](https://github.com/pytest-dev/pytest/issues/13598): Fixed possible collection confusion on Windows when short paths and symlinks are involved.

-   [#&#8203;13716](https://github.com/pytest-dev/pytest/issues/13716): Fixed a bug where a nonsensical invocation like `pytest x.py[a]` (a file cannot be parametrized) was silently treated as `pytest x.py`. This is now a usage error.

-   [#&#8203;13722](https://github.com/pytest-dev/pytest/issues/13722): Fixed a misleading assertion failure message when using `pytest.approx` on mappings with differing lengths.

-   [#&#8203;13773](https://github.com/pytest-dev/pytest/issues/13773): Fixed the static fixture closure calculation to properly consider transitive dependencies requested by overridden fixtures.

-   [#&#8203;13816](https://github.com/pytest-dev/pytest/issues/13816): Fixed `pytest.approx` which now returns a clearer error message when comparing mappings with different keys.

-   [#&#8203;13849](https://github.com/pytest-dev/pytest/issues/13849): Hidden `.pytest.ini` files are now picked up as the config file even if empty.
    This was an inconsistency with non-hidden `pytest.ini`.

-   [#&#8203;13865](https://github.com/pytest-dev/pytest/issues/13865): Fixed <span class="title-ref">--show-capture</span> with <span class="title-ref">--tb=line</span>.

-   [#&#8203;13522](https://github.com/pytest-dev/pytest/issues/13522): Fixed `pytester` in subprocess mode ignored all :attr\`pytester.plugins \<pytest.Pytester.plugins>\` except the first.

    Fixed `pytester` in subprocess mode silently ignored non-str `pytester.plugins <pytest.Pytester.plugins>`.
    Now it errors instead.
    If you are affected by this, specify the plugin by name, or switch the affected tests to use `pytester.runpytest_inprocess <pytest.Pytester.runpytest_inprocess>` explicitly instead.

#### Packaging updates and notes for downstreams

-   [#&#8203;13791](https://github.com/pytest-dev/pytest/issues/13791): Minimum requirements on `iniconfig` and `packaging` were bumped to `1.0.1` and `22.0.0`, respectively.

#### Contributor-facing changes

-   [#&#8203;12244](https://github.com/pytest-dev/pytest/issues/12244): Fixed self-test failures when <span class="title-ref">TERM=dumb</span>.
-   [#&#8203;12474](https://github.com/pytest-dev/pytest/issues/12474): Added scheduled GitHub Action Workflow to run Sphinx linkchecks in repo documentation.
-   [#&#8203;13621](https://github.com/pytest-dev/pytest/issues/13621): pytest's own testsuite now handles the `lsof` command hanging (e.g. due to unreachable network filesystems), with the affected selftests being skipped after 10 seconds.
-   [#&#8203;13638](https://github.com/pytest-dev/pytest/issues/13638): Fixed deprecated `gh pr new` command in `scripts/prepare-release-pr.py`.
    The script now uses `gh pr create` which is compatible with GitHub CLI v2.0+.
-   [#&#8203;13695](https://github.com/pytest-dev/pytest/issues/13695): Flush <span class="title-ref">stdout</span> and <span class="title-ref">stderr</span> in <span class="title-ref">Pytester.run</span> to avoid truncated outputs in <span class="title-ref">test_faulthandler.py::test_timeout</span> on CI -- by `ogrisel`.
-   [#&#8203;13771](https://github.com/pytest-dev/pytest/issues/13771): Skip <span class="title-ref">test_do_not_collect_symlink_siblings</span> on Windows environments without symlink support to avoid false negatives.
-   [#&#8203;13841](https://github.com/pytest-dev/pytest/issues/13841): `tox>=4` is now required when contributing to pytest.
-   [#&#8203;13625](https://github.com/pytest-dev/pytest/issues/13625): Added missing docstrings to `pytest_addoption()`, `pytest_configure()`, and `cacheshow()` functions in `cacheprovider.py`.

#### Miscellaneous internal changes

-   [#&#8203;13830](https://github.com/pytest-dev/pytest/issues/13830): Configuration overrides (`-o`/`--override-ini`) are now processed during startup rather than during `config.getini() <pytest.Config.getini>`.

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzOS4yNjQuMCIsInVwZGF0ZWRJblZlciI6IjM5LjI2NC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJib3QiLCJtYWpvciJdfQ==-->

Reviewed-on: https://git.djeex.fr/Djeex/lumeex/pulls/35
2026-08-31 09:17:12 +02:00
lumeex-ci c8f42b429a Bump build version to 2.1.5 [skip ci] 2026-08-26 13:41:23 +00:00
Djeex f22634e9d9 CI/CD hardening: lint, secret scan, coverage gate, auto CVE-fix PRs, GHCR + GitHub mirror publishing (#34)
CI / build-and-scan (push) Successful in 2m43s
- release changelog: commits rendered as description (link), divider lines dropped
- gitleaks secret scan and hadolint on every push/PR
- ruff lint/format gate (Python repos) with a pytest --cov-fail-under gate
- scheduled CRITICAL Trivy failures attempt an apk upgrade rebuild and open a follow-up PR if it clears the finding, instead of just failing red
- images also published to ghcr.io/djeex/<repo>
- a matching GitHub Release is created on the GitHub mirror, with a notice pointing back to this repo as the source of truth
2026-08-26 15:38:54 +02:00
lumeex-ci edd39febd1 Bump build version to 2.1.4 [skip ci] 2026-08-24 09:03:52 +00:00
Djeex 33766f0b0b Merge pull request 'Update python Docker tag to v3.14.7' (#32) from renovate/python-3.x into main
CI / build-and-scan (push) Successful in 1m39s
Reviewed-on: #32
2026-08-24 11:02:16 +02:00
Djeex f954ace53b Update python Docker tag to v3.14.7
CI / build-and-scan (pull_request) Successful in 1m7s
2026-08-24 11:00:55 +02:00
Djeex 6df2d561d9 Update dependency pytest-cov to v7.1.0
CI / build-and-scan (pull_request) Successful in 1m31s
CI / build-and-scan (push) Successful in 1m36s
2026-08-24 03:03:26 +00:00
Djeex 18c894f780 Merge pull request 'New release template' (#30) from dev-ci into main
CI / build-and-scan (push) Successful in 1m1s
Reviewed-on: #30
2026-08-23 23:35:50 +02:00
Djeex ca4240c84a Link each commit in the release changelog to its own commit page
CI / build-and-scan (pull_request) Successful in 1m13s
COMMIT_LIST only rendered the bare subject line per commit, with no
way to jump to that specific commit — only the triggering commit
(Source:) had a link. Each line now reads
"- [<short-sha>](<repo>/commit/<sha>) <subject>", matching the same
link style already used for Source.
2026-08-23 23:20:39 +02:00
Djeex 83d2dc7ee6 Match adguard-cidre's current template exactly (post 2026-08-23 update)
adguard-cidre switched its release changelog from a changed-files list
to a commits list (with --- dividers) after the previous commit here
was written from an already-stale read of that file. Also drops the
glob-based smoke test (never actually part of the real template, only
of nvidia-stock-bot's own drift) for an explicit per-file check
matching the canonical error-handling shape, and aligns the remaining
step names/commit message wording.
2026-08-23 23:09:35 +02:00
Djeex 8f4d763048 Align CI release workflow with the adguard-cidre/nvidia-stock-bot template
Pin Trivy to 0.74.0 instead of latest, make the deprecation check
non-blocking (warning only), log the release API's HTTP status code so
a failure is actually visible in the CI logs, drop the redundant
Docker socket mount on the Trivy steps, and switch the version-bump
push to the same URL-embedded-token approach and extraheader key the
other repos use. Also generalizes the smoke test to every top-level
.py file instead of a hardcoded list, and enriches the release body
(changelog header, source links, changed files) to match the other
repos' output.

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

Dockerfile gains a `test` stage (pytest) between the wheel builder
and the prod image, and pins the alpine base to a full patch tag so
Renovate can classify updates. CI workflow builds, smoke-tests, runs
the suite, scans with Trivy, and publishes/releases on merge to main,
following the same pipeline already running on adguard-cidre.
2026-08-23 10:24:02 +02:00
Djeex fc5b1c4234 Merge pull request 'Hotfix - HDR artefacts and ICC issues' (#27) from dev into main
Reviewed-on: #27
2026-08-17 15:27:39 +02:00
Djeex 2968e875a7 Add Lumeex-web link to README.MD 2026-08-17 15:20:06 +02:00
Djeex a7ce49b989 v2.1.2 2026-08-17 15:13:36 +02:00
Djeex 26e3ca684b Fixed: Preserve embedded ICC color profiles when converting and resizing images
Image.open() reads the source's ICC profile into img.info, but that dict resets to empty after convert() or resize(), and save() was never passing icc_profile anyway. DCI-P3 and Rec.2020 photos were silently falling back to sRGB interpretation once processed.
2026-08-17 15:13:36 +02:00
Djeex a938234495 Merge pull request 'Hotfix - Chrome HDR artefacting fix' (#26) from dev into main
Reviewed-on: #26
2026-08-16 23:06:36 +02:00
Djeex 193b1704f8 Chrome HDR artefacting fix 2026-08-16 22:43:02 +02:00
Djeex 9d56d729c1 v2.1.2 2026-08-16 22:43:02 +02:00
Djeex 40a01c4701 Merge pull request 'Multi-stage smaller alpine docker image and python 3.13' (#24) from alpine into main
Reviewed-on: #24
2025-09-19 22:51:22 +02:00
Djeex 28e3b087b0 Alpine - smaller image 2025-09-19 22:40:30 +02:00
Djeex 116a9574ce Merge branch 'Beta-2.1' 2025-09-04 11:23:59 +00:00
Djeex 6c7216c0e1 Hotfix - default copy 2025-09-04 13:18:42 +02:00
Djeex 7c98a6ceaf Merge pull request 'Beta-2.1 - The clearer, the faster' (#22) from Beta-2.1 into main
Reviewed-on: #22
2025-09-04 12:50:26 +02:00
Djeex 69d303c7a1 Refactored site-info 2025-09-04 12:28:01 +02:00
Djeex 1d1d7d8304 Refactored Galley Editor 2025-09-04 12:22:52 +02:00
Djeex 205dcae2bc Refactored upload.js 2025-09-04 12:16:08 +02:00
Djeex b7fdcacf77 Fixed wrong focus and suggestion drop down delay 2025-09-04 12:11:58 +02:00
Djeex 9cdf6bbd32 Fixed hero images 2025-09-04 11:10:04 +02:00
Djeex f92060603d Fixed custom font issue 2025-09-04 10:41:58 +02:00
Djeex 54e9281793 Fixed mobile theme css issue 2025-09-04 10:10:39 +02:00
Djeex 5fb6ef18d1 Fixed log issues 2025-09-04 10:00:57 +02:00
Djeex 1119647884 Fixed default file copy 2025-09-04 09:56:56 +02:00
Djeex 2cc0a213c9 Fixed laggy menu appearance 2025-09-04 00:24:54 +02:00
Djeex 24113a4aa8 Fixed build section status 2025-09-03 10:43:54 +02:00
Djeex be8ce4f62c Fixed message theme selector 2025-09-03 10:18:05 +02:00
Djeex edca473e29 New suggestion list css 2025-09-03 10:12:18 +02:00
Djeex eac90820d2 Fixed hero img fade in 2025-09-03 09:43:52 +02:00
Djeex 191fa82711 Cleaning 2025-09-02 23:44:18 +02:00
Djeex b17652e471 Fade in img 2025-09-02 23:10:23 +02:00
Djeex 65fd62e342 Untagged filter 2025-09-02 22:53:21 +02:00
Djeex 6b03ee30fa Fixes save menu issues 2025-09-02 21:58:58 +02:00
Djeex fd45ebbd53 Fixed UI and conf template 2025-09-02 20:40:02 +02:00
Djeex 795f5fbd13 Redone form save logic 2025-09-02 14:44:54 +02:00
Djeex f8bebb9c95 Fixed forms and key issues 2025-09-01 22:13:16 +00:00
Djeex 3198755576 Top save button + fixed some CSS 2025-09-01 16:46:43 +00:00
Djeex f98f2d598f Fixed footer icon and git link 2025-09-01 16:16:27 +00:00
Djeex e8718e71ab Fixed port 2025-09-01 14:59:13 +00:00
Djeex 5b65e5efe3 Better logging 2025-09-01 14:57:52 +00:00
Djeex 021e0c7974 Preview link 2025-09-01 14:37:31 +00:00
Djeex debbf07280 New bottom upload btn + images count 2025-08-31 14:34:22 +02:00
51 changed files with 3552 additions and 1002 deletions
+2
View File
@@ -0,0 +1,2 @@
# Shell scripts run inside Linux containers: keep LF even on Windows checkouts
*.sh text eol=lf
+285
View File
@@ -0,0 +1,285 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1"
jobs:
build-and-scan:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: Scan for secrets
run: |
# docker cp, not a build COPY: a repo's own .dockerignore (e.g. one that
# excludes .git for prod builds) would otherwise silently give an empty,
# falsely-clean scan.
CID=$(docker create zricethezav/gitleaks:v8.30.1 detect --source=/repo --no-banner -v)
docker cp . "$CID:/repo"
docker start -a "$CID"
STATUS=$?
docker rm "$CID" > /dev/null
exit $STATUS
- name: Lint Dockerfile with hadolint
run: docker run --rm -i hadolint/hadolint:v2.15.1-alpine hadolint --failure-threshold error - < Dockerfile
- name: Build Docker image
run: |
docker build -t lumeex:ci . 2>&1 | tee build.log
if grep -q "Building wheel for" build.log; then
echo "::warning::A dependency was built from source — check Python/Alpine compatibility"
fi
- name: Smoke test (syntax check)
run: |
docker run --rm --entrypoint python lumeex:ci -c "
import ast
ok = True
for f in ('build.py', 'gallery.py'):
with open(f) as fh:
source = fh.read()
try:
ast.parse(source)
except SyntaxError as e:
print(f'::error::Syntax error in {f}: {e}')
ok = False
if not ok:
exit(1)
print('OK: syntax is valid')
"
- name: Run unit tests
run: |
docker build --target test -t lumeex:test .
docker run --rm lumeex:test pytest -v --cov=. --cov-report=term-missing --cov-fail-under=90
- name: Lint with ruff
run: docker build --target lint -t lumeex:lint .
- name: Check deprecation warnings
run: |
docker run --rm --entrypoint python lumeex:ci -W error::DeprecationWarning -c "import src.py.webui.webui" 2>&1 | tee deprecation.log || true
if grep -qi "deprecat" deprecation.log; then
echo "::warning::Deprecation warning detected, check logs"
fi
- name: Scan with Trivy (critical - blocking)
id: trivy_critical
continue-on-error: true
run: |
docker run --rm \
-e DOCKER_HOST=tcp://dockerhost:2375 \
--add-host=dockerhost:host-gateway \
aquasec/trivy:0.74.0 image --exit-code 1 --severity CRITICAL lumeex:ci
- name: Handle CRITICAL findings
if: steps.trivy_critical.outcome == 'failure'
run: |
if [ "${{ github.event_name }}" != "schedule" ]; then
echo "::error::CRITICAL vulnerabilities found, failing the build."
exit 1
fi
echo "Scheduled scan found CRITICAL vulnerabilities — attempting an automatic apk upgrade + rescan."
sed -i '/^FROM .* AS base$/a RUN apk upgrade --no-cache' Dockerfile
docker build -t lumeex:remediated .
if docker run --rm \
-e DOCKER_HOST=tcp://dockerhost:2375 \
--add-host=dockerhost:host-gateway \
aquasec/trivy:0.74.0 image --exit-code 1 --severity CRITICAL lumeex:remediated; then
echo "apk upgrade clears the CRITICAL finding(s) — opening a PR for review."
BRANCH="auto/cve-fix-$(date +%Y%m%d)-$(echo "${{ github.sha }}" | cut -c1-7)"
git config user.name "lumeex-ci"
git config user.email "[email protected]"
git checkout -b "$BRANCH"
git add Dockerfile
git commit -m "Auto-remediate CRITICAL CVE via apk upgrade"
git config --unset-all http.https://git.djeex.fr/.extraheader || true
git push "https://Djeex:${{ secrets.CI_PUSH_TOKEN }}@git.djeex.fr/Djeex/lumeex.git" "HEAD:$BRANCH"
PR_JSON=$(curl -s -X POST \
-H "Authorization: token ${{ secrets.CI_PUSH_TOKEN }}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg head "$BRANCH" '{title: "🔒 Auto: remediate CRITICAL CVE via apk upgrade", head: $head, base: "main", body: "Opened automatically by the scheduled CVE scan. An `apk upgrade --no-cache` cleared the CRITICAL Trivy finding(s) in a rebuild — review the diff and merge to publish the fix."}')" \
"https://git.djeex.fr/api/v1/repos/Djeex/lumeex/pulls")
echo "PR API response: $(echo "$PR_JSON" | jq -r '.html_url // .message // "unknown"')"
else
echo "::error::apk upgrade does not clear the CRITICAL finding(s) — no automatic fix available, needs manual review."
exit 1
fi
- name: Scan with Trivy (high - informative)
run: |
docker run --rm \
-e DOCKER_HOST=tcp://dockerhost:2375 \
--add-host=dockerhost:host-gateway \
aquasec/trivy:0.74.0 image --exit-code 0 --severity HIGH lumeex:ci
- name: Publish tagged image
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
BEFORE="${{ github.event.before }}"
if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$BEFORE" 2>/dev/null; then
BASE_REF="$BEFORE"
else
BASE_REF="HEAD~1"
fi
CHANGED=$(git diff --name-only "$BASE_REF" "${{ github.sha }}")
echo "Changed files:"
echo "$CHANGED"
if ! echo "$CHANGED" | grep -qE '^(Dockerfile|requirements\.txt|VERSION|build\.py|gallery\.py)$|^(src|config)/|^docker/\.sh/entrypoint\.sh$'; then
echo "No container-relevant file changed, skipping publish."
exit 0
fi
if echo "$CHANGED" | grep -qE '^VERSION$'; then
echo "VERSION was manually edited in this push, using it as-is."
else
echo "VERSION untouched but container files changed, auto-bumping the build number (Z)."
OLD_VERSION=$(tr -d '[:space:]' < VERSION)
IFS='.' read -r MAJOR MINOR PATCH <<< "$OLD_VERSION"
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
echo "$NEW_VERSION" > VERSION
git config user.name "lumeex-ci"
git config user.email "[email protected]"
git add VERSION
git commit -m "Bump build version to $NEW_VERSION [skip ci]"
# Belt and suspenders: actions/checkout can leave its own ephemeral
# credential injected as an extraheader, which would silently override
# the URL-embedded token below. persist-credentials:false on checkout
# should already prevent this, but strip it here too just in case.
git config --unset-all http.https://git.djeex.fr/.extraheader || true
git push "https://Djeex:${{ secrets.CI_PUSH_TOKEN }}@git.djeex.fr/Djeex/lumeex.git" HEAD:main
fi
VERSION=$(tr -d '[:space:]' < VERSION)
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
MINOR_TAG="${MAJOR}.${MINOR}"
IMAGE=git.djeex.fr/djeex/lumeex
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.djeex.fr -u Djeex --password-stdin
# Retag the already-built, already-scanned image — never rebuild for publish,
# so what ships is byte-for-byte what Trivy just scanned.
docker tag lumeex:ci "$IMAGE:latest"
docker tag lumeex:ci "$IMAGE:$MINOR_TAG"
docker tag lumeex:ci "$IMAGE:$VERSION"
docker push "$IMAGE:latest"
docker push "$IMAGE:$MINOR_TAG"
docker push "$IMAGE:$VERSION"
GHCR_IMAGE=ghcr.io/djeex/lumeex
echo "${{ secrets.GH_TOKEN }}" | docker login ghcr.io -u Djeex --password-stdin
docker tag lumeex:ci "$GHCR_IMAGE:latest"
docker tag lumeex:ci "$GHCR_IMAGE:$MINOR_TAG"
docker tag lumeex:ci "$GHCR_IMAGE:$VERSION"
docker push "$GHCR_IMAGE:latest"
docker push "$GHCR_IMAGE:$MINOR_TAG"
docker push "$GHCR_IMAGE:$VERSION"
TRIGGER_MSG=$(git log -1 --format=%s "${{ github.sha }}")
PR_NUM=$(echo "$TRIGGER_MSG" | grep -oE '#[0-9]+' | head -1 | tr -d '#' || true)
CATEGORY="🔧 Maintenance"
CHANGE_TITLE="$TRIGGER_MSG"
if [ -n "$PR_NUM" ]; then
PR_JSON=$(curl -s -H "Authorization: token ${{ secrets.CI_PUSH_TOKEN }}" \
"https://git.djeex.fr/api/v1/repos/Djeex/lumeex/pulls/$PR_NUM")
PR_TITLE=$(echo "$PR_JSON" | jq -r '.title // empty' 2>/dev/null || true)
LABELS=$(echo "$PR_JSON" | jq -r '.labels[]?.name' 2>/dev/null || true)
if [ -n "$PR_TITLE" ]; then
CHANGE_TITLE="$PR_TITLE"
fi
if echo "$LABELS" | grep -qx 'bug'; then
CATEGORY="⚠️ Hotfix"
elif echo "$LABELS" | grep -qx 'major'; then
CATEGORY="💥 Breaking change"
elif echo "$LABELS" | grep -qx 'minor'; then
CATEGORY="✨ Update"
fi
fi
REPO_URL="https://git.djeex.fr/Djeex/lumeex"
COMMIT_LIST=$(git log --no-merges --format="- %s ([%h](${REPO_URL}/commit/%H))" "$BASE_REF".."${{ github.sha }}")
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
SOURCE_LINE="[${SHORT_SHA}](${REPO_URL}/commit/${{ github.sha }})"
if [ -n "$PR_NUM" ]; then
SOURCE_LINE="[#${PR_NUM}](${REPO_URL}/pulls/${PR_NUM}) · ${SOURCE_LINE}"
fi
BODY=$(cat <<EOF
## Changelog
### ${CATEGORY}
${CHANGE_TITLE}
**Source:** ${SOURCE_LINE}
**Image:** \`${IMAGE}:${VERSION}\`
**Commits:**
${COMMIT_LIST}
EOF
)
JSON_PAYLOAD=$(jq -n \
--arg tag "$VERSION" \
--arg name "$VERSION" \
--arg body "$BODY" \
'{tag_name: $tag, name: $name, target_commitish: "main", body: $body}')
curl -s -o /dev/null -w "Release API response: %{http_code}\n" -X POST \
-H "Authorization: token ${{ secrets.CI_PUSH_TOKEN }}" \
-H "Content-Type: application/json" \
-d "$JSON_PAYLOAD" \
"https://git.djeex.fr/api/v1/repos/Djeex/lumeex/releases"
MIRROR_NOTICE="_This github repo is a mirror of https://git.djeex.fr/Djeex/lumeex. You'll find full package, PR, history and release note there._"
GH_BODY=$(printf '%s\n\n%s' "$MIRROR_NOTICE" "$BODY")
GH_JSON_PAYLOAD=$(jq -n \
--arg tag "$VERSION" \
--arg name "$VERSION" \
--arg body "$GH_BODY" \
--arg sha "${{ github.sha }}" \
'{tag_name: $tag, name: $name, target_commitish: $sha, body: $body}')
GH_STATUS=0
for i in 1 2 3 4 5; do
GH_STATUS=$(curl -s -o /tmp/gh_release.json -w "%{http_code}" -X POST \
-H "Authorization: Bearer ${{ secrets.GH_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/json" \
-d "$GH_JSON_PAYLOAD" \
"https://api.github.com/repos/Djeex/lumeex/releases")
if [ "$GH_STATUS" = "201" ]; then
break
fi
echo "GitHub Release attempt $i failed (HTTP $GH_STATUS) — mirror may not have synced this commit yet, retrying in 15s..."
sleep 15
done
echo "GitHub Release API response: $GH_STATUS"
if [ "$GH_STATUS" != "201" ]; then
cat /tmp/gh_release.json 2>/dev/null || true
fi
+6
View File
@@ -1,5 +1,11 @@
.*
!.env
!.sh
!.gitignore
!.gitattributes
!.gitea
!.gitea/**
output/
__pycache__/
htmlcov/
site_output.zip
+26 -5
View File
@@ -1,10 +1,18 @@
FROM python:3.11-slim
FROM python:3.14.7-alpine AS builder
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 ./src/ ./src/
@@ -12,7 +20,20 @@ COPY ./config /app/default
COPY ./docker/.sh/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
RUN printf '#!/bin/sh\n/app/entrypoint.sh build\n' > /usr/local/bin/build && chmod +x /usr/local/bin/build && \
printf '#!/bin/sh\n/app/entrypoint.sh gallery\n' > /usr/local/bin/gallery && chmod +x /usr/local/bin/gallery
FROM base AS test
COPY requirements.txt requirements-dev.txt pytest.ini /app/
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY ./tests/ ./tests/
COPY ./demo/ ./demo/
FROM base 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"]
+2
View File
@@ -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.
👉 __Check the online version : [Lumeex-web](https://lumeex.djeex.fr)__
The project includes two thoughtfully designed themes—one modern, one minimalistic—both crafted to keep the spotlight on your photos:
- **Modern** — [View Demo](https://modern.djeex.fr)
+1 -1
View File
@@ -1 +1 @@
2.0.1
2.1.5
+1
View File
@@ -1,4 +1,5 @@
import logging
from src.py.builder.site_builder import build
if __name__ == "__main__":
+7 -10
View File
@@ -1,4 +1,3 @@
# Please change this by your settings.
info:
title:
subtitle:
@@ -9,7 +8,7 @@ info:
social:
instagram_url:
thumbnail:
thumbnail: ''
menu:
items:
@@ -18,19 +17,17 @@ menu:
footer:
copyright: Copyright © 2025
legal_link: '/legals/'
legal_link: /legals/
legal_label: Legal notice
# Build parameters
build:
theme: modern # choose a theme in config/theme folder
convert_images: true # true to enable image conversion
resize_images: true # true to enable image resizing
theme: modern
convert_images: true
resize_images: true
# Change this by your legals
legals:
hoster_name:
hoster_adress:
hoster_address:
hoster_contact:
intellectual_property:
- paragraph: ""
- paragraph: ''
+1 -4
View File
@@ -35,16 +35,13 @@ img, tag {
#footer {
box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5);
margin: auto;
}
@media (max-width: 768px) {
#footer {
box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5);
border-radius: 15px 15px 0 0;
max-width: 1140px;
margin: 0;
}
.tag {
@@ -53,7 +50,7 @@ img, tag {
.hero-background {
max-width: 90%;
margin: auto;
margin: 0 auto;
}
.back-button {
+21 -22
View File
@@ -1,32 +1,31 @@
#-----------------------------------#
# Modern theme for Lumeex #
# https://git.djeex.fr/Djeex/lumeex #
#-----------------------------------#
colors:
primary: '#0065a1'
primary: '#0065A1'
primary_dark: '#005384'
secondary: '#00b0f0'
accent: '#ffc700'
secondary: '#00B0F0'
accent: '#FFC700'
text_dark: '#616161'
background: '#ffffff'
browser_color: '#ffffff'
background: '#FFFFFF'
browser_color: '#FFFFFF'
favicon:
path: favicon.png
google_fonts:
- family: Lato
weights:
- '200'
- '400'
- '700'
- family: Montserrat
weights:
- '200'
- '400'
- '700'
fonts:
primary:
name: Lato
fallback: sans-serif
name: Lato
secondary:
name: Montserrat
fallback: serif
name: Montserrat
google_fonts:
- family: 'Lato'
weights:
- '200'
- '400'
- '700'
- family: Montserrat
weights:
- '200'
- '400'
- '700'
+7 -11
View File
@@ -1,21 +1,17 @@
#-----------------------------------#
# Typewriter theme for Lumeex #
# https://git.djeex.fr/Djeex/lumeex #
#-----------------------------------#
colors:
primary: '#0065a1'
accent: '#FFC700'
background: '#FFFFFF'
browser_color: '#FFFFFF'
primary: '#0065A1'
primary_dark: '#005384'
secondary: '#00b0f0'
accent: '#ffc700'
secondary: '#00B0F0'
text_dark: '#616161'
background: '#ffffff'
browser_color: '#ffffff'
favicon:
path: favicon.png
fonts:
primary:
name: Trixie
name: trixie
fallback: sans-serif
secondary:
name: Trixie
name: trixie
fallback: serif
+21 -22
View File
@@ -1,32 +1,31 @@
#-----------------------------------#
# Modern theme for Lumeex #
# https://git.djeex.fr/Djeex/lumeex #
#-----------------------------------#
colors:
primary: '#0065a1'
primary: '#0065A1'
primary_dark: '#005384'
secondary: '#00b0f0'
accent: '#ffc700'
secondary: '#00B0F0'
accent: '#FFC700'
text_dark: '#616161'
background: '#fff'
browser_color: '#fff'
background: '#FFFFFF'
browser_color: '#FFFFFF'
favicon:
path: favicon.png
google_fonts:
- family: Lato
weights:
- '200'
- '400'
- '700'
- family: Montserrat
weights:
- '200'
- '400'
- '700'
fonts:
primary:
name: Lato
fallback: sans-serif
name: Lato
secondary:
name: Montserrat
fallback: serif
name: Montserrat
google_fonts:
- family: ''
weights:
- '200'
- '400'
- '700'
- family: Montserrat
weights:
- '200'
- '400'
- '700'
+9 -11
View File
@@ -1,21 +1,19 @@
#-----------------------------------#
# Typewriter theme for Lumeex #
# https://git.djeex.fr/Djeex/lumeex #
#-----------------------------------#
colors:
primary: '#0065a1'
accent: '#FFC700'
background: '#FFFFFF'
browser_color: '#FFFFFF'
primary: '#0065A1'
primary_dark: '#005384'
secondary: '#00b0f0'
accent: '#ffc700'
secondary: '#00B0F0'
text_dark: '#616161'
background: '#fff'
browser_color: '#fff'
favicon:
path: favicon.png
fonts:
primary:
name: Trixie
name: trixie.woff
fallback: sans-serif
secondary:
name: Trixie
name: trixie.woff
fallback: serif
+2
View File
@@ -0,0 +1,2 @@
PREVIEW_PORT=3000
WEBUI_PORT=5000
+23 -18
View File
@@ -1,33 +1,36 @@
#!/bin/bash
#!/bin/sh
set -e
CYAN="\033[1;36m"
NC="\033[0m"
copy_default_config() {
echo "Checking configuration directory..."
if [ ! -d "/app/config" ]; then
echo "[~] Checking configuration directory..."
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
for file in /app/default/*; do
filename=$(basename "$file")
target="/app/config/$filename"
for src in $(find /app/default -mindepth 1); do
relpath="${src#/app/default/}"
target="/app/config/$relpath"
if [ ! -e "$target" ]; then
echo "Copying default config file: $filename"
cp -r "$file" "$target"
echo "[→] Copying: $relpath"
if [ -d "$src" ]; then
mkdir -p "$target"
cp -r "$src/" "$target/"
else
mkdir -p "$(dirname "$target")"
cp "$src" "$target"
fi
files_copied=true
fi
done
if [ "$files_copied" = true ]; then
echo "Default configuration files copied successfully."
echo "[✓] Default configuration files/folders copied successfully."
else
echo "No default files needed to be copied."
echo "[✓] No default files/folders needed to be copied."
fi
}
@@ -43,11 +46,13 @@ start_server() {
cat /tmp/build_logs_fifo >&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 &
SERVER_PID=$!
echo "Starting Lumeex Flask webui..."
echo "[~] Starting Lumeex Flask webui..."
python3 -u -m src.py.webui.webui &
WEBUI_PID=$!
@@ -72,15 +77,15 @@ fi
case "$1" in
build)
echo "Running build.py..."
echo "[~] Running build.py..."
python3 -u /app/build.py 2>&1 | tee /tmp/build_logs_fifo
;;
gallery)
echo "Running gallery.py..."
echo "[~] Running gallery.py..."
python3 -u /app/gallery.py 2>&1 | tee /tmp/build_logs_fifo2
;;
*)
echo "Unknown command: $1"
echo "[!] Unknown command: $1"
exec "$@"
;;
esac
+7 -2
View File
@@ -2,10 +2,15 @@ services:
lumeex:
container_name: lmx
build: ..
env_file:
- .env
environment:
- PREVIEW_PORT=${PREVIEW_PORT:-3000} # port for preview server - set it in .env file
- WEBUI_PORT=${WEBUI_PORT:-5000} # port for webui server - set it in .env file
volumes:
- ../config:/app/config # mount config directory
- ../output:/app/output # mount output directory
ports:
- "3000:3000"
- "5000:5000"
- "${PREVIEW_PORT:-3000}:3000"
- "${WEBUI_PORT:-5000}:5000"
+1
View File
@@ -1,4 +1,5 @@
import logging
from src.py.builder.gallery_builder import update_gallery, update_hero
if __name__ == "__main__":
+3
View File
@@ -0,0 +1,3 @@
[pytest]
pythonpath = .
testpaths = tests
+29
View File
@@ -0,0 +1,29 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"timezone": "Europe/Paris",
"labels": ["bot"],
"packageRules": [
{
"matchManagers": ["pip_requirements"],
"matchUpdateTypes": ["patch", "minor"],
"automerge": true
},
{
"matchManagers": ["dockerfile"],
"matchUpdateTypes": ["patch"],
"automerge": true
},
{
"matchUpdateTypes": ["major"],
"addLabels": ["major"]
},
{
"matchUpdateTypes": ["minor"],
"addLabels": ["minor"]
}
],
"vulnerabilityAlerts": {
"addLabels": ["bug"]
}
}
+3
View File
@@ -0,0 +1,3 @@
-r requirements.txt
pytest==9.1.1
pytest-cov==7.1.0
+15
View File
@@ -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"]
+8 -1
View File
@@ -9,7 +9,6 @@ window.addEventListener("DOMContentLoaded", () => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
console.log("Lazy-loading image:", img.dataset.src);
img.src = img.dataset.src;
img.onload = () => {
img.classList.add("loaded");
@@ -33,6 +32,14 @@ window.addEventListener("DOMContentLoaded", () => {
img.classList.add("loaded");
};
// Chrome HDR artefacting fix
const clearWillChange = (e) => {
if (e.propertyName !== "transform") return;
img.style.willChange = "auto";
img.removeEventListener("transitionend", clearWillChange);
};
img.addEventListener("transitionend", clearWillChange);
if (img.complete && img.naturalHeight !== 0) {
onLoad(); // already loaded
} else {
+187
View File
@@ -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();
});
+141 -5
View File
@@ -78,6 +78,8 @@ html,body {
font-weight: 400;
line-height:1.5;
color:var(--color-primary-dark);
display: flex;
flex-direction: column;
}
html {
@@ -179,8 +181,8 @@ h2 {
/* animation */
.appear {
-webkit-transition: all 0.3s;
transition: all 0.3s;
-webkit-transition: all 1s;
transition: all 1s;
opacity: 0;
-webkit-transform: translateY(20px);
transform: translateY(20px);
@@ -237,12 +239,23 @@ h2 {
/* Hero */
#hero {
min-height: 100vh;
flex: 1 0 auto;
display: flex;
flex-direction: column;
}
#hero .section {
height: 100%;
display: flex;
flex-direction: column;
width: 100%;
}
#hero .content-wrapper, #hero .section {
height:100%;
}
.hero-background {
height: 66%;
width: 100%;
@@ -303,7 +316,6 @@ h2 {
font-size: 22px;
}
.gallery {
padding-top: 15px;
}
@@ -319,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;
@@ -341,6 +473,11 @@ h2 {
/* Footer */
#footer {
margin: auto 0 0 0;
width: 100%;
}
.navigation {
text-align: center;
padding-top: 40px;
@@ -470,8 +607,7 @@ h2 {
#legals.content-wrapper {
max-width: 90%;
margin: auto;
margin-top: 50px;
margin: 50px auto;
}
.legals-content {
+9 -3
View File
@@ -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">'
+14 -14
View File
@@ -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([
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)")
+56 -3
View File
@@ -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>"""
+82 -5
View File
@@ -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:
@@ -11,6 +17,7 @@ def convert_and_resize_image(input_path, output_path, resize=True, max_width=114
return
img = Image.open(input_path)
icc_profile = img.info.get("icc_profile")
if img.mode != "RGB":
img = img.convert("RGB")
@@ -27,12 +34,16 @@ def convert_and_resize_image(input_path, output_path, resize=True, max_width=114
if fmt == "JPEG":
output_path = output_path.with_suffix(".jpg")
img.save(output_path, fmt, quality=90 if fmt == "JPEG" else 100)
save_kwargs = {"quality": 90 if fmt == "JPEG" else 100}
if icc_profile:
save_kwargs["icc_profile"] = icc_profile
img.save(output_path, fmt, **save_kwargs)
logging.info(f"[✓] Processed image: {input_path} → {output_path}")
except Exception as e:
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:
@@ -48,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:
@@ -66,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")
@@ -83,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)
@@ -93,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:
@@ -106,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)
+70 -11
View File
@@ -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)
@@ -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")
@@ -128,7 +156,9 @@ def build():
# 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
View File
@@ -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]:
+6 -2
View File
@@ -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):
"""
@@ -63,4 +68,3 @@ def upload_photo(section: str):
return {"status": "ok", "uploaded": uploaded}
return {"error": "No valid files uploaded"}, 400
+131 -40
View File
@@ -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,13 @@ 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))
# --- Config paths ---
SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
@@ -38,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"
@@ -65,16 +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)
return dict(lumeex_version=lumeex_version, preview_port=PREVIEW_PORT)
# --- Gallery & Hero API ---
@app.route("/gallery-editor")
@@ -82,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."""
@@ -103,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."""
@@ -112,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():
@@ -136,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."""
@@ -147,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."""
@@ -161,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."""
@@ -175,39 +208,57 @@ 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."""
data = request.json
new_data = request.json
with open(SITE_YAML) as f:
old_data = yaml.safe_load(f) or {}
def deep_merge(old, new):
for k, v in new.items():
if isinstance(v, dict) and isinstance(old.get(k), dict):
old[k] = deep_merge(old[k], v)
else:
old[k] = v
return old
merged = deep_merge(old_data, new_data)
with open(SITE_YAML, "w") as f:
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
yaml.safe_dump(merged, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"})
# --- Theme management ---
@app.route("/api/themes")
def list_themes():
@@ -216,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():
@@ -226,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."""
@@ -240,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"] = ""
@@ -248,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():
@@ -267,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."""
@@ -283,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."""
@@ -299,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")
@@ -311,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."""
@@ -332,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():
@@ -347,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."""
@@ -367,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"] = ""
@@ -375,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):
@@ -382,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():
@@ -396,7 +460,9 @@ def upload_font():
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True)
file.save(fonts_dir / file.filename)
return jsonify({"status": "ok", "filename": file.filename})
font_basename = Path(file.filename).stem
return jsonify({"status": "ok", "filename": font_basename})
@app.route("/api/font/remove", methods=["POST"])
def remove_font():
@@ -413,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():
@@ -426,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
@@ -434,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)
@@ -459,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():
"""
@@ -485,7 +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("[~] 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)
+28
View File
@@ -18,7 +18,16 @@
<button id="remove-all-hero" class="up-btn">🗑 Delete all</button>
</div>
<input type="file" id="upload-hero" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
<span id="hero-count" class="photo-count"></span>
<div id="hero"></div>
<input type="file" id="upload-hero-bottom" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
<div id="bottom-hero-upload" class="upload-actions-row bottom-action-row">
<span id="hero-count-bottom" class="photo-count"></span>
<label for="upload-hero-bottom" class="up-btn">
📸 Upload photos
</label>
<button id="remove-all-hero-bottom" class="up-btn bottom-remove-btn">🗑 Delete all</button>
</div>
</div>
<!-- Gallery Upload Section -->
@@ -31,8 +40,27 @@
</label>
<button id="remove-all-gallery" class="up-btn">🗑 Delete all</button>
</div>
<div class="filter-row">
<label>
<input type="radio" name="gallery-filter" id="show-all-radio" checked>
All
</label>
<label>
<input type="radio" name="gallery-filter" id="show-untagged-radio">
Untagged
</label>
</div>
<span id="gallery-count" class="photo-count"></span>
<input type="file" id="upload-gallery" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
<div id="gallery"></div>
<input type="file" id="upload-gallery-bottom" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
<div id="bottom-gallery-upload" class="upload-actions-row bottom-action-row">
<span id="gallery-count-bottom" class="photo-count"></span>
<label for="upload-gallery-bottom" class="up-btn">
📸 Upload photos
</label>
<button id="remove-all-gallery-bottom" class="up-btn bottom-remove-btn">🗑 Delete all</button>
</div>
</div>
<div class="section">
+12
View File
@@ -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
if (buildModal && buildModalClose) {
buildModalClose.onclick = () => {
+90 -78
View File
@@ -1,18 +1,29 @@
// --- Arrays to store gallery and hero images ---
let galleryImages = [];
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 ---
async function loadData() {
try {
const galleryRes = await fetch('/api/gallery');
galleryImages = await galleryRes.json();
galleryImages = await (await fetch('/api/gallery')).json();
updateAllTags();
renderGallery();
const heroRes = await fetch('/api/hero');
heroImages = await heroRes.json();
heroImages = await (await fetch('/api/hero')).json();
renderHero();
} catch(err) {
console.error(err);
@@ -24,22 +35,43 @@ async function loadData() {
function updateAllTags() {
allTags = [];
galleryImages.forEach(img => {
if (img.tags) img.tags.forEach(t => {
(img.tags || []).forEach(t => {
if (!allTags.includes(t)) allTags.push(t);
});
});
}
// --- Helper: update count and button visibility ---
function updateCountAndButtons(prefix, count) {
const countTop = document.getElementById(`${prefix}-count`);
const countBottom = document.getElementById(`${prefix}-count-bottom`);
if (countTop) countTop.innerHTML = `<p>${count} photos</p>`;
if (countBottom) countBottom.innerHTML = `<p>${count} photos</p>`;
const removeAllBtn = document.getElementById(`remove-all-${prefix}`);
const removeAllBtnBottom = document.getElementById(`remove-all-${prefix}-bottom`);
if (removeAllBtn) removeAllBtn.style.display = count > 0 ? 'inline-block' : 'none';
if (removeAllBtnBottom) removeAllBtnBottom.style.display = count > 0 ? 'inline-block' : 'none';
const bottomUpload = document.getElementById(`bottom-${prefix}-upload`);
if (bottomUpload) bottomUpload.style.display = count > 0 ? 'flex' : 'none';
}
// --- Render gallery images with tags and delete buttons ---
function renderGallery() {
const container = document.getElementById('gallery');
container.innerHTML = '';
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');
div.className = 'photo flex-item flex-column';
div.innerHTML = `
<div class="flex-item">
<img src="/photos/${img.src}">
<img class="fade-in-img" src="/photos/${img.src}">
</div>
<div class="tags-display" data-index="${i}"></div>
<div class="flex-item flex-full">
@@ -50,22 +82,17 @@ function renderGallery() {
</div>
`;
container.appendChild(div);
renderTags(i, img.tags || []);
});
// Show/hide Remove All button
const removeAllBtn = document.getElementById('remove-all-gallery');
if (removeAllBtn) {
removeAllBtn.style.display = galleryImages.length > 0 ? 'inline-block' : 'none';
}
updateCountAndButtons('gallery', imagesToShow.length);
applyFadeInImages(container);
}
// --- Render tags for a single image ---
function renderTags(imgIndex, tags) {
const tagsDisplay = document.querySelector(`.tags-display[data-index="${imgIndex}"]`);
const inputContainer = document.querySelector(`.tag-input[data-index="${imgIndex}"]`);
tagsDisplay.innerHTML = '';
inputContainer.innerHTML = '';
@@ -73,7 +100,6 @@ function renderTags(imgIndex, tags) {
const span = document.createElement('span');
span.className = 'tag';
span.textContent = tag;
const remove = document.createElement('span');
remove.className = 'remove-tag';
remove.textContent = '×';
@@ -82,7 +108,6 @@ function renderTags(imgIndex, tags) {
updateTags(imgIndex, tags);
renderTags(imgIndex, tags);
};
span.appendChild(remove);
tagsDisplay.appendChild(span);
});
@@ -92,11 +117,10 @@ function renderTags(imgIndex, tags) {
input.placeholder = 'Add tag...';
inputContainer.appendChild(input);
// --- Validate button ---
const validateBtn = document.createElement('button');
validateBtn.textContent = '✔️';
validateBtn.className = 'validate-tag-btn';
validateBtn.style.display = 'none'; // hidden by default
validateBtn.style.display = 'none';
validateBtn.style.marginLeft = '4px';
inputContainer.appendChild(validateBtn);
@@ -116,30 +140,20 @@ function renderTags(imgIndex, tags) {
const updateSuggestions = () => {
const value = input.value.toLowerCase();
const allTagsFlat = galleryImages.flatMap(img => img.tags || []);
const tagCount = {};
allTagsFlat.forEach(t => tagCount[t] = (tagCount[t] || 0) + 1);
const allTagsSorted = Object.keys(tagCount)
.sort((a, b) => tagCount[b] - tagCount[a]);
const allTagsSorted = Object.keys(tagCount).sort((a, b) => tagCount[b] - tagCount[a]);
const suggestions = allTagsSorted.filter(t => t.toLowerCase().startsWith(value) && !tags.includes(t));
suggestionBox.innerHTML = '';
selectedIndex = -1;
if (suggestions.length) {
suggestionBox.style.display = 'block';
suggestions.forEach((s, idx) => {
const li = document.createElement('li');
li.style.fontStyle = 'italic';
li.style.textAlign = 'left';
const boldPart = `<b>${s.substring(0, input.value.length)}</b>`;
const rest = s.substring(input.value.length);
li.innerHTML = boldPart + rest;
li.innerHTML = `<b>${s.substring(0, input.value.length)}</b>${s.substring(input.value.length)}`;
li.addEventListener('mousedown', (e) => {
e.preventDefault();
addTag(s);
@@ -147,7 +161,6 @@ function renderTags(imgIndex, tags) {
input.focus();
updateSuggestions();
});
li.onmouseover = () => selectedIndex = idx;
suggestionBox.appendChild(li);
});
@@ -164,7 +177,6 @@ function renderTags(imgIndex, tags) {
updateSuggestions();
validateBtn.style.display = input.value.trim() ? 'inline-block' : 'none';
});
input.addEventListener('keydown', (e) => {
const items = suggestionBox.querySelectorAll('li');
if (e.key === 'ArrowDown') {
@@ -195,27 +207,22 @@ function renderTags(imgIndex, tags) {
validateBtn.style.display = 'none';
}
});
input.addEventListener('blur', () => {
setTimeout(() => {
suggestionBox.style.display = 'none';
input.value = '';
validateBtn.style.display = 'none';
}, 150);
});
// --- Validate button action ---
validateBtn.onclick = () => {
validateBtn.addEventListener('mousedown', (e) => {
e.preventDefault();
if (input.value.trim()) {
addTag(input.value.trim());
input.value = '';
updateSuggestions();
validateBtn.style.display = 'none';
}
};
input.focus();
});
updateSuggestions();
if (!input.value.trim()) suggestionBox.style.display = 'none';
}
// --- Update tags in galleryImages array ---
@@ -233,7 +240,7 @@ function renderHero() {
div.className = 'photo flex-item flex-column';
div.innerHTML = `
<div class="flex-item">
<img src="/photos/${img.src}">
<img class="fade-in-img" src="/photos/${img.src}">
</div>
<div class="flex-item flex-full">
<div class="flex-item flex-end">
@@ -243,12 +250,8 @@ function renderHero() {
`;
container.appendChild(div);
});
// Show/hide Remove All button
const removeAllBtn = document.getElementById('remove-all-hero');
if (removeAllBtn) {
removeAllBtn.style.display = heroImages.length > 0 ? 'inline-block' : 'none';
}
updateCountAndButtons('hero', heroImages.length);
applyFadeInImages(container);
}
// --- Save gallery to server ---
@@ -294,33 +297,27 @@ async function refreshHero() {
function showToast(message, type = "success", duration = 3000) {
const container = document.getElementById("toast-container");
if (!container) return;
const toast = document.createElement("div");
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add("show"));
setTimeout(() => {
toast.classList.remove("show");
toast.addEventListener("transitionend", () => toast.remove());
}, duration);
}
let pendingDelete = null; // { type: 'gallery'|'hero'|'gallery-all'|'hero-all', index: number|null }
let pendingDelete = null;
// --- Show delete confirmation modal ---
function showDeleteModal(type, index = null) {
pendingDelete = { type, index };
const modalText = document.getElementById('delete-modal-text');
if (type === 'gallery-all') {
modalText.textContent = "Are you sure you want to delete ALL gallery images?";
} else if (type === 'hero-all') {
modalText.textContent = "Are you sure you want to delete ALL hero images?";
} else {
modalText.textContent = "Are you sure you want to delete this image?";
}
modalText.textContent =
type === 'gallery-all' ? "Are you sure you want to delete ALL gallery images?"
: type === 'hero-all' ? "Are you sure you want to delete ALL hero images?"
: "Are you sure you want to delete this image?";
document.getElementById('delete-modal').style.display = 'flex';
}
@@ -333,15 +330,10 @@ function hideDeleteModal() {
// --- Confirm deletion ---
async function confirmDelete() {
if (!pendingDelete) return;
if (pendingDelete.type === 'gallery') {
await actuallyDeleteGalleryImage(pendingDelete.index);
} else if (pendingDelete.type === 'hero') {
await actuallyDeleteHeroImage(pendingDelete.index);
} else if (pendingDelete.type === 'gallery-all') {
await actuallyDeleteAllGalleryImages();
} else if (pendingDelete.type === 'hero-all') {
await actuallyDeleteAllHeroImages();
}
if (pendingDelete.type === 'gallery') await actuallyDeleteGalleryImage(pendingDelete.index);
else if (pendingDelete.type === 'hero') await actuallyDeleteHeroImage(pendingDelete.index);
else if (pendingDelete.type === 'gallery-all') await actuallyDeleteAllGalleryImages();
else if (pendingDelete.type === 'hero-all') await actuallyDeleteAllHeroImages();
hideDeleteModal();
}
@@ -423,15 +415,35 @@ async function actuallyDeleteAllHeroImages() {
// --- Modal event listeners and bulk delete buttons ---
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('delete-modal-close').onclick = hideDeleteModal;
document.getElementById('delete-modal-cancel').onclick = hideDeleteModal;
document.getElementById('delete-modal-confirm').onclick = confirmDelete;
['delete-modal-close', 'delete-modal-cancel'].forEach(id => {
const el = document.getElementById(id);
if (el) el.onclick = hideDeleteModal;
});
const confirmBtn = document.getElementById('delete-modal-confirm');
if (confirmBtn) confirmBtn.onclick = confirmDelete;
// Gallery filter radios
const showAllRadio = document.getElementById('show-all-radio');
const showUntaggedRadio = document.getElementById('show-untagged-radio');
if (showAllRadio) showAllRadio.addEventListener('change', () => {
showOnlyUntagged = false;
renderGallery();
});
if (showUntaggedRadio) showUntaggedRadio.addEventListener('change', () => {
showOnlyUntagged = true;
renderGallery();
});
// Bulk delete buttons
const removeAllGalleryBtn = document.getElementById('remove-all-gallery');
const removeAllHeroBtn = document.getElementById('remove-all-hero');
if (removeAllGalleryBtn) removeAllGalleryBtn.onclick = () => showDeleteModal('gallery-all');
if (removeAllHeroBtn) removeAllHeroBtn.onclick = () => showDeleteModal('hero-all');
[
['remove-all-gallery', 'gallery-all'],
['remove-all-gallery-bottom', 'gallery-all'],
['remove-all-hero', 'hero-all'],
['remove-all-hero-bottom', 'hero-all']
].forEach(([btnId, type]) => {
const btn = document.getElementById(btnId);
if (btn) btn.onclick = () => showDeleteModal(type);
});
});
// --- Initialize ---
+301 -223
View File
@@ -12,7 +12,6 @@ function showToast(message, type = "success", duration = 3000) {
}, duration);
}
// --- Loader helpers ---
function showLoader(text = "Uploading...") {
const loader = document.getElementById("global-loader");
if (loader) {
@@ -26,119 +25,99 @@ function hideLoader() {
}
document.addEventListener("DOMContentLoaded", () => {
// Form and menu logic
const form = document.getElementById("site-info-form");
// --- Section Forms ---
const forms = {
info: document.getElementById("info-form"),
social: document.getElementById("social-form"),
menu: document.getElementById("menu-form"),
footer: document.getElementById("footer-form"),
legals: document.getElementById("legals-form"),
build: document.getElementById("build-form")
};
// --- Menu logic ---
const menuList = document.getElementById("menu-items-list");
const addMenuBtn = document.getElementById("add-menu-item");
let menuItems = [];
// Render menu items
function renderMenuItems() {
menuList.innerHTML = "";
menuItems.forEach((item, idx) => {
const div = document.createElement("div");
div.style.display = "flex";
div.style.gap = "8px";
div.style.marginBottom = "6px";
div.innerHTML = `
menuList.innerHTML += `
<div style="display:flex;gap:8px;margin-bottom:6px;">
<input type="text" placeholder="Label" value="${item.label || ""}" style="flex:1;" data-idx="${idx}" data-type="label" required>
<input type="text" placeholder="?tag=tag1,tag2" value="${item.href || ""}" style="flex:2;" data-idx="${idx}" data-type="href" required>
<button type="button" class="remove-menu-item" data-idx="${idx}">🗑</button>
</div>
`;
menuList.appendChild(div);
});
}
// Update menu items from inputs
function updateMenuItemsFromInputs() {
const inputs = menuList.querySelectorAll("input");
const items = [];
menuItems = [];
for (let i = 0; i < inputs.length; i += 2) {
const label = inputs[i].value.trim();
const href = inputs[i + 1].value.trim();
if (label || href) 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 addIpBtn = document.getElementById("add-ip-paragraph");
let ipParagraphs = [];
// Render IP paragraphs
function renderIpParagraphs() {
ipList.innerHTML = "";
ipParagraphs.forEach((item, idx) => {
const div = document.createElement("div");
div.style.display = "flex";
div.style.gap = "8px";
div.style.marginBottom = "6px";
div.innerHTML = `
ipList.innerHTML += `
<div style="display:flex;gap:8px;margin-bottom:6px;">
<textarea placeholder="Paragraph" required style="flex:1;" data-idx="${idx}">${item.paragraph || ""}</textarea>
<button type="button" class="remove-ip-paragraph" data-idx="${idx}">🗑</button>
</div>
`;
ipList.appendChild(div);
});
}
// Update IP paragraphs from textareas
function updateIpParagraphsFromInputs() {
const textareas = ipList.querySelectorAll("textarea");
ipParagraphs = Array.from(textareas).map(textarea => ({
paragraph: textarea.value.trim()
})).filter(item => item.paragraph !== "");
ipParagraphs = Array.from(ipList.querySelectorAll("textarea"))
.map(textarea => ({ paragraph: textarea.value.trim() }))
.filter(item => item.paragraph !== "");
}
// Build options
// --- Build options & Theme select ---
const convertImagesCheckbox = document.getElementById("convert-images-checkbox");
const resizeImagesCheckbox = document.getElementById("resize-images-checkbox");
// Theme select
const themeSelect = document.getElementById("theme-select");
// Thumbnail upload and modal logic
const thumbnailInput = form?.elements["social.thumbnail"];
// --- Thumbnail upload and modal logic ---
const thumbnailInput = document.getElementById("social-thumbnail");
const thumbnailUpload = document.getElementById("thumbnail-upload");
const chooseThumbnailBtn = document.getElementById("choose-thumbnail-btn");
const thumbnailPreview = document.getElementById("thumbnail-preview");
const removeThumbnailBtn = document.getElementById("remove-thumbnail-btn");
// Modal elements for delete confirmation
const deleteModal = document.getElementById("delete-modal");
const deleteModalClose = document.getElementById("delete-modal-close");
const deleteModalConfirm = document.getElementById("delete-modal-confirm");
const deleteModalCancel = document.getElementById("delete-modal-cancel");
// --- Modal helpers ---
function setupModal(modal, closeBtn, confirmBtn, cancelBtn, onConfirm) {
if (!modal) return;
if (closeBtn) closeBtn.onclick = () => modal.style.display = "none";
if (cancelBtn) cancelBtn.onclick = () => modal.style.display = "none";
window.addEventListener("click", (e) => {
if (e.target === modal) modal.style.display = "none";
});
if (confirmBtn && onConfirm) confirmBtn.onclick = onConfirm;
}
// Modal elements for theme deletion
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
// --- Thumbnail preview logic ---
function updateThumbnailPreview(src) {
if (thumbnailPreview) {
thumbnailPreview.src = src || "";
thumbnailPreview.style.display = src ? "block" : "none";
}
if (removeThumbnailBtn) {
removeThumbnailBtn.style.display = src ? "inline-block" : "none";
}
if (chooseThumbnailBtn) {
chooseThumbnailBtn.style.display = src ? "none" : "inline-block";
}
if (removeThumbnailBtn) removeThumbnailBtn.style.display = src ? "inline-block" : "none";
if (chooseThumbnailBtn) chooseThumbnailBtn.style.display = src ? "none" : "inline-block";
}
// Choose thumbnail button triggers file input
if (chooseThumbnailBtn && thumbnailUpload) {
chooseThumbnailBtn.addEventListener("click", () => thumbnailUpload.click());
}
// Handle thumbnail upload and refresh preview (with cache busting)
if (thumbnailUpload) {
thumbnailUpload.addEventListener("change", async (e) => {
const file = e.target.files[0];
@@ -156,27 +135,20 @@ document.addEventListener("DOMContentLoaded", () => {
} else {
showToast("❌ Error uploading thumbnail", "error");
}
updateSectionStatus("social");
});
}
// Remove thumbnail button triggers modal
if (removeThumbnailBtn) {
removeThumbnailBtn.addEventListener("click", () => {
deleteModal.style.display = "flex";
document.getElementById("delete-modal").style.display = "flex";
});
}
// Modal logic for thumbnail deletion
if (deleteModal && deleteModalClose && deleteModalConfirm && deleteModalCancel) {
deleteModalClose.onclick = deleteModalCancel.onclick = () => {
deleteModal.style.display = "none";
};
window.onclick = function(event) {
if (event.target === deleteModal) {
deleteModal.style.display = "none";
}
};
deleteModalConfirm.onclick = async () => {
setupModal(
document.getElementById("delete-modal"),
document.getElementById("delete-modal-close"),
document.getElementById("delete-modal-confirm"),
document.getElementById("delete-modal-cancel"),
async () => {
const res = await fetch("/api/thumbnail/remove", { method: "POST" });
const result = await res.json();
if (result.status === "ok") {
@@ -186,48 +158,38 @@ document.addEventListener("DOMContentLoaded", () => {
} else {
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 chooseThemeBtn = document.getElementById("choose-theme-btn");
if (chooseThemeBtn && themeUpload) {
chooseThemeBtn.addEventListener("click", () => themeUpload.click());
themeUpload.addEventListener("change", async (e) => {
const files = Array.from(e.target.files);
if (files.length === 0) return;
if (!files.length) return;
showLoader("Uploading theme...");
const formData = new FormData();
files.forEach(file => {
formData.append("files", file, file.webkitRelativePath || file.name);
});
files.forEach(file => formData.append("files", file, file.webkitRelativePath || file.name));
const res = await fetch("/api/theme/upload", { method: "POST", body: formData });
const result = await res.json();
hideLoader();
if (result.status === "ok") {
showToast("✅ Theme uploaded!", "success");
// Refresh theme select after upload
fetch("/api/themes")
.then(res => res.json())
.then(themes => {
themeSelect.innerHTML = "";
themes.forEach(theme => {
const option = document.createElement("option");
option.value = theme;
option.textContent = theme;
themeSelect.appendChild(option);
});
});
refreshThemes();
} else {
showToast("❌ Error uploading theme", "error");
}
updateSectionStatus("build");
});
}
// Remove theme button triggers modal
// --- Remove theme logic ---
const removeThemeBtn = document.getElementById("remove-theme-btn");
let themeToDelete = null;
if (removeThemeBtn && themeSelect) {
removeThemeBtn.addEventListener("click", () => {
const theme = themeSelect.value;
@@ -237,24 +199,16 @@ document.addEventListener("DOMContentLoaded", () => {
return;
}
themeToDelete = theme;
deleteThemeModalText.textContent = `Are you sure you want to remove theme "${theme}"?`;
deleteThemeModal.style.display = "flex";
document.getElementById("delete-theme-modal-text").textContent = `Are you sure you want to remove theme "${theme}"?`;
document.getElementById("delete-theme-modal").style.display = "flex";
});
}
// Modal logic for theme deletion
if (deleteThemeModal && deleteThemeModalClose && deleteThemeModalConfirm && deleteThemeModalCancel) {
deleteThemeModalClose.onclick = deleteThemeModalCancel.onclick = () => {
deleteThemeModal.style.display = "none";
themeToDelete = null;
};
window.onclick = function(event) {
if (event.target === deleteThemeModal) {
deleteThemeModal.style.display = "none";
themeToDelete = null;
}
};
deleteThemeModalConfirm.onclick = async () => {
setupModal(
document.getElementById("delete-theme-modal"),
document.getElementById("delete-theme-modal-close"),
document.getElementById("delete-theme-modal-confirm"),
document.getElementById("delete-theme-modal-cancel"),
async () => {
if (!themeToDelete) return;
showLoader("Removing theme...");
const res = await fetch("/api/theme/remove", {
@@ -266,28 +220,18 @@ document.addEventListener("DOMContentLoaded", () => {
hideLoader();
if (result.status === "ok") {
showToast("✅ Theme removed!", "success");
// Refresh theme select
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);
});
});
refreshThemes();
} else {
showToast(result.error || "❌ Error removing theme", "error");
}
deleteThemeModal.style.display = "none";
document.getElementById("delete-theme-modal").style.display = "none";
themeToDelete = null;
};
updateSectionStatus("build");
}
);
// Fetch theme list and populate select
if (themeSelect) {
// --- Theme select refresh ---
function refreshThemes() {
fetch("/api/themes")
.then(res => res.json())
.then(themes => {
@@ -298,148 +242,276 @@ document.addEventListener("DOMContentLoaded", () => {
option.textContent = theme;
themeSelect.appendChild(option);
});
// Set selected value after loading config
fetch("/api/site-info")
.then(res => res.json())
.then(data => {
themeSelect.value = data.build?.theme || "";
});
loadConfigAndUpdateBuildStatus();
});
}
// Load config from server and populate form
if (form) {
// --- Config loading ---
let loadedConfig = {};
function loadConfigAndUpdateBuildStatus() {
fetch("/api/site-info")
.then(res => res.json())
.then(data => {
loadedConfig = data;
// Info
if (forms.info) {
forms.info.elements["info.title"].value = data.info?.title || "";
forms.info.elements["info.subtitle"].value = data.info?.subtitle || "";
forms.info.elements["info.description"].value = data.info?.description || "";
forms.info.elements["info.canonical"].value = data.info?.canonical || "";
forms.info.elements["info.keywords"].value = Array.isArray(data.info?.keywords) ? data.info.keywords.join(", ") : (data.info?.keywords || "");
forms.info.elements["info.author"].value = data.info?.author || "";
}
// Social
if (forms.social) {
forms.social.elements["social.instagram_url"].value = data.social?.instagram_url || "";
if (thumbnailInput) thumbnailInput.value = data.social?.thumbnail || "";
updateThumbnailPreview(data.social?.thumbnail ? `/photos/${data.social.thumbnail}?t=${Date.now()}` : "");
}
// Menu
menuItems = Array.isArray(data.menu?.items) ? data.menu.items : [];
renderMenuItems();
// Footer
if (forms.footer) {
forms.footer.elements["footer.copyright"].value = data.footer?.copyright || "";
forms.footer.elements["footer.legal_label"].value = data.footer?.legal_label || "";
}
// Legals
ipParagraphs = Array.isArray(data.legals?.intellectual_property)
? data.legals.intellectual_property
: [];
renderIpParagraphs();
menuItems = Array.isArray(data.menu?.items) ? data.menu.items : [];
renderMenuItems();
form.elements["info.title"].value = data.info?.title || "";
form.elements["info.subtitle"].value = data.info?.subtitle || "";
form.elements["info.description"].value = data.info?.description || "";
form.elements["info.canonical"].value = data.info?.canonical || "";
form.elements["info.keywords"].value = Array.isArray(data.info?.keywords) ? data.info.keywords.join(", ") : (data.info?.keywords || "");
form.elements["info.author"].value = data.info?.author || "";
form.elements["social.instagram_url"].value = data.social?.instagram_url || "";
if (thumbnailInput) thumbnailInput.value = data.social?.thumbnail || "";
updateThumbnailPreview(data.social?.thumbnail ? `/photos/${data.social.thumbnail}?t=${Date.now()}` : "");
form.elements["footer.copyright"].value = data.footer?.copyright || "";
form.elements["footer.legal_label"].value = data.footer?.legal_label || "";
if (themeSelect) {
themeSelect.value = data.build?.theme || "";
}
form.elements["legals.hoster_name"].value = data.legals?.hoster_name || "";
form.elements["legals.hoster_address"].value = data.legals?.hoster_address || "";
form.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || "";
// Build checkboxes
if (convertImagesCheckbox) {
convertImagesCheckbox.checked = !!data.build?.convert_images;
}
if (resizeImagesCheckbox) {
resizeImagesCheckbox.checked = !!data.build?.resize_images;
if (forms.legals) {
forms.legals.elements["legals.hoster_name"].value = data.legals?.hoster_name || "";
forms.legals.elements["legals.hoster_address"].value = data.legals?.hoster_address || "";
forms.legals.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || "";
}
// Build
if (themeSelect) themeSelect.value = data.build?.theme || "";
if (convertImagesCheckbox) convertImagesCheckbox.checked = !!data.build?.convert_images;
if (resizeImagesCheckbox) resizeImagesCheckbox.checked = !!data.build?.resize_images;
["info", "social", "menu", "footer", "legals"].forEach(updateSectionStatus);
updateSectionStatus("build");
});
}
if (themeSelect) refreshThemes();
else loadConfigAndUpdateBuildStatus();
// Add menu item
if (addMenuBtn) {
addMenuBtn.addEventListener("click", () => {
// --- Add/remove menu items ---
if (addMenuBtn) addMenuBtn.addEventListener("click", () => {
menuItems.push({ label: "", href: "" });
renderMenuItems();
updateSectionStatus("menu");
});
}
// Remove menu item
menuList.addEventListener("click", (e) => {
if (e.target.classList.contains("remove-menu-item")) {
const idx = parseInt(e.target.getAttribute("data-idx"));
menuItems.splice(idx, 1);
renderMenuItems();
updateSectionStatus("menu");
}
});
// Update menuItems on input change
menuList.addEventListener("input", () => {
updateMenuItemsFromInputs();
updateSectionStatus("menu");
});
// Add paragraph
if (addIpBtn) {
addIpBtn.addEventListener("click", () => {
// --- Add/remove IP paragraphs ---
if (addIpBtn) addIpBtn.addEventListener("click", () => {
ipParagraphs.push({ paragraph: "" });
renderIpParagraphs();
updateSectionStatus("legals");
});
}
// Remove paragraph
ipList.addEventListener("click", (e) => {
if (e.target.classList.contains("remove-ip-paragraph")) {
const idx = parseInt(e.target.getAttribute("data-idx"));
ipParagraphs.splice(idx, 1);
renderIpParagraphs();
updateSectionStatus("legals");
}
});
// Update ipParagraphs on input change
ipList.addEventListener("input", () => {
updateIpParagraphsFromInputs();
updateSectionStatus("legals");
});
// Save config to server
if (form) {
form.addEventListener("submit", async (e) => {
e.preventDefault();
// --- Section value helpers ---
function getSectionValues(section) {
switch (section) {
case "info":
return {
title: forms.info.elements["info.title"].value,
subtitle: forms.info.elements["info.subtitle"].value,
description: forms.info.elements["info.description"].value,
canonical: forms.info.elements["info.canonical"].value,
keywords: forms.info.elements["info.keywords"].value.split(",").map(i => i.trim()).filter(Boolean),
author: forms.info.elements["info.author"].value
};
case "social":
return {
instagram_url: forms.social.elements["social.instagram_url"].value,
thumbnail: thumbnailInput ? thumbnailInput.value : ""
};
case "menu":
updateMenuItemsFromInputs();
return { items: menuItems };
case "footer":
return {
copyright: forms.footer.elements["footer.copyright"].value,
legal_label: forms.footer.elements["footer.legal_label"].value
};
case "legals":
updateIpParagraphsFromInputs();
// 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;
}
const build = {
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)
};
const payload = {
info: {
title: form.elements["info.title"].value,
subtitle: form.elements["info.subtitle"].value,
description: form.elements["info.description"].value,
canonical: form.elements["info.canonical"].value,
keywords: form.elements["info.keywords"].value.split(",").map(i => i.trim()).filter(Boolean),
author: form.elements["info.author"].value
},
social: {
instagram_url: form.elements["social.instagram_url"].value,
thumbnail: thumbnailInput ? thumbnailInput.value : ""
},
menu: {
items: menuItems
},
footer: {
copyright: form.elements["footer.copyright"].value,
legal_label: form.elements["footer.legal_label"].value
},
build,
legals: {
hoster_name: form.elements["legals.hoster_name"].value,
hoster_address: form.elements["legals.hoster_address"].value,
hoster_contact: form.elements["legals.hoster_contact"].value,
intellectual_property: ipParagraphs
default:
return {};
}
};
// --- REMOVE loader for save ---
// showLoader("Saving...");
}
function isSectionSaved(section) {
const values = getSectionValues(section);
const config = loadedConfig[section] || {};
function normalizeMenuItems(items) {
return (items || []).map(item => ({
label: item.label || "",
href: item.href || ""
}));
}
switch (section) {
case "info":
return Object.keys(values).every(
key => values[key] && (
key === "keywords"
? Array.isArray(config.keywords) && values.keywords.join(",") === config.keywords.join(",")
: values[key] === (config[key] || "")
)
);
case "social":
return values.instagram_url && values.thumbnail &&
values.instagram_url === (config.instagram_url || "") &&
values.thumbnail === (config.thumbnail || "");
case "menu":
return JSON.stringify(normalizeMenuItems(values.items)) === JSON.stringify(normalizeMenuItems(config.items));
case "footer":
return values.copyright && values.legal_label &&
values.copyright === (config.copyright || "") &&
values.legal_label === (config.legal_label || "");
case "legals":
return values.hoster_name && values.hoster_address && values.hoster_contact &&
values.hoster_name === (config.hoster_name || "") &&
values.hoster_address === (config.hoster_address || "") &&
values.hoster_contact === (config.hoster_contact || "") &&
JSON.stringify(values.intellectual_property) === JSON.stringify(config.intellectual_property || []);
case "build":
return values.theme === (config.theme || "") &&
!!values.convert_images === !!config.convert_images &&
!!values.resize_images === !!config.resize_images;
default:
return true;
}
}
function isSectionComplete(section) {
const values = getSectionValues(section);
switch (section) {
case "info":
return (
values.title &&
values.subtitle &&
values.description &&
values.canonical &&
values.keywords.length > 0 &&
values.author
);
case "social":
return values.instagram_url && values.thumbnail;
case "menu":
return Array.isArray(values.items) && values.items.every(item => item.label && item.href);
case "footer":
return values.copyright && values.legal_label;
case "legals":
return (
values.hoster_name &&
values.hoster_address &&
values.hoster_contact &&
Array.isArray(values.intellectual_property) &&
values.intellectual_property.length > 0 &&
values.intellectual_property.every(ip => ip.paragraph)
);
case "build":
return !!values.theme;
default:
return true;
}
}
function updateSectionStatus(section) {
const statusEl = document.querySelector(`#${section}-section .section-status`);
if (!statusEl) return;
if (!isSectionComplete(section)) {
statusEl.innerHTML = "⚠️ Section not yet saved. Please fill required fields";
statusEl.style.color = "#ffc700";
statusEl.style.display = "";
statusEl.style.fontStyle = "normal";
return;
}
if (isSectionSaved(section)) {
statusEl.innerHTML = "";
statusEl.style.display = "none";
} else {
statusEl.innerHTML = "⚠️ Section not yet saved";
statusEl.style.color = "#ffc700";
statusEl.style.display = "";
statusEl.style.fontStyle = "normal";
}
}
// --- Listen for changes in each section ---
Object.entries(forms).forEach(([section, form]) => {
if (!form) return;
form.addEventListener("input", () => updateSectionStatus(section));
form.addEventListener("change", () => updateSectionStatus(section));
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!form.reportValidity()) {
showToast("❌ Please fill all required fields before saving.", "error");
updateSectionStatus(section);
return;
}
if (section === "social" && (!thumbnailInput || !thumbnailInput.value)) {
showToast("❌ Thumbnail is required.", "error");
updateSectionStatus(section);
return;
}
if (section === "menu") {
updateMenuItemsFromInputs();
if (!menuItems.length || !menuItems.every(item => item.label && item.href)) {
showToast("❌ Please fill all menu item fields.", "error");
updateSectionStatus(section);
return;
}
}
if (section === "legals") {
updateIpParagraphsFromInputs();
if (!ipParagraphs.length || !ipParagraphs.every(ip => ip.paragraph)) {
showToast("❌ Please fill all intellectual property paragraphs.", "error");
updateSectionStatus(section);
return;
}
}
let payload = {};
payload[section] = getSectionValues(section);
const res = await fetch("/api/site-info", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -447,10 +519,16 @@ document.addEventListener("DOMContentLoaded", () => {
});
const result = await res.json();
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 {
showToast("❌ Error saving site info", "error");
showToast("❌ Error saving section", "error");
}
});
}
});
});
+207 -66
View File
@@ -70,9 +70,11 @@ function setupColorPicker(colorId, btnId, textId, initial) {
function setFontDropdown(selectId, value, options) {
const select = document.getElementById(selectId);
if (!select) return;
select.innerHTML = options.map(opt =>
`<option value="${opt}"${opt === value ? " selected" : ""}>${opt}</option>`
).join("");
select.innerHTML = options.map(opt => {
// Remove extension if present
const base = opt.replace(/\.(woff2?|ttf|otf)$/, "");
return `<option value="${base}"${base === value ? " selected" : ""}>${base}</option>`;
}).join("");
}
function setFallbackDropdown(selectId, value) {
@@ -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 () => {
const themeInfo = await fetchThemeInfo();
const themeNameSpan = document.getElementById("current-theme");
if (themeNameSpan) themeNameSpan.textContent = themeInfo.theme_name;
const themeYaml = themeInfo.theme_yaml;
const googleFonts = themeYaml.google_fonts ? JSON.parse(JSON.stringify(themeYaml.google_fonts)) : [];
let loadedConfig = themeInfo.theme_yaml;
let googleFonts = loadedConfig.google_fonts ? JSON.parse(JSON.stringify(loadedConfig.google_fonts)) : [];
let localFonts = await fetchLocalFonts(themeInfo.theme_name);
// Colors
if (themeYaml.colors) {
setupColorPicker("color-primary", "color-primary-btn", "color-primary-text", themeYaml.colors.primary || "#0065a1");
setupColorPicker("color-primary-dark", "color-primary-dark-btn", "color-primary-dark-text", themeYaml.colors.primary_dark || "#005384");
setupColorPicker("color-secondary", "color-secondary-btn", "color-secondary-text", themeYaml.colors.secondary || "#00b0f0");
setupColorPicker("color-accent", "color-accent-btn", "color-accent-text", themeYaml.colors.accent || "#ffc700");
setupColorPicker("color-text-dark", "color-text-dark-btn", "color-text-dark-text", themeYaml.colors.text_dark || "#616161");
setupColorPicker("color-background", "color-background-btn", "color-background-text", themeYaml.colors.background || "#fff");
setupColorPicker("color-browser-color", "color-browser-color-btn", "color-browser-color-text", themeYaml.colors.browser_color || "#fff");
if (loadedConfig.colors) {
setupColorPicker("color-primary", "color-primary-btn", "color-primary-text", loadedConfig.colors.primary || "#0065a1");
setupColorPicker("color-primary-dark", "color-primary-dark-btn", "color-primary-dark-text", loadedConfig.colors.primary_dark || "#005384");
setupColorPicker("color-secondary", "color-secondary-btn", "color-secondary-text", loadedConfig.colors.secondary || "#00b0f0");
setupColorPicker("color-accent", "color-accent-btn", "color-accent-text", loadedConfig.colors.accent || "#ffc700");
setupColorPicker("color-text-dark", "color-text-dark-btn", "color-text-dark-text", loadedConfig.colors.text_dark || "#616161");
setupColorPicker("color-background", "color-background-btn", "color-background-text", loadedConfig.colors.background || "#fff");
setupColorPicker("color-browser-color", "color-browser-color-btn", "color-browser-color-text", loadedConfig.colors.browser_color || "#fff");
}
// Fonts
function refreshFontDropdowns() {
setFontDropdown("font-primary", document.getElementById("font-primary").value, [
setFontDropdown("font-primary", loadedConfig.fonts?.primary?.name || "Lato", [
...googleFonts.map(f => f.family),
...localFonts
]);
setFontDropdown("font-secondary", document.getElementById("font-secondary").value, [
setFontDropdown("font-secondary", loadedConfig.fonts?.secondary?.name || "Montserrat", [
...googleFonts.map(f => f.family),
...localFonts
]);
setFallbackDropdown("font-primary-fallback", loadedConfig.fonts?.primary?.fallback || "sans-serif");
setFallbackDropdown("font-secondary-fallback", loadedConfig.fonts?.secondary?.fallback || "serif");
}
if (themeYaml.fonts) {
setFontDropdown("font-primary", themeYaml.fonts.primary?.name || "Lato", [
...googleFonts.map(f => f.family),
...localFonts
]);
setFallbackDropdown("font-primary-fallback", themeYaml.fonts.primary?.fallback || "sans-serif");
setFontDropdown("font-secondary", themeYaml.fonts.secondary?.name || "Montserrat", [
...googleFonts.map(f => f.family),
...localFonts
]);
setFallbackDropdown("font-secondary-fallback", themeYaml.fonts.secondary?.fallback || "serif");
}
refreshFontDropdowns();
// Font upload logic
const fontUploadInput = document.getElementById("font-upload");
const chooseFontBtn = document.getElementById("choose-font-btn");
const fontUploadStatus = document.getElementById("font-upload-status");
const localFontsList = document.getElementById("local-fonts-list");
// Modal logic for font deletion
@@ -341,9 +462,9 @@ document.addEventListener("DOMContentLoaded", async () => {
};
}
if (themeYaml.favicon && themeYaml.favicon.path) {
faviconInput.value = themeYaml.favicon.path;
updateFaviconPreview(`/themes/${themeInfo.theme_name}/${themeYaml.favicon.path}?t=${Date.now()}`);
if (loadedConfig.favicon && loadedConfig.favicon.path) {
faviconInput.value = loadedConfig.favicon.path;
updateFaviconPreview(`/themes/${themeInfo.theme_name}/${loadedConfig.favicon.path}?t=${Date.now()}`);
} else {
updateFaviconPreview("");
}
@@ -367,6 +488,7 @@ document.addEventListener("DOMContentLoaded", async () => {
googleFonts.push(...updatedGoogleFonts);
renderGoogleFonts(googleFonts);
refreshFontDropdowns();
updateSectionStatus("google-fonts", loadedConfig);
});
}
@@ -396,6 +518,7 @@ document.addEventListener("DOMContentLoaded", async () => {
googleFonts.push(...updatedGoogleFonts);
renderGoogleFonts(googleFonts);
refreshFontDropdowns();
updateSectionStatus("google-fonts", loadedConfig);
}
}, true);
@@ -414,54 +537,72 @@ document.addEventListener("DOMContentLoaded", async () => {
googleFonts.push(...updatedGoogleFonts);
renderGoogleFonts(googleFonts);
refreshFontDropdowns();
updateSectionStatus("google-fonts", loadedConfig);
}
});
}
document.getElementById("theme-editor-form").addEventListener("submit", async (e) => {
// --- Section status listeners ---
[
{ form: document.getElementById("colors-form"), section: "colors" },
{ form: document.getElementById("google-fonts-form"), section: "google-fonts" },
{ form: document.getElementById("fonts-form"), section: "fonts" },
{ form: document.getElementById("favicon-form"), section: "favicon" }
].forEach(({ form, section }) => {
if (!form) return;
form.addEventListener("input", () => updateSectionStatus(section, loadedConfig));
form.addEventListener("change", () => updateSectionStatus(section, loadedConfig));
});
// --- Section save handlers ---
[
{ form: document.getElementById("colors-form"), section: "colors" },
{ form: document.getElementById("google-fonts-form"), section: "google-fonts" },
{ form: document.getElementById("fonts-form"), section: "fonts" },
{ form: document.getElementById("favicon-form"), section: "favicon" }
].forEach(({ form, section }) => {
if (!form) return;
form.addEventListener("submit", async (e) => {
e.preventDefault();
showLoader("Saving theme...");
const data = {};
data.colors = {
primary: document.getElementById("color-primary-text").value,
primary_dark: document.getElementById("color-primary-dark-text").value,
secondary: document.getElementById("color-secondary-text").value,
accent: document.getElementById("color-accent-text").value,
text_dark: document.getElementById("color-text-dark-text").value,
background: document.getElementById("color-background-text").value,
browser_color: document.getElementById("color-browser-color-text").value
};
data.fonts = {
primary: {
name: document.getElementById("font-primary").value,
fallback: document.getElementById("font-primary-fallback").value
},
secondary: {
name: document.getElementById("font-secondary").value,
fallback: document.getElementById("font-secondary-fallback").value
if (!form.reportValidity() || !isSectionComplete(section)) {
showToast("❌ Please fill all required fields before saving.", "error");
updateSectionStatus(section, loadedConfig);
return;
}
};
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 });
});
// 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: data })
body: JSON.stringify({ theme_name: themeInfo.theme_name, theme_yaml: payload })
});
hideLoader();
if (res.ok) {
showToast("✅ Theme saved!", "success");
showToast("✅ Section saved!", "success");
const updatedThemeInfo = await fetchThemeInfo();
loadedConfig = updatedThemeInfo.theme_yaml;
updateSectionStatus(section, loadedConfig);
} else {
showToast("Error saving theme.", "error");
showToast("Error saving section.", "error");
}
});
});
// Initial status update
["colors", "google-fonts", "fonts", "favicon"].forEach(section => updateSectionStatus(section, loadedConfig));
});
+17 -34
View File
@@ -11,54 +11,37 @@ function hideLoader() {
if (loader) loader.classList.remove("active");
}
// --- Upload gallery images ---
const galleryInput = document.getElementById('upload-gallery');
if (galleryInput) {
galleryInput.addEventListener('change', async (e) => {
// --- Generic upload handler ---
function setupUpload(inputId, apiUrl, loaderText, successMsg, refreshFn) {
const input = document.getElementById(inputId);
if (!input) return;
input.addEventListener('change', async (e) => {
const files = e.target.files;
if (!files.length) return;
showLoader("Uploading photos...");
showLoader(loaderText);
const formData = new FormData();
for (const file of files) formData.append('files', file);
try {
const res = await fetch('/api/gallery/upload', { method: 'POST', body: formData });
const res = await fetch(apiUrl, { method: 'POST', body: formData });
const data = await res.json();
hideLoader();
if (res.ok) {
showToast(`✅ ${data.uploaded.length} gallery image(s) uploaded!`, "success");
if (typeof refreshGallery === "function") refreshGallery();
showToast(`✅ ${data.uploaded.length} ${successMsg}`, "success");
if (typeof refreshFn === "function") refreshFn();
} else showToast('Error: ' + data.error, "error");
} catch(err) {
hideLoader();
console.error(err);
showToast('Server error!', "error");
} finally { e.target.value = ''; }
} finally {
e.target.value = '';
}
});
}
// --- Upload hero images ---
const heroInput = document.getElementById('upload-hero');
if (heroInput) {
heroInput.addEventListener('change', async (e) => {
const files = e.target.files;
if (!files.length) return;
showLoader("Uploading hero photos...");
const formData = new FormData();
for (const file of files) formData.append('files', file);
try {
const res = await fetch('/api/hero/upload', { method: 'POST', body: formData });
const data = await res.json();
hideLoader();
if (res.ok) {
showToast(`✅ ${data.uploaded.length} hero image(s) uploaded!`, "success");
if (typeof refreshHero === "function") refreshHero();
} else showToast('Error: ' + data.error, "error");
} catch(err) {
hideLoader();
console.error(err);
showToast('Server error!', "error");
} finally { e.target.value = ''; }
});
}
// --- Setup all upload inputs ---
setupUpload('upload-gallery', '/api/gallery/upload', "Uploading photos...", "gallery image(s) uploaded!", refreshGallery);
setupUpload('upload-hero', '/api/hero/upload', "Uploading hero photos...", "hero image(s) uploaded!", refreshHero);
setupUpload('upload-gallery-bottom', '/api/gallery/upload', "Uploading photos...", "gallery image(s) uploaded!", refreshGallery);
setupUpload('upload-hero-bottom', '/api/hero/upload', "Uploading hero photos...", "hero image(s) uploaded!", refreshHero);
+56 -28
View File
@@ -4,11 +4,13 @@
{% block content %}
<h1>Edit Site Info</h1>
<form id="site-info-form">
<!-- Info Section -->
<fieldset>
<h1>Edit Site Info</h1>
<!-- Info Section -->
<form id="info-form" autocomplete="off">
<fieldset id="info-section">
<h2>Info</h2>
<p class="section-status"></p>
<p>Set the basic information for your site and SEO</p>
<div class="fields">
<div class="input-field">
@@ -36,10 +38,15 @@
<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>
<!-- Social Section -->
<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">
@@ -49,16 +56,21 @@
<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">
<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>
<!-- Menu Section -->
<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%;">
@@ -66,10 +78,15 @@
<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>
<!-- Footer Section -->
<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">
@@ -78,13 +95,18 @@
</div>
<div class="input-field">
<label>Legal Label</label>
<input type="text" name="footer.legal_label" re>
<input type="text" name="footer.legal_label" required>
</div>
</div>
<button type="submit" class="section-save-btn" data-section="footer">Save</button>
</fieldset>
<!-- Legals Section -->
<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">
@@ -105,15 +127,20 @@
<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>
<!-- Build Section -->
<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" required></select>
<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>
@@ -129,12 +156,14 @@
</label>
</div>
</div>
<button type="submit" class="section-save-btn" data-section="build">Save</button>
</fieldset>
<button type="submit">Save</button>
</form>
<div class="section">
</form>
<!-- Stepper -->
<div class="section">
<h2>Steps</h2>
<p> Follow the steps to generate your static gallery</p>
<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>
@@ -144,10 +173,9 @@
<div></div>
<li><button id="stepper-build">Generate your static site!</button></li>
</ul>
</div>
</div>
</div>
<!-- Delete thumbnail confirmation modal-->
<div class="content-inner">
<div class="content-inner">
<div id="delete-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="delete-modal-close" class="modal-close">&times;</span>
@@ -159,9 +187,9 @@
</div>
</div>
</div>
</div>
<!-- Delete theme confirmation modal -->
<div class="content-inner">
</div>
<!-- Delete theme confirmation modal -->
<div class="content-inner">
<div id="delete-theme-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="delete-theme-modal-close" class="modal-close">&times;</span>
@@ -173,7 +201,7 @@
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+99 -37
View File
@@ -7,7 +7,7 @@ body {
flex-direction: column;
min-height: 100vh;
margin:0px;
width: 100vw;
min-width: 320px;
}
a {
@@ -236,11 +236,32 @@ h2 {
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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 15px;
margin: 30px 0 0 0;
}
#hero-count-bottom, #gallery-count-bottom {
flex-basis: 100%;
}
.bottom-action-row {
margin-top: 30px;
}
@@ -316,6 +337,20 @@ h2 {
.toast.success { background-color: #28a7468c; }
.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 --- */
.tag-input {
display: flex;
@@ -366,15 +401,18 @@ h2 {
z-index: 999;
display: none;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
padding: 8px;
border-radius: 10px;
}
.tag-input ul.suggestions li {
padding: 6px 8px;
padding: 6px 10px;
cursor: pointer;
}
.tag-input ul.suggestions li:hover {
background-color: #007782;
border-radius: 7px;
}
.tags-display {
@@ -406,7 +444,9 @@ h2 {
background-color: #007782;
color: white;
cursor: pointer;
border-radius: 7px;
}
.suggestions li {
cursor: pointer;
}
@@ -416,6 +456,14 @@ h2 {
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-direction: column;
}
@@ -521,18 +569,23 @@ h2 {
align-items: center;
gap: 16px;
margin-bottom: 10px;
flex-wrap: wrap;
}
/* --- Remove All Buttons --- */
#remove-all-hero, #remove-all-gallery {
#remove-all-hero, #remove-all-gallery, .bottom-remove-btn {
background: #2d2d2d;
color: white;
display: none;
margin-bottom: 6px;
}
.bottom-remove-btn {
display: inherit;
}
#remove-all-gallery:hover,
#remove-all-hero:hover {
#remove-all-hero:hover, .bottom-remove-btn:hover {
background: rgb(121, 26, 19);
}
@@ -541,6 +594,7 @@ h2 {
flex-direction: column;
align-items: stretch;
gap: 8px;
flex-wrap: wrap;
}
}
@@ -588,9 +642,9 @@ label {
letter-spacing: 0.5px;
}
#site-info-form input, #theme-editor-form input,
#site-info-form textarea, #theme-editor-form textarea,
#site-info-form select, #theme-editor-form select {
form input,
form textarea,
form select {
background: #1f2223;
color: #fff;
border: 1px solid #585858;
@@ -612,27 +666,24 @@ label {
gap: 0 18px;
}
.theme-info {
margin-bottom: 25px;
}
#site-info-form input::placeholder,
#theme-editor-form input::placeholder,
#site-info-form textarea::placeholder,
#theme-editor-form textarea::placeholder {
form input::placeholder,
form textarea::placeholder {
color: #585858;
font-style: italic;
}
#site-info-form input:focus,
#theme-editor-form input:focus,
#site-info-form textarea:focus,
#theme-editor-form textarea:focus,
#site-info-form select:focus,
#theme-editor-form select:focus {
form input:focus,
form textarea:focus,
form select:focus {
border-color: #585858;
background: #161616;
}
#site-info-form textarea,
#theme-editor-form textarea {
form textarea {
min-height: 60px;
resize: vertical;
}
@@ -651,7 +702,7 @@ img#thumbnail-preview {
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);
color: #fff;
font-weight: 700;
@@ -659,17 +710,18 @@ img#thumbnail-preview {
border-radius: 30px;
padding: 12px 32px;
font-size: 1.1em;
margin: 0 0 45px 0;
margin: 16px 0 0 0;
cursor: pointer;
box-shadow: 0 4px 16px rgba(38,196,255,0.15);
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);
}
#site-info-form button[type="button"], #theme-editor-form button[type="button"] {
form button[type="button"] {
background: #00000000;
color: #fff;
border: none;
@@ -682,34 +734,36 @@ img#thumbnail-preview {
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;
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-bottom: 4px;
border-radius: 30px;
background: #2d2d2d;
}
#site-info-form button.remove-menu-item:hover, #site-info-form button.remove-ip-paragraph:hover, #theme-editor-form button.remove-menu-item:hover, #theme-editor-form button.remove-ip-paragraph:hover {
form button.remove-menu-item:hover,
form button.remove-ip-paragraph:hover {
background: rgb(121, 26, 19);
}
#site-info-form button.remove-btn, #theme-editor-form button.remove-btn {
form button.remove-btn {
border-radius: 30px;
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);
}
#site-info-form .thumbnail-form-label, #theme-editor-form .thumbnail-form-label {
form .thumbnail-form-label {
margin-top: 10px;
}
@@ -771,6 +825,10 @@ fieldset p, .section p {
margin-top: 16px;
}
.section-status {
font-style: normal;
}
/* --- Stepper --- */
#stepper {
@@ -905,6 +963,7 @@ justify-content: center;
#footer a {
color: #fff;
margin: auto;
}
.footer-credit .lum-first::before {
@@ -920,6 +979,7 @@ justify-content: center;
width: 16px;
height: 16px;
display: flex;
margin: auto;
}
.icon-text {
@@ -994,12 +1054,12 @@ justify-content: center;
flex-direction: column;
}
#stepper {
flex-direction: column;
form button[type="button"], form button[type="submit"], #stepper li {
width: 100%;
}
#stepper li {
width: 100%;
#stepper {
flex-direction: column;
}
.footer-container, .footer-links {
@@ -1020,14 +1080,16 @@ justify-content: center;
/* Hide the default arrow */
color: transparent;
}
#site-info-form, #theme-editor-form {
padding: 18px 8px;
}
.input-field {
min-width: 100%;
margin-bottom: 12px;
}
#site-info-form button[type="submit"], #theme-editor-form button[type="submit"] {
width: 100%;
}
#color-picker .input-field{
min-width: 170px;
}
+5 -2
View File
@@ -48,7 +48,10 @@
<span id="build-success-modal-close" class="modal-close">&times;</span>
<h3>✅ Build completed!</h3>
<p>Your files are available in the output folder.</p>
<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>
</div>
@@ -65,7 +68,7 @@
</div>
<div class="footer-links">
<a class="footer-link documentation" href="https://lumeex.djeex.fr"><span class="icon"><img src="/img/favicon.svg"></span><span class="icon-text">Documentation</span></a>
<a class="footer-link gitea" href="https://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>
</div>
</div>
+53 -36
View File
@@ -4,121 +4,138 @@
{% block content %}
<h1>Edit Theme</h1>
<!-- Show current theme -->
<div class="theme-info">
<h1>Edit Theme</h1>
<!-- Show current theme -->
<div class="theme-info">
<strong>Current theme:</strong> <span id="current-theme"></span>
</div>
<form id="theme-editor-form">
</div>
<div id="theme-editor-form">
<!-- Colors Section -->
<form id="colors-form" autocomplete="off">
<fieldset id="color-picker">
<h2>Colors</h2>
<p class="section-status"></p>
<p>Set the color values for your theme</p>
<div class="fields">
<!-- Example for one color field, repeat for all -->
<div class="input-field">
<label>Primary</label>
<div class="fields color-fields">
<button type="button" id="color-primary-btn" class="color-btn"></button>
<input type="color" name="colors.primary" id="color-primary" class="color-input">
<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>
<input type="text" name="colors.primary_text" id="color-primary-text" style="width:100px;" required>
</div>
</div>
<div class="input-field">
<label>Primary Dark</label>
<div class="fields color-fields">
<button type="button" id="color-primary-dark-btn" class="color-btn"></button>
<input type="color" name="colors.primary_dark" id="color-primary-dark" class="color-input">
<input type="text" name="colors.primary_dark_text" id="color-primary-dark-text" style="width:100px;">
<input type="color" name="colors.primary_dark" id="color-primary-dark" class="color-input" required>
<input type="text" name="colors.primary_dark_text" id="color-primary-dark-text" style="width:100px;" required>
</div>
</div>
<div class="input-field">
<label>Secondary</label>
<div class="fields color-fields">
<button type="button" id="color-secondary-btn" class="color-btn"></button>
<input type="color" name="colors.secondary" id="color-secondary" class="color-input">
<input type="text" name="colors.secondary_text" id="color-secondary-text" style="width:100px;">
<input type="color" name="colors.secondary" id="color-secondary" class="color-input" required>
<input type="text" name="colors.secondary_text" id="color-secondary-text" style="width:100px;" required>
</div>
</div>
<div class="input-field">
<label>Accent</label>
<div class="fields color-fields">
<button type="button" id="color-accent-btn" class="color-btn"></button>
<input type="color" name="colors.accent" id="color-accent" class="color-input">
<input type="text" name="colors.accent_text" id="color-accent-text" style="width:100px;">
<input type="color" name="colors.accent" id="color-accent" class="color-input" required>
<input type="text" name="colors.accent_text" id="color-accent-text" style="width:100px;" required>
</div>
</div>
<div class="input-field">
<label>Text Dark</label>
<div class="fields color-fields">
<button type="button" id="color-text-dark-btn" class="color-btn"></button>
<input type="color" name="colors.text_dark" id="color-text-dark" class="color-input">
<input type="text" name="colors.text_dark_text" id="color-text-dark-text" style="width:100px;">
<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">
<input type="text" name="colors.background_text" id="color-background-text" style="width:100px;">
<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">
<input type="text" name="colors.browser_color_text" id="color-browser-color-text" style="width:100px;">
<input type="color" name="colors.browser_color" id="color-browser-color" class="color-input" required>
<input type="text" name="colors.browser_color_text" id="color-browser-color-text" style="width:100px;" required>
</div>
</div>
</div>
<button type="submit" class="section-save-btn" data-section="colors">Save</button>
</fieldset>
</form>
<!-- Google Fonts Section -->
<form id="google-fonts-form" autocomplete="off">
<fieldset>
<h2>Google Fonts</h2>
<p class="section-status"></p>
<p>Add Google Fonts to your theme</p>
<div class="fields" id="google-fonts-fields">
<!-- JS will render font family and weights inputs here -->
</div>
<button type="button" id="add-google-font">Add Google Font</button>
<button type="submit" class="section-save-btn" data-section="google-fonts">Save</button>
</fieldset>
</form>
<!-- Custom Font Upload Section -->
<form id="font-upload-form" autocomplete="off">
<fieldset>
<h2>Upload Custom Font</h2>
<p class="section-status"></p>
<p>Supported formats: .woff, .woff2</p>
<input type="file" id="font-upload" accept=".woff,.woff2" style="display:none;">
<div id="local-fonts-list" class="font-list"></div>
<button type="button" id="choose-font-btn" class="up-btn">🖋️ Upload font</button>
<!-- Save button not needed for upload-only section -->
</fieldset>
</form>
<!-- Fonts Section -->
<form id="fonts-form" autocomplete="off">
<fieldset>
<h2>Fonts</h2>
<p class="section-status"></p>
<p>Select where to apply your fonts</p>
<div class="fields">
<div class="input-field">
<label>Primary Font</label>
<select name="fonts.primary.name" id="font-primary"></select>
<select name="fonts.primary.name" id="font-primary" required></select>
<label>Fallback</label>
<select name="fonts.primary.fallback" id="font-primary-fallback">
<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"></select>
<select name="fonts.secondary.name" id="font-secondary" required></select>
<label>Fallback</label>
<select name="fonts.secondary.fallback" id="font-secondary-fallback">
<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">
@@ -132,12 +149,14 @@
</div>
</div>
</div>
<button type="submit" class="section-save-btn" data-section="favicon">Save</button>
</fieldset>
<button type="submit">Save Theme</button>
</form>
<div class="section">
</div>
<!-- Stepper -->
<div class="section">
<h2>Steps</h2>
<p> Follow the steps to generate your static gallery</p>
<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>
@@ -147,11 +166,9 @@
<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>
<!-- Delete confirmation modal for favicon -->
<div id="delete-favicon-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="delete-favicon-modal-close" class="modal-close">&times;</span>
<h3>Confirm Deletion</h3>
@@ -161,9 +178,9 @@
<button id="delete-favicon-modal-cancel" class="modal-btn">Cancel</button>
</div>
</div>
</div>
<!-- Delete confirmation modal for font -->
<div id="delete-font-modal" class="modal" style="display:none;">
</div>
<!-- Delete confirmation modal for font -->
<div id="delete-font-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="delete-font-modal-close" class="modal-close">&times;</span>
<h3>Confirm Deletion</h3>
@@ -173,7 +190,7 @@
<button id="delete-font-modal-cancel" class="modal-btn">Cancel</button>
</div>
</div>
</div>
</div>
{% endblock %}
+83
View File
@@ -0,0 +1,83 @@
from pathlib import Path
import pytest
from PIL import Image
REPO_ROOT = Path(__file__).resolve().parent.parent
DEMO_ROOT = REPO_ROOT / "demo" / "config"
@pytest.fixture
def repo_root():
return REPO_ROOT
@pytest.fixture
def demo_root():
return DEMO_ROOT
@pytest.fixture
def make_image():
"""Factory that writes a small real image to disk and returns its path."""
def _make(path: Path, size=(64, 48), color=(200, 100, 50), fmt="JPEG", **save_kwargs):
path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", size, color).save(path, fmt, **save_kwargs)
return path
return _make
@pytest.fixture
def app_env(tmp_path, monkeypatch, make_image):
"""
Isolate the Flask webui from the real repo filesystem.
webui.py resolves most config paths from `Path(__file__).resolve().parents[3]`
(recomputed inline in most routes) or from module-level globals computed once
at import time (PHOTOS_DIR, SITE_YAML). We patch `__file__` so inline lookups
land in a throwaway tmp_path repo skeleton, patch the two import-time globals
directly, and chdir so the plain-relative paths used by gallery_builder
(GALLERY_YAML, GALLERY_DIR, HERO_DIR) resolve there too.
"""
import src.py.webui.webui as webui
root = tmp_path
(root / "config" / "photos" / "gallery").mkdir(parents=True)
(root / "config" / "photos" / "hero").mkdir(parents=True)
theme_dir = root / "config" / "themes" / "modern"
theme_dir.mkdir(parents=True)
(theme_dir / "theme.yaml").write_text(
"colors:\n browser_color: '#ffffff'\nfavicon:\n path: favicon.png\ngoogle_fonts: []\n",
encoding="utf-8",
)
make_image(theme_dir / "favicon.png", size=(32, 32), fmt="PNG")
(root / "config" / "gallery.yaml").write_text(
"hero:\n images: []\ngallery:\n images: []\n", encoding="utf-8"
)
(root / "config" / "site.yaml").write_text(
"info:\n title: Test\n canonical: https://example.com\nsocial:\n thumbnail: ''\n",
encoding="utf-8",
)
fake_module_file = root / "src" / "py" / "webui" / "webui.py"
fake_module_file.parent.mkdir(parents=True, exist_ok=True)
monkeypatch.chdir(root)
monkeypatch.setattr(webui, "__file__", str(fake_module_file))
monkeypatch.setattr(webui, "PHOTOS_DIR", root / "config" / "photos")
monkeypatch.setattr(webui, "SITE_YAML", root / "config" / "site.yaml")
webui.app.config["PHOTOS_DIR"] = root / "config" / "photos"
webui.app.config["TESTING"] = True
return root
@pytest.fixture
def client(app_env):
import src.py.webui.webui as webui
return webui.app.test_client()
+91
View File
@@ -0,0 +1,91 @@
from src.py.builder import utils
def test_load_yaml_missing_file_returns_empty_dict(tmp_path, caplog):
result = utils.load_yaml(tmp_path / "missing.yaml")
assert result == {}
def test_load_yaml_parses_existing_file(tmp_path):
path = tmp_path / "data.yaml"
path.write_text("foo: bar\nnum: 1\n", encoding="utf-8")
assert utils.load_yaml(path) == {"foo": "bar", "num": 1}
def test_load_theme_config_success(tmp_path):
themes_dir = tmp_path / "themes"
theme_dir = themes_dir / "modern"
theme_dir.mkdir(parents=True)
(theme_dir / "theme.yaml").write_text("colors:\n primary: '#000'\n", encoding="utf-8")
theme_vars, returned_dir = utils.load_theme_config("modern", themes_dir)
assert theme_vars == {"colors": {"primary": "#000"}}
assert returned_dir == theme_dir
def test_load_theme_config_missing_raises(tmp_path):
themes_dir = tmp_path / "themes"
try:
utils.load_theme_config("missing", themes_dir)
raise AssertionError("expected FileNotFoundError")
except FileNotFoundError:
pass
def test_clear_dir_creates_missing_dir(tmp_path):
target = tmp_path / "out"
utils.clear_dir(target)
assert target.is_dir()
assert list(target.iterdir()) == []
def test_clear_dir_removes_existing_content(tmp_path):
target = tmp_path / "out"
target.mkdir()
(target / "file.txt").write_text("x", encoding="utf-8")
sub = target / "sub"
sub.mkdir()
(sub / "nested.txt").write_text("y", encoding="utf-8")
utils.clear_dir(target)
assert target.is_dir()
assert list(target.iterdir()) == []
def test_ensure_dir_creates_and_clears(tmp_path):
target = tmp_path / "out"
utils.ensure_dir(target)
assert target.is_dir()
(target / "stale.txt").write_text("x", encoding="utf-8")
utils.ensure_dir(target)
assert list(target.iterdir()) == []
def test_copy_assets_copies_existing_folders(tmp_path):
js_dir = tmp_path / "js"
style_dir = tmp_path / "style"
build_dir = tmp_path / "output"
js_dir.mkdir()
style_dir.mkdir()
(js_dir / "app.js").write_text("console.log(1)", encoding="utf-8")
(style_dir / "style.css").write_text("body{}", encoding="utf-8")
utils.copy_assets(js_dir, style_dir, build_dir)
assert (build_dir / "js" / "app.js").exists()
assert (build_dir / "style" / "style.css").exists()
def test_copy_assets_skips_missing_folder(tmp_path, caplog):
js_dir = tmp_path / "missing_js"
style_dir = tmp_path / "style"
style_dir.mkdir()
build_dir = tmp_path / "output"
utils.copy_assets(js_dir, style_dir, build_dir)
assert not (build_dir / "missing_js").exists()
assert (build_dir / "style").exists()
+68
View File
@@ -0,0 +1,68 @@
from src.py.builder import css_generator as cg
def test_generate_css_variables(tmp_path):
output_path = tmp_path / "style" / "colors.css"
cg.generate_css_variables({"primary_dark": "#005384", "accent": "#FFC700"}, output_path)
content = output_path.read_text(encoding="utf-8")
assert ":root {" in content
assert "--color-primary-dark: #005384;" in content
assert "--color-accent: #FFC700;" in content
def test_generate_fonts_css_with_font_files_and_config(tmp_path):
fonts_dir = tmp_path / "theme" / "fonts"
fonts_dir.mkdir(parents=True)
(fonts_dir / "trixie.woff2").write_bytes(b"fake-woff2")
(fonts_dir / "trixie.woff").write_bytes(b"fake-woff")
(fonts_dir / "readme.txt").write_text("skip me", encoding="utf-8")
output_path = tmp_path / "build" / "style" / "fonts.css"
fonts_cfg = {
"primary": {"name": "Lato", "fallback": "sans-serif"},
"secondary": {"name": "Trixie", "fallback": "monospace"},
}
preload_links = cg.generate_fonts_css(fonts_dir, output_path, fonts_cfg=fonts_cfg)
content = output_path.read_text(encoding="utf-8")
assert "@font-face {" in content
assert "font-family: 'trixie';" in content
assert "--font-primary: 'Lato', sans-serif;" in content
assert "--font-secondary: 'Trixie', monospace;" in content
assert (tmp_path / "build" / "fonts" / "trixie.woff2").exists()
assert (tmp_path / "build" / "fonts" / "trixie.woff").exists()
assert len(preload_links) == 2
assert any("trixie.woff2" in link for link in preload_links)
def test_generate_fonts_css_with_no_fonts(tmp_path):
fonts_dir = tmp_path / "empty_fonts"
fonts_dir.mkdir()
output_path = tmp_path / "build" / "fonts.css"
preload_links = cg.generate_fonts_css(fonts_dir, output_path)
assert preload_links == []
assert output_path.exists()
def test_generate_google_fonts_link_empty():
assert cg.generate_google_fonts_link([]) == ""
assert cg.generate_google_fonts_link(None) == ""
def test_generate_google_fonts_link_with_weights():
fonts = [
{"family": "Lato", "weights": ["200", "400", "700"]},
{"family": "Open Sans"},
]
link = cg.generate_google_fonts_link(fonts)
assert "family=Lato:wght@200;400;700" in link
assert "family=Open+Sans" in link
assert link.startswith('<link href="https://fonts.googleapis.com/css2?')
assert link.endswith('&display=swap" rel="stylesheet">')
+111
View File
@@ -0,0 +1,111 @@
from src.py.builder import gallery_builder as gb
def _setup_repo(tmp_path, monkeypatch):
(tmp_path / "config" / "photos" / "gallery").mkdir(parents=True)
(tmp_path / "config" / "photos" / "hero").mkdir(parents=True)
monkeypatch.chdir(tmp_path)
def test_load_yaml_missing_file(tmp_path, monkeypatch):
_setup_repo(tmp_path, monkeypatch)
assert gb.load_yaml("config/gallery.yaml") == {}
def test_load_yaml_and_save_yaml_roundtrip(tmp_path, monkeypatch):
_setup_repo(tmp_path, monkeypatch)
data = {"gallery": {"images": [{"src": "gallery/a.jpg", "tags": ["x"]}]}}
gb.save_yaml(data, "config/gallery.yaml")
loaded = gb.load_yaml("config/gallery.yaml")
assert loaded == data
def test_get_all_image_paths_filters_and_sorts(tmp_path, monkeypatch, make_image):
_setup_repo(tmp_path, monkeypatch)
gallery_dir = tmp_path / "config" / "photos" / "gallery"
make_image(gallery_dir / "b.jpg")
make_image(gallery_dir / "a.png", fmt="PNG")
(gallery_dir / "notes.txt").write_text("ignore me", encoding="utf-8")
paths = gb.get_all_image_paths(gallery_dir)
assert paths == ["gallery/a.png", "gallery/b.jpg"]
def test_update_gallery_adds_new_and_removes_deleted(tmp_path, monkeypatch, make_image, capsys):
_setup_repo(tmp_path, monkeypatch)
gallery_dir = tmp_path / "config" / "photos" / "gallery"
make_image(gallery_dir / "kept.jpg")
make_image(gallery_dir / "new.jpg")
gb.save_yaml(
{
"gallery": {
"images": [
{"src": "gallery/kept.jpg", "tags": ["portrait"]},
{"src": "gallery/gone.jpg", "tags": ["stale"]},
]
}
},
gb.GALLERY_YAML,
)
gb.update_gallery()
result = gb.load_yaml(gb.GALLERY_YAML)
images = {img["src"]: img for img in result["gallery"]["images"]}
assert set(images) == {"gallery/kept.jpg", "gallery/new.jpg"}
assert images["gallery/kept.jpg"]["tags"] == ["portrait"]
assert images["gallery/new.jpg"]["tags"] == []
def test_update_gallery_no_changes(tmp_path, monkeypatch, make_image, capsys):
_setup_repo(tmp_path, monkeypatch)
gallery_dir = tmp_path / "config" / "photos" / "gallery"
make_image(gallery_dir / "only.jpg")
gb.save_yaml(
{"gallery": {"images": [{"src": "gallery/only.jpg", "tags": []}]}},
gb.GALLERY_YAML,
)
gb.update_gallery()
out = capsys.readouterr().out
assert "No changes to gallery.yaml (gallery)" in out
def test_update_hero_adds_new_and_removes_deleted(tmp_path, monkeypatch, make_image):
_setup_repo(tmp_path, monkeypatch)
hero_dir = tmp_path / "config" / "photos" / "hero"
make_image(hero_dir / "kept.jpg")
make_image(hero_dir / "new.jpg")
gb.save_yaml(
{
"hero": {
"images": [
{"src": "hero/kept.jpg"},
{"src": "hero/gone.jpg"},
]
}
},
gb.GALLERY_YAML,
)
gb.update_hero()
result = gb.load_yaml(gb.GALLERY_YAML)
srcs = {img["src"] for img in result["hero"]["images"]}
assert srcs == {"hero/kept.jpg", "hero/new.jpg"}
def test_update_gallery_on_empty_yaml_initializes_section(tmp_path, monkeypatch, make_image):
_setup_repo(tmp_path, monkeypatch)
gallery_dir = tmp_path / "config" / "photos" / "gallery"
make_image(gallery_dir / "only.jpg")
gb.update_gallery()
result = gb.load_yaml(gb.GALLERY_YAML)
assert result["gallery"]["images"][0]["src"] == "gallery/only.jpg"
+116
View File
@@ -0,0 +1,116 @@
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_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>&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;</title>" in page
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"?>')
+213
View File
@@ -0,0 +1,213 @@
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()
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
+114
View File
@@ -0,0 +1,114 @@
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_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
site_data["build"]["resize_images"] = False
site_file = tmp_path / "site.yaml"
site_file.write_text(yaml.dump(site_data), encoding="utf-8")
output_dir = tmp_path / "output"
monkeypatch.setattr(site_builder, "BUILD_DIR", output_dir)
monkeypatch.setattr(site_builder, "GALLERY_FILE", demo_root / "gallery.yaml")
monkeypatch.setattr(site_builder, "SITE_FILE", site_file)
monkeypatch.setattr(site_builder, "IMG_DIR", demo_root / "photos")
monkeypatch.setattr(site_builder, "THEMES_DIR", demo_root / "themes")
site_builder.build()
copied = list((output_dir / "img" / "gallery").glob("*.jpg"))
assert copied, "original jpg files should be copied unmodified"
def test_build_minimal_site_skips_optional_sections(tmp_path, monkeypatch, demo_root):
gallery_file = tmp_path / "gallery.yaml"
gallery_file.write_text("hero:\n images: []\ngallery:\n images: []\n", encoding="utf-8")
site_file = tmp_path / "site.yaml"
site_file.write_text(
"info:\n title: Minimal\n"
"menu:\n items: []\n"
"build:\n theme: modern\n convert_images: true\n resize_images: true\n",
encoding="utf-8",
)
output_dir = tmp_path / "output"
monkeypatch.setattr(site_builder, "BUILD_DIR", output_dir)
monkeypatch.setattr(site_builder, "GALLERY_FILE", gallery_file)
monkeypatch.setattr(site_builder, "SITE_FILE", site_file)
monkeypatch.setattr(site_builder, "IMG_DIR", demo_root / "photos")
monkeypatch.setattr(site_builder, "THEMES_DIR", demo_root / "themes")
site_builder.build()
assert (output_dir / "index.html").exists()
assert not (output_dir / "legals").exists()
assert not (output_dir / "robots.txt").exists()
assert not (output_dir / "sitemap.xml").exists()
assert not (output_dir / "data" / "gallery.json").exists()
assert not (output_dir / "img" / "social").exists()
+82
View File
@@ -0,0 +1,82 @@
import io
from src.py.webui.upload import allowed_file, save_uploaded_file
def test_allowed_file_accepts_known_extensions():
for name in ["a.jpg", "a.JPEG", "a.png", "a.webp"]:
assert allowed_file(name) is True
def test_allowed_file_rejects_unknown_or_missing_extension():
assert allowed_file("a.gif") is False
assert allowed_file("noextension") is False
def test_save_uploaded_file_sanitizes_filename(tmp_path):
class FakeFile:
filename = "../../evil.jpg"
def save(self, path):
self.saved_path = path
folder = tmp_path / "gallery"
fake_file = FakeFile()
result = save_uploaded_file(fake_file, folder)
assert result == "evil.jpg"
assert fake_file.saved_path == folder / "evil.jpg"
assert folder.exists()
def test_upload_photo_invalid_section(client):
resp = client.post(
"/api/invalid/upload",
data={"files": (io.BytesIO(b"data"), "a.jpg")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
def test_upload_photo_no_files_key(client):
resp = client.post("/api/gallery/upload", data={}, content_type="multipart/form-data")
assert resp.status_code == 400
def test_upload_photo_skips_disallowed_extensions(client):
resp = client.post(
"/api/gallery/upload",
data={"files": (io.BytesIO(b"data"), "notes.txt")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
assert "No valid files uploaded" in resp.get_json()["error"]
def test_upload_photo_saves_valid_files_and_updates_gallery(client, app_env):
resp = client.post(
"/api/gallery/upload",
data={"files": (io.BytesIO(b"fake-jpg-bytes"), "photo.jpg")},
content_type="multipart/form-data",
)
body = resp.get_json()
assert body["status"] == "ok"
assert body["uploaded"] == ["photo.jpg"]
assert (app_env / "config" / "photos" / "gallery" / "photo.jpg").exists()
gallery = client.get("/api/gallery").get_json()
assert gallery[0]["src"] == "gallery/photo.jpg"
def test_upload_photo_hero_section(client, app_env):
resp = client.post(
"/api/hero/upload",
data={"files": (io.BytesIO(b"fake-jpg-bytes"), "photo.jpg")},
content_type="multipart/form-data",
)
assert resp.get_json()["status"] == "ok"
assert (app_env / "config" / "photos" / "hero" / "photo.jpg").exists()
hero = client.get("/api/hero").get_json()
assert hero[0]["src"] == "hero/photo.jpg"
+316
View File
@@ -0,0 +1,316 @@
import io
import zipfile
import yaml
def test_index_page(client):
resp = client.get("/")
assert resp.status_code == 200
def test_get_gallery_and_hero_start_empty(client):
assert client.get("/api/gallery").get_json() == []
assert client.get("/api/hero").get_json() == []
def test_update_gallery_and_hero(client, app_env):
images = [{"src": "gallery/a.jpg", "tags": ["x"]}]
resp = client.post("/api/gallery/update", json=images)
assert resp.get_json() == {"status": "ok"}
assert client.get("/api/gallery").get_json() == images
hero_images = [{"src": "hero/a.jpg"}]
resp = client.post("/api/hero/update", json=hero_images)
assert resp.get_json() == {"status": "ok"}
assert client.get("/api/hero").get_json() == hero_images
def test_refresh_gallery_and_hero_pick_up_new_files(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "gallery" / "new.jpg")
make_image(app_env / "config" / "photos" / "hero" / "new.jpg")
client.post("/api/gallery/refresh")
client.post("/api/hero/refresh")
gallery = client.get("/api/gallery").get_json()
hero = client.get("/api/hero").get_json()
assert gallery[0]["src"] == "gallery/new.jpg"
assert hero[0]["src"] == "hero/new.jpg"
def test_delete_gallery_photo(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "gallery" / "a.jpg")
resp = client.post("/api/gallery/delete", json={"src": "a.jpg"})
assert resp.get_json() == {"status": "ok"}
assert not (app_env / "config" / "photos" / "gallery" / "a.jpg").exists()
def test_delete_gallery_photo_not_found(client):
resp = client.post("/api/gallery/delete", json={"src": "missing.jpg"})
assert resp.status_code == 404
def test_delete_hero_photo(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "hero" / "a.jpg")
resp = client.post("/api/hero/delete", json={"src": "a.jpg"})
assert resp.get_json() == {"status": "ok"}
assert not (app_env / "config" / "photos" / "hero" / "a.jpg").exists()
def test_delete_all_gallery_photos(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "gallery" / "a.jpg")
make_image(app_env / "config" / "photos" / "gallery" / "b.jpg")
client.post("/api/gallery/update", json=[{"src": "gallery/a.jpg"}, {"src": "gallery/b.jpg"}])
resp = client.post("/api/gallery/delete_all")
body = resp.get_json()
assert body["status"] == "ok"
assert body["deleted"] == 2
assert client.get("/api/gallery").get_json() == []
assert list((app_env / "config" / "photos" / "gallery").iterdir()) == []
def test_delete_all_hero_photos(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "hero" / "a.jpg")
client.post("/api/hero/update", json=[{"src": "hero/a.jpg"}])
resp = client.post("/api/hero/delete_all")
body = resp.get_json()
assert body["deleted"] == 1
assert client.get("/api/hero").get_json() == []
def test_serve_photo_by_section(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "gallery" / "a.jpg")
resp = client.get("/photos/gallery/a.jpg")
assert resp.status_code == 200
def test_serve_photo_at_root(client, app_env, make_image):
make_image(app_env / "config" / "photos" / "thumbnail.png", fmt="PNG")
resp = client.get("/photos/thumbnail.png")
assert resp.status_code == 200
def test_site_info_get_and_post(client, app_env):
resp = client.get("/api/site-info")
data = resp.get_json()
assert data["info"]["title"] == "Test"
client.post(
"/api/site-info",
json={
"info": {"subtitle": "New subtitle"},
"social": {"instagram_url": "https://insta.example"},
},
)
updated = client.get("/api/site-info").get_json()
assert updated["info"]["title"] == "Test"
assert updated["info"]["subtitle"] == "New subtitle"
assert updated["social"]["instagram_url"] == "https://insta.example"
def test_list_themes(client):
assert client.get("/api/themes").get_json() == ["modern"]
def test_thumbnail_upload_and_remove(client, app_env):
data = {"file": (io.BytesIO(b"fake-png-bytes"), "thumb.png")}
resp = client.post("/api/thumbnail/upload", data=data, content_type="multipart/form-data")
body = resp.get_json()
assert body["status"] == "ok"
assert (app_env / "config" / "photos" / "thumbnail.png").exists()
site_info = client.get("/api/site-info").get_json()
assert site_info["social"]["thumbnail"] == "thumbnail.png"
resp = client.post("/api/thumbnail/remove")
assert resp.get_json() == {"status": "ok"}
assert not (app_env / "config" / "photos" / "thumbnail.png").exists()
site_info = client.get("/api/site-info").get_json()
assert site_info["social"]["thumbnail"] == ""
def test_thumbnail_upload_missing_file(client):
resp = client.post("/api/thumbnail/upload", data={}, content_type="multipart/form-data")
assert resp.status_code == 400
def test_theme_upload_and_remove(client, app_env):
data = {
"files": [
(io.BytesIO(b"colors: {}"), "custom/theme.yaml"),
(io.BytesIO(b"body{}"), "custom/theme.css"),
]
}
resp = client.post("/api/theme/upload", data=data, content_type="multipart/form-data")
body = resp.get_json()
assert body["status"] == "ok"
assert body["theme"] == "custom"
assert (app_env / "config" / "themes" / "custom" / "theme.yaml").exists()
assert (app_env / "config" / "themes" / "custom" / "theme.css").exists()
resp = client.post("/api/theme/remove", json={"theme": "custom"})
assert resp.get_json() == {"status": "ok"}
assert not (app_env / "config" / "themes" / "custom").exists()
def test_theme_remove_protected_theme(client):
resp = client.post("/api/theme/remove", json={"theme": "modern"})
assert resp.status_code == 400
def test_theme_remove_missing_theme(client):
resp = client.post("/api/theme/remove", json={"theme": "does-not-exist"})
assert resp.status_code == 404
def test_theme_info_get_and_post(client, app_env):
resp = client.get("/api/theme-info")
body = resp.get_json()
assert body["theme_name"] == "modern"
assert body["theme_yaml"]["favicon"]["path"] == "favicon.png"
new_theme_yaml = dict(body["theme_yaml"])
new_theme_yaml["colors"] = {"primary": "#123456"}
resp = client.post(
"/api/theme-info",
json={"theme_name": "modern", "theme_yaml": new_theme_yaml},
)
assert resp.get_json() == {"status": "ok"}
saved = yaml.safe_load((app_env / "config" / "themes" / "modern" / "theme.yaml").read_text())
assert saved["colors"]["primary"] == "#123456"
def test_update_theme_google_fonts(client, app_env):
resp = client.post(
"/api/theme-google-fonts",
json={"theme_name": "modern", "google_fonts": [{"family": "Lato"}]},
)
assert resp.get_json() == {"status": "ok"}
saved = yaml.safe_load((app_env / "config" / "themes" / "modern" / "theme.yaml").read_text())
assert saved["google_fonts"] == [{"family": "Lato"}]
def test_local_fonts_empty_then_populated(client, app_env):
assert client.get("/api/local-fonts?theme=modern").get_json() == []
fonts_dir = app_env / "config" / "themes" / "modern" / "fonts"
fonts_dir.mkdir(parents=True)
(fonts_dir / "trixie.woff2").write_bytes(b"fake")
assert client.get("/api/local-fonts?theme=modern").get_json() == ["trixie.woff2"]
def test_favicon_upload_invalid_extension(client):
data = {"theme": "modern", "file": (io.BytesIO(b"not-an-image"), "favicon.txt")}
resp = client.post("/api/favicon/upload", data=data, content_type="multipart/form-data")
assert resp.status_code == 400
def test_favicon_upload_and_remove(client, app_env):
data = {"theme": "modern", "file": (io.BytesIO(b"fake-png"), "favicon.png")}
resp = client.post("/api/favicon/upload", data=data, content_type="multipart/form-data")
assert resp.get_json()["status"] == "ok"
assert (app_env / "config" / "themes" / "modern" / "favicon.png").exists()
resp = client.post("/api/favicon/remove", json={"theme": "modern"})
assert resp.get_json() == {"status": "ok"}
assert not (app_env / "config" / "themes" / "modern" / "favicon.png").exists()
def test_serve_theme_asset(client, app_env):
(app_env / "config" / "themes" / "modern" / "theme.css").write_text("body{}", encoding="utf-8")
resp = client.get("/themes/modern/theme.css")
assert resp.status_code == 200
def test_font_upload_invalid_extension(client):
data = {"theme": "modern", "file": (io.BytesIO(b"data"), "font.ttf")}
resp = client.post("/api/font/upload", data=data, content_type="multipart/form-data")
assert resp.status_code == 400
def test_font_upload_and_remove(client, app_env):
data = {"theme": "modern", "file": (io.BytesIO(b"data"), "trixie.woff2")}
resp = client.post("/api/font/upload", data=data, content_type="multipart/form-data")
body = resp.get_json()
assert body["status"] == "ok"
assert body["filename"] == "trixie"
resp = client.post("/api/font/remove", json={"theme": "modern", "font": "trixie.woff2"})
assert resp.get_json() == {"status": "ok"}
def test_font_remove_missing(client):
resp = client.post("/api/font/remove", json={"theme": "modern", "font": "missing.woff2"})
assert resp.status_code == 404
def test_trigger_build_missing_site_yaml(client, app_env):
(app_env / "config" / "site.yaml").unlink()
resp = client.post("/api/build")
assert resp.status_code == 400
assert "site.yaml not found" in resp.get_json()["message"]
def test_trigger_build_missing_required_field(client, app_env):
resp = client.post("/api/build")
assert resp.status_code == 400
assert "Site info are not set" in resp.get_json()["message"]
def test_trigger_build_success_invokes_subprocess(client, app_env, monkeypatch):
import src.py.webui.webui as webui
(app_env / "config" / "site.yaml").write_text(
"info:\n title: Test\n canonical: https://example.com\nsocial:\n thumbnail: t.png\n",
encoding="utf-8",
)
calls = []
monkeypatch.setattr(webui.subprocess, "run", lambda *a, **k: calls.append((a, k)))
resp = client.post("/api/build")
assert resp.get_json() == {"status": "ok"}
assert calls
def test_trigger_build_subprocess_failure_returns_500(client, app_env, monkeypatch):
import src.py.webui.webui as webui
(app_env / "config" / "site.yaml").write_text(
"info:\n title: Test\n canonical: https://example.com\nsocial:\n thumbnail: t.png\n",
encoding="utf-8",
)
def _raise(*a, **k):
raise RuntimeError("boom")
monkeypatch.setattr(webui.subprocess, "run", _raise)
resp = client.post("/api/build")
assert resp.status_code == 500
assert "boom" in resp.get_json()["message"]
def test_download_output_zip(client, app_env):
output_dir = app_env / "output"
output_dir.mkdir()
(output_dir / "index.html").write_text("<html></html>", encoding="utf-8")
resp = client.post("/download-output-zip")
assert resp.status_code == 200
zip_bytes = io.BytesIO(resp.data)
with zipfile.ZipFile(zip_bytes) as zf:
assert "index.html" in zf.namelist()
assert not (app_env / "site_output.zip").exists()