Spaces:
Sleeping
Sleeping
| /** | |
| * Q-Simplified β Shared Utilities | |
| * Include this in every page: <script src="/js/app.js"></script> | |
| */ | |
| // βββ CONFIG ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const QS = { | |
| API_BASE: '', // Same origin // β Update after Railway deploy | |
| VERSION: '1.0.0', | |
| }; | |
| // βββ AUTH MODULE βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const Auth = { | |
| getToken() { | |
| const token = localStorage.getItem('qs_token'); | |
| const exp = +localStorage.getItem('qs_token_exp'); | |
| if (!token || Date.now() > exp) return null; | |
| return token; | |
| }, | |
| getUser() { | |
| try { return JSON.parse(localStorage.getItem('qs_user') || 'null'); } | |
| catch { return null; } | |
| }, | |
| isLoggedIn() { return !!this.getToken(); }, | |
| save(data) { | |
| localStorage.setItem('qs_token', data.access_token); | |
| localStorage.setItem('qs_user', JSON.stringify(data.user)); | |
| localStorage.setItem('qs_token_exp', Date.now() + data.expires_in * 1000); | |
| }, | |
| logout() { | |
| ['qs_token', 'qs_user', 'qs_token_exp'].forEach(k => localStorage.removeItem(k)); | |
| window.location.href = '/login.html'; | |
| }, | |
| requireAuth(redirectAfter = window.location.href) { | |
| if (!this.isLoggedIn()) { | |
| window.location.href = `/login.html?redirect=${encodeURIComponent(redirectAfter)}`; | |
| return false; | |
| } | |
| return true; | |
| }, | |
| authHeaders() { | |
| const token = this.getToken(); | |
| return token ? { 'Authorization': `Bearer ${token}` } : {}; | |
| }, | |
| }; | |
| // βββ API MODULE ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const API = { | |
| async get(path) { | |
| const res = await fetch(`${QS.API_BASE}${path}`, { | |
| headers: { ...Auth.authHeaders(), 'Content-Type': 'application/json' } | |
| }); | |
| if (res.status === 401) { Auth.logout(); return null; } | |
| if (!res.ok) throw new Error(`API Error ${res.status}`); | |
| return res.json(); | |
| }, | |
| async post(path, body) { | |
| const res = await fetch(`${QS.API_BASE}${path}`, { | |
| method: 'POST', | |
| headers: { ...Auth.authHeaders(), 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(body), | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.detail || 'Request failed'); | |
| return data; | |
| }, | |
| async put(path, body) { | |
| const res = await fetch(`${QS.API_BASE}${path}`, { | |
| method: 'PUT', | |
| headers: { ...Auth.authHeaders(), 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(body), | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.detail || 'Request failed'); | |
| return data; | |
| }, | |
| async delete(path) { | |
| const res = await fetch(`${QS.API_BASE}${path}`, { | |
| method: 'DELETE', | |
| headers: { ...Auth.authHeaders() }, | |
| }); | |
| if (res.status === 204) return null; | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.detail || 'Request failed'); | |
| return data; | |
| }, | |
| async postForm(path, formData) { | |
| const res = await fetch(`${QS.API_BASE}${path}`, { | |
| method: 'POST', | |
| headers: { ...Auth.authHeaders() }, | |
| body: formData, | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.detail || 'Upload failed'); | |
| return data; | |
| }, | |
| // Convenience | |
| market: { | |
| getLatest: () => API.get('/api/market-data/latest'), | |
| getHistory: (symbol, period='1mo', interval) => | |
| API.get(`/api/market-data/history/${encodeURIComponent(symbol)}?period=${period}${interval ? '&interval=' + interval : ''}`), | |
| }, | |
| news: { | |
| getInsights: () => API.get('/api/news/live-insights'), | |
| getLatest: (limit=20, category=null) => API.get(`/api/news/latest?limit=${limit}${category?'&category='+category:''}`), | |
| }, | |
| blogs: { | |
| getAll: (opts={}) => API.get(`/api/blogs?limit=${opts.limit||20}&offset=${opts.offset||0}${opts.category?'&category='+opts.category:''}`), | |
| getFeatured: () => API.get('/api/blogs/featured'), | |
| getRecent: (n=3) => API.get(`/api/blogs/recent?limit=${n}`), | |
| getOne: (slug) => API.get(`/api/blogs/${slug}`), | |
| create: (body) => API.post('/api/blogs', body), | |
| update: (slug, body) => API.put(`/api/blogs/${slug}`, body), | |
| remove: (slug) => API.delete(`/api/blogs/${slug}`), | |
| getMine: (published) => API.get(`/api/blogs/mine${published !== undefined ? '?published=' + published : ''}`), | |
| }, | |
| upload: { | |
| image: (file) => { | |
| const fd = new FormData(); | |
| fd.append('file', file); | |
| return API.postForm('/api/upload/image', fd); | |
| }, | |
| }, | |
| }; | |
| // βββ NAV AUTO-UPDATE βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function initNav() { | |
| const user = Auth.getUser(); | |
| const btn = document.getElementById('nav-auth-btn'); | |
| if (!btn) return; | |
| if (user && Auth.isLoggedIn()) { | |
| btn.textContent = user.full_name?.split(' ')[0] || 'Account'; | |
| btn.href = '/profile.html'; | |
| btn.style.background = 'rgba(255,255,255,0.1)'; | |
| } | |
| } | |
| // βββ TOAST NOTIFICATIONS ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function showToast(msg, type='info', duration=3000) { | |
| const colors = { info:'#003465', success:'#1a7a4a', error:'#c0392b' }; | |
| const toast = document.createElement('div'); | |
| toast.style.cssText = ` | |
| position:fixed;bottom:1.5rem;right:1.5rem;z-index:9999; | |
| background:${colors[type]||colors.info};color:#fff; | |
| padding:0.875rem 1.25rem;border-radius:9999px; | |
| font-family:'Work Sans',sans-serif;font-size:0.875rem;font-weight:500; | |
| box-shadow:0 8px 32px rgba(0,0,0,0.2); | |
| animation:slideIn 0.3s ease; | |
| max-width:340px; | |
| `; | |
| toast.textContent = msg; | |
| document.body.appendChild(toast); | |
| if (!document.getElementById('toast-style')) { | |
| const s = document.createElement('style'); | |
| s.id = 'toast-style'; | |
| s.textContent = '@keyframes slideIn{from{transform:translateY(1rem);opacity:0}to{transform:translateY(0);opacity:1}}'; | |
| document.head.appendChild(s); | |
| } | |
| setTimeout(() => { toast.style.opacity='0'; toast.style.transition='opacity 0.3s'; setTimeout(()=>toast.remove(),300); }, duration); | |
| } | |
| // βββ FORMAT HELPERS βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const Format = { | |
| currency: (n) => 'βΉ' + n.toLocaleString('en-IN'), | |
| number: (n) => n.toLocaleString('en-IN'), | |
| pct: (n) => (n > 0 ? '+' : '') + n.toFixed(2) + '%', | |
| timeAgo(dateStr) { | |
| const diff = Date.now() - new Date(dateStr).getTime(); | |
| const mins = Math.floor(diff / 60000); | |
| const hrs = Math.floor(diff / 3600000); | |
| const days = Math.floor(diff / 86400000); | |
| if (mins < 60) return mins <= 1 ? 'Just now' : `${mins} mins ago`; | |
| if (hrs < 24) return `${hrs} hour${hrs>1?'s':''} ago`; | |
| if (days < 7) return `${days} day${days>1?'s':''} ago`; | |
| return new Date(dateStr).toLocaleDateString('en-IN', { day:'numeric', month:'short', year:'numeric' }); | |
| }, | |
| }; | |
| // βββ INIT ON PAGE LOAD βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| document.addEventListener('DOMContentLoaded', initNav); | |
| // Export for use in page scripts | |
| window.QS = QS; | |
| window.Auth = Auth; | |
| window.API = API; | |
| window.Format = Format; | |
| window.showToast = showToast; | |