11 changed files with 1000 additions and 0 deletions
@ -0,0 +1,245 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 |
||||
|
||||
@ -0,0 +1,6 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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