diff --git a/server/index.js b/server/index.js index c5b003b..316bf74 100644 --- a/server/index.js +++ b/server/index.js @@ -14,7 +14,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = process.env.PORT || 3001; const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, 'uploads'); // Public URL prefix on Apache (e.g. /citpl_website). Leave empty if site is at domain root. -const SITE_BASE = (process.env.SITE_BASE || '/citpl_website').replace(/\/$/, ''); +const SITE_BASE = (process.env.SITE_BASE ?? '').replace(/\/$/, ''); const distDir = path.join(__dirname, '..', 'dist'); const publicAssetsDir = path.join(__dirname, '..', 'public', 'assets'); const serveStatic = @@ -31,7 +31,11 @@ app.use(cors({ credentials: true, })); -app.use(express.json({ limit: '10mb' })); +// Only parse JSON for non-multipart requests — multer handles multipart/form-data +app.use((req, res, next) => { + if (req.headers['content-type']?.startsWith('multipart/form-data')) return next(); + express.json({ limit: '10mb' })(req, res, next); +}); function mountApp(base) { const prefix = base || ''; @@ -48,8 +52,8 @@ function mountApp(base) { if (serveStatic) { app.use(prefix || '/', express.static(distDir)); - // SPA fallback only for the app's base path, not root-level unknown files - app.get(`${prefix}/*`, (req, res, next) => { + // SPA fallback — Express 5 requires named wildcard /*path (not /*) + app.get(`${prefix}/*path`, (req, res, next) => { const ext = req.path.split('.').pop(); if (ext && ext !== req.path) return next(); res.sendFile(path.join(distDir, 'index.html')); diff --git a/server/routes/upload.js b/server/routes/upload.js index a073bfa..857e1e8 100644 --- a/server/routes/upload.js +++ b/server/routes/upload.js @@ -33,7 +33,23 @@ const upload = multer({ const router = Router(); -router.post('/', requireAuth, upload.single('file'), (req, res) => { +// Wrap multer for Express 5 — multer v2 doesn't handle Express 5 async errors natively +function runUpload(req, res) { + return new Promise((resolve, reject) => { + upload.single('file')(req, res, (err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +router.post('/', requireAuth, async (req, res) => { + try { + await runUpload(req, res); + } catch (err) { + const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400; + return res.status(status).json({ error: err.message, code: err.code || 'UPLOAD_ERROR' }); + } if (!req.file) { return res.status(400).json({ error: 'No file uploaded', code: 'VALIDATION' }); } diff --git a/src/admin/api.js b/src/admin/api.js index f7aace9..b67afe2 100644 --- a/src/admin/api.js +++ b/src/admin/api.js @@ -1,4 +1,6 @@ import { API_BASE } from '../lib/apiBase'; +import { uploadLocal } from '../lib/localFileStore'; +import { notifyContentUpdated } from '../context/ContentContext'; /** Frontend-only fallback when the Node API is not reachable on the host. */ const STATIC_USER = 'admin'; @@ -45,7 +47,9 @@ async function apiFetch(path, options = {}) { : res.status === 502 ? 'API server unreachable. Start Node on the host (port 3001).' : 'Request failed'; - throw new Error(data.error || fallback); + const err = new Error(data.error || fallback); + err.status = res.status; + throw err; } return data; } @@ -65,7 +69,17 @@ export const adminApi = { me: async () => { if (isStaticSession()) { - return { username: STATIC_USER, mode: 'static' }; + // If server is now running, clear the stale static token so real login is used + try { + await apiFetch('/api/auth/me'); + // Server responded — clear static token so user re-authenticates properly + clearToken(); + throw new Error('Session expired, please log in again'); + } catch (err) { + if (err.status) throw err; // server is up but rejected — force re-login + // Server unreachable — static session is fine + return { username: STATIC_USER, mode: 'static' }; + } } return apiFetch('/api/auth/me'); }, @@ -82,14 +96,28 @@ export const adminApi = { }, updateSection: async (section, data) => { - try { - return await apiFetch(`/api/content/${section}`, { method: 'PUT', body: JSON.stringify(data) }); - } catch { + // Skip API entirely for static sessions + if (isStaticSession()) { const saved = localStorage.getItem('static_content'); const { default: seed } = await import('../../server/seed/default-content.json'); const current = saved ? JSON.parse(saved) : seed; const updated = { ...current, [section]: data, _updatedAt: new Date().toISOString() }; localStorage.setItem('static_content', JSON.stringify(updated)); + notifyContentUpdated(); + return { message: `${section} updated (local)`, updatedAt: updated._updatedAt }; + } + try { + const result = await apiFetch(`/api/content/${section}`, { method: 'PUT', body: JSON.stringify(data) }); + notifyContentUpdated(); + return result; + } catch (err) { + if (err.status === 401) clearToken(); + const saved = localStorage.getItem('static_content'); + const { default: seed } = await import('../../server/seed/default-content.json'); + const current = saved ? JSON.parse(saved) : seed; + const updated = { ...current, [section]: data, _updatedAt: new Date().toISOString() }; + localStorage.setItem('static_content', JSON.stringify(updated)); + notifyContentUpdated(); return { message: `${section} updated (local)`, updatedAt: updated._updatedAt }; } }, @@ -103,18 +131,7 @@ export const adminApi = { }, upload: async (file) => { - // Try Node API first; fall back to local data URL when server is unavailable. - try { - const form = new FormData(); - form.append('file', file); - return await apiFetch('/api/upload', { method: 'POST', body: form }); - } catch { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve({ url: reader.result, filename: file.name }); - reader.onerror = () => reject(new Error('Failed to read file')); - reader.readAsDataURL(file); - }); - } + // Store file locally in IndexedDB — no server needed + return uploadLocal(file); }, }; diff --git a/src/admin/components/FormFields.jsx b/src/admin/components/FormFields.jsx index 6d9da25..694cc81 100644 --- a/src/admin/components/FormFields.jsx +++ b/src/admin/components/FormFields.jsx @@ -14,7 +14,7 @@ export function ImageUpload({ value, onChange, label = 'Image' }) { const { url } = await adminApi.upload(file); onChange(url); } catch (err) { - alert(err.message); + alert('Upload failed: ' + err.message); } finally { setUploading(false); } diff --git a/src/admin/main.jsx b/src/admin/main.jsx index dcabdb4..36a17b5 100644 --- a/src/admin/main.jsx +++ b/src/admin/main.jsx @@ -2,9 +2,13 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import './admin.css'; import App from './App.jsx'; +import { restoreAllBlobUrls } from '../lib/localFileStore'; -createRoot(document.getElementById('root')).render( - - - -); +// Restore IndexedDB-stored file blob URLs before rendering +restoreAllBlobUrls().then(() => { + createRoot(document.getElementById('root')).render( + + + + ); +}); diff --git a/src/context/ContentContext.jsx b/src/context/ContentContext.jsx index 359ae0e..db3b6b3 100644 --- a/src/context/ContentContext.jsx +++ b/src/context/ContentContext.jsx @@ -2,81 +2,113 @@ import { createContext, useContext, useEffect, useState } from 'react'; import defaultContent from '../../server/seed/default-content.json'; import { API_BASE } from '../lib/apiBase'; import { prefixAssets } from '../lib/assetUrl'; +import { restoreAllBlobUrls } from '../lib/localFileStore'; const ContentContext = createContext(null); +// BroadcastChannel works across tabs on the same origin +const BC = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('citpl_content') : null; + +// Called by admin api.js after every save to push update to all tabs +export function notifyContentUpdated() { + BC?.postMessage({ type: 'updated' }); + window.dispatchEvent(new CustomEvent('citpl:content-updated')); +} + function allowedPreviewOrigins() { const origins = new Set(['https://demo.cavinkare.in/', 'https://demo.cavinkare.in']); if (typeof window !== 'undefined') origins.add(window.location.origin); return origins; } +function loadFromLocalStorage() { + const saved = localStorage.getItem('static_content'); + if (!saved) return null; + try { + const { _updatedAt, ...rest } = JSON.parse(saved); + return { content: prefixAssets(rest), updatedAt: _updatedAt ?? null }; + } catch { return null; } +} + export function ContentProvider({ children, previewMode = false }) { const [content, setContent] = useState(prefixAssets(defaultContent)); const [loading, setLoading] = useState(!previewMode); const [error, setError] = useState(null); const [updatedAt, setUpdatedAt] = useState(null); + function applyContent(data) { + const { _updatedAt, ...rest } = data; + setContent(prefixAssets(rest)); + setUpdatedAt(_updatedAt ?? null); + setError(null); + } + + function refresh() { + restoreAllBlobUrls().then(() => { + fetch(`${API_BASE}/api/content`) + .then((res) => res.ok ? res.json() : Promise.reject()) + .then((data) => applyContent(data)) + .catch(() => { + const local = loadFromLocalStorage(); + if (local) { setContent(local.content); setUpdatedAt(local.updatedAt); } + }); + }); + } + useEffect(() => { if (previewMode) return; - fetch(`${API_BASE}/api/content`) - .then((res) => { - if (!res.ok) throw new Error('Failed to load content'); - return res.json(); - }) - .then((data) => { - const { _updatedAt, ...rest } = data; - setContent(prefixAssets(rest)); - setUpdatedAt(_updatedAt); - setError(null); - }) - .catch((err) => { - console.warn('Using fallback content:', err.message); - const saved = localStorage.getItem('static_content'); - if (saved) { - try { - const { _updatedAt, ...rest } = JSON.parse(saved); - setContent(prefixAssets(rest)); - setUpdatedAt(_updatedAt ?? null); - return; - } catch { /* ignore parse error */ } - } - setError(err.message); - }) - .finally(() => setLoading(false)); + // Initial load + restoreAllBlobUrls().then(() => { + fetch(`${API_BASE}/api/content`) + .then((res) => { + if (!res.ok) throw new Error('Failed to load content'); + return res.json(); + }) + .then((data) => applyContent(data)) + .catch((err) => { + console.warn('Using fallback content:', err.message); + const local = loadFromLocalStorage(); + if (local) { setContent(local.content); setUpdatedAt(local.updatedAt); } + else setError(err.message); + }) + .finally(() => setLoading(false)); + }); + + // Cross-tab: BroadcastChannel (admin tab → main site tab) + if (BC) BC.onmessage = (e) => { if (e.data?.type === 'updated') refresh(); }; + // Same-tab: custom event + window.addEventListener('citpl:content-updated', refresh); + // Cross-tab fallback: storage event + const onStorage = (e) => { if (e.key === 'static_content') refresh(); }; + window.addEventListener('storage', onStorage); + + return () => { + window.removeEventListener('citpl:content-updated', refresh); + window.removeEventListener('storage', onStorage); + if (BC) BC.onmessage = null; + }; }, [previewMode]); useEffect(() => { if (!previewMode) return; - setLoading(false); const origins = allowedPreviewOrigins(); - const onMessage = (event) => { if (!origins.has(event.origin)) return; if (event.data?.type !== 'ADMIN_PREVIEW' || !event.data.fullContent) return; - setContent(prefixAssets(event.data.fullContent)); setLoading(false); - const anchor = event.data.anchor; if (!anchor) return; - window.setTimeout(() => { document.getElementById(anchor)?.scrollIntoView({ behavior: 'auto', block: 'start' }); }, 180); }; - window.addEventListener('message', onMessage); for (const origin of origins) { - try { - window.parent?.postMessage({ type: 'PREVIEW_READY' }, origin); - } catch { - /* ignore */ - } + try { window.parent?.postMessage({ type: 'PREVIEW_READY' }, origin); } catch { /* ignore */ } } - return () => window.removeEventListener('message', onMessage); }, [previewMode]); diff --git a/src/lib/assetUrl.js b/src/lib/assetUrl.js index 4ae1bca..8eb1f6e 100644 --- a/src/lib/assetUrl.js +++ b/src/lib/assetUrl.js @@ -1,3 +1,5 @@ +import { blobCache } from './localFileStore'; + /** * Vite base, e.g. "/citpl_website/" in production and "/" in local dev. * Runtime fallback: if an old/dev build baked base "/" but the page is @@ -26,6 +28,9 @@ export function assetUrl(path) { if (/^(data:|blob:)/i.test(path)) return path; + // Resolve locally uploaded files from IndexedDB blob cache + if (blobCache.has(path)) return blobCache.get(path); + const assetBase = resolveAssetBase(); const prefix = assetBase.replace(/\/$/, ''); diff --git a/src/lib/localFileStore.js b/src/lib/localFileStore.js new file mode 100644 index 0000000..dc67150 --- /dev/null +++ b/src/lib/localFileStore.js @@ -0,0 +1,83 @@ +/** + * Local file store using IndexedDB. + * Stores uploaded files as binary blobs keyed by a stable path like /assets/filename. + * On load, restores blob URLs so images/videos display correctly. + */ + +const DB_NAME = 'citpl_uploads'; +const STORE = 'files'; +const DB_VERSION = 1; + +function openDb() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = (e) => e.target.result.createObjectStore(STORE); + req.onsuccess = (e) => resolve(e.target.result); + req.onerror = () => reject(req.error); + }); +} + +export async function saveFile(key, file) { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).put({ blob: file, name: file.name, type: file.type }, key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +export async function getFileUrl(key) { + const db = await openDb(); + return new Promise((resolve) => { + const req = db.transaction(STORE).objectStore(STORE).get(key); + req.onsuccess = () => { + if (req.result) resolve(URL.createObjectURL(req.result.blob)); + else resolve(null); + }; + req.onerror = () => resolve(null); + }); +} + +export async function getAllKeys() { + const db = await openDb(); + return new Promise((resolve) => { + const req = db.transaction(STORE).objectStore(STORE).getAllKeys(); + req.onsuccess = () => resolve(req.result); + req.onerror = () => resolve([]); + }); +} + +/** + * Save file locally and return a stable key path like /assets/user_upload_filename.ext + */ +export async function uploadLocal(file) { + const ext = file.name.split('.').pop().toLowerCase(); + const base = file.name.replace(/[^a-zA-Z0-9-_]/g, '_').replace(/\.[^.]+$/, '').slice(0, 40); + const key = `/assets/user_upload_${base}_${Date.now()}.${ext}`; + await saveFile(key, file); + const url = URL.createObjectURL(file); + // Cache the blob URL in memory for this session + blobCache.set(key, url); + return { url: key, blobUrl: url }; +} + +// In-memory cache: key → blob URL (valid for current session) +export const blobCache = new Map(); + +/** + * Call once on app init to restore blob URLs for all stored files. + * Returns a Map of key → blobUrl. + */ +export async function restoreAllBlobUrls() { + const keys = await getAllKeys(); + await Promise.all( + keys.map(async (key) => { + if (!blobCache.has(key)) { + const url = await getFileUrl(key); + if (url) blobCache.set(key, url); + } + }) + ); + return blobCache; +} diff --git a/src/main.jsx b/src/main.jsx index 79680f4..76f58f5 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -3,11 +3,14 @@ import { createRoot } from 'react-dom/client' import './index.css' import App from './App.jsx' import { ContentProvider } from './context/ContentContext.jsx' +import { restoreAllBlobUrls } from './lib/localFileStore' -createRoot(document.getElementById('root')).render( - - - - - , -) +restoreAllBlobUrls().then(() => { + createRoot(document.getElementById('root')).render( + + + + + , + ) +}) diff --git a/vite.config.js b/vite.config.js index 74756de..85a4599 100644 --- a/vite.config.js +++ b/vite.config.js @@ -12,8 +12,16 @@ export default defineConfig((configEnv) => { plugins: [react()], server: { proxy: { - '/api': 'http://localhost:3001', - '/uploads': 'http://localhost:3001', + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + proxyTimeout: 120000, + timeout: 120000, + }, + '/uploads': { + target: 'http://localhost:3001', + changeOrigin: true, + }, }, }, build: {