fix(gallery): fill tall portrait cards to full block width with object-cover crop

- ArtworkCard: add w-full to nova-card-media, use absolute inset-0 on img so
  object-cover fills the max-height capped box instead of collapsing the width
- MasonryGallery.css: add width:100% to media container, position img
  absolutely so top/bottom is cropped rather than leaving dark gaps
- Add React MasonryGallery + ArtworkCard components and entry point
- Add recommendation system: UserRecoProfile model/DTO/migration,
  SuggestedCreatorsController, SuggestedTagsController, Recommendation
  services, config/recommendations.php
- SimilarArtworksController, DiscoverController, HomepageService updates
- Update routes (api + web) and discover/for-you views
- Refresh favicon assets, update vite.config.js
This commit is contained in:
2026-02-27 13:34:08 +01:00
parent 09eadf9003
commit 67ef79766c
37 changed files with 3096 additions and 58 deletions

View File

@@ -0,0 +1,138 @@
/*
* MasonryGallery scoped CSS
*
* Grid column definitions (activated when React adds .is-enhanced to the root).
* Mirrors the blade @push('styles') blocks so the same rules apply whether the
* page is rendered server-side or by the React component.
*/
/* ── Masonry grid ─────────────────────────────────────────────────────────── */
[data-nova-gallery].is-enhanced [data-gallery-grid] {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
grid-auto-rows: 8px;
gap: 1rem;
}
@media (min-width: 768px) {
[data-nova-gallery].is-enhanced [data-gallery-grid] {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
[data-nova-gallery] [data-gallery-grid].force-5 { grid-template-columns: repeat(5, minmax(0, 1fr)) !important; }
}
@media (min-width: 1600px) {
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
[data-nova-gallery] [data-gallery-grid].force-5 { grid-template-columns: repeat(6, minmax(0, 1fr)) !important; }
}
@media (min-width: 2600px) {
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
[data-nova-gallery] [data-gallery-grid].force-5 { grid-template-columns: repeat(7, minmax(0, 1fr)) !important; }
}
[data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; }
/* ── Fallback aspect-ratio for cards without stored dimensions ───────────── */
/*
* When ArtworkCard has no width/height data it renders the img as h-auto,
* meaning the container height is 0 until the image loads. Setting a
* default aspect-ratio here reserves approximate space immediately and
* prevents applyMasonry from calculating span=1 then jumping on load.
* Cards with an inline aspect-ratio style (from real dimensions) override this.
*/
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
aspect-ratio: 3 / 2;
width: 100%; /* prevent aspect-ratio + max-height from shrinking the column width */
}
/* Override: when an inline aspect-ratio is set by ArtworkCard those values */
/* take precedence naturally (inline style > class). No extra selector needed. */
/* ── Card max-height cap ──────────────────────────────────────────────────── */
/*
* Limits any single card to the height of 2 stacked 16:9 images in its column.
* Formula: 2 × (col_width × 9/16) = col_width × 9/8
*
* 5-col (lg+): col_width = (100vw - 80px_padding - 4×24px_gaps) / 5
* = (100vw - 176px) / 5
* max-height = (100vw - 176px) / 5 × 9/8
* = (100vw - 176px) × 0.225
*
* 2-col (md): col_width = (100vw - 80px - 1×24px) / 2
* = (100vw - 104px) / 2
* max-height = (100vw - 104px) / 2 × 9/8
* = (100vw - 104px) × 0.5625
*
* 1-col mobile: uncapped portrait images are fine filling the full width.
*/
/* Global selector covers both the React-rendered gallery and the blade fallback */
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
overflow: hidden; /* ensure img is clipped at max-height */
}
@media (min-width: 1024px) {
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
/* 5-column layout: 2 × (col_width × 9/16) = col_width × 9/8 */
max-height: calc((100vw - 176px) * 9 / 40);
}
/* Wide (2-col spanning) cards get double the column width */
[data-nova-gallery] [data-gallery-grid] .nova-card--wide .nova-card-media {
max-height: calc((100vw - 176px) * 9 / 20);
}
}
@media (min-width: 768px) and (max-width: 1023px) {
[data-nova-gallery] [data-gallery-grid] .nova-card-media {
/* 2-column layout */
max-height: calc((100vw - 104px) * 9 / 16);
}
[data-nova-gallery] [data-gallery-grid] .nova-card--wide .nova-card-media {
/* 2-col span fills full width on md breakpoint */
max-height: calc((100vw - 104px) * 9 / 8);
}
}
/* Image is positioned absolutely inside the container so it always fills
the capped box (max-height), cropping top/bottom via object-fit: cover. */
[data-nova-gallery] [data-gallery-grid] .nova-card-media img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
/* ── Skeleton ─────────────────────────────────────────────────────────────── */
.nova-skeleton-card {
border-radius: 1rem;
min-height: 180px;
background: linear-gradient(
110deg,
rgba(255, 255, 255, 0.06) 8%,
rgba(255, 255, 255, 0.12) 18%,
rgba(255, 255, 255, 0.06) 33%
);
background-size: 200% 100%;
animation: novaShimmer 1.2s linear infinite;
}
@keyframes novaShimmer {
to { background-position-x: -200%; }
}
/* ── Card enter animation (appended by infinite scroll) ───────────────────── */
.nova-card-enter { opacity: 0; transform: translateY(8px); }
.nova-card-enter-active {
opacity: 1;
transform: translateY(0);
transition: opacity 200ms ease-out, transform 200ms ease-out;
}