Browse Source

Pinstr

imwald
Nuša Pukšič 3 months ago
parent
commit
ef4bbd9fc3
  1. 183
      assets/controllers/media-loader_controller.js
  2. 2
      assets/styles/03-components/video-event.css
  3. 99
      assets/styles/04-pages/author-media.css
  4. 80
      src/Controller/AuthorController.php
  5. 31
      src/Service/NostrClient.php
  6. 118
      src/Service/RedisCacheService.php
  7. 216
      templates/pages/author-media.html.twig

183
assets/controllers/media-loader_controller.js

@ -0,0 +1,183 @@
import { Controller } from '@hotwired/stimulus';
/*
* Media Loader Controller
* Handles "Load More" functionality for author media galleries
* Fetches additional media items from cache and appends to masonry grid
*/
export default class extends Controller {
static targets = ['grid', 'button', 'status'];
static values = {
npub: String,
page: { type: Number, default: 2 },
total: Number
};
connect() {
this.isLoading = false;
}
async loadMore() {
if (this.isLoading) return;
this.isLoading = true;
this.buttonTarget.disabled = true;
this.buttonTarget.textContent = 'Loading...';
try {
const url = `/p/${this.npubValue}/media/load-more?page=${this.pageValue}`;
const response = await fetch(url);
const data = await response.json();
// Add new media items to the grid
data.events.forEach(event => {
const item = this.createMediaItem(event);
this.gridTarget.insertAdjacentHTML('beforeend', item);
});
this.pageValue++;
// Update status
const currentCount = this.gridTarget.querySelectorAll('.masonry-item').length;
this.statusTarget.textContent = `Showing ${currentCount} of ${data.total} media items`;
// Hide button if no more items
if (!data.hasMore) {
this.buttonTarget.style.display = 'none';
this.statusTarget.textContent = `All ${data.total} media items loaded`;
}
} catch (error) {
console.error('Error loading more media:', error);
this.buttonTarget.textContent = 'Error - Click to retry';
} finally {
this.isLoading = false;
this.buttonTarget.disabled = false;
if (this.buttonTarget.textContent === 'Loading...') {
this.buttonTarget.textContent = 'Load More';
}
}
}
createMediaItem(event) {
// Extract title
let title = null;
let firstImageUrl = null;
let firstVideoUrl = null;
let imageAlt = null;
let isVideo = false;
// Find title tag
event.tags.forEach(tag => {
if (tag[0] === 'title') {
title = tag[1];
}
});
// Extract first image from imeta tags
event.tags.forEach(tag => {
if (tag[0] === 'imeta') {
let videoUrl = null;
let imageUrl = null;
let previewImage = null;
for (let i = 1; i < tag.length; i++) {
const param = tag[i];
if (param.startsWith('url ')) {
const potentialUrl = param.substring(4);
if (/\.(mp4|webm|ogg|mov)$/i.test(potentialUrl) || /video/i.test(potentialUrl)) {
videoUrl = potentialUrl;
isVideo = true;
} else {
imageUrl = potentialUrl;
}
} else if (param.startsWith('image ')) {
previewImage = param.substring(6);
} else if (param.startsWith('alt ')) {
imageAlt = param.substring(4);
}
}
if (videoUrl && !firstVideoUrl) {
firstVideoUrl = videoUrl;
if (previewImage && !firstImageUrl) {
firstImageUrl = previewImage;
} else if (imageUrl && !firstImageUrl) {
firstImageUrl = imageUrl;
}
}
if (!videoUrl && !firstImageUrl) {
if (imageUrl) {
firstImageUrl = imageUrl;
} else if (previewImage) {
firstImageUrl = previewImage;
}
}
}
});
const eventDate = new Date(event.created_at * 1000).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
const contentPreview = event.content && event.content.length > 100
? event.content.substring(0, 100) + '...'
: event.content || '';
let imageHtml = '';
if (firstImageUrl) {
imageHtml = `
<div class="masonry-image-container">
<img src="${this.escapeHtml(firstImageUrl)}"
alt="${this.escapeHtml(imageAlt || title || (isVideo ? 'Video' : 'Picture'))}"
class="masonry-image"
loading="lazy" />
${isVideo ? `
<div class="video-overlay">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="white" opacity="0.9">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
` : ''}
${title || contentPreview ? `
<div class="masonry-hover-caption">
${title ? `<h4>${this.escapeHtml(title)}</h4>` : ''}
${contentPreview ? `<p>${this.escapeHtml(contentPreview)}</p>` : ''}
</div>
` : ''}
</div>
`;
} else if (isVideo) {
imageHtml = `
<div class="masonry-image-container video-no-preview">
<div class="video-placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.4">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
${title || contentPreview ? `
<div class="masonry-hover-caption">
${title ? `<h4>${this.escapeHtml(title)}</h4>` : ''}
${contentPreview ? `<p>${this.escapeHtml(contentPreview)}</p>` : ''}
</div>
` : ''}
</div>
`;
}
return `
<div class="masonry-item">
<a href="/e/${event.noteId}" class="masonry-link">
${imageHtml}
</a>
</div>
`;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}

2
assets/styles/03-components/video-event.css

@ -29,6 +29,7 @@
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
height: auto; height: auto;
max-height: 100vh;
display: block; display: block;
background-color: #000; background-color: #000;
} }
@ -142,4 +143,3 @@
padding: 4px 8px; padding: 4px 8px;
} }
} }

99
assets/styles/04-pages/author-media.css

@ -26,6 +26,7 @@
} }
.masonry-grid { .masonry-grid {
/* Simple CSS column masonry */
column-count: 3; column-count: 3;
column-gap: 1.5rem; column-gap: 1.5rem;
margin: 2rem 0; margin: 2rem 0;
@ -47,7 +48,7 @@
break-inside: avoid; break-inside: avoid;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
background: #fff; background: #fff;
border-radius: 8px; border-radius: 0;
overflow: hidden; overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease, box-shadow 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease;
@ -68,6 +69,39 @@
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
background-color: #f5f5f5; background-color: #f5f5f5;
position: relative;
}
.masonry-hover-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, var(--color-secondary) 0%, rgba(var(--color-secondary-rgb, 0, 100, 0), 0.8) 70%, transparent 100%);
color: white;
padding: 2rem 1rem 1rem;
transform: translateY(100%);
transition: transform 0.3s ease;
pointer-events: none;
}
.masonry-item:hover .masonry-hover-caption {
transform: translateY(0);
}
.masonry-hover-caption h4 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
font-weight: 600;
color: white;
line-height: 1.3;
}
.masonry-hover-caption p {
margin: 0;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.9);
line-height: 1.4;
} }
.masonry-image { .masonry-image {
@ -101,14 +135,13 @@
} }
.masonry-meta { .masonry-meta {
padding: 0.75rem 1rem; padding: 0.5rem 1rem 1rem;
border-top: 1px solid #f0f0f0; font-size: 0.85rem;
margin-top: 0.5rem; color: #999;
} }
.event-date { .event-date {
font-size: 0.85rem; font-style: italic;
color: #999;
} }
.no-media { .no-media {
@ -121,3 +154,57 @@
font-size: 1.1rem; font-size: 1.1rem;
} }
.video-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
}
.video-overlay svg {
margin-left: 4px;
}
.video-no-preview {
background: linear-gradient(135deg, var(--color-secondary) 0%, var(--color-accent) 100%);
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.video-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: #9e9e9e;
}
.video-placeholder svg {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
}
.btn-load-more:disabled {
background-color: #ccc;
cursor: not-allowed;
transform: none;
}
.load-more-container {
text-align: center;
margin: 2rem 0;
}
.load-more-status {
margin-top: 1rem;
color: #666;
font-size: 0.9rem;
}

80
src/Controller/AuthorController.php

@ -12,6 +12,7 @@ use FOS\ElasticaBundle\Finder\FinderInterface;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use swentel\nostr\Nip19\Nip19Helper; use swentel\nostr\Nip19\Nip19Helper;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@ -25,47 +26,13 @@ class AuthorController extends AbstractController
{ {
$author = $redisCacheService->getMetadata($npub); $author = $redisCacheService->getMetadata($npub);
// Retrieve picture events (kind 20) for the author // Use paginated cached media events - fetches 200 from relays, serves first 24
try { $paginatedData = $redisCacheService->getMediaEventsPaginated($npub, 1, 24);
$pictureEvents = $nostrClient->getPictureEventsForPubkey($npub, 30); $mediaEvents = $paginatedData['events'];
} catch (Exception $e) {
$pictureEvents = [];
}
// Retrieve video shorts (kind 22) for the author
try {
$videoShorts = $nostrClient->getVideoShortsForPubkey($npub, 30);
} catch (Exception $e) {
$videoShorts = [];
}
// Retrieve normal videos (kind 21) for the author
try {
$normalVideos = $nostrClient->getNormalVideosForPubkey($npub, 30);
} catch (Exception $e) {
$normalVideos = [];
}
// Merge picture events, video shorts, and normal videos
$mediaEvents = array_merge($pictureEvents, $videoShorts, $normalVideos);
// Deduplicate by event ID
$uniqueEvents = [];
foreach ($mediaEvents as $event) {
if (!isset($uniqueEvents[$event->id])) {
$uniqueEvents[$event->id] = $event;
}
}
// Convert back to indexed array and sort by date (newest first)
$mediaEvents = array_values($uniqueEvents);
usort($mediaEvents, function ($a, $b) {
return $b->created_at <=> $a->created_at;
});
// Encode event IDs as note1... for each event // Encode event IDs as note1... for each event
foreach ($mediaEvents as $event) { foreach ($mediaEvents as $event) {
$nip19 = new Nip19Helper(); // The NIP-19 helper class. $nip19 = new Nip19Helper();
$event->noteId = $nip19->encodeNote($event->id); $event->noteId = $nip19->encodeNote($event->id);
} }
@ -73,10 +40,47 @@ class AuthorController extends AbstractController
'author' => $author, 'author' => $author,
'npub' => $npub, 'npub' => $npub,
'pictureEvents' => $mediaEvents, 'pictureEvents' => $mediaEvents,
'hasMore' => $paginatedData['hasMore'],
'total' => $paginatedData['total'],
'is_author_profile' => true, 'is_author_profile' => true,
]); ]);
} }
/**
* AJAX endpoint to load more media events
*/
#[Route('/p/{npub}/media/load-more', name: 'author-media-load-more', requirements: ['npub' => '^npub1.*'])]
public function mediaLoadMore($npub, Request $request, RedisCacheService $redisCacheService): Response
{
$page = $request->query->getInt('page', 2); // Default to page 2
// Get paginated data from cache - 24 items per page
$paginatedData = $redisCacheService->getMediaEventsPaginated($npub, $page, 24);
$mediaEvents = $paginatedData['events'];
// Encode event IDs as note1... for each event
foreach ($mediaEvents as $event) {
$nip19 = new Nip19Helper();
$event->noteId = $nip19->encodeNote($event->id);
}
return $this->json([
'events' => array_map(function($event) {
return [
'id' => $event->id,
'noteId' => $event->noteId,
'content' => $event->content ?? '',
'created_at' => $event->created_at,
'kind' => $event->kind,
'tags' => $event->tags ?? [],
];
}, $mediaEvents),
'hasMore' => $paginatedData['hasMore'],
'page' => $paginatedData['page'],
'total' => $paginatedData['total'],
]);
}
/** /**
* @throws Exception * @throws Exception
*/ */

31
src/Service/NostrClient.php

@ -606,6 +606,37 @@ class NostrClient
}); });
} }
/**
* Get all media events (pictures and videos) for a given pubkey in a single request
* This is more efficient than making 3 separate requests
* @throws \Exception
*/
public function getAllMediaEventsForPubkey(string $ident, int $limit = 30): array
{
// Add user relays to the default set
$authorRelays = $this->getTopReputableRelaysForAuthor($ident);
// Create a RelaySet from the author's relays
$relaySet = $this->defaultRelaySet;
if (!empty($authorRelays)) {
$relaySet = $this->createRelaySet($authorRelays);
}
// Create request for all media kinds (20, 21, 22) in ONE request
$request = $this->createNostrRequest(
kinds: [20, 21, 22], // NIP-68 Pictures, NIP-71 Videos (normal and shorts)
filters: [
'authors' => [$ident],
'limit' => $limit
],
relaySet: $relaySet
);
// Process the response and return raw events
return $this->processResponse($request->send(), function($event) {
return $event; // Return the raw event
});
}
public function getArticles(array $slugs): array public function getArticles(array $slugs): array
{ {
$articles = []; $articles = [];

118
src/Service/RedisCacheService.php

@ -280,4 +280,122 @@ readonly class RedisCacheService
return false; return false;
} }
} }
/**
* Get media events (pictures and videos) for a given npub with caching
*
* @param string $npub The author's npub
* @param int $limit Maximum number of events to fetch
* @return array Array of media events
*/
public function getMediaEvents(string $npub, int $limit = 30): array
{
$cacheKey = 'media_' . $npub . '_' . $limit;
try {
return $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($npub, $limit) {
$item->expiresAfter(600); // 10 minutes cache for media events
try {
// Use the optimized single-request method
$mediaEvents = $this->nostrClient->getAllMediaEventsForPubkey($npub, $limit);
// Deduplicate by event ID
$uniqueEvents = [];
foreach ($mediaEvents as $event) {
if (!isset($uniqueEvents[$event->id])) {
$uniqueEvents[$event->id] = $event;
}
}
// Convert back to indexed array and sort by date (newest first)
$mediaEvents = array_values($uniqueEvents);
usort($mediaEvents, function ($a, $b) {
return $b->created_at <=> $a->created_at;
});
return $mediaEvents;
} catch (\Exception $e) {
$this->logger->error('Error getting media events.', ['exception' => $e, 'npub' => $npub]);
return [];
}
});
} catch (InvalidArgumentException $e) {
$this->logger->error('Cache error getting media events.', ['exception' => $e]);
return [];
}
}
/**
* Get all media events for pagination (fetches large batch, caches, returns paginated)
*
* @param string $npub The author's npub
* @param int $page Page number (1-based)
* @param int $pageSize Number of items per page
* @return array ['events' => array, 'hasMore' => bool, 'total' => int]
*/
public function getMediaEventsPaginated(string $npub, int $page = 1, int $pageSize = 60): array
{
// Cache key for all media events (not page-specific)
$cacheKey = 'media_all_' . $npub;
try {
// Fetch and cache all media events
$allMediaEvents = $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($npub) {
$item->expiresAfter(600); // 10 minutes cache
try {
// Fetch a large batch to account for deduplication
// Nostr relays are unstable, so we fetch more than we need
$mediaEvents = $this->nostrClient->getAllMediaEventsForPubkey($npub, 200);
// Deduplicate by event ID
$uniqueEvents = [];
foreach ($mediaEvents as $event) {
if (!isset($uniqueEvents[$event->id])) {
$uniqueEvents[$event->id] = $event;
}
}
// Convert back to indexed array and sort by date (newest first)
$mediaEvents = array_values($uniqueEvents);
usort($mediaEvents, function ($a, $b) {
return $b->created_at <=> $a->created_at;
});
return $mediaEvents;
} catch (\Exception $e) {
$this->logger->error('Error getting media events.', ['exception' => $e, 'npub' => $npub]);
return [];
}
});
// Perform pagination on cached results
$total = count($allMediaEvents);
$offset = ($page - 1) * $pageSize;
// Get the page slice
$pageEvents = array_slice($allMediaEvents, $offset, $pageSize);
// Check if there are more pages
$hasMore = ($offset + $pageSize) < $total;
return [
'events' => $pageEvents,
'hasMore' => $hasMore,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize,
];
} catch (InvalidArgumentException $e) {
$this->logger->error('Cache error getting paginated media events.', ['exception' => $e]);
return [
'events' => [],
'hasMore' => false,
'total' => 0,
'page' => $page,
'pageSize' => $pageSize,
];
}
}
} }

216
templates/pages/author-media.html.twig

@ -37,93 +37,129 @@
<div class="w-container"> <div class="w-container">
{% if pictureEvents|length > 0 %} {% if pictureEvents|length > 0 %}
<div class="masonry-grid"> <div data-controller="media-loader"
{% for event in pictureEvents %} data-media-loader-npub-value="{{ npub }}"
<div class="masonry-item"> data-media-loader-total-value="{{ total }}">
{# Extract title #} <div class="masonry-grid" data-media-loader-target="grid">
{% set title = null %} {% for event in pictureEvents %}
{% for tag in event.tags %} <div class="masonry-item">
{% if tag[0] == 'title' %} {# Extract title #}
{% set title = tag[1] %} {% set title = null %}
{% endif %} {% for tag in event.tags %}
{% endfor %} {% if tag[0] == 'title' %}
{% set title = tag[1] %}
{% endif %}
{% endfor %}
{# Extract first image from imeta tags #} {# Extract first image from imeta tags #}
{% set firstImageUrl = null %} {% set firstImageUrl = null %}
{% set firstVideoUrl = null %} {% set firstVideoUrl = null %}
{% set imageAlt = null %} {% set imageAlt = null %}
{% set isVideo = false %} {% set isVideo = false %}
{% for tag in event.tags %} {% for tag in event.tags %}
{% if tag[0] == 'imeta' %} {% if tag[0] == 'imeta' %}
{% set videoUrl = null %} {% set videoUrl = null %}
{% set imageUrl = null %} {% set imageUrl = null %}
{% for i in 1..(tag|length - 1) %} {% set previewImage = null %}
{% set param = tag[i] %} {% for i in 1..(tag|length - 1) %}
{% if param starts with 'url ' %} {% set param = tag[i] %}
{% set potentialUrl = param[4:] %} {% if param starts with 'url ' %}
{# Check if it's a video URL #} {% set potentialUrl = param[4:] %}
{% if potentialUrl matches '/\\.(mp4|webm|ogg|mov)$/i' or potentialUrl matches '/video/i' %} {# Check if it's a video URL #}
{% set videoUrl = potentialUrl %} {% if potentialUrl matches '/\\.(mp4|webm|ogg|mov)$/i' or potentialUrl matches '/video/i' %}
{% set isVideo = true %} {% set videoUrl = potentialUrl %}
{% else %} {% set isVideo = true %}
{% set imageUrl = potentialUrl %} {% else %}
{% set imageUrl = potentialUrl %}
{% endif %}
{% elseif param starts with 'image ' %}
{# Preview/poster image for video or regular image #}
{% set previewImage = param[6:] %}
{% elseif param starts with 'alt ' %}
{% set imageAlt = param[4:] %}
{% endif %}
{% endfor %}
{# Set the first video and image found #}
{% if videoUrl and firstVideoUrl is null %}
{% set firstVideoUrl = videoUrl %}
{# Use preview image if available, otherwise try to use the main image URL #}
{% if previewImage and firstImageUrl is null %}
{% set firstImageUrl = previewImage %}
{% elseif imageUrl and firstImageUrl is null %}
{% set firstImageUrl = imageUrl %}
{% endif %}
{% endif %}
{# For non-video items, use the image URL or preview #}
{% if not videoUrl and firstImageUrl is null %}
{% if imageUrl %}
{% set firstImageUrl = imageUrl %}
{% elseif previewImage %}
{% set firstImageUrl = previewImage %}
{% endif %} {% endif %}
{% elseif param starts with 'image ' %}
{# Preview image for video #}
{% set imageUrl = param[6:] %}
{% elseif param starts with 'alt ' %}
{% set imageAlt = param[4:] %}
{% endif %} {% endif %}
{% endfor %}
{# Set the first video and image found #}
{% if videoUrl and firstVideoUrl is null %}
{% set firstVideoUrl = videoUrl %}
{% endif %}
{% if imageUrl and firstImageUrl is null %}
{% set firstImageUrl = imageUrl %}
{% endif %} {% endif %}
{% endif %} {% endfor %}
{% endfor %}
{# Generate nevent for linking #} {# Generate nevent for linking #}
{% set eventId = event.id %} {% set eventId = event.id %}
{% set noteId = event.noteId %} {% set noteId = event.noteId %}
<a href="/e/{{ noteId }}" class="masonry-link"> <a href="/e/{{ noteId }}" class="masonry-link">
{% if firstImageUrl %} {% if firstImageUrl %}
<div class="masonry-image-container"> <div class="masonry-image-container">
<img src="{{ firstImageUrl }}" <img src="{{ firstImageUrl }}"
alt="{{ imageAlt|default(title|default(isVideo ? 'Video' : 'Picture')) }}" alt="{{ imageAlt|default(title|default(isVideo ? 'Video' : 'Picture')) }}"
class="masonry-image" class="masonry-image"
loading="lazy" /> loading="lazy" />
{% if isVideo %} {% if isVideo %}
<div class="video-overlay"> <div class="video-overlay">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="white" opacity="0.9"> <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="white" opacity="0.9">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
{% endif %}
{% if event.content %}
<div class="masonry-hover-caption">
{% if event.content %}
<p>{{ event.content|length > 100 ? event.content[:100] ~ '...' : event.content }}</p>
{% endif %}
</div>
{% endif %}
</div>
{% elseif isVideo %}
{# Video without preview image - show placeholder with video icon #}
<div class="masonry-image-container video-no-preview">
<div class="video-placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.4">
<path d="M8 5v14l11-7z"/> <path d="M8 5v14l11-7z"/>
</svg> </svg>
</div> </div>
{% endif %} {% if event.content %}
</div> <div class="masonry-hover-caption">
{% endif %} {% if event.content %}
<p>{{ event.content|length > 100 ? event.content[:100] ~ '...' : event.content }}</p>
{% if title %} {% endif %}
<div class="masonry-caption"> </div>
<h3>{{ title }}</h3> {% endif %}
</div> </div>
{% endif %} {% endif %}
</a>
{% if event.content %} </div>
<div class="masonry-description mt-1"> {% endfor %}
{{ event.content|length > 100 ? event.content[:100] ~ '...' : event.content }} </div>
</div>
{% endif %}
<div class="masonry-meta"> {% if hasMore %}
<span class="event-date">{{ event.created_at|date('M j, Y') }}</span> <div class="load-more-container">
</div> <button data-media-loader-target="button"
</a> data-action="click->media-loader#loadMore"
class="btn-load-more">
Load More
</button>
<p data-media-loader-target="status" class="load-more-status">
Showing {{ pictureEvents|length }} of {{ total }} media items
</p>
</div> </div>
{% endfor %} {% endif %}
</div> </div>
{% else %} {% else %}
<div class="no-media"> <div class="no-media">
@ -132,31 +168,3 @@
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
{% block stylesheets %}
{{ parent() }}
<style>
.masonry-image-container {
position: relative;
}
.video-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
}
.video-overlay svg {
margin-left: 4px;
}
</style>
{% endblock %}

Loading…
Cancel
Save