PageSourceSearch

https://sportfan.ro/js/main.js?v=1772122758322

js sportfan.ro collected 2026-10-07 17:43:10 UTC 19,405 bytes, 452 lines download raw bytes

1// ============================================================
2// SportFan.ro — Main JavaScript (Public Site)
3// Folosește Firestore REST API (funcționează și din file://)
4// ============================================================
5
6const SF_PROJECT = 'sportfan-e33d9';
7const SF_API_KEY = 'AIzaSyAsT3nhmeG_MLU7QUn0d_fTaKmQP9t3MNM';
8const SF_BASE = `https://firestore.googleapis.com/v1/projects/${SF_PROJECT}/databases/(default)/documents`;
9
10// ── Firestore REST Helpers ──────────────────────────────
11function parseVal(v) {
12    if (!v) return null;
13    if (v.stringValue !== undefined) return v.stringValue;
14    if (v.booleanValue !== undefined) return v.booleanValue;
15    if (v.integerValue !== undefined) return parseInt(v.integerValue);
16    if (v.doubleValue !== undefined) return parseFloat(v.doubleValue);
17    if (v.nullValue !== undefined) return null;
18    if (v.timestampValue !== undefined) {
19        const d = new Date(v.timestampValue);
20        return { toDate: () => d, toMillis: () => d.getTime() };
21    }
22    if (v.arrayValue !== undefined)
23        return (v.arrayValue.values || []).map(parseVal);
24    if (v.mapValue !== undefined)
25        return parseFields(v.mapValue.fields || {});
26    return null;
27}
28
29function parseFields(fields) {
30    const obj = {};
31    for (const [k, v] of Object.entries(fields || {})) obj[k] = parseVal(v);
32    return obj;
33}
34
35function docToArticle(docObj) {
36    const id = docObj.name.split('/').pop();
37    return { id, ...parseFields(docObj.fields) };
38}
39
40// ── Utilities ───────────────────────────────────────────
41function formatDate(ts) {
42    if (!ts) return '';
43    const d = ts.toDate ? ts.toDate() : new Date(ts);
44    const now = new Date();
45    const diff = Math.floor((now - d) / 1000);
46    if (diff < 60) return 'Acum câteva secunde';
47    if (diff < 3600) return `Acum ${Math.floor(diff / 60)} min`;
48    if (diff < 86400) return `Acum ${Math.floor(diff / 3600)}h`;
49    return d.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
50}
51
52function catLabel(cat) {
53    return { fotbal: 'Fotbal', tenis: 'Tenis', baschet: 'Baschet', stiri_actuale: 'Știri Actuale', altele: 'Altele' }[cat] || (cat || 'Altele');
54}
55
56// ── Article Card HTML ───────────────────────────────────
57function renderCard(art) {
58    const cat = art.category ? art.category.toLowerCase() : "altele";
59    const urlCat = cat === 'stiri_actuale' ? 'stiri-actuale' : cat;
60    const url = `/${urlCat}/${art.slug || art.id}`;
61    const image = art.image || `https://picsum.photos/seed/${art.id}/400/250`;
62    return `
63    <div class="article-card" onclick="location.href='${url}'">
64      <div class="card-img">
65        <img src="${image}" alt="${art.title}" loading="lazy"
66             onerror="this.src='https://picsum.photos/seed/${art.id}x/400/250'">
67      </div>
68      <div class="card-body">
69        <div class="card-category cat-${cat}">
70          <span class="cat-dot cat-dot-${cat}"></span>${catLabel(cat)}
71        </div>
72        <div class="card-title">${art.title}</div>
73        <div class="card-meta">
74          <span class="date">🕒 ${formatDate(art.createdAt)}</span>
75          <span class="views">👁️ ${art.views || 0}</span>
76        </div>
77        ${art.author ? `<div style="font-size:11px;color:#9aa0b4;margin-top:4px">✍️ ${art.author}</div>` : ''}
78      </div>
79    </div>`;
80}
81
82// ── Hero Article HTML ───────────────────────────────────
83function renderHero(art) {
84    const cat = art.category ? art.category.toLowerCase() : "altele";
85    const urlCat = cat === 'stiri_actuale' ? 'stiri-actuale' : cat;
86    const url = `/${urlCat}/${art.slug || art.id}`;
87    const image = art.image || `https://picsum.photos/seed/${art.id}/1200/500`;
88    return `
89    <div class="hero-article" onclick="location.href='${url}'">
90      <img src="${image}" alt="${art.title}"
91           onerror="this.src='https://picsum.photos/seed/hero/1200/500'">
92      <div class="hero-overlay">
93        <span class="hero-badge cat-badge-${cat}">${catLabel(cat)}</span>
94        <div class="hero-title">${art.title}</div>
95        <div class="hero-meta">
96          <span>✍️ ${art.author || 'Redacție'}</span>
97          <span>🕒 ${formatDate(art.createdAt)}</span>
98          <span>👁️ ${art.views || 0} vizualizări</span>
99        </div>
100      </div>
101    </div>`;
102}
103
104// ── List Item HTML (sidebar) ────────────────────────────
105function renderListItem(art) {
106    const cat = art.category ? art.category.toLowerCase() : "altele";
107    const urlCat = cat === 'stiri_actuale' ? 'stiri-actuale' : cat;
108    const url = `/${urlCat}/${art.slug || art.id}`;
109    const image = art.image || `https://picsum.photos/seed/${art.id}/160/120`;
110    return `
111    <div class="article-list-item" onclick="location.href='${url}'">
112      <div class="ali-img">
113        <img src="${image}" alt="${art.title}" loading="lazy"
114             onerror="this.src='https://picsum.photos/seed/${art.id}r/160/120'">
115      </div>
116      <div class="ali-body">
117        <div class="ali-title">${art.title}</div>
118        <div class="ali-date cat-${art.category}">${catLabel(art.category)} • ${formatDate(art.createdAt)}</div>
119      </div>
120    </div>`;
121}
122
123// ── Skeleton Placeholder ────────────────────────────────
124function renderSkeletons(n, gridClass = '') {
125    return `<div class="articles-grid ${gridClass}">` +
126        Array(n).fill('<div class="skeleton skeleton-card"></div>').join('') +
127        '</div>';
128}
129
130// ── Fetch All Published Articles (REST API + cache) ─────
131let _cache = null;
132let _cacheTime = 0;
133const CACHE_MS = 5 * 60 * 1000; // 5 minutes
134
135async function fetchAllArticles() {
136    if (_cache && Date.now() - _cacheTime < CACHE_MS) return _cache;
137
138    // PRIMARY: Simple GET — works from file:// (no CORS preflight)
139    try {
140        const res = await fetch(
141            `${SF_BASE}/articles?key=${SF_API_KEY}&pageSize=100`
142        );
143        if (!res.ok) throw new Error(`HTTP ${res.status}`);
144        const data = await res.json();
145        _cache = (data.documents || [])
146            .map(d => docToArticle(d))
147            .filter(a => a.published !== false)
148            .sort((a, b) => {
149                const ta = a.createdAt?.toMillis ? a.createdAt.toMillis() : 0;
150                const tb = b.createdAt?.toMillis ? b.createdAt.toMillis() : 0;
151                return tb - ta;
152            });
153        _cacheTime = Date.now();
154        return _cache;
155    } catch (e) {
156        console.warn('GET eșuat, încerc runQuery:', e);
157    }
158
159    // FALLBACK: runQuery POST (funcționează de pe server HTTP)
160    try {
161        const res = await fetch(`${SF_BASE}:runQuery?key=${SF_API_KEY}`, {
162            method: 'POST',
163            headers: { 'Content-Type': 'application/json' },
164            body: JSON.stringify({
165                structuredQuery: {
166                    from: [{ collectionId: 'articles' }],
167                    orderBy: [{ field: { fieldPath: 'createdAt' }, direction: 'DESCENDING' }],
168                    limit: 100
169                }
170            })
171        });
172        if (!res.ok) throw new Error(`HTTP ${res.status}`);
173        const rows = await res.json();
174        _cache = rows
175            .filter(r => r.document)
176            .map(r => docToArticle(r.document))
177            .filter(a => a.published !== false);
178        _cacheTime = Date.now();
179        return _cache;
180    } catch (e) {
181        console.error('Ambele metode au eșuat:', e);
182        return [];
183    }
184}
185
186// ── Fetch Single Article by Firestore ID (REST API) ────
187async function fetchArticle(id) {
188    const res = await fetch(`${SF_BASE}/articles/${id}?key=${SF_API_KEY}`);
189    if (!res.ok) throw new Error(`HTTP ${res.status}`);
190    const doc = await res.json();
191    return docToArticle(doc);
192}
193
194// ── Fetch Single Article by Slug (Firestore SDK) ────────
195async function fetchArticleBySlug(slug) {
196    const db = firebase.firestore();
197    const snap = await db.collection('articles')
198        .where('slug', '==', slug)
199        .limit(1)
200        .get();
201    if (snap.empty) throw new Error('Articolul nu a fost găsit.');
202    const doc = snap.docs[0];
203    return { id: doc.id, ...doc.data() };
204}
205
206// ── Increment Views (REST API PATCH) ────────────────────
207async function incrementViews(id) {
208    try {
209        // Use Firestore runQuery to get current views, then PATCH
210        const art = await fetchArticle(id);
211        const newViews = (art.views || 0) + 1;
212        await fetch(
213            `${SF_BASE}/articles/${id}?key=${SF_API_KEY}&updateMask.fieldPaths=views`,
214            {
215                method: 'PATCH',
216                headers: { 'Content-Type': 'application/json' },
217                body: JSON.stringify({ fields: { views: { integerValue: String(newViews) } } })
218            }
219        );
220    } catch (e) { /* silent */ }
221}
222
223// ── Load Featured Article ───────────────────────────────
224async function loadFeatured(targetId) {
225    const el = document.getElementById(targetId);
226    if (!el) return;
227    el.innerHTML = '<div class="skeleton skeleton-hero"></div>';
228    try {
229        const arts = await fetchAllArticles();
230        const featured = arts.find(a => a.featured) || arts[0];
231        if (!featured) {
232            el.innerHTML = `<div class="empty-state">
233        <div class="icon">📰</div>
234        <p>Nu există articole. Accesați <a href="setup.html">setup.html</a> pentru date demo.</p>
235      </div>`;
236            return;
237        }
238        el.innerHTML = renderHero(featured);
239    } catch (e) { el.innerHTML = '';
239 console.error(e); }
240}
241
242// ── Load Articles Grid ──────────────────────────────────
243async function loadArticles({ targetId, category, limit = 8, exclude = null, gridClass = '' }) {
244    const el = document.getElementById(targetId);
245    if (!el) return;
246    el.innerHTML = renderSkeletons(Math.min(limit, 4), gridClass);
247    try {
248        const arts = await fetchAllArticles();
249        const filtered = arts
250            .filter(a => !category || a.category === category)
251            .filter(a => a.id !== exclude)
252            .slice(0, limit);
253
254        if (!filtered.length) {
255            el.innerHTML = `<div class="empty-state">
256        <div class="icon">📰</div>
257        <p>${category ? `Nu există articole în <strong>${catLabel(category)}</strong>.` : 'Nu există articole.'}</p>
258      </div>`;
259            return;
260        }
261        el.innerHTML = `<div class="articles-grid ${gridClass}">` +
262            filtered.map(renderCard).join('') + '</div>';
263    } catch (e) {
264        el.innerHTML = `<div class="empty-state"><p>Eroare la încărcarea articolelor.</p></div>`;
265        console.error(e);
266    }
267}
268
269// ── Load Breaking News Ticker ───────────────────────────
270async function loadTicker() {
271    const el = document.querySelector('.ticker-content');
272    if (!el) return;
273    try {
274        const arts = await fetchAllArticles();
275        if (!arts.length) return;
276        const items = arts.slice(0, 6).map(a =>
277            `<span class="ticker-item" onclick="location.href='/${(a.category || 'altele').toLowerCase()}/${a.slug || a.id}'">${a.title}</span>`
278        );
279        el.innerHTML = items.join('') + items.join('');
280    } catch (e) { console.error(e); }
281}
282
283// ── Load Sidebar Recent ─────────────────────────────────
284async function loadSidebarRecent() {
285    const el = document.getElementById('sidebar-recent');
286    if (!el) return;
287    try {
288        const arts = await fetchAllArticles();
289        el.innerHTML = arts.slice(0, 7).map(renderListItem).join('') ||
290            '<div style="padding:16px;color:#999;font-size:13px">Nu există articole recente.</div>';
291    } catch (e) { console.error(e); }
292}
293
294// ── Load Related Articles ───────────────────────────────
295async function loadRelated(category, excludeId) {
296    const el = document.getElementById('related-articles');
297    if (!el) return;
298    try {
299        const arts = await fetchAllArticles();
300        const related = arts.filter(a => a.category === category && a.id !== excludeId).slice(0, 4);
301        if (!related.length) { el.parentElement && (el.parentElement.style.display = 'none'); return; }
302        el.innerHTML = `<div class="articles-grid col-3">` + related.map(renderCard).join('') + '</div>';
303    } catch (e) { console.error(e); }
304}
305
306// ── Load Single Article (by slug OR by id) ──────────────
307async function loadArticle(slugOrId) {
308    const heroEl = document.getElementById('article-hero');
309    const metaEl = document.getElementById('article-meta');
310    const contentEl = document.getElementById('article-content');
311    if (!contentEl) return;
312
313    try {
314        // Detect: if it looks like a Firestore ID (20 chars, no hyphens except Firestore IDs)
315        // We try slug first, fallback to ID
316        let art;
317        const looksLikeId = /^[A-Za-z0-9]{15,}$/.test(slugOrId);
318        if (looksLikeId) {
319            try { art = await fetchArticle(slugOrId); }
320            catch { art = await fetchArticleBySlug(slugOrId); }
321        } else {
322            try { art = await fetchArticleBySlug(slugOrId); }
323            catch { art = await fetchArticle(slugOrId); }
324        }
325
326        document.title = `${art.title} — SportFan.ro`;
327        const canonical = document.getElementById('canonical-url');
328        const cat = art.category ? art.category.toLowerCase() : 'altele';
329        const urlCat = cat === 'stiri_actuale' ? 'stiri-actuale' : cat;
330        if (canonical) canonical.href = `/${urlCat}/${art.slug || art.id}`;
331
332        const image = art.image || `https://picsum.photos/seed/${art.id}/1200/600`;
333
334        if (heroEl) {
335            heroEl.innerHTML = `
336        <div class="article-hero">
337          <img src="${image}" alt="${art.title}"
338               onerror="this.src='https://picsum.photos/seed/art/1200/600'">
339          <div class="article-hero-overlay">
340            <span class="badge cat-badge-${cat}">${catLabel(cat)}</span>
341            <h1>${art.title}</h1>
342          </div>
343        </div>`;
344        }
345        if (metaEl) {
346            metaEl.innerHTML = `
347        <span>✍️ ${art.author || 'Redacție SportFan.ro'}</span>
348        <span>🕒 ${formatDate(art.createdAt)}</span>
349        <span>👁️ ${(art.views || 0) + 1} vizualizări</span>
350        <span>🏷️ <a href="/${cat}" class="cat-${cat}">${catLabel(cat)}</a></span>`;
351        }
352        let rawContent = art.content || '<p>Conținut indisponibil.</p>';
353
354
355
356        contentEl.innerHTML = rawContent;
357        const bcCat = document.getElementById('breadcrumb-cat');
358        const bcTitle = document.getElementById('breadcrumb-title');
359        if (bcCat) bcCat.innerHTML = `<a href="/${cat}">${catLabel(cat)}</a>`;
360        if (bcTitle) bcTitle.textContent = art.title;
361
362        incrementViews(art.id);
363        loadRelated(cat, art.id);
364        loadSidebarRecent();
365
366        return art;
367    } catch (e) {
368        if (contentEl) contentEl.innerHTML = '<p style="color:red">Articolul nu a fost găsit sau a apărut o eroare.</p>';
369        console.error(e);
370        throw e;
371    }
372}
373
374// ── Lazy Load (IntersectionObserver) ────────────────────
375// Only loads a section when it's close to the viewport
376function lazyLoad(targetId, loaderFn) {
377    const el = document.getElementById(targetId);
378    if (!el) return;
379    if (!('IntersectionObserver' in window)) { loaderFn(); return; }
380    const obs = new IntersectionObserver((entries, o) => {
381        entries.forEach(entry => {
382            if (entry.isIntersecting) {
383                o.unobserve(entry.target);
384                loaderFn();
385            }
386        });
387    }, { rootMargin: '200px' }); // start loading 200px before visible
388    obs.observe(el);
389}
390
391
392// ── Active Nav ──────────────────────────────────────────
393function setActiveNav() {
394    const page = location.pathname.split('/').pop() || 'index.html';
395    document.querySelectorAll('.main-nav a').forEach(a => {
396        a.classList.remove('active');
397        const href = a.getAttribute('href') || '';
398        if (href === page || (page === '' && href === 'index.html')) a.classList.add('active');
399    });
400}
401
402// ── Back to Top ─────────────────────────────────────────
403function initBackToTop() {
404    const btn = document.querySelector('.back-to-top');
405    if (!btn) return;
406    window.addEventListener('scroll', () => btn.classList.toggle('visible', window.scrollY > 400));
407    btn.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' }));
408}
409
410// ── Init ────────────────────────────────────────────────
411document.addEventListener('DOMContentLoaded', () => {
412    setActiveNav();
413    initBackToTop();
414    loadTicker();
415    initLiveVisitors();
416});
417
418// ── Live Visitors Tracker ───────────────────────────────
419function initLiveVisitors() {
420    if (typeof rtdb === 'undefined' || !rtdb) return;
421
422    // Check if we are on a public page with the counter element
423    const counterEl = document.getElementById('live-visitors-count');
424    if (!counterEl) return;
425
426    // Generate a unique session ID for the current tab
427    const sessionId = Math.random().toString(36).substr(2, 9);
428
429    // References
430    const presenceRef = rtdb.ref('presence/' + sessionId);
431    const listRef = rtdb.ref('presence');
432    const connectedRef = rtdb.ref('.info/connected');
433
434    // Manage Connection State
435    connectedRef.on('value', (snap) => {
436        if (snap.val() === true) {
437            // Remove ourselves when we disconnect
438            presenceRef.onDisconnect().remove();
439
440            // Set our presence status
441            presenceRef.set({
442                timestamp: firebase.database.ServerValue.TIMESTAMP
443            });
444        }
445    });
446
447    // Listen for changes in the total number of connected users
448    listRef.on('value', (snap) => {
449        const count = snap.numChildren();
450        counterEl.textContent = count > 0 ? count : 1;
451    });
452}

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.