Browse Source

Media

imwald
Nuša Pukšič 3 months ago
parent
commit
bf8ce2b9b2
  1. 245
      assets/controllers/discover-scroll_controller.js
  2. 55
      assets/controllers/image-loader_controller.js
  3. 21
      assets/controllers/topic-filter_controller.js
  4. 194
      assets/styles/media-discovery.css
  5. 1
      docker/cron/crontab
  6. 6
      docker/cron/media_discovery.sh
  7. 170
      src/Command/CacheMediaDiscoveryCommand.php
  8. 72
      src/Controller/MediaDiscoveryController.php
  9. 53
      src/Service/NostrClient.php
  10. 3
      templates/layout.html.twig
  11. 180
      templates/pages/media-discovery.html.twig

245
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 = '<p style="text-align: center; color: #999;">Error loading more items. Scroll to retry.</p>';
}
} 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 = `
<div class="${containerClass}" ${styleAttr} data-controller="image-loader">
<img data-src="${this.escapeHtml(firstImageUrl)}"
alt="${this.escapeHtml(imageAlt || title || (isVideo ? 'Video' : 'Picture'))}"
class="masonry-image"
data-image-loader-target="image"
onerror="this.closest('.masonry-item').style.display='none'" />
${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" style="padding-bottom: 75%;">
<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) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}

55
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');
}
}
}

21
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);
}
}

194
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;
}
}

1
docker/cron/crontab

@ -1 +1,2 @@
0 */6 * * * /index_articles.sh >> /var/log/cron.log 2>&1 0 */6 * * * /index_articles.sh >> /var/log/cron.log 2>&1
0 */2 * * * /media_discovery.sh >> /var/log/cron.log 2>&1

6
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

170
src/Command/CacheMediaDiscoveryCommand.php

@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Service\NostrClient;
use Psr\Log\LoggerInterface;
use swentel\nostr\Nip19\Nip19Helper;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\Cache\CacheInterface;
#[AsCommand(
name: 'app:cache-media-discovery',
description: 'Fetch and cache media events for the discovery page',
)]
class CacheMediaDiscoveryCommand extends Command
{
private const int CACHE_TTL = 10800; // 3 hours in seconds
// Hardcoded topic to hashtag mapping (same as controller)
private const TOPIC_HASHTAGS = [
'photography' => ['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
});
}
}

72
src/Controller/MediaDiscoveryController.php

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class MediaDiscoveryController extends AbstractController
{
private const int CACHE_TTL = 10800; // 3 hours in seconds
private const int MAX_DISPLAY_EVENTS = 42;
// Hardcoded topic to hashtag mapping
private const TOPIC_HASHTAGS = [
'photography' => ['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.',
]);
}
}
}

53
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 public function getArticles(array $slugs): array
{ {
$articles = []; $articles = [];

3
templates/layout.html.twig

@ -14,6 +14,9 @@
<li> <li>
<a href="{{ path('latest_articles') }}">Latest articles</a> <a href="{{ path('latest_articles') }}">Latest articles</a>
</li> </li>
<li>
<a href="{{ path('media-discovery') }}">Multimedia</a>
</li>
<li> <li>
<a href="{{ path('reading_list_index') }}">Reading Lists</a> <a href="{{ path('reading_list_index') }}">Reading Lists</a>
</li> </li>

180
templates/pages/media-discovery.html.twig

@ -0,0 +1,180 @@
{% extends 'layout.html.twig' %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('styles/media-discovery.css') }}">
{% endblock %}
{% block body %}
<div class="discover-header">
<h1>Media</h1>
<p class="discover-subtitle">Discovery through serendipity</p>
</div>
<div class="w-container">
{% if error is defined %}
<div class="error-message">
<p>{{ error }}</p>
</div>
{% endif %}
{% if mediaEvents|length > 0 %}
<div class="masonry-grid">
{% for event in mediaEvents %}
<div class="masonry-item">
{# 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 %}
<a href="/e/{{ noteId }}" class="masonry-link">
{% if firstImageUrl %}
<div class="masonry-image-container {% if aspectRatio %}has-dimensions{% endif %}" {% if aspectRatio %}style="padding-bottom: {{ aspectRatio }}%;"{% endif %} data-controller="image-loader">
<img data-src="{{ firstImageUrl }}"
alt="{{ imageAlt|default(title|default(isVideo ? 'Video' : 'Picture')) }}"
class="masonry-image"
data-image-loader-target="image"
onerror="this.closest('.masonry-item').style.display='none'" />
{% if 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>
{% 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"/>
</svg>
</div>
{% 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>
{% endif %}
</a>
</div>
{% endfor %}
</div>
<div class="discover-footer">
<p class="media-count">End of the line. Reload for a new batch.</p>
</div>
{% else %}
<div class="no-media">
<p>No media found. Reload to try again.</p>
</div>
{% endif %}
</div>
{% endblock %}
{% block aside %}
{# <div class="topic-filter">#}
{# <h3 class="aside-heading">Browse by Topic</h3>#}
{# <div class="topic-list">#}
{# {% for topic in topics %}#}
{# <a href="{{ path('media-discovery', {'topic': topic}) }}"#}
{# class="topic-item {{ selectedTopic == topic ? 'active' : '' }}">#}
{# {{ topic|capitalize }}#}
{# </a>#}
{# {% endfor %}#}
{# </div>#}
{# </div>#}
{% endblock %}
Loading…
Cancel
Save