diff --git a/assets/controllers/media-loader_controller.js b/assets/controllers/media-loader_controller.js new file mode 100644 index 0000000..00ecfd3 --- /dev/null +++ b/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 = ` +
+ ${this.escapeHtml(imageAlt || title || (isVideo ? 'Video' : 'Picture'))} + ${isVideo ? ` +
+ + + +
+ ` : ''} + ${title || contentPreview ? ` +
+ ${title ? `

${this.escapeHtml(title)}

` : ''} + ${contentPreview ? `

${this.escapeHtml(contentPreview)}

` : ''} +
+ ` : ''} +
+ `; + } else if (isVideo) { + imageHtml = ` +
+
+ + + +
+ ${title || contentPreview ? ` +
+ ${title ? `

${this.escapeHtml(title)}

` : ''} + ${contentPreview ? `

${this.escapeHtml(contentPreview)}

` : ''} +
+ ` : ''} +
+ `; + } + + return ` +
+ + ${imageHtml} + +
+ `; + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +} diff --git a/assets/styles/03-components/video-event.css b/assets/styles/03-components/video-event.css index dc7cf4c..299baa9 100644 --- a/assets/styles/03-components/video-event.css +++ b/assets/styles/03-components/video-event.css @@ -29,6 +29,7 @@ width: 100%; max-width: 100%; height: auto; + max-height: 100vh; display: block; background-color: #000; } @@ -142,4 +143,3 @@ padding: 4px 8px; } } - diff --git a/assets/styles/04-pages/author-media.css b/assets/styles/04-pages/author-media.css index 293e5c2..f8b1cee 100644 --- a/assets/styles/04-pages/author-media.css +++ b/assets/styles/04-pages/author-media.css @@ -26,6 +26,7 @@ } .masonry-grid { + /* Simple CSS column masonry */ column-count: 3; column-gap: 1.5rem; margin: 2rem 0; @@ -47,7 +48,7 @@ break-inside: avoid; margin-bottom: 1.5rem; background: #fff; - border-radius: 8px; + border-radius: 0; overflow: hidden; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); transition: transform 0.2s ease, box-shadow 0.2s ease; @@ -68,6 +69,39 @@ width: 100%; overflow: hidden; 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 { @@ -101,14 +135,13 @@ } .masonry-meta { - padding: 0.75rem 1rem; - border-top: 1px solid #f0f0f0; - margin-top: 0.5rem; + padding: 0.5rem 1rem 1rem; + font-size: 0.85rem; + color: #999; } .event-date { - font-size: 0.85rem; - color: #999; + font-style: italic; } .no-media { @@ -121,3 +154,57 @@ 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; +} diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php index f7efd36..1691b1b 100644 --- a/src/Controller/AuthorController.php +++ b/src/Controller/AuthorController.php @@ -12,6 +12,7 @@ use FOS\ElasticaBundle\Finder\FinderInterface; use swentel\nostr\Key\Key; use swentel\nostr\Nip19\Nip19Helper; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -25,47 +26,13 @@ class AuthorController extends AbstractController { $author = $redisCacheService->getMetadata($npub); - // Retrieve picture events (kind 20) for the author - try { - $pictureEvents = $nostrClient->getPictureEventsForPubkey($npub, 30); - } 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; - }); + // Use paginated cached media events - fetches 200 from relays, serves first 24 + $paginatedData = $redisCacheService->getMediaEventsPaginated($npub, 1, 24); + $mediaEvents = $paginatedData['events']; // Encode event IDs as note1... for each event foreach ($mediaEvents as $event) { - $nip19 = new Nip19Helper(); // The NIP-19 helper class. + $nip19 = new Nip19Helper(); $event->noteId = $nip19->encodeNote($event->id); } @@ -73,10 +40,47 @@ class AuthorController extends AbstractController 'author' => $author, 'npub' => $npub, 'pictureEvents' => $mediaEvents, + 'hasMore' => $paginatedData['hasMore'], + 'total' => $paginatedData['total'], '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 */ diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index ee07782..2fbe9b4 100644 --- a/src/Service/NostrClient.php +++ b/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 { $articles = []; diff --git a/src/Service/RedisCacheService.php b/src/Service/RedisCacheService.php index 15b03eb..a0d8e06 100644 --- a/src/Service/RedisCacheService.php +++ b/src/Service/RedisCacheService.php @@ -280,4 +280,122 @@ readonly class RedisCacheService 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, + ]; + } + } } diff --git a/templates/pages/author-media.html.twig b/templates/pages/author-media.html.twig index 1503186..7289930 100644 --- a/templates/pages/author-media.html.twig +++ b/templates/pages/author-media.html.twig @@ -37,93 +37,129 @@
{% if pictureEvents|length > 0 %} -
- {% for event in pictureEvents %} -
- {# Extract title #} - {% set title = null %} - {% for tag in event.tags %} - {% if tag[0] == 'title' %} - {% set title = tag[1] %} - {% endif %} - {% endfor %} +
+
+ {% for event in pictureEvents %} +
+ {# Extract title #} + {% set title = null %} + {% for tag in event.tags %} + {% if tag[0] == 'title' %} + {% set title = tag[1] %} + {% endif %} + {% endfor %} - {# Extract first image from imeta tags #} - {% set firstImageUrl = null %} - {% set firstVideoUrl = null %} - {% set imageAlt = null %} - {% set isVideo = false %} - {% for tag in event.tags %} - {% if tag[0] == 'imeta' %} - {% set videoUrl = null %} - {% set imageUrl = null %} - {% for i in 1..(tag|length - 1) %} - {% set param = tag[i] %} - {% if param starts with 'url ' %} - {% set potentialUrl = param[4:] %} - {# Check if it's a video URL #} - {% if potentialUrl matches '/\\.(mp4|webm|ogg|mov)$/i' or potentialUrl matches '/video/i' %} - {% set videoUrl = potentialUrl %} - {% set isVideo = true %} - {% else %} - {% set imageUrl = potentialUrl %} + {# Extract first image from imeta tags #} + {% set firstImageUrl = null %} + {% set firstVideoUrl = null %} + {% set imageAlt = null %} + {% set isVideo = false %} + {% for tag in event.tags %} + {% if tag[0] == 'imeta' %} + {% set videoUrl = null %} + {% set imageUrl = null %} + {% set previewImage = null %} + {% for i in 1..(tag|length - 1) %} + {% set param = tag[i] %} + {% if param starts with 'url ' %} + {% set potentialUrl = param[4:] %} + {# Check if it's a video URL #} + {% if potentialUrl matches '/\\.(mp4|webm|ogg|mov)$/i' or potentialUrl matches '/video/i' %} + {% set videoUrl = potentialUrl %} + {% set isVideo = true %} + {% 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 %} - {% elseif param starts with 'image ' %} - {# Preview image for video #} - {% set imageUrl = 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 %} - {% endif %} - {% if imageUrl and firstImageUrl is null %} - {% set firstImageUrl = imageUrl %} {% endif %} - {% endif %} - {% endfor %} + {% endfor %} - {# Generate nevent for linking #} - {% set eventId = event.id %} - {% set noteId = event.noteId %} + {# Generate nevent for linking #} + {% set eventId = event.id %} + {% set noteId = event.noteId %} - - {% if firstImageUrl %} - + {% endfor %} +
-
- {{ event.created_at|date('M j, Y') }} -
- + {% if hasMore %} +
+ +

+ Showing {{ pictureEvents|length }} of {{ total }} media items +

- {% endfor %} + {% endif %}
{% else %}
@@ -132,31 +168,3 @@ {% endif %}
{% endblock %} - -{% block stylesheets %} -{{ parent() }} - -{% endblock %}