+ `;
+ }
+
+ 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 %}
-