From bf8ce2b9b21b1cb08213e65025d2f3cf188ff77f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Wed, 8 Oct 2025 11:30:02 +0200 Subject: [PATCH] Media --- .../controllers/discover-scroll_controller.js | 245 ++++++++++++++++++ assets/controllers/image-loader_controller.js | 55 ++++ assets/controllers/topic-filter_controller.js | 21 ++ assets/styles/media-discovery.css | 194 ++++++++++++++ docker/cron/crontab | 1 + docker/cron/media_discovery.sh | 6 + src/Command/CacheMediaDiscoveryCommand.php | 170 ++++++++++++ src/Controller/MediaDiscoveryController.php | 72 +++++ src/Service/NostrClient.php | 53 ++++ templates/layout.html.twig | 3 + templates/pages/media-discovery.html.twig | 180 +++++++++++++ 11 files changed, 1000 insertions(+) create mode 100644 assets/controllers/discover-scroll_controller.js create mode 100644 assets/controllers/image-loader_controller.js create mode 100644 assets/controllers/topic-filter_controller.js create mode 100644 assets/styles/media-discovery.css create mode 100644 docker/cron/media_discovery.sh create mode 100644 src/Command/CacheMediaDiscoveryCommand.php create mode 100644 src/Controller/MediaDiscoveryController.php create mode 100644 templates/pages/media-discovery.html.twig diff --git a/assets/controllers/discover-scroll_controller.js b/assets/controllers/discover-scroll_controller.js new file mode 100644 index 0000000..a7e7f33 --- /dev/null +++ b/assets/controllers/discover-scroll_controller.js @@ -0,0 +1,245 @@ +import { Controller } from '@hotwired/stimulus'; + +/* + * Discover Scroll Controller + * Handles infinite scroll for the media discovery page + * Loads more media items as user scrolls down + */ +export default class extends Controller { + static targets = ['grid', 'loader']; + static values = { + page: { type: Number, default: 1 }, + loading: { type: Boolean, default: false }, + hasMore: { type: Boolean, default: true } + }; + + connect() { + // Infinite scroll observer + this.scrollObserver = new IntersectionObserver( + entries => this.handleIntersection(entries), + { + root: null, + rootMargin: '400px', // Start loading 400px before reaching the end + threshold: 0 + } + ); + + if (this.hasLoaderTarget) { + this.scrollObserver.observe(this.loaderTarget); + } + } + + disconnect() { + if (this.scrollObserver) { + this.scrollObserver.disconnect(); + } + } + + handleIntersection(entries) { + entries.forEach(entry => { + if (entry.isIntersecting && !this.loadingValue && this.hasMoreValue) { + this.loadMore(); + } + }); + } + + async loadMore() { + if (this.loadingValue || !this.hasMoreValue) return; + + this.loadingValue = true; + this.pageValue++; + + try { + const url = `/discover/load-more?page=${this.pageValue}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error('Failed to load more items'); + } + + const data = await response.json(); + + // Add new media items to the grid + if (data.events && data.events.length > 0) { + const fragment = document.createDocumentFragment(); + + data.events.forEach(event => { + const item = this.createMediaItem(event); + const div = document.createElement('div'); + div.innerHTML = item.trim(); + fragment.appendChild(div.firstChild); + }); + + this.gridTarget.appendChild(fragment); + } + + // Update hasMore flag + this.hasMoreValue = data.hasMore; + + // Hide loader if no more items + if (!data.hasMore && this.hasLoaderTarget) { + this.loaderTarget.style.display = 'none'; + } + + } catch (error) { + console.error('Error loading more media:', error); + if (this.hasLoaderTarget) { + this.loaderTarget.innerHTML = '

Error loading more items. Scroll to retry.

'; + } + } finally { + this.loadingValue = false; + } + } + + createMediaItem(event) { + // Extract title + let title = null; + let firstImageUrl = null; + let firstVideoUrl = null; + let imageAlt = null; + let isVideo = false; + let imageWidth = null; + let imageHeight = null; + + // Find title tag + if (event.tags) { + 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; + let width = null; + let height = 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); + } else if (param.startsWith('dim ')) { + // Extract dimensions like "dim 1920x1080" + const dimensions = param.substring(4).split('x'); + if (dimensions.length === 2) { + width = parseInt(dimensions[0]); + height = parseInt(dimensions[1]); + } + } + } + + if (videoUrl && !firstVideoUrl) { + firstVideoUrl = videoUrl; + if (previewImage && !firstImageUrl) { + firstImageUrl = previewImage; + } else if (imageUrl && !firstImageUrl) { + firstImageUrl = imageUrl; + } + if (width && height && !imageWidth) { + imageWidth = width; + imageHeight = height; + } + } + + if (!videoUrl && !firstImageUrl) { + if (imageUrl) { + firstImageUrl = imageUrl; + if (width && height && !imageWidth) { + imageWidth = width; + imageHeight = height; + } + } else if (previewImage) { + firstImageUrl = previewImage; + } + } + } + }); + } + + // Calculate aspect ratio percentage for placeholder if dimensions available + let aspectRatio = null; + let hasDimensions = false; + if (imageWidth && imageHeight) { + aspectRatio = (imageHeight / imageWidth * 100).toFixed(2); + hasDimensions = true; + } + + const contentPreview = event.content && event.content.length > 100 + ? event.content.substring(0, 100) + '...' + : event.content || ''; + + let imageHtml = ''; + if (firstImageUrl) { + const containerClass = hasDimensions ? 'masonry-image-container has-dimensions' : 'masonry-image-container'; + const styleAttr = hasDimensions ? `style="padding-bottom: ${aspectRatio}%;"` : ''; + + 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) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +} diff --git a/assets/controllers/image-loader_controller.js b/assets/controllers/image-loader_controller.js new file mode 100644 index 0000000..056015d --- /dev/null +++ b/assets/controllers/image-loader_controller.js @@ -0,0 +1,55 @@ +import { Controller } from '@hotwired/stimulus'; + +/* + * Image Loader Controller + * Defers loading images until they scroll nearby using Intersection Observer + */ +export default class extends Controller { + static targets = ['image']; + + connect() { + // Create intersection observer to load images when nearby + this.observer = new IntersectionObserver( + (entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + this.loadImage(entry.target); + this.observer.unobserve(entry.target); + } + }); + }, + { + root: null, + rootMargin: '200px', // Start loading 200px before image enters viewport + threshold: 0 + } + ); + + // Start observing the image + this.observer.observe(this.imageTarget); + } + + disconnect() { + if (this.observer) { + this.observer.disconnect(); + } + } + + loadImage(img) { + const src = img.getAttribute('data-src'); + if (!src) return; + + // Set up load event before setting src + img.addEventListener('load', () => { + img.classList.add('loaded'); + }, { once: true }); + + // Start loading the image + img.src = src; + + // If image is already cached, it might load instantly + if (img.complete) { + img.classList.add('loaded'); + } + } +} diff --git a/assets/controllers/topic-filter_controller.js b/assets/controllers/topic-filter_controller.js new file mode 100644 index 0000000..28bd89e --- /dev/null +++ b/assets/controllers/topic-filter_controller.js @@ -0,0 +1,21 @@ +import { Controller } from '@hotwired/stimulus'; + +/* + * Topic Filter Controller + * Handles topic filtering interactions for the media discovery page + * Provides visual feedback and smooth transitions + */ +export default class extends Controller { + connect() { + console.log('Topic filter controller connected'); + } + + // Add smooth scroll to top when changing topics + selectTopic(event) { + // Let the link navigate normally, but add smooth scroll + setTimeout(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, 100); + } +} + diff --git a/assets/styles/media-discovery.css b/assets/styles/media-discovery.css new file mode 100644 index 0000000..c0a7b29 --- /dev/null +++ b/assets/styles/media-discovery.css @@ -0,0 +1,194 @@ +/* Media Discovery Page Styles */ + +.discover-header { + text-align: center; + padding: 2rem 1rem; + max-width: 800px; + margin: 0 auto; +} + +.discover-header h1 { + font-size: 2.5rem; + margin-bottom: 0.5rem; +} + +.discover-subtitle { + color: #666; + font-size: 1.1rem; +} + +.error-message { + background: #f8d7da; + color: #721c24; + padding: 1rem; + border-radius: 8px; + margin-bottom: 2rem; + text-align: center; +} + +.no-media { + text-align: center; + padding: 3rem 1rem; + color: #666; +} + +.discover-footer { + text-align: center; + padding: 2rem 1rem; + color: #666; +} + +.media-count { + font-size: 0.95rem; +} + +/* Masonry Grid - Prevent reflow on image load */ +.masonry-image-container { + position: relative; + width: 100%; + background: linear-gradient(135deg, + #667eea 0%, + #764ba2 25%, + #f093fb 50%, + #4facfe 75%, + #00f2fe 100%); + background-size: 400% 400%; + animation: gradientShift 15s ease infinite; + overflow: hidden; +} + +/* Containers with known dimensions use aspect ratio padding */ +.masonry-image-container.has-dimensions { + padding-bottom: 75%; /* Overridden by inline style */ +} + +/* Containers without dimensions stretch naturally with content */ +.masonry-image-container:not(.has-dimensions) { + min-height: 200px; +} + +.masonry-image-container:not(.has-dimensions) img { + position: relative; + width: 100%; + height: auto; + display: block; +} + +@keyframes gradientShift { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +.masonry-image { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transition: opacity 0.3s ease; +} + +.masonry-image.loaded { + opacity: 1; +} + +/* Video placeholder styling */ +.video-no-preview { + position: relative; + width: 100%; + padding-bottom: 75%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.video-placeholder { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; +} + +/* Infinite Scroll Loader */ +.infinite-scroll-loader { + text-align: center; + padding: 3rem 1rem; + color: #666; +} + +.loader-spinner { + width: 40px; + height: 40px; + margin: 0 auto 1rem; + border: 4px solid #f3f3f3; + border-top: 4px solid #007bff; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Topic Filter Sidebar */ +.topic-filter { + padding: 1rem; +} + +.aside-heading { + font-size: 1.1rem; + margin-bottom: 1rem; + font-weight: 600; + color: #333; +} + +.topic-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.topic-item { + padding: 0.75rem 1rem; + border-radius: 8px; + background: #f8f9fa; + color: #333; + text-decoration: none; + font-weight: 500; + transition: all 0.2s ease; + border-left: 3px solid transparent; +} + +.topic-item:hover { + background: #e9ecef; + border-left-color: #007bff; + transform: translateX(4px); +} + +.topic-item.active { + background: #007bff; + color: white; + border-left-color: #0056b3; +} + +/* Responsive Styles */ +@media (max-width: 768px) { + .discover-header h1 { + font-size: 2rem; + } + + .topic-filter { + padding: 0.5rem; + } + + .topic-item { + padding: 0.6rem 0.8rem; + font-size: 0.9rem; + } +} diff --git a/docker/cron/crontab b/docker/cron/crontab index ad396cb..13d5ea5 100644 --- a/docker/cron/crontab +++ b/docker/cron/crontab @@ -1 +1,2 @@ 0 */6 * * * /index_articles.sh >> /var/log/cron.log 2>&1 +0 */2 * * * /media_discovery.sh >> /var/log/cron.log 2>&1 diff --git a/docker/cron/media_discovery.sh b/docker/cron/media_discovery.sh new file mode 100644 index 0000000..a27f11e --- /dev/null +++ b/docker/cron/media_discovery.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -e +export PATH="/usr/local/bin:/usr/bin:/bin" + +# Run Symfony commands sequentially +php /var/www/html/bin/console app:cache-media-discovery diff --git a/src/Command/CacheMediaDiscoveryCommand.php b/src/Command/CacheMediaDiscoveryCommand.php new file mode 100644 index 0000000..9a1d5ed --- /dev/null +++ b/src/Command/CacheMediaDiscoveryCommand.php @@ -0,0 +1,170 @@ + ['photography', 'photo', 'photostr', 'photographer', 'photos', 'picture'], + 'nature' => ['nature', 'landscape', 'wildlife', 'outdoor', 'naturephotography', 'pets', 'catstr', 'dogstr', 'flowers', 'forest', 'mountains', 'beach', 'sunset', 'sunrise'], + 'travel' => ['travel', 'traveling', 'wanderlust', 'adventure', 'explore', 'city', 'vacation', 'trip'], + ]; + + public function __construct( + private readonly NostrClient $nostrClient, + private readonly CacheInterface $cache, + private readonly LoggerInterface $logger, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force refresh even if cache is valid') + ->setHelp('This command fetches media events from Nostr relays and caches them for the discovery page.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $force = $input->getOption('force'); + + $io->title('Media Discovery Cache Update'); + + try { + // Get all hashtags from all topics + $allHashtags = []; + foreach (array_keys(self::TOPIC_HASHTAGS) as $topic) { + $allHashtags = array_merge($allHashtags, self::TOPIC_HASHTAGS[$topic]); + } + + $cacheKey = 'media_discovery_events_' . md5(implode(',', $allHashtags)); + + if ($force) { + $io->info('Force refresh enabled - deleting existing cache'); + $this->cache->delete($cacheKey); + } + + $io->info(sprintf('Fetching media events for %d hashtags...', count($allHashtags))); + $startTime = microtime(true); + + // Fetch and cache the events + $mediaEvents = $this->cache->get($cacheKey, function () use ($io, $allHashtags) { + $io->comment('Cache miss - fetching from Nostr relays...'); + + // Fetch media events that match these hashtags + $mediaEvents = $this->nostrClient->getMediaEventsByHashtags($allHashtags); + + $io->comment(sprintf('Fetched %d total events', count($mediaEvents))); + + // Deduplicate by event ID + $uniqueEvents = []; + foreach ($mediaEvents as $event) { + if (!isset($uniqueEvents[$event->id])) { + $uniqueEvents[$event->id] = $event; + } + } + + $mediaEvents = array_values($uniqueEvents); + $io->comment(sprintf('After deduplication: %d unique events', count($mediaEvents))); + + // Filter out NSFW content + $mediaEvents = $this->filterNSFW($mediaEvents); + $io->comment(sprintf('After NSFW filter: %d events', count($mediaEvents))); + + // Encode event IDs as note1... for each event + $nip19 = new Nip19Helper(); + foreach ($mediaEvents as $event) { + $event->noteId = $nip19->encodeNote($event->id); + } + + $this->logger->info('Media discovery cache updated', [ + 'event_count' => count($mediaEvents), + 'hashtags' => count($allHashtags), + ]); + + return $mediaEvents; + }); + + $duration = round(microtime(true) - $startTime, 2); + + $io->success([ + sprintf('Successfully cached %d media events', count($mediaEvents)), + sprintf('Duration: %s seconds', $duration), + sprintf('Cache TTL: %d seconds (%d hours)', self::CACHE_TTL, self::CACHE_TTL / 3600), + ]); + + return Command::SUCCESS; + + } catch (\Exception $e) { + $io->error('Failed to cache media events: ' . $e->getMessage()); + $this->logger->error('Media discovery cache update failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return Command::FAILURE; + } + } + + /** + * Filter out NSFW content from events + * Checks for content-warning tags and NSFW-related hashtags + */ + private function filterNSFW(array $events): array + { + return array_filter($events, function($event) { + if (!isset($event->tags) || !is_array($event->tags)) { + return true; // Keep if no tags + } + + foreach ($event->tags as $tag) { + if (!is_array($tag) || count($tag) < 1) { + continue; + } + + // Check for content-warning tag (NIP-32) + if ($tag[0] === 'content-warning') { + return false; + } + + // Check for L tag with NSFW marking + if ($tag[0] === 'L' && count($tag) >= 2 && strtolower($tag[1]) === 'nsfw') { + return false; + } + + // Check for hashtags that indicate NSFW content + if ($tag[0] === 't' && count($tag) >= 2) { + $hashtag = strtolower($tag[1]); + if (in_array($hashtag, ['nsfw', 'adult', 'explicit', '18+', 'nsfl'])) { + return false; + } + } + } + + return true; // Keep the event if no NSFW markers found + }); + } +} + diff --git a/src/Controller/MediaDiscoveryController.php b/src/Controller/MediaDiscoveryController.php new file mode 100644 index 0000000..cdf6497 --- /dev/null +++ b/src/Controller/MediaDiscoveryController.php @@ -0,0 +1,72 @@ + ['photography', 'photo', 'photostr', 'photographer', 'photos', 'picture'], + 'nature' => ['nature', 'landscape', 'wildlife', 'outdoor', 'naturephotography', 'pets', 'catstr', 'dogstr', 'flowers', 'forest', 'mountains', 'beach', 'sunset', 'sunrise'], + 'travel' => ['travel', 'traveling', 'wanderlust', 'adventure', 'explore', 'city', 'vacation', 'trip'], + ]; + + #[Route('/discover', name: 'media-discovery')] + public function discover(CacheInterface $cache): Response + { + // Defaulting to all, might do topics later + try { + $allHashtags = []; + // Get all topics + foreach (array_keys(self::TOPIC_HASHTAGS) as $topic) { + $allHashtags = array_merge($allHashtags, self::TOPIC_HASHTAGS[$topic]); + } + + // Cache key for all media events + $cacheKey = 'media_discovery_events_' . md5(implode(',', $allHashtags)); + + // Read from cache only - the cache is populated by the CacheMediaDiscoveryCommand + $allCachedEvents = $cache->get($cacheKey, function (ItemInterface $item) { + $item->expiresAfter(self::CACHE_TTL); + // Return empty array if cache is not populated yet + // The command should be run to populate this + return []; + }); + + // Randomize from the cached events + $mediaEvents = $allCachedEvents; + if (count($mediaEvents) > self::MAX_DISPLAY_EVENTS) { + shuffle($mediaEvents); + $mediaEvents = array_slice($mediaEvents, 0, self::MAX_DISPLAY_EVENTS); + } + + return $this->render('pages/media-discovery.html.twig', [ + 'mediaEvents' => $mediaEvents, + 'total' => count($mediaEvents), + 'topics' => array_keys(self::TOPIC_HASHTAGS), + 'selectedTopic' => $topic ?? null, + ]); + + } catch (\Exception $e) { + // Log error and show empty state + return $this->render('pages/media-discovery.html.twig', [ + 'mediaEvents' => [], + 'total' => 0, + 'topics' => array_keys(self::TOPIC_HASHTAGS), + 'selectedTopic' => $topic ?? null, + 'error' => 'Unable to load media at this time. Please try again later.', + ]); + } + } +} diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 2fbe9b4..bd9bab2 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -637,6 +637,59 @@ class NostrClient }); } + /** + * Get recent media events (pictures and videos) from the last 3 days + * Fetches from all relays without filtering by author + * @throws \Exception + */ + public function getRecentMediaEvents(int $limit = 100): array + { + // Create request for all media kinds (20, 21, 22) from the last 3 days + $request = $this->createNostrRequest( + kinds: [20, 21, 22], // NIP-68 Pictures, NIP-71 Videos (normal and shorts) + filters: [ + 'limit' => $limit + ], + relaySet: $this->defaultRelaySet + ); + + // Process the response and return raw events + return $this->processResponse($request->send(), function($event) { + return $event; // Return the raw event + }); + } + + /** + * Get media events filtered by specific hashtags + * @throws \Exception + */ + public function getMediaEventsByHashtags(array $hashtags): array + { + $allEvents = []; + $relayset = new RelaySet(); + $relayset->addRelay(new Relay('wss://theforest.nostr1.com')); + + // Fetch events for each hashtag + foreach ($hashtags as $hashtag) { + $request = $this->createNostrRequest( + kinds: [20], // NIP-68 Pictures, later maybe NIP-71 Videos + filters: [ + 'tag' => ['#t', [$hashtag]], + 'limit' => 100 // Fetch 100 per hashtag + ], + relaySet: $relayset + ); + + $events = $this->processResponse($request->send(), function($event) { + return $event; + }); + + $allEvents = array_merge($allEvents, $events); + } + + return $allEvents; + } + public function getArticles(array $slugs): array { $articles = []; diff --git a/templates/layout.html.twig b/templates/layout.html.twig index c5b6b97..ffcfcc5 100644 --- a/templates/layout.html.twig +++ b/templates/layout.html.twig @@ -14,6 +14,9 @@
  • Latest articles
  • +
  • + Multimedia +
  • Reading Lists
  • diff --git a/templates/pages/media-discovery.html.twig b/templates/pages/media-discovery.html.twig new file mode 100644 index 0000000..4125125 --- /dev/null +++ b/templates/pages/media-discovery.html.twig @@ -0,0 +1,180 @@ +{% extends 'layout.html.twig' %} + +{% block stylesheets %} + {{ parent() }} + +{% endblock %} + +{% block body %} + +
    +

    Media

    +

    Discovery through serendipity

    +
    + +
    + {% if error is defined %} +
    +

    {{ error }}

    +
    + {% endif %} + + {% if mediaEvents|length > 0 %} +
    + {% for event in mediaEvents %} +
    + {# 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 %} + {% set imageWidth = null %} + {% set imageHeight = null %} + {% for tag in event.tags %} + {% if tag[0] == 'imeta' %} + {% set videoUrl = null %} + {% set imageUrl = null %} + {% set previewImage = null %} + {% set width = null %} + {% set height = 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:] %} + {% elseif param starts with 'dim ' %} + {# Extract dimensions like "dim 1920x1080" #} + {% set dimensions = param[4:]|split('x') %} + {% if dimensions|length == 2 %} + {% set width = dimensions[0] %} + {% set height = dimensions[1] %} + {% endif %} + {% 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 %} + {% if width and height and imageWidth is null %} + {% set imageWidth = width %} + {% set imageHeight = height %} + {% endif %} + {% endif %} + {# For non-video items, use the image URL or preview #} + {% if not videoUrl and firstImageUrl is null %} + {% if imageUrl %} + {% set firstImageUrl = imageUrl %} + {% if width and height and imageWidth is null %} + {% set imageWidth = width %} + {% set imageHeight = height %} + {% endif %} + {% elseif previewImage %} + {% set firstImageUrl = previewImage %} + {% endif %} + {% endif %} + {% endif %} + {% endfor %} + + {# Calculate aspect ratio percentage for placeholder if dimensions available #} + {% set aspectRatio = null %} + {% if imageWidth and imageHeight %} + {% set aspectRatio = (imageHeight / imageWidth * 100)|round(2) %} + {% endif %} + + {# Generate nevent for linking #} + {% set eventId = event.id %} + {% set noteId = event.noteId %} + + + {% if firstImageUrl %} +
    + {{ imageAlt|default(title|default(isVideo ? 'Video' : 'Picture')) }} + {% if isVideo %} +
    + + + +
    + {% endif %} + {% if event.content %} +
    + {% if event.content %} +

    {{ event.content|length > 100 ? event.content[:100] ~ '...' : event.content }}

    + {% endif %} +
    + {% endif %} +
    + {% elseif isVideo %} + {# Video without preview image - show placeholder with video icon #} +
    +
    + + + +
    + {% if event.content %} +
    + {% if event.content %} +

    {{ event.content|length > 100 ? event.content[:100] ~ '...' : event.content }}

    + {% endif %} +
    + {% endif %} +
    + {% endif %} +
    +
    + {% endfor %} +
    + + + {% else %} +
    +

    No media found. Reload to try again.

    +
    + {% endif %} +
    +{% endblock %} + +{% block aside %} +{#
    #} +{#

    Browse by Topic

    #} +{#
    #} +{# {% for topic in topics %}#} +{# #} +{# {{ topic|capitalize }}#} +{# #} +{# {% endfor %}#} +{#
    #} +{#
    #} +{% endblock %}