11 changed files with 1000 additions and 0 deletions
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
@ -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'); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -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 +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 |
||||||
|
|||||||
@ -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 |
||||||
@ -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 |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -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.', |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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…
Reference in new issue