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 `
+
+ `;
+ }
+
+ 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 %}
+
+
+
+
+ {% if error is defined %}
+
+ {% 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 %}
+
+ {% endif %}
+
+{% endblock %}
+
+{% block aside %}
+{# #}
+{#
Browse by Topic
#}
+{#
#}
+{#
#}
+{% endblock %}