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: {