Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
c975l-gallery
c975l/gallery-bundle
claude-code
symfony
bundle
gallery
photos

Use this skill when working with photo galleries in a Symfony application built on the c975L ecosystem with c975l/gallery-bundle. Covers categories and medias, the admin-renamable url prefix, the two-step trash, batch upload and the three image derivatives, videos and embeds, the two gallery blocks, theming, and every extension point the bundle offers. Triggers on: GalleryCategory, GalleryMedia, gallery-route-prefix, gallery_index, gallery_category, gallery_media, gallery_categories, gallery_medias, c975l:gallery:rebuild-thumbnails, c975l:gallery:fill-slugs, c975l:gallery:automatic:dedupe, photo gallery, thumbnail, lightbox, Image:Zoom, image-zoom__image, batch upload, upload progress, passe-partout, trash, restore, delete permanently, 410 Gone, download highres, download originals, GalleryMediaArchiver, move medias, move selection, GalleryMediaMover, moveMedias, files-gallery, health check, automatic gallery, latest additions, GalleryLatestProvider, findOrCreateAutomatic, findLatest, gallery-latest-days, gallery-latest-max, gallery-rating, likes, like a photo, heart, rating, findVisibleByCategories, setLoadedMedias, media caption, media description, GalleryCustomizationProviderInterface, gallery.customization_provider, GalleryDataField, getDataValue, gallery-video-self-hosted-max-height, self-hosted video, portrait video., GallerySampleCatalog, GalleryDemoFixtureProvider, DemoFixtureProviderInterface, demo gallery, ReplacingFile, DemoFixtureMediaCopier, hidden, hide a gallery, masking, sell prints, print shop, limited edition, editionSize, printable, certificate of authenticity, gallery_print_certificate, gallery_print_file, gallery_print_callback, c975l:gallery:print:sync, GalleryPrintOrderTracker, GalleryMaintenanceTaskProvider, callbackUrl, gallery-print-enabled, gallery-printable-max, gallery-print-provider, gallery-print-sandbox, gallery-print-signature, GalleryPrintFormat, GalleryPrintOrder, GalleryPrintCopy, PrintCopySnapshot, PrintFulfilmentInterface, ProdigiFulfilment, ManualFulfilment, gallery.print_fulfilment, PrintCatalogueProviderInterface, PrintCatalogueImporter, ProdigiCatalogue, import print catalogue, GalleryPrintOrderRepository, claim, findForUser, AccountDataProvider, AccountDataProviderInterface, data export, gallery-print-edition-hold, shop-currency, gallery-print-setup, gallery-print-order, c975lStimulusApp, BasketItemProviderInterface, CatalogueBasketItemProviderInterface, getCatalogueUrl, continue shopping, ShippingBasketItemProviderInterface, shipsParcels, WeighableBasketItemProviderInterface, getWeight, print weight, AutomaticGalleryInterface, gallery.automatic_gallery, GalleryAutomaticProvider, GalleryPrintableProvider, automaticKind, qr code, GalleryShowcaseProvider, ui-showcase-demo-url, block showcase, block-thumbs, block picker silhouette, ui.management_stylesheet, getManagementStylesheets, GallerySnippetBuilder, GalleryJsonLdExtension, gallery_media_json_ld, gallery_json_ld, gallery_index_json_ld, gallery_print_json_ld, buildPrint, Product, Offer, merchant listing, BreadcrumbList, breadcrumb_json_ld, JsonLdBuilder, ImageObject, VideoObject, ImageGallery, acquireLicensePage, license, gallery-license-url, GalleryLicense, Creative Commons, licence icons, gallery-license-icon, tdm-reservation, TDMRep, Licensable, creditText, copyrightNotice, embedUrl, GalleryBlockCacheTagProvider, GalleryBlockCacheInvalidator, GalleryCacheInvalidationListener, BlockCacheTagProviderInterface, block cache, cacheable, invalidateTags, gallery-thumbnail-whole, translation, translate a gallery, multilingual gallery, GalleryTranslator, GalleryTranslatedLocales, GalleryLinkLocalizer, GalleryTranslationPurgeListener, ContentLocaleCrudTrait, ContentLocaleScreen, contenu, language tabs, localized_path, gallery_index_localized, gallery_category_localized, gallery_media_localized, gallery-translation, site_translation, infinite scroll, load more, MEDIAS_PER_PAGE, Pagination, social networks, GallerySocialContentSource, SocialContentSourceInterface, ScopedSocialContentSourceInterface, getScopes, series, GalleryPickableMediaProvider, PickableMediaProviderInterface, findPickable, Ajouter depuis le site, gallery-social-order, gallery-social, BrowsableSocialContentSourceInterface, findContents, getContentScope, Changer le contenu, SocialContentStatusProviderInterface, social badge, GalleryFeedProvider, FeedProviderInterface, Atom feed, /feed/gallery.xml, LinkableRouteCacheTagsInterface, meta description, text.meta_gallery, text.meta_category, text.meta_media, c975l_gallery.print_dir, print directory.

Install
php artisan boost:add-skill c975l/gallery-bundle

Save this content to: .claude/skills/c975l-gallery/SKILL.md

# c975L GalleryBundle

> Photo and video galleries on the c975L core — `GalleryCategory` → `GalleryMedia`, batch upload, automatic thumb/medium/highres derivatives, a public viewer, and two blocks so a gallery can be placed on any composed page.

**Package:** `c975l/gallery-bundle` · **Namespace:** `c975L\GalleryBundle\` · **Twig namespace:** `@c975LGallery` · **Translation domain:** `gallery`

**Key source paths** (relative to the package root):
`src/Controller/GalleryController.php`, `src/Controller/Management/Trait/`, `src/Contract/`, `src/Entity/`, `src/Field/`, `src/Model/`, `src/Repository/`, `src/Routing/GalleryRoutePrefix.php`, `src/Listener/`, `src/Service/`, `src/Service/Fulfilment/`, `src/Twig/Extension/`, `src/Management/`, `src/Form/Block/`, `templates/gallery/`, `templates/print/`, `templates/components/Gallery/`, `templates/blocks/`, `config/configs.json`, `config/services.yaml`

**Related documentation:** this package's `README.md` is the exhaustive reference — every section named below is an anchor in it. The ecosystem's own rules (database-backed configuration, blocks, media library, management contributions) live in `c975l/core-bundle`.

## Quick start

```bash
composer require c975l/gallery-bundle
php bin/console doctrine:migrations:diff && php bin/console doctrine:migrations:migrate
php bin/console c975l:config:load-all      # seeds config/configs.json into the back office
php bin/console assets:install --symlink
php bin/console c975l:scaffold:install     # copies assets/styles/themes/gallery.css into the app
```

```yaml
# config/routes.yaml
c975_l_gallery:
    resource: "@c975LGalleryBundle/src/Controller/"
    type: attribute
    prefix: /
```

Nothing else is registered by hand. The Stimulus entrypoints (`assets/controllers.js` for the public
pages, `assets/controllers-admin.js` for the back office) are picked up by UiBundle's script registry,
and their `importmap.php` entries are written by `Management\ImportmapProvider` on the next
`composer update`. Both join the page's one Stimulus application, `globalThis.c975lStimulusApp`, whichever
c975L barrel loads first starting it: a barrel of its own would build the `live` controller a second time
and a Live Component would answer twice.

`GalleryMedia::$user` is typed against `c975L\ConfigBundle\Contract\UserInterface`: the app's
`App\Entity\User` must implement it. The scaffolded `User` already does.

## The data model

There is **no gallery entity**. A site's galleries *are* its categories — `GalleryCategory` is the
top-level unit, and each holds its `GalleryMedia`.

- `GalleryCategory` — `slug` (the url segment), `title`, `summarySocialNetwork` (rich text, printed
  above the grid through `|sanitize_html('c975l.rich')`, never `|raw`, and reused as the page's description metas, the `text.meta_category` sentence standing
  in when empty, or closing a summary shorter than 50 characters), `position`, `coverMedia`,
  `uncategorized` (the lazily-created catch-all), `automaticKind` (which automatic gallery it is, null
  for an ordinary one, see below), `hidden` (kept out of every public page without being deleted, see
  *Masking is not deleting*), `license` (a `Model\GalleryLicense` value, `reserved` by default, see
  *Structured data*), `medias`. Implements `HasBlocksInterface` (its own editorial heading) and
  `TrashableInterface`.
- A site adds fields of its own to a category or a media by implementing
  `GalleryCustomizationProviderInterface` (`getCategoryDataFormType()`, `getMediaDataFormType()`),
  collected on sight of the interface through `gallery.customization_provider` — nothing to tag. They
  live in the `data` JSON column of each, are rendered by `GalleryDataField` on the edit screen, read
  back with `getDataValue()`, and travel through the export/import. Declaring nothing means no field.
- `GalleryMedia` — belongs to one category, keyed publicly by `slug` which is **unique within its
  category only**. Carries `title`, `data` (the site's own fields, see below), `description` (the caption printed under the photograph and reused
  as the page's description metas when set, else composed from site, gallery, title and credits, or the `text.meta_media` sentence when that is under 50 characters, a plain textarea opted into Donovan's rephrase button by `data-ai-rephrase`), `credits`, `rightsReserved`, `position`, `mediaType`
  (`image` / a platform name / `embed`, always derived, never set), `externalUrl`, an optional
  uploaded video file, and the Vich fields. Implements `TrashableInterface`,
  `VichMultiSizeImageInterface`, `VichMediaNamableInterface`, `VichOriginalKeepableInterface`,
  `VichWatermarkableInterface`.

## Deleting takes two steps

Both entities carry UiBundle's `TrashableTrait`, and **the delete button removes nothing**. It flags the
row: the category leaves the site, its medias, its heading blocks and every one of their files stay
exactly where they are — the cascade on the medias and `GalleryMediaDerivativeCleanupListener` are
simply never reached.

- The category index switches to what the trash holds with a `trash` query parameter, a category's edit
  screen does the same for its medias with `mediasTrash`. A media has a trash of its own, so a photo is
  taken off a gallery that stays online, and restoring a category gives back exactly the medias that
  were showing when it left.
- Only `GalleryCategoryCrudController::removeForGood()` — reached from the trash's row button
  `deletePermanently()` and from the trash's batch "Delete" — and `deleteMediasPermanently()` for a
  selection of medias ever remove a row and reach its files. They sit behind the `site-role-admin`
  setting; the rest of the gallery back office sits behind `site-role-editor`.
- The batch "Delete" runs `deleteEntity()`, which trashes on the galleries and removes for good in the
  trash: `isTrash()` decides, the row's `isDeleted()` only guards against a stale view, and
  ConfigBundle's `TrashBatchDeleteTrait::batchDelete()` checks the admin role before the first row
  when in the trash and says once how many went. `deletePermanently()`
  ignores a category that is not in the trash.
- A trashed category or media answers **410 Gone** on its public url, not 404, straight from the row.
  That 410 lasts only as long as the entity can be restored: the permanent deletion is what writes the
  "gone" `Redirect` rows that outlive it, and restoring releases any such row left under the url.
- Every listing query leaves the trash out — `findAllOrdered()`, `countVisible()`,
  `GalleryMediaRepository::findByCategory()`, `GalleryCategory::getMediasCount()` and
  `getCoverOrRandomMedia()`. `findOneBySlug()` and `findOneBySlugInCategory()` are deliberately
  unfiltered, the front-office needing the row in hand to answer 410.
- The export/import carries the flag both ways: a category archived out of the trash comes back to it.
- **The likes go with the permanent deletion, never with the trash.** A rating names its owner
  (`gallery_media` + id) rather than relating to it, so no foreign key cascades it: `removeForGood()`,
  `deleteMediasPermanently()` and `GalleryImportProvider::import()` (which replaces a category's whole
  collection) each call `RatingRepository::deleteForOwners()` themselves, once for the whole set and only
  after the flush that actually removed the rows. A trashed media keeps its likes — it can come back.

## Masking is not deleting

Both `GalleryCategory` and `GalleryMedia` carry a `hidden` flag, the answer to what is worth keeping and
not worth showing — a gallery being prepared, one taken down for a season, a photograph withheld.

- A masked row stays whole in the back office: the category listing shows it with a switch saving on the
  spot, and its own edit screen fills, arranges and trashes its medias as usual.
- It answers **404**, never the trash's 410: masking is reversible, and nothing a change of mind would
  have to take back should be told to a crawler. A masked category takes its medias' pages down with it,
  those being resolved through their category (`GalleryController::resolveCategory()`).
- `findAllOrdered()` and `countVisible()` drop it, which is what takes a masked gallery off the index,
  out of the blocks, out of the sitemap and out of the menu targets — and off the back office's move
  targets and block pickers with them, a public page must not be composed on a url answering 404.
- Its photographs leave the automatic galleries too (`c.hidden = false` in `latestMedias()` and
  `findPrintable()`) and are not sold as prints (`GalleryPrintService::isPrintable()`, the basket being
  reached without ever going through the page that would have said so).
- Masking a category marks none of its medias, exactly as trashing one marks none of them — so showing
  it again gives back precisely what was showing before.
- Masking is not privacy: the files stay under `public/medias/`, served to whoever has their address. Never
  present `hidden` as a way to keep a photograph confidential.
- The export/import carries both flags: a gallery archived masked comes back masked.

## Public routes and the renamable prefix

| Route name | Url | Renders |
| --- | --- | --- |
| `gallery_index` | `/{prefix}` | one thumbnail per category |
| `gallery_category` | `/{prefix}/{category}` | the category grid, photos and videos alike |
| `gallery_media` | `/{prefix}/{category}/{slug}` | the medium-resolution photo, or the video player |
| `gallery_print_certificate` | `/certificate/{certificate}` | the public check page of one numbered print |
| `gallery_print_file` | `/gallery-print-file/{copy}` | the print file, fetched by the lab through a signed url |
| `gallery_print_callback` | `/gallery-print-callback/{provider}` | where a lab reports an order has moved on |

The last three sit **outside** the renamable prefix: one is printed on paper and has to outlive a rename,
the other is never read by a visitor.

`gallery_category` serves its grid `GalleryController::MEDIAS_PER_PAGE` (60) medias at a time, read from
`?p=`: `category.html.twig` receives `medias` as UiBundle's `Pagination` — countable and iterable, plus
`getCurrentPageNumber()`, `getPageCount()`, `getTotalItemCount()`, `route` and `query()` — and UiBundle's
`infinite-scroll.js` appends the next page as the visitor scrolls, into the grid
`<twig:c975LGallery:Gallery:Medias … infinite="true"/>` marks. A page past the last one answers 404.

The first segment is the `gallery-route-prefix` setting, renamed from the dashboard (`galerie`,
`fotos`) with **no cache to clear**. A route path is compiled into the router cache, so the prefix
cannot *be* the path: the three routes are declared as `/{gallery_prefix}/…` and each carries a
`condition` asking `Routing\GalleryRoutePrefix` whether the segment it was handed is the configured
one. `Listener\GalleryRoutePrefixListener` puts the same value in the router's request context, which
is where the generator takes the missing parameter from.

**Generate urls, never write them.** The route parameter is filled for you, and `localized_path` keeps a
visitor in the language they are reading (see *Translation*):

```twig
{{ localized_path('gallery_index') }}
{{ localized_path('gallery_category', { category: category.slug }) }}
{{ localized_path('gallery_media', { category: category.slug, slug: media.slug }) }}
```

Route *names* never change, whatever the prefix. Renaming the prefix breaks the previously indexed
urls — declare a redirect in ConfigBundle's **Redirections** screen. Renaming a *category* is handled
for you: `GalleryCategoryCrudController::updateEntity()` writes the permanent redirect, plus a
wildcarded row for the medias underneath.

## The automatic galleries

A category carrying an `automaticKind` **holds no media of its own**: it is handed a list at read time.
There are two kinds out of the box — `GalleryCategory::AUTOMATIC_LATEST` (the last additions) and
`AUTOMATIC_PRINTABLE` (the photographs on sale as prints, only on a site that sells them). A kind is
written by `GalleryCategoryRepository::findOrCreateAutomatic()` the first time the galleries are listed
(the back-office listing, the public index or a categories block, whichever comes first) and is a normal
category from then on — renamed, described, given a heading, arranged, linked from a menu, declared in
the sitemap. **Nobody creates one, and it is never an option ticked on one of the site's own galleries.**
Moving it to the trash is how a site says it does not want it: unlike the catch-all, a trashed one is
left exactly where it was put.

**The plumbing is written once, the kinds only answer what they gather.** `Service\GalleryAutomaticProvider`
is the one place every screen asks (`ResetInterface`, so it reads once per request); each kind is an
`AutomaticGalleryInterface` — `getKind()`, `isAvailable()`, `getMedias()` — collected through
`gallery.automatic_gallery`. `Service\GalleryLatestProvider` and `Service\GalleryPrintableProvider` are the
two shipped. A kind answering `false` to `isAvailable()` never gets a row written, so a feature nobody
turned on leaves nothing behind — and `prepare()` drops it from the list it hands back even when a row
already exists, so turning the feature off afterwards takes the gallery off the index too. A kind no
provider answers for at all is kept: it is the ordinary gallery it became the day its bundle went.

`GalleryLatestProvider`'s list comes from `GalleryMediaRepository::findLatest($days, $max)`: a rolling
window of calendar days, today included, falling back on the last day that carries an addition when the
window catches nothing, so the gallery is never empty as long as the site holds a photo. The
`gallery_media_created_at` index on `created_at` is what keeps that read off a full scan.

`GalleryAutomaticProvider`'s own API:

- `getMedias(GalleryCategory $category)` — what that category's kind gathers.
- `ensureCategory(string $kind)` / `ensureCategories()` — the rows written for the kinds available.
- `findAutomaticBySlug(string $slug)` — the automatic gallery a url names, its row written on the way; null,
  and nothing written, for any other slug and for a kind turned off.
- `findPreviousAndNext(GalleryMedia $media, GalleryCategory $category)` — the neighbours **within** the
  automatic gallery being browsed, which are not the ones the media's own category files it next to.
- `prepare(array $categories)` — hands back the listed categories with the automatic ones among them,
  holding their medias. For a caller reading the list itself (the public index, the categories block).
- `hydrate(iterable $categories)` — hands the automatic ones their lists, the others left alone. For a
  caller holding entities it did not read (the back-office listing, whose rows EasyAdmin paginates).
- Both also hand **every other listed category** the medias its tile and its media count read, in one
  `GalleryMediaRepository::findVisibleByCategories()` query for the whole list, posed through
  `GalleryCategory::setLoadedMedias()` — the relation is lazy, so a listing otherwise reads it category by
  category. A list of one is left alone, the lazy relation running the one query this would.
`GalleryLatestProvider::getMediasByDay()` cuts that same list into the days its medias were added on —
the back-office screen alone reads it, an upload session being what an admin credits or downloads in one go.

`GalleryCategory::getMediasCount()` and `getCoverOrRandomMedia()` read that handed list rather than the
relation, so a tile, a count and an `og:image` work without knowing which kind of category they hold —
the automatic one showing its **newest** photo rather than one at random.

**Urls stay canonical.** A thumbnail of the automatic gallery links its photo under **its own**
category, never a second path, with `?from=<automatic slug>` over it. `GalleryController::media()`
reads it: previous and next are then the medias added just before and just after, whatever category
they sit in, and the breadcrumb leads back to the last additions. A photo that has left the window is
browsed as its own category's again.

**Its back-office edit screen is the cross-gallery selection screen**: the same grid
(`templates/management/_gallery_media_tile.html.twig`) cut into one section per day, each thumbnail
naming the gallery the photo belongs to. Credits, rights, downloads and move to trash act on the photo
where it really sits, cover included. What the owning category alone carries is left out: no upload
button, no drag to reorder, no cover radio, no trash view. The media CRUD's category picker leaves out
the automatic and the trashed galleries.

## The library's contact sheet

`GalleryMediaCrudController::index()` answers two screens, told apart by one query parameter:

- **with `category`** — every media screen opened from a gallery carries it along — it redirects to that
  gallery's edit screen, where its medias are listed. Unchanged.
- **without it** — the `gallery_media` sidebar entry, the files health check — it renders **the contact
  sheet of the whole library**: every gallery's photographs in one grid, `crud/index` overridden with
  `templates/management/gallery_media_index.html.twig`, the same tile a category's own grid draws.

`createIndexQueryBuilder()` leaves out the trash on both sides — the media's own flag and its gallery's,
trashing a category flagging the category alone. `configureFilters()` offers the gallery (automatic and
trashed ones excluded, as the category picker does), `printable`, `hidden` and `rightsReserved`; the search
is pinned to `title`, `description` and `credits`.

Overriding `crud/index` replaces EasyAdmin's `main` block, and the filters modal is rendered inside it while
the button opening it comes from `page_actions`: `gallery_media_index.html.twig` therefore includes
`@EasyAdmin/crud/includes/_filters_modal.html.twig` itself, under `filters|length > 0`, or the button throws.

**The tile is the single place a thumbnail's badges are drawn** (`_gallery_media_tile.html.twig`): masked,
on sale, edition size, likes. Both screens hand it `likes`, counted for the whole grid by
`Service\GalleryMediaLikeCounter` — one `RatingRepository::getAggregates()`, and none at all where
`gallery-rating` is off. `selectable` is what turns the selection box on: the category screen has a form
for it to post into, the contact sheet has none.

## Showing a gallery outside its own routes

Two block kinds are contributed to UiBundle (declared in `config/services.yaml`), so a gallery goes on
any page composed in the back office:

| Kind | Shows | Options |
| --- | --- | --- |
| `gallery_categories` | every category, one thumbnail each | optional maximum |
| `gallery_medias` | one category's medias | category, optional maximum, random draw, link to the full category |

Both resolve their content live through `Twig\Extension\GalleryBlockExtension`, so a block never goes
stale against the media library. What a block stores is *what* to show — a category **slug**, a
maximum — never the medias themselves.

Both are `cacheable: true` all the same. `Service\GalleryBlockCacheTagProvider` (UiBundle's
`BlockCacheTagProviderInterface`) puts every entry under one tag, and
`Listener\GalleryCacheInvalidationListener` drops it whenever a `GalleryCategory` or a `GalleryMedia` is
saved or removed, or one of the settings the blocks are drawn from is: the automatic galleries' four
entries, `gallery-route-prefix` and `gallery-thumbnail-whole`. The tag never goes out before the flush
is over, so a rollback leaves the cache alone. UiBundle only ever invalidates the Block that was
edited, and knows nothing of what these two kinds query at render time — which is what that listener is
for.

Three cases decline the entry and render live instead, an entry never expiring: a block with the random
draw ticked, which would be frozen into one single draw; the gallery of the last additions, whose
photographs leave it as the days go by with nothing saved to drop the tag on; and the listing, for as
long as a gallery has no cover of its own, its tile being drawn at random too.

In a template of your own, the same Twig functions and components are available:

```twig
{# categories, optionally capped #}
{% for category in gallery_block_categories(6) %}…{% endfor %}

{# one category's medias: slug, max, random #}
{% set medias = gallery_block_medias('landscapes', 12, true) %}

{# the grids themselves #}
<twig:c975LGallery:Gallery:Categories categories="{{ categories }}"/>
<twig:c975LGallery:Gallery:Medias category="{{ category }}" medias="{{ medias }}" displayTitle="{{ true }}"/>
```

Anonymous components under `templates/components/Gallery/`: `Categories`, `Category`, `Medias`,
`Media`, `Navigation`, `Previous`, `Next`, `Lightbox`, `Video`, `Credits`.

`Lightbox` writes no markup of its own: it names the two files and the words the link is announced with,
and hands them to UiBundle's `<twig:c975LUi:Image:Zoom>` (`c975l/core-bundle` `^1.26`), which carries the
`<dialog>` and its Stimulus controller. What stays this bundle's is the passe-partout, applied on
`.gallery-media-container .image-zoom__image`, and the three `--gallery-lightbox-*` tokens handed to the
zoom's own on `.gallery-media-container`.

Each kind is drawn as a silhouette in `sass/block-thumbs.scss`, compiled to
`public/css/block-thumbs.min.css` and handed to the back office by `Service\StylesheetProvider`
(`BundleStylesheetManagementProviderInterface`, tag `ui.management_stylesheet`), so the block picker shows
what a kind looks like instead of UiBundle's default frame. A new kind adds its own rule there —
`StylesheetProviderTest` reads the tagged kinds off `config/services.yaml` and fails on one left undrawn.

## Images: what the bundle generates

Three derivatives per uploaded image, generated automatically by UiBundle's `VichImageResizeListener`
through the `VichMultiSizeImageInterface` contract — this bundle only declares the sizes:

| Derivative | Constant | Size | Used by |
| --- | --- | --- | --- |
| thumbnail | `GalleryMedia::THUMBNAIL_SIZE` | 600px longest side | both grids |
| medium | `GalleryMedia::MEDIUM_WIDTH` | 1024px | the media page, and the `og:image` a share carries |
| highres | `GalleryMedia::HIGHRES_WIDTH` | 2048px | the zoom, fetched only on demand |

**The thumbnail file always holds the whole photo** — it is square only for a square photo. What the
grid does with it inside its square tile is the `gallery-thumbnail-whole` setting (`cover` off,
`contain` on): one CSS class, applied on the next request, reversible, nothing regenerated.

Originals can optionally be kept outside the document root (`GalleryMedia::ORIGINAL_DIRECTORY`).
Media files live under `GalleryMedia::MEDIA_DIRECTORY` (`medias/gallery`).

`Service\GalleryMediaArchiver` is what hands those two back: the medias checked on a category's edit
screen are downloaded as one zip, either their highres derivatives or the kept originals
(`GalleryCategoryCrudController::downloadMedias()`, `site-role-editor` like the rest of the screen).
Entries are named after each media's slug and stored rather than deflated, a selection past
`MAX_TOTAL_BYTES` is refused with its size rather than truncated, and a selection with no file at all
gives a message rather than an empty archive. **Do not write a file download of your own** for a
gallery's medias, and do not expose the original directory over http.

`Service\GalleryMediaMover` is the single place a media changes gallery, both ways going through it:
the **Move selection** button of the same toolbar (`GalleryCategoryCrudController::moveMedias()`) and
the category field of a media's own edit form (`GalleryMediaCrudController::updateEntity()`). It moves
the stored file, its `-thumb`/`-highres` siblings, the kept original and the media's own video into
`medias/gallery/{arrival gallery}/`, leaves the old media page redirecting to the new one, suffixes a
slug the arrival gallery already holds, appends the medias to its ranks while closing the gap behind
them, releases a cover pointing at a media that has left, and renumbers the titles when a title root is
given. The destination is chosen explicitly — nothing is preselected in that list, and its last entry
creates the gallery on the spot under the name typed beside it (a slug already taken is refused, never
suffixed, a category's slug being its natural key). **Only the directory moves** — the filename itself
carries the slug the media had at upload and never changes. The files are renamed in `postFlush` so a failed save leaves them where the rows still
point at them; the media is deliberately **left in the gallery's own collection**, that relation being
`orphanRemoval` and removing it there deleting the very row being moved. **Do not reassign
`GalleryMedia::$category` by hand** — the files would stay behind in the gallery the media left.

Both downloads are offered in the medias trash as well, and are the one selection action that does not
filter on `isDeleted` - the state is what keeps a selection posted from the grid away from the permanent
deletion, where reading a file is the same act either way. The category's own **Move to trash** is
rendered at the **foot of the edit screen**, not in the page toolbar (`page_actions` is overridden to
leave it out): next to Save, one row above the photographs being checked, it was clicked for a selection
and took the whole gallery off the site.

```bash
php bin/console c975l:gallery:rebuild-thumbnails [--dry-run]   # rewrites every -thumb.webp from the highres
php bin/console c975l:gallery:fill-slugs                       # backfills slugs on medias predating them
php bin/console c975l:gallery:automatic:dedupe [--dry-run]     # merges a "latest-2" into the leftover "latest"
```

**Batch upload** ceilings are checked client-side before the request is sent, because PHP truncates an
oversized batch silently rather than refusing it: `Service\UploadLimits::MAX_FILES` (100) and
`MAX_FILE_SIZE` (20 MiB), each capped in turn by the running PHP's `max_file_uploads`,
`upload_max_filesize` and `post_max_size`. Both constants are there to be raised by an app that knows
its server takes more. The batch screen only ever creates images.

A batch is minutes of transfer then of processing, so the form is armed with UiBundle's
`UploadProgress`: it posts over `XMLHttpRequest`, counts the megabytes as they leave, then says the
files are being processed, the submit button taken away for the whole wait. The controller answers with
the arrival url instead of redirecting, so the "medias added" flash reaches the screen that follows.

## Videos

A media becomes a video by carrying **the url of the page the video is watched on** — nothing to
extract by hand. Whatever it carries, an entry always has its own uploaded still, which is what the
grids show, so one category holds photos and videos alike.

- **Which platforms is UiBundle's question**, not this bundle's: `c975L\UiBundle\Video\VideoPlatform`
  is where one is declared. YouTube, TikTok, Vimeo and Dailymotion ship declared. What gets stored is
  always the platform's privacy-first embed url (`youtube-nocookie.com`, `dnt=1`).
- An url belonging to no declared platform is stored as pasted, typed `embed`, framed 16/9. There is
  deliberately **no "paste your embed code" field** — third-party HTML in the database is an XSS and a
  CSP hole.
- A media can also carry an **uploaded video file** (mp4, webm, ogg), played by the browser with the
  still as its poster. A media carrying both plays its own copy. It is the one player whose shape
  nothing declares — the browser reads it off the file — so it is capped by
  `--gallery-video-self-hosted-max-height` (70vh), a height where a framed player's cap
  (`--gallery-video-portrait-max-width`) is a width.
- Players render through `<twig:c975LUi:Video:Iframe>`, created client-side only once the visitor has
  accepted the cookie banner.
- CSP: UiBundle exposes every declared origin as `%c975l_ui.video.embed_origins%`, for `frame-src`,
  `child-src` and any `Permissions-Policy` naming `fullscreen`.

## Likes on a photo

`gallery-rating`, on out of the box, prints UiBundle's rating widget under the media page's photo, asked
for one icon and one only:

```twig
<twig:c975LUi:Rating:Rating ownerType="gallery_media" ownerId="{{ media.id }}" scale="1" icon="heart"/>
```

A photo is liked or it is not, so there is no average to print — the line under it says how many people
liked it, and clicking the heart again takes the like back. `scale` and `icon` are stated here rather than
left to the site's own `ui-rating-icon` / `ui-rating-scale`, which serve the scales elsewhere. No login is
asked for: an authenticated visitor is keyed on their account, anyone else on a token their own browser
mints. The `rating` table comes with `c975l/core-bundle` `^1.13.1` — an app updating to it generates and
plays a migration, or every media page fails on an unknown table. See UiBundle's *Visitor ratings*.

## Configuration

Everything is in the database, declared in `config/configs.json`, group **gallery**, edited in
EasyAdmin — **never in `.env`, `parameters:` or a Configuration/TreeBuilder class**. Read it with
`ConfigServiceInterface::get('slug')` in PHP or `config('slug')` in Twig.

| Slug | Kind | What it settles |
| --- | --- | --- |
| `gallery-route-prefix` | text | the first url segment (empty falls back to `gallery`) |
| `gallery-thumbnail-whole` | bool | grid tiles `contain` instead of `cover` |
| `gallery-rating` | bool | the heart under a photo, on out of the box (see *Likes on a photo*) |
| `gallery-latest-days` | int | how many days back the automatic gallery reaches (empty or 0 falls back to 7) |
| `gallery-latest-max` | int | how many medias it stops at (empty or 0 falls back to 200) |
| `gallery-license-url` | text | the photographs' terms of use page, published as `license` and as `acquireLicensePage` when no print is offered (see *Structured data*) |
| `gallery-printable-max` | int | how many photographs the prints gallery stops at (empty or 0 falls back to 200) |
| `gallery-print-enabled` | bool | opens the print sale to the public; off out of the box. It hides nothing in the back office: the two print screens, their guided projects and the test-mode tile stay, the indexes warning that the sale is closed (see *Selling prints*) |
| `gallery-print-provider` | text | which lab fulfils the orders (`prodigi` out of the box, `manual` to fulfil them by hand) |
| `gallery-print-api-key` | text, sensitive | the lab's api key |
| `gallery-print-sandbox` | bool | the lab's test mode, on out of the box - toggled from the dashboard tile |
| `gallery-print-signature` | text | the signature laid on print files, a path under `public/`; empty leaves prints unsigned |
| `gallery-social-order` | choice `random`/`oldest` | the order SocialBundle's publication takes the photographs in, `random` out of the box |
| `gallery-style` | choice `light`/`dark` | the ground a photo is shown against; empty keeps the site's own colors |
| `gallery-frame` | choice `none`/`thin`/`wide` | the passe-partout around a displayed media |
| `theme-color-gallery-frame` | text | passe-partout color |
| `theme-color-gallery-nav` | text | arrows |
| `theme-color-gallery-nav-hover` | text | arrows, hovered |
| `theme-color-gallery-nav-background` | text | arrow buttons |
| `theme-color-gallery-backdrop` | text | lightbox backdrop |
| `theme-color-gallery-breadcrumb` | text | breadcrumb |
| `theme-color-gallery-credits` | text | credits line |
| `theme-color-gallery-badge` | text | video badge |
| `theme-color-gallery-badge-text` | text | video badge label |

A `theme-` slug is compiled into a `--c975l-color-gallery-*` custom property by UiBundle's
`ThemeVariablesCssListener`. Four of them are deliberately left empty, their fallback being an
expression that follows a light or a dark gallery rather than a fixed color.

The gallery back office sits behind ConfigBundle's `site-role-editor` setting, except the two permanent
deletions, held at `site-role-admin` (see *Deleting takes two steps*). Every sidebar entry states that same
bar (`MenuProvider`, `'role'` key) and so do the screens themselves (`setPermission(Action::INDEX, ...)` on
each CRUD), so an editor reaches them instead of seeing no entry at all — read by `c975l/core-bundle`
`^1.14.0` and up, earlier ones giving every entry the admin bar.

## Selling prints

Closed to the public unless `gallery-print-enabled` is on, which only governs the public side: the back
office never hides anything because of it. `MenuProvider` always lists the `gallery_print_order` and
`gallery_print_format` entries, `GalleryGuidedProjectProvider` always contributes the two print parcours,
`GalleryShortcutProvider` always offers the test-mode tile, and the bulk **printable** action of a gallery's
screen is always there. While the switch is off, both print indexes draw an `alert alert-warning`
(`_gallery_print_sale_notice.html.twig`, fed `print_enabled` and `print_switch_url` by each CRUD's
`configureResponseParameters()`), the switch's edit url - from UiBundle's `ConfigEditUrlResolver` - being
handed to `site-role-admin` only, ConfigCrudController denying anything below. Requires `c975l/payment-bundle` `^6.18`: a print is sold through
the one basket, this bundle plugging in as a `BasketItemProviderInterface` of kind `gallery_print`. It
also answers `CatalogueBasketItemProviderInterface`, whose `getCatalogueUrl()` hands the basket the path
its "continue shopping" button goes back to — `gallery_index`, PaymentBundle knowing of no shop to send
anybody to on a site running the gallery and the basket alone. It answers `ShippingBasketItemProviderInterface` too, `shipsParcels()` declaring prints as posted to PaymentBundle's shipping health check while `gallery-print-enabled` is on and the catalogue holds a format, and `WeighableBasketItemProviderInterface`, `getWeight()` reading the format's weight frozen into the line (`item.weight`) times its quantity, null when unweighed.

Three entities. `GalleryPrintFormat` is the catalogue — a size, its dpi, its price, its packed `weight` in whole grams (nullable), and the **sku** the
lab knows it by (distinct from the slug; only the sku is ever sent to a lab). `GalleryPrintOrder` is one
consignment. `GalleryPrintCopy` is **the register**: one row per print.

**The catalogue is imported rather than typed** where the lab publishes its range: a driver answering
`PrintCatalogueProviderInterface` (`ProdigiCatalogue` is the shipped one) hands `PrintCatalogueImporter`
the sizes, papers and skus, and the **Import the catalogue** action of `GalleryPrintFormatCrudController`
writes them unpublished and unpriced — the prices are the shop's, never the lab's. Each entry carries its
dpi by its size (300 up to 30 cm on the long edge, 240 up to 40, 200 beyond) and its paper's description as
a `print_paper.*` translation id, written in the site's default locale at import. Run again, the importer
adds what the range gained and refreshes only what is still as shipped — a description still in the
earlier English, a dpi still at the default — never a sentence or a resolution the admin set
(`PrintCatalogueImportReport::$refreshed`). The action is only drawn where a driver answers, a shop
printing by hand writing its formats itself.

`GalleryMedia` carries `printable` (on offer), `hidden` (kept out of every public page without being
deleted, filtered in the repository and 404 in the controller) and `editionSize` (null for an open
edition). Announcing an edition writes its rows at once through `GalleryPrintCopyRepository::openEdition()`,
and `GalleryMediaCrudController::settleEdition()` refuses every later change to the number — raising an
announced edition is a forgery. Selling one claims the lowest free row with a single
`UPDATE … WHERE order_id IS NULL`, so two checkouts on the last copy cannot both win.

A format is only offered for a photograph whose original actually has the pixels and the proportions
(±3 %, `GalleryPrintService::getOffers()`) — nothing is ever cropped to fit a size.

Everything a certificate states is frozen onto the copy at the sale (`PrintCopySnapshot`: format, label,
sku, price, title, credits, issuer). **Read nothing live when drawing a certificate** — the sheet is
signed by hand and posted, so a retitled photograph or a renamed site must not contradict it. The
certificate is a pdf carrying a qr code to its public page, `/certificate/{token}`, which sits outside
the renamable gallery prefix because it is printed on paper.

An open edition goes straight to the lab through Messenger. A limited one stops and waits: two e-mails
go out (the buyer's numbers, and the admin's "sign this"), and the admin releases it from the orders
screen.

A lab is a `PrintFulfilmentInterface`; every implementation is tagged on sight by `TaggedInterfacePass`,
so a site adding its own printer wires nothing. `ProdigiFulfilment` ships, `ManualFulfilment` throws on
purpose so the order stays pending in the back office rather than claiming it was sent. A sold edition is
always announced; the letter asking for a signature only goes out where `gallery-print-edition-hold` is on
and the order really is waiting to be released.

The print file is composed from the untouched original, never from the web derivative, and the signature
is restamped at print resolution (`GalleryPrintFileBuilder`). The lab fetches it through a `UriSigner`
url expiring at seven days; an unsigned request gets 404, not 403.

The file waits in `%kernel.project_dir%/var/gallery-print/`, named `{copy id}-{certificate}.jpg`. A second
context running against its own database (a demo) sets the `c975l_gallery.print_dir` container parameter,
or its prints land among the site's.

What the lab says afterwards reaches the order two ways, and both write through
`GalleryPrintOrderTracker` and nowhere else: the lab's callbacks on `gallery_print_callback` (whose
address `ProdigiFulfilment` sends per order as `callbackUrl`, rather than relying on the lab's dashboard),
and the nightly `c975l:gallery:print:sync`, declared by `GalleryMaintenanceTaskProvider`, which asks each
lab about the orders it holds. **A callback is read as a name, never as a state** — a print lab signs
nothing and that url is public, so the state is then asked of the lab itself. Shipped, the order is
stamped, the buyer gets *your prints are on their way*, and the composed files are discarded. States never
walk backwards, and nothing a lab says moves an order waiting for a signature. The move itself is a single
conditional `UPDATE` (`GalleryPrintOrderRepository::claim()`), so a callback the lab replayed at the same
moment as the nightly command sends one letter and frees the files once. A cancellation writes to
the shop and never to the buyer - the refund is the shopkeeper's decision.

The consuming site routes `GalleryPrintOrderMessage` to an asynchronous transport, so a slow lab never
reaches the payment webhook.

A member's print orders travel in the export of their data (`/account/export`): `Management\AccountDataProvider`
answers ConfigBundle's `AccountDataProviderInterface` under a `prints` key, one entry per order of the
account's baskets (`GalleryPrintOrderRepository::findForUser()`), its lab state and its copies, the basket
number tying it to the `orders` part. An account with no print order adds no key.

## Extending and overriding

- **Templates** — every one of them is overridable from `templates/bundles/c975LGalleryBundle/`,
  mirroring the path under `templates/`. Public templates `{% extends 'layout.html.twig' %}` (no `@`),
  which the app's own file resolves.
- **Theme** — `assets/styles/themes/gallery.css` is copied into the app by `c975l:scaffold:install`
  and owned by it from then on. Every token ships commented out at the bundle's default: uncomment a
  line to take it over. Fonts and site colors are deliberately absent — the gallery reads UiBundle's
  `--text` / `--background` / `--font-family-body`, so it looks like the site it is installed on.
- **Blocks on a category** — `GalleryCategory` implements `HasBlocksInterface`; render them with
  `<twig:c975LUi:Blocks:Blocks blocks="{{ category.blocks }}"/>`.
- **Fields of the site's own** — implement `GalleryCustomizationProviderInterface` and return a form
  type from `getCategoryDataFormType()` / `getMediaDataFormType()`. Nothing to tag: the interface is
  collected through `gallery.customization_provider`, read by `GalleryCustomizationRegistry` (first
  provider answering wins) and rendered by `GalleryDataField` on the edit screen. The values land in
  the `data` JSON column, read back with `getDataValue()`.
- **An automatic gallery of the site's own** — implement `AutomaticGalleryInterface` (`getKind()`,
  `isAvailable()`, `getMedias()`). Nothing to tag: `TaggedInterfacePass` collects every implementation
  through `gallery.automatic_gallery`, and the category, its place on the index, its menu target and its
  sitemap line follow from `GalleryAutomaticProvider` alone.
- **A lab of the site's own** — implement `PrintFulfilmentInterface`, collected the same way through
  `gallery.print_fulfilment` and picked by the `gallery-print-provider` setting.
- **Repositories** — `GalleryCategoryRepository::findAllOrdered()`, `findOneBySlug()`,
  `countVisible()`, `findOrCreateUncategorized()`, `findOrCreateAutomatic()` (both suffixing a slug
  already taken, the latter first taking back an empty ordinary gallery left under `latest`),
  `findAutomaticDuplicate()` (the `latest-2` and that leftover, for `c975l:gallery:automatic:dedupe`);
  `GalleryMediaRepository::findByCategory()`, `findOneBySlugInCategory()`,
  `findPreviousAndNext()`, `findLatest()` (what the automatic gallery shows), `findVisibleByCategories()`
  (the same list for several categories at once, grouped by category id), `findWithFilename()`
  (the rows naming a file, for the files health check).
  `findAllOrdered()` is memoized for the request (`ResetInterface`), its callers knowing nothing of
  each other.

What the bundle already contributes to the dashboard, so you do not have to: `MenuProvider` (its own
**Galerie** sidebar section, holding the `gallery`, `gallery_media`, `gallery_print_order` and
`gallery_print_format` entries, rather than the shared
"management" one),
`LinkableRouteProvider` (the index and each category offered as a SiteBundle menu target, tagged `gallery_galleries` through `LinkableRouteCacheTagsInterface` so a menu item pointing at one is cached),
`GallerySitemapProvider`, `GalleryFeedProvider` (ConfigBundle's `FeedProviderInterface`: the latest visible medias as an Atom feed at `/feed/gallery.xml`), `GalleryUrlMetadataProvider`, `GalleryFilesHealthCheckProvider` (kind
`files-gallery`, reporting every media whose image or self-hosted video is gone from the server),
`GalleryExportProvider` /
`GalleryImportProvider` (categories as a zip, files included), `GalleryBackupPathProvider`,
`GalleryBlockOwnerResolver`, `GalleryGuidedProjectProvider` (whose `gallery-print-setup` and
`gallery-print-order` parcours are offered whatever `gallery-print-enabled` says, and `gallery-social` only where SocialBundle is installed), `WhatsNewProvider`, `ImportmapProvider`,
`Service\ScriptProvider`, `Service\StylesheetProvider` (public and management stylesheets both),
`Service\GalleryShowcaseProvider`,
`GalleryShortcutProvider` (the tile toggling the lab's test mode), `GalleryDemoFixtureProvider`,
`Service\GalleryPrintBasketItemProvider` (PaymentBundle's basket, kind `gallery_print`, and the
catalogue its "continue shopping" button returns to, the parcels declared to the shipping health check, and their weight),
`Email\GalleryEmailTemplateProvider`, `Service\GallerySocialContentSource` (UiBundle's `ScopedSocialContentSourceInterface` and `BrowsableSocialContentSourceInterface`, source type `gallery_media`, also `GallerySocialContentSource::SOURCE_TYPE`: one photograph at a time for SocialBundle's publication, a video or an embed going out by its still, never the same twice on a network, read off the configured route prefix since the run has no request; its scopes, `getScopes()`, are the non-automatic visible galleries a SocialBundle series may narrow the draw to), `Service\GalleryPickableMediaProvider` (UiBundle's `PickableMediaProviderInterface`: the visible photographs and uploaded videos, latest first, searched on their title, slug or gallery title, for SocialBundle's "Ajouter depuis le site" to add to a post, each carrying the source type and its id so the pick is held as that content).

## Seeding a demo gallery

`GallerySampleCatalog` holds a made-up gallery once as plain data — three categories of four named
photographs. `GalleryShowcaseProvider` builds the arrays the block showcase renders from it,
`GalleryDemoFixtureProvider` (UiBundle's `DemoFixtureProviderInterface`) hands the lot over to whichever
demo site loads it - this bundle ships no command that writes to a database. Enriching the catalog shows
up in both, and everything a visitor reads is a key of the `gallery` domain.

The photographs come from `PlaceholderMediaProviderInterface`: each media takes the one declared under
its own `gallery/<slug>` key (`keyed_images`), so the showcase and the demo site show the same photograph
under the same title, and falls back on the generic pool, rotated over the catalog, for a slug declared
nowhere. The demo takes its files as a temporary copy, made by UiBundle's `DemoFixtureMediaCopier` — an upload moves the file it is handed. A site
declaring none is seeded with nothing at all: a gallery is its photographs. The showcase's thumbnails
lead to the demonstration site UiBundle's `ui-showcase-demo-url` names, the only place holding these
categories, and render as plain images when none is named. A media's slug is set before the flush, being
half of where its file lands (see `GalleryMedia::getVichMediaPath()`).

Only the categories are yielded, their medias following through the cascade so Vich takes their files
and derivatives off the disk with them.

## Translation

Requires `c975l/core-bundle` `^1.28.0`. A category, a media and a print format are **one row in every language**:
`Service\GalleryTranslator` stores the other languages beside the row in UiBundle's `site_translation`, under
`GalleryTranslator::OWNER_CATEGORY` / `OWNER_MEDIA` / `OWNER_PRINT_FORMAT`. Only `CATEGORY_FIELDS` (`title`,
`summarySocialNetwork`), `MEDIA_FIELDS` (`title`, `description`) and `PRINT_FORMAT_FIELDS` (`label`, `paper`,
`paperDescription`) are translated — slugs, files, SKUs and credits never are. Nothing is read on a site
declaring one language (`isActive()`).

- **Public side** — `GalleryController` doubles its three routes as `gallery_index_localized`,
  `gallery_category_localized` and `gallery_media_localized` under `/{_locale}/{gallery_prefix}`, answering in
  every language `Service\GalleryTranslatedLocales::all()` returns. What is rendered is handed to
  `GalleryTranslator::apply()`, which calls each row's `setTranslated()` so its getters read that language
  for the render; `getUntranslated()` still hands back the row's own text. `GalleryBlockExtension` and
  `GalleryPrintExtension::getOffers()` apply it too. `Service\GalleryLinkLocalizer` rewrites gallery urls
  stored in rich text.
- **Back office** — `Controller\Management\Trait\ContentLocaleCrudTrait` gives the three CRUD controllers a
  language screen through ConfigBundle's `ContentLocaleScreen` (`?contenu=xx`): `translationFields()` for
  `configureFields()`, `addContentLocaleParameters()` for the tabs, `stageContentLocale()` for the edit form,
  and `translateAction()`, icon-only, on the galleries and print formats indexes. The edit templates include
  `@c975LConfig/management/_content_locale_tabs.html.twig`. A language screen submits the translated texts
  alone, so `GalleryCategoryCrudController::createEditFormBuilder()` skips the slug normalizer and the blocks
  listener there, and `GalleryMediaCrudController` skips the watermark listener.
- **Deletion** — `Listener\GalleryTranslationPurgeListener` drops a row's translations on its removal.
- The `gallery-translation` guided project walks a gallery's translation.

## Structured data

The three public pages publish a schema.org graph, built by `Service\GallerySnippetBuilder` and rendered by the
Twig functions `gallery_media_json_ld()`, `gallery_json_ld()` and `gallery_index_json_ld()`
(`Twig\Extension\GalleryJsonLdExtension`) — never by a template of the bundle, so a site overriding a page keeps
its structured data by calling the same function.

A photograph is an `ImageObject`, a video a `VideoObject` whose still becomes the `thumbnailUrl` and whose own
file the `contentUrl` — a video framed from a platform having none, it names its player as `embedUrl` instead
(`GalleryMedia::getEmbedUrl()`). Four properties are what an image search reads a licence from: `creator` and
`creditText` off `GalleryMedia::$credits`, `copyrightNotice` as `© credits` whenever credits are set (not
`$rightsReserved`: a CC photograph is still copyrighted), and `acquireLicensePage` —
the photograph's own url where the page actually prints an offer for it, the terms page otherwise. That terms
page, config `gallery-license-url` (a path made absolute by `UrlHelper` in the extension, or a full url), is also
published as `license` while the gallery keeps all rights reserved; empty, neither is. `GalleryCategory::$license`
offers the Creative Commons 4.0 set and CC0 (`GalleryLicense::all()`, `deedUrl()`, `label()`, `name()`, `icons()`): the deed then
becomes `license`, read off the media's own category rather than the gallery walked through, and is linked by
`Gallery:Credits` in place of the rights-reserved mark, drawn with Creative Commons' icons (`public/icons/cc-*.svg`, CSS masks) and its short name (`CC BY-NC 4.0`), while a reserved one links `gallery-license-url` when set; a category keeping `reserved` has its pages carry
`<meta name="tdm-reservation" content="1">`. An unknown value given to `setLicense()` falls back on `reserved`. The print offer is handed to
`buildMedia()` as its `$printAvailable` argument, the very boolean `gallery_print_available()` gave the page:
the builder asks the shop nothing itself, so the graph can never announce a licence page the visitor is not
being offered. A gallery is an `ImageGallery` whose `mainEntity` is the `ItemList` of the photographs the page
was served with, numbered from `gallery_json_ld()`'s fourth argument, `$offset` - `(page - 1) * perPage` on a
page after the first; the index is that `ItemList` alone.

Two graphs are the exception to "never by a template": `print/_offer.html.twig` publishes the prints as a `Product`
through `gallery_print_json_ld(media, offersByPaper, remaining, imageUrl, url)` (`GallerySnippetBuilder::buildPrint()`,
one `Offer` per `PrintOffer`, price in cents divided by 100, `shop-currency`, `SoldOut` when `remaining` is `0`),
and `Gallery:Navigation` publishes its trail as a `BreadcrumbList` through UiBundle's `breadcrumb_json_ld()`, counts
left out. Item lists, plain text and encoding are UiBundle's `JsonLdBuilder`, injected in the builder.

The urls are resolved by the caller, only a template turning a stored file into an absolute one — the builder
publishes no graph for a media with neither a file to name nor a player to frame.

## Do not

- **Do not copy the old `Lightbox` body into an override.** Its Stimulus controller, `.gallery-media-zoom`, `.gallery-lightbox` and `.gallery-lightbox__image` are gone: a copy keeps a `data-action` nobody listens to, and the click leaves the page for the raw file. Override the component and keep handing `c975LUi:Image:Zoom` its five props.
- **Do not put an `offers` node on the `ImageObject`** — the prints are a `Product` of their own (`buildPrint()`), priced by the offer the page prints.
- **Do not hardcode `/gallery`** in a template, a link or a test. The prefix is admin-editable; use
  `localized_path('gallery_category', {category: slug})`.
- **Do not generate a public gallery url with `path()` in a template.** It sends a visitor reading another
  language back to the writing one; `localized_path()` keeps them where they are.
- **Do not call `GalleryTranslator::apply()` on a back-office screen, nor persist what it lays on a row.** The
  getters then read the translation, and a form built on them would write it over the row's own text.
- **Do not put `createEditFormBuilder()` or `configureResponseParameters()` in `ContentLocaleCrudTrait`.** A
  controller's own method shadows a trait's in silence; the controller calls `stageContentLocale()` and
  `addContentLocaleParameters()` from its own override instead.
- **Do not add a `.env` variable, a container parameter or a bundle Configuration class** for a
  gallery setting. It goes in `config/configs.json` and is read through `ConfigServiceInterface`. The one
  container parameter, `c975l_gallery.print_dir`, is a filesystem path, not a setting.
- **Do not write an image resizer, a thumbnail command or a Vich naming rule.** Sizes are declared on
  the entity, the work belongs to UiBundle's `VichImageResizeListener`.
- **Do not read an automatic category's `medias` relation** — it is empty by definition. Ask
  `GalleryAutomaticProvider::getMedias($category)`, or go through `getMediasCount()` /
  `getCoverOrRandomMedia()`, which already do. And do not create a second category of a kind, nor turn
  one of the site's own galleries into one.
- **Do not put in `data`** anything the database has to filter, sort or join on, nor anything every
  gallery wants — the first stays a real column, the second belongs to the bundle (a caption did, hence
  `GalleryMedia::$description`).
- **Do not query a media by slug alone** — a slug is unique only within its category.
- **Do not override EasyAdmin's `main` block without carrying its modals over.** A CRUD declaring filters
  keeps its "Filters" button from `page_actions` while the modal it targets lives in `main`; drop it and the
  button throws on the first click.
- **Do not raise or lower an announced edition's size.** `settleEdition()` refuses it: the rows are
  already written and a certificate already says "3 / 10". Open a new edition instead.
- **Do not read a photograph, a format or the site's name when drawing a certificate.** Everything the
  sheet states is frozen on the copy at the sale (`PrintCopySnapshot`) — a signed sheet cannot be allowed
  to disagree with the page checking it.
- **Do not claim a copy with a read-then-write.** `GalleryPrintCopyRepository::claimNumber()` is a single
  conditional `UPDATE` precisely so two checkouts on the last copy cannot both win.
- **Do not move an order's state with a read-then-write either.** Go through
  `GalleryPrintOrderTracker::apply()`, whose `GalleryPrintOrderRepository::claim()` is the same single
  conditional `UPDATE` — a replayed callback would otherwise post the shipping letter twice.
- **Do not write a lab driver that creates an order twice when sent twice.** `ProdigiFulfilment` sends an
  `idempotencyKey` stable per order and per site; the release button or a lost answer can send one again.
- **Do not print a price with a hardcoded currency.** Read `shop-currency`, as
  `templates/print/_offer.html.twig` and PaymentBundle's own basket templates do.
- **Do not render `<twig:c975LPayment:Basket:Navbar/>` in a gallery template.** UiBundle's layout places it
  once on every page (`c975l/core-bundle` `^1.31`); a second one is never filled. `Basket:Message` stays.
- **Do not send the catalogue slug to a lab, nor the web derivative as a print file.** The frozen sku is
  what a lab knows, and `GalleryPrintFileBuilder` composes from the untouched original.
- **Do not hand VichUploader a plain `File`** when seeding a media — `UploadHandler::hasUploadedFile()`
  ignores it in silence, writing the row with no file name and nothing on the disk. Hand it the
  `ReplacingFile` UiBundle's `DemoFixtureMediaCopier::copy()` returns.
- **Do not have a fixture provider empty a table** — a demo site keeps its own content in those very tables.
- **Do not list categories or medias with `findAll()` / `findBy()`.** Those see the trash; use
  `findAllOrdered()` and `GalleryMediaRepository::findByCategory()`, which do not.
- **Do not count a grid's likes one thumbnail at a time.** `GalleryMediaLikeCounter::count()` reads them
  for the whole page in one query, and reads nothing where the site shows no heart.
- **Do not add a listing action to the contact sheet.** A photograph's position and the actions on a
  selection belong to the gallery holding it, whose own screen carries the form they post into.
- **Do not remove a media row without dropping its ratings.** Nothing cascades them; go through
  `RatingRepository::deleteForOwners('gallery_media', $ids)` after the flush (see *Deleting takes two steps*).
- **Do not call `remove()` on a category or a media**, and do not write a soft-delete flag of your own.
  Flag it through `TrashableInterface`; the permanent deletion is the back office's own action.
- **Do not add a video platform here.** Declare it in `c975L\UiBundle\Video\VideoPlatform`.
- **Do not render a third-party iframe directly.** Use `<twig:c975LUi:Video:Iframe>`, which honours the
  cookie banner.
- **Do not cache a gallery block without its tag.** They resolve live, so an entry only holds where
  `GalleryBlockCacheTagProvider` puts it under the tag `GalleryCacheInvalidationListener` drops — and
  where nothing is drawn at random or by a window of days, which that provider declines.
- **Do not add a page layout to this bundle.** A satellite never ships one; templates extend the app's
  `layout.html.twig`.
- **Do not build an import command for an existing photo folder.** There is no reliable way to tell
  originals from an old gallery's derivatives; photos already on another c975L site move across with
  the export/import instead.

c975L GalleryBundle

Photo and video galleries on the c975L core — GalleryCategory → GalleryMedia, batch upload, automatic thumb/medium/highres derivatives, a public viewer, and two blocks so a gallery can be placed on any composed page.

Package: c975l/gallery-bundle · Namespace: c975L\GalleryBundle\ · Twig namespace: @c975LGallery · Translation domain: gallery

Key source paths (relative to the package root): src/Controller/GalleryController.php, src/Controller/Management/Trait/, src/Contract/, src/Entity/, src/Field/, src/Model/, src/Repository/, src/Routing/GalleryRoutePrefix.php, src/Listener/, src/Service/, src/Service/Fulfilment/, src/Twig/Extension/, src/Management/, src/Form/Block/, templates/gallery/, templates/print/, templates/components/Gallery/, templates/blocks/, config/configs.json, config/services.yaml

Related documentation: this package's README.md is the exhaustive reference — every section named below is an anchor in it. The ecosystem's own rules (database-backed configuration, blocks, media library, management contributions) live in c975l/core-bundle.

Quick start

composer require c975l/gallery-bundle
php bin/console doctrine:migrations:diff && php bin/console doctrine:migrations:migrate
php bin/console c975l:config:load-all      # seeds config/configs.json into the back office
php bin/console assets:install --symlink
php bin/console c975l:scaffold:install     # copies assets/styles/themes/gallery.css into the app
# config/routes.yaml
c975_l_gallery:
    resource: "@c975LGalleryBundle/src/Controller/"
    type: attribute
    prefix: /

Nothing else is registered by hand. The Stimulus entrypoints (assets/controllers.js for the public pages, assets/controllers-admin.js for the back office) are picked up by UiBundle's script registry, and their importmap.php entries are written by Management\ImportmapProvider on the next composer update. Both join the page's one Stimulus application, globalThis.c975lStimulusApp, whichever c975L barrel loads first starting it: a barrel of its own would build the live controller a second time and a Live Component would answer twice.

GalleryMedia::$user is typed against c975L\ConfigBundle\Contract\UserInterface: the app's App\Entity\User must implement it. The scaffolded User already does.

The data model

There is no gallery entity. A site's galleries are its categories — GalleryCategory is the top-level unit, and each holds its GalleryMedia.

  • GalleryCategory — slug (the url segment), title, summarySocialNetwork (rich text, printed above the grid through |sanitize_html('c975l.rich'), never |raw, and reused as the page's description metas, the text.meta_category sentence standing in when empty, or closing a summary shorter than 50 characters), position, coverMedia, uncategorized (the lazily-created catch-all), automaticKind (which automatic gallery it is, null for an ordinary one, see below), hidden (kept out of every public page without being deleted, see Masking is not deleting), license (a Model\GalleryLicense value, reserved by default, see Structured data), medias. Implements HasBlocksInterface (its own editorial heading) and TrashableInterface.
  • A site adds fields of its own to a category or a media by implementing GalleryCustomizationProviderInterface (getCategoryDataFormType(), getMediaDataFormType()), collected on sight of the interface through gallery.customization_provider — nothing to tag. They live in the data JSON column of each, are rendered by GalleryDataField on the edit screen, read back with getDataValue(), and travel through the export/import. Declaring nothing means no field.
  • GalleryMedia — belongs to one category, keyed publicly by slug which is unique within its category only. Carries title, data (the site's own fields, see below), description (the caption printed under the photograph and reused as the page's description metas when set, else composed from site, gallery, title and credits, or the text.meta_media sentence when that is under 50 characters, a plain textarea opted into Donovan's rephrase button by data-ai-rephrase), credits, rightsReserved, position, mediaType (image / a platform name / embed, always derived, never set), externalUrl, an optional uploaded video file, and the Vich fields. Implements TrashableInterface, VichMultiSizeImageInterface, VichMediaNamableInterface, VichOriginalKeepableInterface, VichWatermarkableInterface.

Deleting takes two steps

Both entities carry UiBundle's TrashableTrait, and the delete button removes nothing. It flags the row: the category leaves the site, its medias, its heading blocks and every one of their files stay exactly where they are — the cascade on the medias and GalleryMediaDerivativeCleanupListener are simply never reached.

  • The category index switches to what the trash holds with a trash query parameter, a category's edit screen does the same for its medias with mediasTrash. A media has a trash of its own, so a photo is taken off a gallery that stays online, and restoring a category gives back exactly the medias that were showing when it left.
  • Only GalleryCategoryCrudController::removeForGood() — reached from the trash's row button deletePermanently() and from the trash's batch "Delete" — and deleteMediasPermanently() for a selection of medias ever remove a row and reach its files. They sit behind the site-role-admin setting; the rest of the gallery back office sits behind site-role-editor.
  • The batch "Delete" runs deleteEntity(), which trashes on the galleries and removes for good in the trash: isTrash() decides, the row's isDeleted() only guards against a stale view, and ConfigBundle's TrashBatchDeleteTrait::batchDelete() checks the admin role before the first row when in the trash and says once how many went. deletePermanently() ignores a category that is not in the trash.
  • A trashed category or media answers 410 Gone on its public url, not 404, straight from the row. That 410 lasts only as long as the entity can be restored: the permanent deletion is what writes the "gone" Redirect rows that outlive it, and restoring releases any such row left under the url.
  • Every listing query leaves the trash out — findAllOrdered(), countVisible(), GalleryMediaRepository::findByCategory(), GalleryCategory::getMediasCount() and getCoverOrRandomMedia(). findOneBySlug() and findOneBySlugInCategory() are deliberately unfiltered, the front-office needing the row in hand to answer 410.
  • The export/import carries the flag both ways: a category archived out of the trash comes back to it.
  • The likes go with the permanent deletion, never with the trash. A rating names its owner (gallery_media + id) rather than relating to it, so no foreign key cascades it: removeForGood(), deleteMediasPermanently() and GalleryImportProvider::import() (which replaces a category's whole collection) each call RatingRepository::deleteForOwners() themselves, once for the whole set and only after the flush that actually removed the rows. A trashed media keeps its likes — it can come back.

Masking is not deleting

Both GalleryCategory and GalleryMedia carry a hidden flag, the answer to what is worth keeping and not worth showing — a gallery being prepared, one taken down for a season, a photograph withheld.

  • A masked row stays whole in the back office: the category listing shows it with a switch saving on the spot, and its own edit screen fills, arranges and trashes its medias as usual.
  • It answers 404, never the trash's 410: masking is reversible, and nothing a change of mind would have to take back should be told to a crawler. A masked category takes its medias' pages down with it, those being resolved through their category (GalleryController::resolveCategory()).
  • findAllOrdered() and countVisible() drop it, which is what takes a masked gallery off the index, out of the blocks, out of the sitemap and out of the menu targets — and off the back office's move targets and block pickers with them, a public page must not be composed on a url answering 404.
  • Its photographs leave the automatic galleries too (c.hidden = false in latestMedias() and findPrintable()) and are not sold as prints (GalleryPrintService::isPrintable(), the basket being reached without ever going through the page that would have said so).
  • Masking a category marks none of its medias, exactly as trashing one marks none of them — so showing it again gives back precisely what was showing before.
  • Masking is not privacy: the files stay under public/medias/, served to whoever has their address. Never present hidden as a way to keep a photograph confidential.
  • The export/import carries both flags: a gallery archived masked comes back masked.

Public routes and the renamable prefix

Route name Url Renders
gallery_index /{prefix} one thumbnail per category
gallery_category /{prefix}/{category} the category grid, photos and videos alike
gallery_media /{prefix}/{category}/{slug} the medium-resolution photo, or the video player
gallery_print_certificate /certificate/{certificate} the public check page of one numbered print
gallery_print_file /gallery-print-file/{copy} the print file, fetched by the lab through a signed url
gallery_print_callback /gallery-print-callback/{provider} where a lab reports an order has moved on

The last three sit outside the renamable prefix: one is printed on paper and has to outlive a rename, the other is never read by a visitor.

gallery_category serves its grid GalleryController::MEDIAS_PER_PAGE (60) medias at a time, read from ?p=: category.html.twig receives medias as UiBundle's Pagination — countable and iterable, plus getCurrentPageNumber(), getPageCount(), getTotalItemCount(), route and query() — and UiBundle's infinite-scroll.js appends the next page as the visitor scrolls, into the grid <twig:c975LGallery:Gallery:Medias … infinite="true"/> marks. A page past the last one answers 404.

The first segment is the gallery-route-prefix setting, renamed from the dashboard (galerie, fotos) with no cache to clear. A route path is compiled into the router cache, so the prefix cannot be the path: the three routes are declared as /{gallery_prefix}/… and each carries a condition asking Routing\GalleryRoutePrefix whether the segment it was handed is the configured one. Listener\GalleryRoutePrefixListener puts the same value in the router's request context, which is where the generator takes the missing parameter from.

Generate urls, never write them. The route parameter is filled for you, and localized_path keeps a visitor in the language they are reading (see Translation):

{{ localized_path('gallery_index') }}
{{ localized_path('gallery_category', { category: category.slug }) }}
{{ localized_path('gallery_media', { category: category.slug, slug: media.slug }) }}

Route names never change, whatever the prefix. Renaming the prefix breaks the previously indexed urls — declare a redirect in ConfigBundle's Redirections screen. Renaming a category is handled for you: GalleryCategoryCrudController::updateEntity() writes the permanent redirect, plus a wildcarded row for the medias underneath.

The automatic galleries

A category carrying an automaticKind holds no media of its own: it is handed a list at read time. There are two kinds out of the box — GalleryCategory::AUTOMATIC_LATEST (the last additions) and AUTOMATIC_PRINTABLE (the photographs on sale as prints, only on a site that sells them). A kind is written by GalleryCategoryRepository::findOrCreateAutomatic() the first time the galleries are listed (the back-office listing, the public index or a categories block, whichever comes first) and is a normal category from then on — renamed, described, given a heading, arranged, linked from a menu, declared in the sitemap. Nobody creates one, and it is never an option ticked on one of the site's own galleries. Moving it to the trash is how a site says it does not want it: unlike the catch-all, a trashed one is left exactly where it was put.

The plumbing is written once, the kinds only answer what they gather. Service\GalleryAutomaticProvider is the one place every screen asks (ResetInterface, so it reads once per request); each kind is an AutomaticGalleryInterface — getKind(), isAvailable(), getMedias() — collected through gallery.automatic_gallery. Service\GalleryLatestProvider and Service\GalleryPrintableProvider are the two shipped. A kind answering false to isAvailable() never gets a row written, so a feature nobody turned on leaves nothing behind — and prepare() drops it from the list it hands back even when a row already exists, so turning the feature off afterwards takes the gallery off the index too. A kind no provider answers for at all is kept: it is the ordinary gallery it became the day its bundle went.

GalleryLatestProvider's list comes from GalleryMediaRepository::findLatest($days, $max): a rolling window of calendar days, today included, falling back on the last day that carries an addition when the window catches nothing, so the gallery is never empty as long as the site holds a photo. The gallery_media_created_at index on created_at is what keeps that read off a full scan.

GalleryAutomaticProvider's own API:

  • getMedias(GalleryCategory $category) — what that category's kind gathers.
  • ensureCategory(string $kind) / ensureCategories() — the rows written for the kinds available.
  • findAutomaticBySlug(string $slug) — the automatic gallery a url names, its row written on the way; null, and nothing written, for any other slug and for a kind turned off.
  • findPreviousAndNext(GalleryMedia $media, GalleryCategory $category) — the neighbours within the automatic gallery being browsed, which are not the ones the media's own category files it next to.
  • prepare(array $categories) — hands back the listed categories with the automatic ones among them, holding their medias. For a caller reading the list itself (the public index, the categories block).
  • hydrate(iterable $categories) — hands the automatic ones their lists, the others left alone. For a caller holding entities it did not read (the back-office listing, whose rows EasyAdmin paginates).
  • Both also hand every other listed category the medias its tile and its media count read, in one GalleryMediaRepository::findVisibleByCategories() query for the whole list, posed through GalleryCategory::setLoadedMedias() — the relation is lazy, so a listing otherwise reads it category by category. A list of one is left alone, the lazy relation running the one query this would. GalleryLatestProvider::getMediasByDay() cuts that same list into the days its medias were added on — the back-office screen alone reads it, an upload session being what an admin credits or downloads in one go.

GalleryCategory::getMediasCount() and getCoverOrRandomMedia() read that handed list rather than the relation, so a tile, a count and an og:image work without knowing which kind of category they hold — the automatic one showing its newest photo rather than one at random.

Urls stay canonical. A thumbnail of the automatic gallery links its photo under its own category, never a second path, with ?from=<automatic slug> over it. GalleryController::media() reads it: previous and next are then the medias added just before and just after, whatever category they sit in, and the breadcrumb leads back to the last additions. A photo that has left the window is browsed as its own category's again.

Its back-office edit screen is the cross-gallery selection screen: the same grid (templates/management/_gallery_media_tile.html.twig) cut into one section per day, each thumbnail naming the gallery the photo belongs to. Credits, rights, downloads and move to trash act on the photo where it really sits, cover included. What the owning category alone carries is left out: no upload button, no drag to reorder, no cover radio, no trash view. The media CRUD's category picker leaves out the automatic and the trashed galleries.

The library's contact sheet

GalleryMediaCrudController::index() answers two screens, told apart by one query parameter:

  • with category — every media screen opened from a gallery carries it along — it redirects to that gallery's edit screen, where its medias are listed. Unchanged.
  • without it — the gallery_media sidebar entry, the files health check — it renders the contact sheet of the whole library: every gallery's photographs in one grid, crud/index overridden with templates/management/gallery_media_index.html.twig, the same tile a category's own grid draws.

createIndexQueryBuilder() leaves out the trash on both sides — the media's own flag and its gallery's, trashing a category flagging the category alone. configureFilters() offers the gallery (automatic and trashed ones excluded, as the category picker does), printable, hidden and rightsReserved; the search is pinned to title, description and credits.

Overriding crud/index replaces EasyAdmin's main block, and the filters modal is rendered inside it while the button opening it comes from page_actions: gallery_media_index.html.twig therefore includes @EasyAdmin/crud/includes/_filters_modal.html.twig itself, under filters|length > 0, or the button throws.

The tile is the single place a thumbnail's badges are drawn (_gallery_media_tile.html.twig): masked, on sale, edition size, likes. Both screens hand it likes, counted for the whole grid by Service\GalleryMediaLikeCounter — one RatingRepository::getAggregates(), and none at all where gallery-rating is off. selectable is what turns the selection box on: the category screen has a form for it to post into, the contact sheet has none.

Showing a gallery outside its own routes

Two block kinds are contributed to UiBundle (declared in config/services.yaml), so a gallery goes on any page composed in the back office:

Kind Shows Options
gallery_categories every category, one thumbnail each optional maximum
gallery_medias one category's medias category, optional maximum, random draw, link to the full category

Both resolve their content live through Twig\Extension\GalleryBlockExtension, so a block never goes stale against the media library. What a block stores is what to show — a category slug, a maximum — never the medias themselves.

Both are cacheable: true all the same. Service\GalleryBlockCacheTagProvider (UiBundle's BlockCacheTagProviderInterface) puts every entry under one tag, and Listener\GalleryCacheInvalidationListener drops it whenever a GalleryCategory or a GalleryMedia is saved or removed, or one of the settings the blocks are drawn from is: the automatic galleries' four entries, gallery-route-prefix and gallery-thumbnail-whole. The tag never goes out before the flush is over, so a rollback leaves the cache alone. UiBundle only ever invalidates the Block that was edited, and knows nothing of what these two kinds query at render time — which is what that listener is for.

Three cases decline the entry and render live instead, an entry never expiring: a block with the random draw ticked, which would be frozen into one single draw; the gallery of the last additions, whose photographs leave it as the days go by with nothing saved to drop the tag on; and the listing, for as long as a gallery has no cover of its own, its tile being drawn at random too.

In a template of your own, the same Twig functions and components are available:

{# categories, optionally capped #}
{% for category in gallery_block_categories(6) %}…{% endfor %}

{# one category's medias: slug, max, random #}
{% set medias = gallery_block_medias('landscapes', 12, true) %}

{# the grids themselves #}
<twig:c975LGallery:Gallery:Categories categories="{{ categories }}"/>
<twig:c975LGallery:Gallery:Medias category="{{ category }}" medias="{{ medias }}" displayTitle="{{ true }}"/>

Anonymous components under templates/components/Gallery/: Categories, Category, Medias, Media, Navigation, Previous, Next, Lightbox, Video, Credits.

Lightbox writes no markup of its own: it names the two files and the words the link is announced with, and hands them to UiBundle's <twig:c975LUi:Image:Zoom> (c975l/core-bundle ^1.26), which carries the <dialog> and its Stimulus controller. What stays this bundle's is the passe-partout, applied on .gallery-media-container .image-zoom__image, and the three --gallery-lightbox-* tokens handed to the zoom's own on .gallery-media-container.

Each kind is drawn as a silhouette in sass/block-thumbs.scss, compiled to public/css/block-thumbs.min.css and handed to the back office by Service\StylesheetProvider (BundleStylesheetManagementProviderInterface, tag ui.management_stylesheet), so the block picker shows what a kind looks like instead of UiBundle's default frame. A new kind adds its own rule there — StylesheetProviderTest reads the tagged kinds off config/services.yaml and fails on one left undrawn.

Images: what the bundle generates

Three derivatives per uploaded image, generated automatically by UiBundle's VichImageResizeListener through the VichMultiSizeImageInterface contract — this bundle only declares the sizes:

Derivative Constant Size Used by
thumbnail GalleryMedia::THUMBNAIL_SIZE 600px longest side both grids
medium GalleryMedia::MEDIUM_WIDTH 1024px the media page, and the og:image a share carries
highres GalleryMedia::HIGHRES_WIDTH 2048px the zoom, fetched only on demand

The thumbnail file always holds the whole photo — it is square only for a square photo. What the grid does with it inside its square tile is the gallery-thumbnail-whole setting (cover off, contain on): one CSS class, applied on the next request, reversible, nothing regenerated.

Originals can optionally be kept outside the document root (GalleryMedia::ORIGINAL_DIRECTORY). Media files live under GalleryMedia::MEDIA_DIRECTORY (medias/gallery).

Service\GalleryMediaArchiver is what hands those two back: the medias checked on a category's edit screen are downloaded as one zip, either their highres derivatives or the kept originals (GalleryCategoryCrudController::downloadMedias(), site-role-editor like the rest of the screen). Entries are named after each media's slug and stored rather than deflated, a selection past MAX_TOTAL_BYTES is refused with its size rather than truncated, and a selection with no file at all gives a message rather than an empty archive. Do not write a file download of your own for a gallery's medias, and do not expose the original directory over http.

Service\GalleryMediaMover is the single place a media changes gallery, both ways going through it: the Move selection button of the same toolbar (GalleryCategoryCrudController::moveMedias()) and the category field of a media's own edit form (GalleryMediaCrudController::updateEntity()). It moves the stored file, its -thumb/-highres siblings, the kept original and the media's own video into medias/gallery/{arrival gallery}/, leaves the old media page redirecting to the new one, suffixes a slug the arrival gallery already holds, appends the medias to its ranks while closing the gap behind them, releases a cover pointing at a media that has left, and renumbers the titles when a title root is given. The destination is chosen explicitly — nothing is preselected in that list, and its last entry creates the gallery on the spot under the name typed beside it (a slug already taken is refused, never suffixed, a category's slug being its natural key). Only the directory moves — the filename itself carries the slug the media had at upload and never changes. The files are renamed in postFlush so a failed save leaves them where the rows still point at them; the media is deliberately left in the gallery's own collection, that relation being orphanRemoval and removing it there deleting the very row being moved. Do not reassign GalleryMedia::$category by hand — the files would stay behind in the gallery the media left.

Both downloads are offered in the medias trash as well, and are the one selection action that does not filter on isDeleted - the state is what keeps a selection posted from the grid away from the permanent deletion, where reading a file is the same act either way. The category's own Move to trash is rendered at the foot of the edit screen, not in the page toolbar (page_actions is overridden to leave it out): next to Save, one row above the photographs being checked, it was clicked for a selection and took the whole gallery off the site.

php bin/console c975l:gallery:rebuild-thumbnails [--dry-run]   # rewrites every -thumb.webp from the highres
php bin/console c975l:gallery:fill-slugs                       # backfills slugs on medias predating them
php bin/console c975l:gallery:automatic:dedupe [--dry-run]     # merges a "latest-2" into the leftover "latest"

Batch upload ceilings are checked client-side before the request is sent, because PHP truncates an oversized batch silently rather than refusing it: Service\UploadLimits::MAX_FILES (100) and MAX_FILE_SIZE (20 MiB), each capped in turn by the running PHP's max_file_uploads, upload_max_filesize and post_max_size. Both constants are there to be raised by an app that knows its server takes more. The batch screen only ever creates images.

A batch is minutes of transfer then of processing, so the form is armed with UiBundle's UploadProgress: it posts over XMLHttpRequest, counts the megabytes as they leave, then says the files are being processed, the submit button taken away for the whole wait. The controller answers with the arrival url instead of redirecting, so the "medias added" flash reaches the screen that follows.

Videos

A media becomes a video by carrying the url of the page the video is watched on — nothing to extract by hand. Whatever it carries, an entry always has its own uploaded still, which is what the grids show, so one category holds photos and videos alike.

  • Which platforms is UiBundle's question, not this bundle's: c975L\UiBundle\Video\VideoPlatform is where one is declared. YouTube, TikTok, Vimeo and Dailymotion ship declared. What gets stored is always the platform's privacy-first embed url (youtube-nocookie.com, dnt=1).
  • An url belonging to no declared platform is stored as pasted, typed embed, framed 16/9. There is deliberately no "paste your embed code" field — third-party HTML in the database is an XSS and a CSP hole.
  • A media can also carry an uploaded video file (mp4, webm, ogg), played by the browser with the still as its poster. A media carrying both plays its own copy. It is the one player whose shape nothing declares — the browser reads it off the file — so it is capped by --gallery-video-self-hosted-max-height (70vh), a height where a framed player's cap (--gallery-video-portrait-max-width) is a width.
  • Players render through <twig:c975LUi:Video:Iframe>, created client-side only once the visitor has accepted the cookie banner.
  • CSP: UiBundle exposes every declared origin as %c975l_ui.video.embed_origins%, for frame-src, child-src and any Permissions-Policy naming fullscreen.

Likes on a photo

gallery-rating, on out of the box, prints UiBundle's rating widget under the media page's photo, asked for one icon and one only:

<twig:c975LUi:Rating:Rating ownerType="gallery_media" ownerId="{{ media.id }}" scale="1" icon="heart"/>

A photo is liked or it is not, so there is no average to print — the line under it says how many people liked it, and clicking the heart again takes the like back. scale and icon are stated here rather than left to the site's own ui-rating-icon / ui-rating-scale, which serve the scales elsewhere. No login is asked for: an authenticated visitor is keyed on their account, anyone else on a token their own browser mints. The rating table comes with c975l/core-bundle ^1.13.1 — an app updating to it generates and plays a migration, or every media page fails on an unknown table. See UiBundle's Visitor ratings.

Configuration

Everything is in the database, declared in config/configs.json, group gallery, edited in EasyAdmin — never in .env, parameters: or a Configuration/TreeBuilder class. Read it with ConfigServiceInterface::get('slug') in PHP or config('slug') in Twig.

Slug Kind What it settles
gallery-route-prefix text the first url segment (empty falls back to gallery)
gallery-thumbnail-whole bool grid tiles contain instead of cover
gallery-rating bool the heart under a photo, on out of the box (see Likes on a photo)
gallery-latest-days int how many days back the automatic gallery reaches (empty or 0 falls back to 7)
gallery-latest-max int how many medias it stops at (empty or 0 falls back to 200)
gallery-license-url text the photographs' terms of use page, published as license and as acquireLicensePage when no print is offered (see Structured data)
gallery-printable-max int how many photographs the prints gallery stops at (empty or 0 falls back to 200)
gallery-print-enabled bool opens the print sale to the public; off out of the box. It hides nothing in the back office: the two print screens, their guided projects and the test-mode tile stay, the indexes warning that the sale is closed (see Selling prints)
gallery-print-provider text which lab fulfils the orders (prodigi out of the box, manual to fulfil them by hand)
gallery-print-api-key text, sensitive the lab's api key
gallery-print-sandbox bool the lab's test mode, on out of the box - toggled from the dashboard tile
gallery-print-signature text the signature laid on print files, a path under public/; empty leaves prints unsigned
gallery-social-order choice random/oldest the order SocialBundle's publication takes the photographs in, random out of the box
gallery-style choice light/dark the ground a photo is shown against; empty keeps the site's own colors
gallery-frame choice none/thin/wide the passe-partout around a displayed media
theme-color-gallery-frame text passe-partout color
theme-color-gallery-nav text arrows
theme-color-gallery-nav-hover text arrows, hovered
theme-color-gallery-nav-background text arrow buttons
theme-color-gallery-backdrop text lightbox backdrop
theme-color-gallery-breadcrumb text breadcrumb
theme-color-gallery-credits text credits line
theme-color-gallery-badge text video badge
theme-color-gallery-badge-text text video badge label

A theme- slug is compiled into a --c975l-color-gallery-* custom property by UiBundle's ThemeVariablesCssListener. Four of them are deliberately left empty, their fallback being an expression that follows a light or a dark gallery rather than a fixed color.

The gallery back office sits behind ConfigBundle's site-role-editor setting, except the two permanent deletions, held at site-role-admin (see Deleting takes two steps). Every sidebar entry states that same bar (MenuProvider, 'role' key) and so do the screens themselves (setPermission(Action::INDEX, ...) on each CRUD), so an editor reaches them instead of seeing no entry at all — read by c975l/core-bundle ^1.14.0 and up, earlier ones giving every entry the admin bar.

Selling prints

Closed to the public unless gallery-print-enabled is on, which only governs the public side: the back office never hides anything because of it. MenuProvider always lists the gallery_print_order and gallery_print_format entries, GalleryGuidedProjectProvider always contributes the two print parcours, GalleryShortcutProvider always offers the test-mode tile, and the bulk printable action of a gallery's screen is always there. While the switch is off, both print indexes draw an alert alert-warning (_gallery_print_sale_notice.html.twig, fed print_enabled and print_switch_url by each CRUD's configureResponseParameters()), the switch's edit url - from UiBundle's ConfigEditUrlResolver - being handed to site-role-admin only, ConfigCrudController denying anything below. Requires c975l/payment-bundle ^6.18: a print is sold through the one basket, this bundle plugging in as a BasketItemProviderInterface of kind gallery_print. It also answers CatalogueBasketItemProviderInterface, whose getCatalogueUrl() hands the basket the path its "continue shopping" button goes back to — gallery_index, PaymentBundle knowing of no shop to send anybody to on a site running the gallery and the basket alone. It answers ShippingBasketItemProviderInterface too, shipsParcels() declaring prints as posted to PaymentBundle's shipping health check while gallery-print-enabled is on and the catalogue holds a format, and WeighableBasketItemProviderInterface, getWeight() reading the format's weight frozen into the line (item.weight) times its quantity, null when unweighed.

Three entities. GalleryPrintFormat is the catalogue — a size, its dpi, its price, its packed weight in whole grams (nullable), and the sku the lab knows it by (distinct from the slug; only the sku is ever sent to a lab). GalleryPrintOrder is one consignment. GalleryPrintCopy is the register: one row per print.

The catalogue is imported rather than typed where the lab publishes its range: a driver answering PrintCatalogueProviderInterface (ProdigiCatalogue is the shipped one) hands PrintCatalogueImporter the sizes, papers and skus, and the Import the catalogue action of GalleryPrintFormatCrudController writes them unpublished and unpriced — the prices are the shop's, never the lab's. Each entry carries its dpi by its size (300 up to 30 cm on the long edge, 240 up to 40, 200 beyond) and its paper's description as a print_paper.* translation id, written in the site's default locale at import. Run again, the importer adds what the range gained and refreshes only what is still as shipped — a description still in the earlier English, a dpi still at the default — never a sentence or a resolution the admin set (PrintCatalogueImportReport::$refreshed). The action is only drawn where a driver answers, a shop printing by hand writing its formats itself.

GalleryMedia carries printable (on offer), hidden (kept out of every public page without being deleted, filtered in the repository and 404 in the controller) and editionSize (null for an open edition). Announcing an edition writes its rows at once through GalleryPrintCopyRepository::openEdition(), and GalleryMediaCrudController::settleEdition() refuses every later change to the number — raising an announced edition is a forgery. Selling one claims the lowest free row with a single UPDATE … WHERE order_id IS NULL, so two checkouts on the last copy cannot both win.

A format is only offered for a photograph whose original actually has the pixels and the proportions (±3 %, GalleryPrintService::getOffers()) — nothing is ever cropped to fit a size.

Everything a certificate states is frozen onto the copy at the sale (PrintCopySnapshot: format, label, sku, price, title, credits, issuer). Read nothing live when drawing a certificate — the sheet is signed by hand and posted, so a retitled photograph or a renamed site must not contradict it. The certificate is a pdf carrying a qr code to its public page, /certificate/{token}, which sits outside the renamable gallery prefix because it is printed on paper.

An open edition goes straight to the lab through Messenger. A limited one stops and waits: two e-mails go out (the buyer's numbers, and the admin's "sign this"), and the admin releases it from the orders screen.

A lab is a PrintFulfilmentInterface; every implementation is tagged on sight by TaggedInterfacePass, so a site adding its own printer wires nothing. ProdigiFulfilment ships, ManualFulfilment throws on purpose so the order stays pending in the back office rather than claiming it was sent. A sold edition is always announced; the letter asking for a signature only goes out where gallery-print-edition-hold is on and the order really is waiting to be released.

The print file is composed from the untouched original, never from the web derivative, and the signature is restamped at print resolution (GalleryPrintFileBuilder). The lab fetches it through a UriSigner url expiring at seven days; an unsigned request gets 404, not 403.

The file waits in %kernel.project_dir%/var/gallery-print/, named {copy id}-{certificate}.jpg. A second context running against its own database (a demo) sets the c975l_gallery.print_dir container parameter, or its prints land among the site's.

What the lab says afterwards reaches the order two ways, and both write through GalleryPrintOrderTracker and nowhere else: the lab's callbacks on gallery_print_callback (whose address ProdigiFulfilment sends per order as callbackUrl, rather than relying on the lab's dashboard), and the nightly c975l:gallery:print:sync, declared by GalleryMaintenanceTaskProvider, which asks each lab about the orders it holds. A callback is read as a name, never as a state — a print lab signs nothing and that url is public, so the state is then asked of the lab itself. Shipped, the order is stamped, the buyer gets your prints are on their way, and the composed files are discarded. States never walk backwards, and nothing a lab says moves an order waiting for a signature. The move itself is a single conditional UPDATE (GalleryPrintOrderRepository::claim()), so a callback the lab replayed at the same moment as the nightly command sends one letter and frees the files once. A cancellation writes to the shop and never to the buyer - the refund is the shopkeeper's decision.

The consuming site routes GalleryPrintOrderMessage to an asynchronous transport, so a slow lab never reaches the payment webhook.

A member's print orders travel in the export of their data (/account/export): Management\AccountDataProvider answers ConfigBundle's AccountDataProviderInterface under a prints key, one entry per order of the account's baskets (GalleryPrintOrderRepository::findForUser()), its lab state and its copies, the basket number tying it to the orders part. An account with no print order adds no key.

Extending and overriding

  • Templates — every one of them is overridable from templates/bundles/c975LGalleryBundle/, mirroring the path under templates/. Public templates {% extends 'layout.html.twig' %} (no @), which the app's own file resolves.
  • Theme — assets/styles/themes/gallery.css is copied into the app by c975l:scaffold:install and owned by it from then on. Every token ships commented out at the bundle's default: uncomment a line to take it over. Fonts and site colors are deliberately absent — the gallery reads UiBundle's --text / --background / --font-family-body, so it looks like the site it is installed on.
  • Blocks on a category — GalleryCategory implements HasBlocksInterface; render them with <twig:c975LUi:Blocks:Blocks blocks="{{ category.blocks }}"/>.
  • Fields of the site's own — implement GalleryCustomizationProviderInterface and return a form type from getCategoryDataFormType() / getMediaDataFormType(). Nothing to tag: the interface is collected through gallery.customization_provider, read by GalleryCustomizationRegistry (first provider answering wins) and rendered by GalleryDataField on the edit screen. The values land in the data JSON column, read back with getDataValue().
  • An automatic gallery of the site's own — implement AutomaticGalleryInterface (getKind(), isAvailable(), getMedias()). Nothing to tag: TaggedInterfacePass collects every implementation through gallery.automatic_gallery, and the category, its place on the index, its menu target and its sitemap line follow from GalleryAutomaticProvider alone.
  • A lab of the site's own — implement PrintFulfilmentInterface, collected the same way through gallery.print_fulfilment and picked by the gallery-print-provider setting.
  • Repositories — GalleryCategoryRepository::findAllOrdered(), findOneBySlug(), countVisible(), findOrCreateUncategorized(), findOrCreateAutomatic() (both suffixing a slug already taken, the latter first taking back an empty ordinary gallery left under latest), findAutomaticDuplicate() (the latest-2 and that leftover, for c975l:gallery:automatic:dedupe); GalleryMediaRepository::findByCategory(), findOneBySlugInCategory(), findPreviousAndNext(), findLatest() (what the automatic gallery shows), findVisibleByCategories() (the same list for several categories at once, grouped by category id), findWithFilename() (the rows naming a file, for the files health check). findAllOrdered() is memoized for the request (ResetInterface), its callers knowing nothing of each other.

What the bundle already contributes to the dashboard, so you do not have to: MenuProvider (its own Galerie sidebar section, holding the gallery, gallery_media, gallery_print_order and gallery_print_format entries, rather than the shared "management" one), LinkableRouteProvider (the index and each category offered as a SiteBundle menu target, tagged gallery_galleries through LinkableRouteCacheTagsInterface so a menu item pointing at one is cached), GallerySitemapProvider, GalleryFeedProvider (ConfigBundle's FeedProviderInterface: the latest visible medias as an Atom feed at /feed/gallery.xml), GalleryUrlMetadataProvider, GalleryFilesHealthCheckProvider (kind files-gallery, reporting every media whose image or self-hosted video is gone from the server), GalleryExportProvider / GalleryImportProvider (categories as a zip, files included), GalleryBackupPathProvider, GalleryBlockOwnerResolver, GalleryGuidedProjectProvider (whose gallery-print-setup and gallery-print-order parcours are offered whatever gallery-print-enabled says, and gallery-social only where SocialBundle is installed), WhatsNewProvider, ImportmapProvider, Service\ScriptProvider, Service\StylesheetProvider (public and management stylesheets both), Service\GalleryShowcaseProvider, GalleryShortcutProvider (the tile toggling the lab's test mode), GalleryDemoFixtureProvider, Service\GalleryPrintBasketItemProvider (PaymentBundle's basket, kind gallery_print, and the catalogue its "continue shopping" button returns to, the parcels declared to the shipping health check, and their weight), Email\GalleryEmailTemplateProvider, Service\GallerySocialContentSource (UiBundle's ScopedSocialContentSourceInterface and BrowsableSocialContentSourceInterface, source type gallery_media, also GallerySocialContentSource::SOURCE_TYPE: one photograph at a time for SocialBundle's publication, a video or an embed going out by its still, never the same twice on a network, read off the configured route prefix since the run has no request; its scopes, getScopes(), are the non-automatic visible galleries a SocialBundle series may narrow the draw to), Service\GalleryPickableMediaProvider (UiBundle's PickableMediaProviderInterface: the visible photographs and uploaded videos, latest first, searched on their title, slug or gallery title, for SocialBundle's "Ajouter depuis le site" to add to a post, each carrying the source type and its id so the pick is held as that content).

Seeding a demo gallery

GallerySampleCatalog holds a made-up gallery once as plain data — three categories of four named photographs. GalleryShowcaseProvider builds the arrays the block showcase renders from it, GalleryDemoFixtureProvider (UiBundle's DemoFixtureProviderInterface) hands the lot over to whichever demo site loads it - this bundle ships no command that writes to a database. Enriching the catalog shows up in both, and everything a visitor reads is a key of the gallery domain.

The photographs come from PlaceholderMediaProviderInterface: each media takes the one declared under its own gallery/<slug> key (keyed_images), so the showcase and the demo site show the same photograph under the same title, and falls back on the generic pool, rotated over the catalog, for a slug declared nowhere. The demo takes its files as a temporary copy, made by UiBundle's DemoFixtureMediaCopier — an upload moves the file it is handed. A site declaring none is seeded with nothing at all: a gallery is its photographs. The showcase's thumbnails lead to the demonstration site UiBundle's ui-showcase-demo-url names, the only place holding these categories, and render as plain images when none is named. A media's slug is set before the flush, being half of where its file lands (see GalleryMedia::getVichMediaPath()).

Only the categories are yielded, their medias following through the cascade so Vich takes their files and derivatives off the disk with them.

Translation

Requires c975l/core-bundle ^1.28.0. A category, a media and a print format are one row in every language: Service\GalleryTranslator stores the other languages beside the row in UiBundle's site_translation, under GalleryTranslator::OWNER_CATEGORY / OWNER_MEDIA / OWNER_PRINT_FORMAT. Only CATEGORY_FIELDS (title, summarySocialNetwork), MEDIA_FIELDS (title, description) and PRINT_FORMAT_FIELDS (label, paper, paperDescription) are translated — slugs, files, SKUs and credits never are. Nothing is read on a site declaring one language (isActive()).

  • Public side — GalleryController doubles its three routes as gallery_index_localized, gallery_category_localized and gallery_media_localized under /{_locale}/{gallery_prefix}, answering in every language Service\GalleryTranslatedLocales::all() returns. What is rendered is handed to GalleryTranslator::apply(), which calls each row's setTranslated() so its getters read that language for the render; getUntranslated() still hands back the row's own text. GalleryBlockExtension and GalleryPrintExtension::getOffers() apply it too. Service\GalleryLinkLocalizer rewrites gallery urls stored in rich text.
  • Back office — Controller\Management\Trait\ContentLocaleCrudTrait gives the three CRUD controllers a language screen through ConfigBundle's ContentLocaleScreen (?contenu=xx): translationFields() for configureFields(), addContentLocaleParameters() for the tabs, stageContentLocale() for the edit form, and translateAction(), icon-only, on the galleries and print formats indexes. The edit templates include @c975LConfig/management/_content_locale_tabs.html.twig. A language screen submits the translated texts alone, so GalleryCategoryCrudController::createEditFormBuilder() skips the slug normalizer and the blocks listener there, and GalleryMediaCrudController skips the watermark listener.
  • Deletion — Listener\GalleryTranslationPurgeListener drops a row's translations on its removal.
  • The gallery-translation guided project walks a gallery's translation.

Structured data

The three public pages publish a schema.org graph, built by Service\GallerySnippetBuilder and rendered by the Twig functions gallery_media_json_ld(), gallery_json_ld() and gallery_index_json_ld() (Twig\Extension\GalleryJsonLdExtension) — never by a template of the bundle, so a site overriding a page keeps its structured data by calling the same function.

A photograph is an ImageObject, a video a VideoObject whose still becomes the thumbnailUrl and whose own file the contentUrl — a video framed from a platform having none, it names its player as embedUrl instead (GalleryMedia::getEmbedUrl()). Four properties are what an image search reads a licence from: creator and creditText off GalleryMedia::$credits, copyrightNotice as © credits whenever credits are set (not $rightsReserved: a CC photograph is still copyrighted), and acquireLicensePage — the photograph's own url where the page actually prints an offer for it, the terms page otherwise. That terms page, config gallery-license-url (a path made absolute by UrlHelper in the extension, or a full url), is also published as license while the gallery keeps all rights reserved; empty, neither is. GalleryCategory::$license offers the Creative Commons 4.0 set and CC0 (GalleryLicense::all(), deedUrl(), label(), name(), icons()): the deed then becomes license, read off the media's own category rather than the gallery walked through, and is linked by Gallery:Credits in place of the rights-reserved mark, drawn with Creative Commons' icons (public/icons/cc-*.svg, CSS masks) and its short name (CC BY-NC 4.0), while a reserved one links gallery-license-url when set; a category keeping reserved has its pages carry <meta name="tdm-reservation" content="1">. An unknown value given to setLicense() falls back on reserved. The print offer is handed to buildMedia() as its $printAvailable argument, the very boolean gallery_print_available() gave the page: the builder asks the shop nothing itself, so the graph can never announce a licence page the visitor is not being offered. A gallery is an ImageGallery whose mainEntity is the ItemList of the photographs the page was served with, numbered from gallery_json_ld()'s fourth argument, $offset - (page - 1) * perPage on a page after the first; the index is that ItemList alone.

Two graphs are the exception to "never by a template": print/_offer.html.twig publishes the prints as a Product through gallery_print_json_ld(media, offersByPaper, remaining, imageUrl, url) (GallerySnippetBuilder::buildPrint(), one Offer per PrintOffer, price in cents divided by 100, shop-currency, SoldOut when remaining is 0), and Gallery:Navigation publishes its trail as a BreadcrumbList through UiBundle's breadcrumb_json_ld(), counts left out. Item lists, plain text and encoding are UiBundle's JsonLdBuilder, injected in the builder.

The urls are resolved by the caller, only a template turning a stored file into an absolute one — the builder publishes no graph for a media with neither a file to name nor a player to frame.

Do not

  • Do not copy the old Lightbox body into an override. Its Stimulus controller, .gallery-media-zoom, .gallery-lightbox and .gallery-lightbox__image are gone: a copy keeps a data-action nobody listens to, and the click leaves the page for the raw file. Override the component and keep handing c975LUi:Image:Zoom its five props.
  • Do not put an offers node on the ImageObject — the prints are a Product of their own (buildPrint()), priced by the offer the page prints.
  • Do not hardcode /gallery in a template, a link or a test. The prefix is admin-editable; use localized_path('gallery_category', {category: slug}).
  • Do not generate a public gallery url with path() in a template. It sends a visitor reading another language back to the writing one; localized_path() keeps them where they are.
  • Do not call GalleryTranslator::apply() on a back-office screen, nor persist what it lays on a row. The getters then read the translation, and a form built on them would write it over the row's own text.
  • Do not put createEditFormBuilder() or configureResponseParameters() in ContentLocaleCrudTrait. A controller's own method shadows a trait's in silence; the controller calls stageContentLocale() and addContentLocaleParameters() from its own override instead.
  • Do not add a .env variable, a container parameter or a bundle Configuration class for a gallery setting. It goes in config/configs.json and is read through ConfigServiceInterface. The one container parameter, c975l_gallery.print_dir, is a filesystem path, not a setting.
  • Do not write an image resizer, a thumbnail command or a Vich naming rule. Sizes are declared on the entity, the work belongs to UiBundle's VichImageResizeListener.
  • Do not read an automatic category's medias relation — it is empty by definition. Ask GalleryAutomaticProvider::getMedias($category), or go through getMediasCount() / getCoverOrRandomMedia(), which already do. And do not create a second category of a kind, nor turn one of the site's own galleries into one.
  • Do not put in data anything the database has to filter, sort or join on, nor anything every gallery wants — the first stays a real column, the second belongs to the bundle (a caption did, hence GalleryMedia::$description).
  • Do not query a media by slug alone — a slug is unique only within its category.
  • Do not override EasyAdmin's main block without carrying its modals over. A CRUD declaring filters keeps its "Filters" button from page_actions while the modal it targets lives in main; drop it and the button throws on the first click.
  • Do not raise or lower an announced edition's size. settleEdition() refuses it: the rows are already written and a certificate already says "3 / 10". Open a new edition instead.
  • Do not read a photograph, a format or the site's name when drawing a certificate. Everything the sheet states is frozen on the copy at the sale (PrintCopySnapshot) — a signed sheet cannot be allowed to disagree with the page checking it.
  • Do not claim a copy with a read-then-write. GalleryPrintCopyRepository::claimNumber() is a single conditional UPDATE precisely so two checkouts on the last copy cannot both win.
  • Do not move an order's state with a read-then-write either. Go through GalleryPrintOrderTracker::apply(), whose GalleryPrintOrderRepository::claim() is the same single conditional UPDATE — a replayed callback would otherwise post the shipping letter twice.
  • Do not write a lab driver that creates an order twice when sent twice. ProdigiFulfilment sends an idempotencyKey stable per order and per site; the release button or a lost answer can send one again.
  • Do not print a price with a hardcoded currency. Read shop-currency, as templates/print/_offer.html.twig and PaymentBundle's own basket templates do.
  • Do not render <twig:c975LPayment:Basket:Navbar/> in a gallery template. UiBundle's layout places it once on every page (c975l/core-bundle ^1.31); a second one is never filled. Basket:Message stays.
  • Do not send the catalogue slug to a lab, nor the web derivative as a print file. The frozen sku is what a lab knows, and GalleryPrintFileBuilder composes from the untouched original.
  • Do not hand VichUploader a plain File when seeding a media — UploadHandler::hasUploadedFile() ignores it in silence, writing the row with no file name and nothing on the disk. Hand it the ReplacingFile UiBundle's DemoFixtureMediaCopier::copy() returns.
  • Do not have a fixture provider empty a table — a demo site keeps its own content in those very tables.
  • Do not list categories or medias with findAll() / findBy(). Those see the trash; use findAllOrdered() and GalleryMediaRepository::findByCategory(), which do not.
  • Do not count a grid's likes one thumbnail at a time. GalleryMediaLikeCounter::count() reads them for the whole page in one query, and reads nothing where the site shows no heart.
  • Do not add a listing action to the contact sheet. A photograph's position and the actions on a selection belong to the gallery holding it, whose own screen carries the form they post into.
  • Do not remove a media row without dropping its ratings. Nothing cascades them; go through RatingRepository::deleteForOwners('gallery_media', $ids) after the flush (see Deleting takes two steps).
  • Do not call remove() on a category or a media, and do not write a soft-delete flag of your own. Flag it through TrashableInterface; the permanent deletion is the back office's own action.
  • Do not add a video platform here. Declare it in c975L\UiBundle\Video\VideoPlatform.
  • Do not render a third-party iframe directly. Use <twig:c975LUi:Video:Iframe>, which honours the cookie banner.
  • Do not cache a gallery block without its tag. They resolve live, so an entry only holds where GalleryBlockCacheTagProvider puts it under the tag GalleryCacheInvalidationListener drops — and where nothing is drawn at random or by a window of days, which that provider declines.
  • Do not add a page layout to this bundle. A satellite never ships one; templates extend the app's layout.html.twig.
  • Do not build an import command for an existing photo folder. There is no reliable way to tell originals from an old gallery's derivatives; photos already on another c975L site move across with the export/import instead.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings