1// === AIS Industry Feed â RSS Aggregator === 2 3const CORS_PROXY = 'https://api.rss2json.com/v1/api.json?rss_url='; 4 5// Curated feed sources organized by category 6const FEED_SOURCES = [ 7 // Industry News 8 { 9 name: 'Psychedelic Alpha', 10 url: 'https://psychedelicalpha.com/feed/', 11 site: 'https://psychedelicalpha.com', 12 category: 'industry', 13 description: 'Leading psychedelic sector news, analysis, and investor intelligence' 14 }, 15 { 16 name: 'Psychedelics Today', 17 url: 'https://psychedelicstoday.com/feed/', 18 site: 'https://psychedelicstoday.com', 19 category: 'industry', 20 description: 'Research-focused psychedelic education and industry coverage' 21 }, 22 { 23 name: 'Lucid News', 24 url: 'https://lucid.news/feed/', 25 site: 'https://lucid.news', 26 category: 'industry', 27 description: 'Independent psychedelic journalism covering science, culture, and policy' 28 }, 29 { 30 name: 'Microdose', 31 url: 'https://microdose.buzz/feed/', 32 site: 'https://microdose.buzz', 33 category: 'industry', 34 description: 'Psychedelic industry content, events, and financial analysis' 35 }, 36 { 37 name: 'DoubleBlind Mag', 38 url: 'https://doubleblindmag.com/feed/', 39 site: 'https://doubleblindmag.com', 40 category: 'community', 41 description: 'Psychedelic culture, education, and harm reduction' 42 }, 43 44 // Research & Science 45 { 46 name: 'MAPS', 47 url: 'https://maps.org/feed/', 48 site: 'https://maps.org', 49 category: 'research', 50 description: 'Multidisciplinary Association for Psychedelic Studies â research and advocacy' 51 }, 52 { 53 name: 'Compass Pathways', 54 url: 'https://compasspathways.com/feed/', 55 site: 'https://compasspathways.com', 56 category: 'research', 57 description: 'Psilocybin therapy clinical trials and drug development' 58 }, 59 { 60 name: 'Translational Psychiatry (Nature)', 61 url: 'https://www.nature.com/tp.rss', 62 site: 'https://www.nature.com/tp', 63 category: 'research', 64 description: 'Peer-reviewed psychiatric research including psychedelic studies' 65 }, 66 { 67 name: 'Sunstone Therapies', 68 url: 'https://www.sunstonetherapies.com/feed/', 69 site: 'https://www.sunstonetherapies.com', 70 category: 'research', 71 description: 'Psilocybin-assisted therapy research for cancer patients' 72 }, 73 74 // Regulatory & Policy 75 { 76 name: 'Marijuana Moment', 77 url: 'https://www.marijuanamoment.net/feed/', 78 site: 'https://www.marijuanamoment.net', 79 category: 'regulatory', 80 description: 'Cannabis and psychedelic legislation tracking across all 50 states' 81 }, 82 { 83 name: 'Chacruna Institute', 84 url: 'https://chacruna.net/feed/', 85 site: 'https://chacruna.net', 86 category: 'regulatory', 87 description: 'Psychedelic policy, ethics, indigenous rights, and cultural sustainability' 88 }, 89 90 // Training & Practice 91 { 92 name: 'Fluence Training', 93 url: 'https://www.fluencetraining.com/about/news?format=rss', 94 site: 'https://www.fluencetraining.com', 95 category: 'training', 96 description: 'Psychedelic facilitator and therapist training education' 97 }, 98 { 99 name: 'Psychedelic Passage', 100 url: 'https://psychedelicpassage.com/feed/', 101 site: 'https://psychedelicpassage.com', 102 category: 'training', 103 description: 'Psychedelic facilitation, harm reduction, and trip sitting education' 104 }, 105 { 106 name: 'The Third Wave', 107 url: 'https://thethirdwave.co/feed/', 108 site: 'https://thethirdwave.co', 109 category: 'training', 110 description: 'Psychedelic education, microdosing protocols, and coaching' 111 }, 112 113 // Business & Investment 114 { 115 name: 'Psychedelic Invest', 116 url: 'https://psychedelicinvest.com/feed/', 117 site: 'https://psychedelicinvest.com', 118 category: 'business', 119 description: 'Psychedelic industry investment analysis and market data' 120 }, 121 { 122 name: 'Green Market Report â Psychedelics', 123 url: 'https://greenmarketreport.com/category/psychedelics/feed/', 124 site: 'https://greenmarketreport.com/category/psychedelics/', 125 category: 'business', 126 description: 'Financial and business news from the psychedelic sector' 127 }, 128 129 // Community & Culture 130 { 131 name: 'Sociedelic', 132 url: 'https://sociedelic.com/feed/', 133 site: 'https://sociedelic.com', 134 category: 'community', 135 description: 'Psychedelic research news, natural therapies, and community education' 136 } 137]; 138 139const CATEGORY_LABELS = { 140 industry: 'Industry News', 141 research: 'Research & Science', 142 regulatory: 'Regulatory & Policy', 143 training: 'Training & Practice', 144 business: 'Business & Investment', 145 community: 'Community & Culture' 146}; 147 148// State 149let allArticles = []; 150let activeCategory = 'all'; 151 152// DOM elements 153const feedGrid = document.getElementById('feedGrid'); 154const statusText = document.getElementById('statusText'); 155const loadingSpinner = document.getElementById('loadingSpinner'); 156const emptyState = document.getElementById('emptyState'); 157const filterBar = document.getElementById('filterBar'); 158const sourcesList = document.getElementById('sourcesList'); 159 160// === Initialize === 161document.addEventListener('DOMContentLoaded', () => { 162 initThemeToggle(); 163 initFilterToggle(); 164 initCategoryFilters(); 165 renderSourcesList(); 166 showSkeletons(); 167 loadAllFeeds(); 168}); 169 170// === Theme Toggle === 171function initThemeToggle() { 172 const toggle = document.querySelector('[data-theme-toggle]'); 173 const html = document.documentElement; 174 let theme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 175 html.setAttribute('data-theme', theme); 176 updateThemeIcon(toggle, theme); 177 178 toggle.addEventListener('click', () => { 179 theme = theme === 'dark' ? 'light' : 'dark'; 180 html.setAttribute('data-theme', theme); 181 updateThemeIcon(toggle, theme); 182 }); 183} 184 185function updateThemeIcon(btn, theme) { 186 btn.innerHTML = theme === 'dark' 187 ? '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>' 188 : '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>'; 189 btn.setAttribute('aria-label', `Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`); 190} 191 192// === Filter Toggle === 193function initFilterToggle() { 194 document.getElementById('filterToggle').addEventListener('click', () => { 195 filterBar.classList.toggle('collapsed'); 196 }); 197} 198 199// === Category Filters === 200function initCategoryFilters() { 201 document.querySelectorAll('.filter-chip').forEach(chip => { 202 chip.addEventListener('click', () => { 203 document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active')); 204 chip.classList.add('active'); 205 activeCategory = chip.dataset.category; 206 renderArticles(); 207 }); 208 }); 209} 210
211// === Skeleton Loading === 212function showSkeletons() { 213 let html = ''; 214 for (let i = 0; i < 9; i++) { 215 html += ` 216 <div class="skeleton-card"> 217 <div class="skeleton-line sm"></div> 218 <div class="skeleton-line lg"></div> 219 <div class="skeleton-line md"></div> 220 <div class="skeleton-line full"></div> 221 <div class="skeleton-line full"></div> 222 <div class="skeleton-line sm" style="margin-top:auto"></div> 223 </div>`; 224 } 225 feedGrid.innerHTML = html; 226} 227 228// === Feed Loading === 229async function loadAllFeeds() { 230 allArticles = []; 231 let loaded = 0; 232 let failed = 0; 233 234 const promises = FEED_SOURCES.map(async (source) => { 235 try { 236 const articles = await fetchFeed(source); 237 allArticles.push(...articles); 238 loaded++; 239 } catch (e) { 240 console.warn(`Failed to load ${source.name}:`, e.message); 241 failed++; 242 } 243 statusText.textContent = `Loading... ${loaded}/${FEED_SOURCES.length} feeds`; 244 }); 245 246 await Promise.allSettled(promises); 247 248 // Sort by date, newest first 249 allArticles.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate)); 250 251 loadingSpinner.classList.add('hidden'); 252 statusText.textContent = `${allArticles.length} articles from ${loaded} feeds${failed ? ` (${failed} unavailable)` : ''} â updated ${formatTime(new Date())}`; 253 renderArticles(); 254} 255 256async function fetchFeed(source) { 257 const url = `${CORS_PROXY}${encodeURIComponent(source.url)}&count=10`; 258 const res = await fetch(url); 259 if (!res.ok) throw new Error(`HTTP ${res.status}`); 260 const data = await res.json(); 261 262 if (data.status !== 'ok' || !data.items) { 263 throw new Error('Invalid feed response'); 264 } 265 266 return data.items.map(item => ({ 267 title: stripHtml(item.title || 'Untitled'), 268 link: item.link || source.site, 269 excerpt: stripHtml(item.description || item.content || '').slice(0, 250), 270 pubDate: item.pubDate || '', 271 source: source.name, 272 sourceSite: source.site, 273 category: source.category 274 })); 275} 276 277// === Rendering === 278function renderArticles() { 279 const filtered = activeCategory === 'all' 280 ? allArticles 281 : allArticles.filter(a => a.category === activeCategory); 282 283 if (filtered.length === 0) { 284 feedGrid.innerHTML = ''; 285 emptyState.style.display = 'block'; 286 return; 287 } 288 289 emptyState.style.display = 'none'; 290 291 // Show max 60 articles 292 const shown = filtered.slice(0, 60); 293 294 feedGrid.innerHTML = shown.map(article => ` 295 <article class="feed-card" data-category="${article.category}"> 296 <div class="card-meta"> 297 <span class="card-category" data-cat="${article.category}">${CATEGORY_LABELS[article.category] || article.category}</span> 298 <span class="card-date">${formatDate(article.pubDate)}</span> 299 </div> 300 <h3 class="card-title"> 301 <a href="${article.link}" target="_blank" rel="noopener">${article.title}</a> 302 </h3> 303 ${article.excerpt ? `<p class="card-excerpt">${article.excerpt}</p>` : ''} 304 <div class="card-source"> 305 <span class="source-icon"> 306 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="5" cy="19" r="3"/><path d="M4 4a16 16 0 0 1 16 16"/><path d="M4 11a9 9 0 0 1 9 9"/></svg> 307 </span> 308 <a href="${article.sourceSite}" target="_blank" rel="noopener">${article.source}</a> 309 </div> 310 </article> 311 `).join(''); 312} 313 314// === Sources Panel ===
315function renderSourcesList() { 316 const grouped = {}; 317 FEED_SOURCES.forEach(s => { 318 if (!grouped[s.category]) grouped[s.category] = []; 319 grouped[s.category].push(s); 320 }); 321 322 sourcesList.innerHTML = Object.entries(grouped).map(([cat, sources]) => ` 323 <div class="source-group"> 324 <h3>${CATEGORY_LABELS[cat]}</h3> 325 <ul> 326 ${sources.map(s => ` 327 <li> 328 <a href="${s.site}" target="_blank" rel="noopener"> 329 ${s.name} 330 <span class="source-tag">RSS</span> 331 </a> 332 </li> 333 `).join('')} 334 </ul> 335 </div> 336 `).join(''); 337} 338 339// === Refresh === 340document.getElementById('refreshBtn').addEventListener('click', () => { 341 loadingSpinner.classList.remove('hidden'); 342 statusText.textContent = 'Refreshing feeds...'; 343 showSkeletons(); 344 loadAllFeeds(); 345}); 346 347// === Utilities === 348function stripHtml(html) { 349 const tmp = document.createElement('div'); 350 tmp.innerHTML = html; 351 return tmp.textContent || tmp.innerText || ''; 352} 353 354function formatDate(dateStr) { 355 if (!dateStr) return ''; 356 const d = new Date(dateStr); 357 if (isNaN(d)) return ''; 358 const now = new Date(); 359 const diff = now - d; 360 const hours = diff / (1000 * 60 * 60); 361 362 if (hours < 1) return 'Just now'; 363 if (hours < 24) return `${Math.floor(hours)}h ago`; 364 if (hours < 48) return 'Yesterday'; 365 if (hours < 168) return `${Math.floor(hours / 24)}d ago`; 366 return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined }); 367} 368 369function formatTime(d) { 370 return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); 371}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.