PageSourceSearch

https://extraordinary-beignet-152d2a.netlify.app/app.js

js extraordinary-beignet-152d2a.netlify.app collected 2026-10-03 10:43:07 UTC 12,004 bytes, 371 lines download raw bytes

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.