Replace isolated template previews with live collection reconstruction:
- Frontend now reconstructs collection container with all template variants
- Users click directly on rendered templates in proper CSS context
- Perfect preservation of grid/flex layouts and responsive behavior
- Simplified API: preview endpoint returns container_html + templates for frontend reconstruction
- Enhanced UX: WYSIWYG template selection shows exactly what will be added
- Removed redundant templates endpoint in favor of unified preview approach
Backend changes:
- Add GET /api/collections/{id}/preview endpoint
- Remove GET /api/collections/{id}/templates endpoint
- Return container HTML + templates for frontend reconstruction
Frontend changes:
- Replace isolated template modal with live collection preview
- Add generateLivePreview() method for container reconstruction
- Update CollectionManager to use preview API
- Add interactive CSS styling for template selection
This provides true contextual template selection where CSS inheritance,
grid layouts, and responsive design work perfectly in preview mode.
569 lines
20 KiB
JavaScript
569 lines
20 KiB
JavaScript
/**
|
|
* ApiClient - Handle communication with content API
|
|
*/
|
|
export class ApiClient {
|
|
constructor(options = {}) {
|
|
// Smart server detection based on environment
|
|
const isDevelopment = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
|
const defaultEndpoint = isDevelopment
|
|
? 'http://localhost:8080/api/content' // Development: separate API server
|
|
: '/api/content'; // Production: same-origin API
|
|
|
|
this.baseUrl = options.apiEndpoint || defaultEndpoint;
|
|
this.siteId = options.siteId || this.handleMissingSiteId();
|
|
|
|
// Log API configuration in development (keep for debugging)
|
|
if (isDevelopment && !options.apiEndpoint) {
|
|
console.log(`🔌 API Client: Using development server at ${this.baseUrl}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get collections API URL (helper to avoid repetition)
|
|
*/
|
|
getCollectionsUrl() {
|
|
return this.baseUrl.replace('/api/content', '/api/collections');
|
|
}
|
|
|
|
async getContent(contentId) {
|
|
try {
|
|
const response = await fetch(`${this.baseUrl}/${contentId}?site_id=${this.siteId}`);
|
|
return response.ok ? await response.json() : null;
|
|
} catch (error) {
|
|
console.warn('Failed to fetch content:', contentId, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
|
|
async createContent(content, htmlMarkup) {
|
|
try {
|
|
const payload = {
|
|
html_markup: htmlMarkup, // Always send HTML markup - server extracts ID or generates new one
|
|
html_content: content,
|
|
file_path: this.getCurrentFilePath() // Always include file path for consistent ID generation
|
|
};
|
|
|
|
const response = await fetch(`${this.baseUrl}?site_id=${this.siteId}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${this.getAuthToken()}`
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
return result;
|
|
} else {
|
|
console.warn(`⚠️ Create failed (${response.status}): server will generate ID`);
|
|
return null;
|
|
}
|
|
} catch (error) {
|
|
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
|
console.warn(`🔌 API Server not reachable at ${this.baseUrl}`);
|
|
console.warn('💡 Start full-stack development: just dev');
|
|
} else {
|
|
console.error('Failed to create content:', error);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async updateContent(contentId, content) {
|
|
try {
|
|
const payload = {
|
|
html_content: content
|
|
};
|
|
|
|
const response = await fetch(`${this.baseUrl}/${contentId}?site_id=${this.siteId}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${this.getAuthToken()}`
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
return result;
|
|
} else {
|
|
console.warn(`⚠️ Update failed (${response.status}): ${contentId}`);
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
|
console.warn(`🔌 API Server not reachable at ${this.baseUrl}`);
|
|
console.warn('💡 Start full-stack development: just dev');
|
|
} else {
|
|
console.error('Failed to update content:', contentId, error);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async getContentVersions(contentId) {
|
|
try {
|
|
const response = await fetch(`${this.baseUrl}/${contentId}/versions?site_id=${this.siteId}`);
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
return result.versions || [];
|
|
} else {
|
|
console.warn(`⚠️ Failed to fetch versions (${response.status}): ${contentId}`);
|
|
return [];
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch version history:', contentId, error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async rollbackContent(contentId, versionId) {
|
|
try {
|
|
const response = await fetch(`${this.baseUrl}/${contentId}/rollback?site_id=${this.siteId}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${this.getAuthToken()}`
|
|
},
|
|
body: JSON.stringify({
|
|
version_id: versionId
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
console.log(`✅ Content rolled back: ${contentId} to version ${versionId}`);
|
|
return await response.json();
|
|
} else {
|
|
console.warn(`⚠️ Rollback failed (${response.status}): ${contentId}`);
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to rollback content:', contentId, error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Collection API Methods
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Create a new collection item
|
|
* @param {string} collectionId - Collection ID
|
|
* @param {number} templateId - Template ID to use (defaults to 1)
|
|
* @param {string} htmlContent - Optional initial HTML content
|
|
* @returns {Promise<Object>} Created collection item
|
|
*/
|
|
async createCollectionItem(collectionId, templateId = 1, htmlContent = '') {
|
|
try {
|
|
const collectionsUrl = this.getCollectionsUrl();
|
|
const payload = {
|
|
site_id: this.siteId,
|
|
template_id: templateId,
|
|
html_content: htmlContent
|
|
};
|
|
|
|
const response = await fetch(`${collectionsUrl}/${collectionId}/items`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${this.getAuthToken()}`
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
return result;
|
|
} else {
|
|
const errorText = await response.text();
|
|
console.error(`❌ Failed to create collection item (${response.status}): ${errorText}`);
|
|
throw new Error(`Failed to create collection item: ${response.status} ${errorText}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Error creating collection item:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a collection item
|
|
* @param {string} collectionId - Collection ID
|
|
* @param {string} itemId - Item ID to delete
|
|
* @returns {Promise<boolean>} Success status
|
|
*/
|
|
async deleteCollectionItem(collectionId, itemId) {
|
|
try {
|
|
const collectionsUrl = this.getCollectionsUrl();
|
|
|
|
const response = await fetch(`${collectionsUrl}/${collectionId}/items/${itemId}?site_id=${this.siteId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${this.getAuthToken()}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
return true;
|
|
} else {
|
|
const errorText = await response.text();
|
|
console.error(`❌ Failed to delete collection item (${response.status}): ${errorText}`);
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Error deleting collection item:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all collection items
|
|
* @param {string} collectionId - Collection ID
|
|
* @returns {Promise<Array>} Array of collection items
|
|
*/
|
|
async getCollectionItems(collectionId) {
|
|
try {
|
|
const collectionsUrl = this.getCollectionsUrl();
|
|
const response = await fetch(`${collectionsUrl}/${collectionId}/items?site_id=${this.siteId}`);
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
return result.items || [];
|
|
} else {
|
|
console.warn(`⚠️ Failed to fetch collection items (${response.status}): ${collectionId}`);
|
|
return [];
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch collection items:', collectionId, error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get collection preview data (container + templates for frontend reconstruction)
|
|
* @param {string} collectionId - Collection ID
|
|
* @returns {Promise<Object>} Object with collection_id, container_html, and templates
|
|
*/
|
|
async getCollectionPreview(collectionId) {
|
|
try {
|
|
const collectionsUrl = this.getCollectionsUrl();
|
|
const response = await fetch(`${collectionsUrl}/${collectionId}/preview?site_id=${this.siteId}`);
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
return result;
|
|
} else {
|
|
console.warn(`⚠️ Failed to fetch collection preview (${response.status}): ${collectionId}`);
|
|
return null;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch collection preview:', collectionId, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reorder collection items in bulk
|
|
* @param {string} collectionId - Collection ID
|
|
* @param {Array} itemOrder - Array of {itemId, position} objects
|
|
* @returns {Promise<boolean>} Success status
|
|
*/
|
|
async reorderCollection(collectionId, itemOrder) {
|
|
try {
|
|
const collectionsUrl = this.getCollectionsUrl();
|
|
const payload = {
|
|
items: itemOrder
|
|
};
|
|
|
|
const response = await fetch(`${collectionsUrl}/${collectionId}/reorder?site_id=${this.siteId}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${this.getAuthToken()}`
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (response.ok) {
|
|
return true;
|
|
} else {
|
|
const errorText = await response.text();
|
|
console.error(`❌ Failed to reorder collection (${response.status}): ${errorText}`);
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Error reordering collection:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trigger site enhancement after collection changes
|
|
* @returns {Promise<boolean>} Success status
|
|
*/
|
|
async enhanceSite() {
|
|
try {
|
|
const enhanceUrl = this.baseUrl.replace('/api/content', '/api/enhance');
|
|
const response = await fetch(`${enhanceUrl}?site_id=${this.siteId}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${this.getAuthToken()}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
console.log('✅ Files enhanced successfully:', result);
|
|
return true;
|
|
} else {
|
|
console.error(`❌ Failed to enhance files (${response.status})`);
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Error enhancing files:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Get authentication token for API requests
|
|
* @returns {string} JWT token or mock token for development
|
|
*/
|
|
getAuthToken() {
|
|
// Check if we have a real JWT token from OAuth
|
|
const realToken = this.getStoredToken();
|
|
if (realToken && !this.isTokenExpired(realToken)) {
|
|
return realToken;
|
|
}
|
|
|
|
// Development/mock token for when no real auth is present
|
|
return this.getMockToken();
|
|
}
|
|
|
|
/**
|
|
* Get current user information from token
|
|
* @returns {string} User identifier
|
|
*/
|
|
getCurrentUser() {
|
|
const token = this.getAuthToken();
|
|
|
|
// If it's a mock token, return mock user
|
|
if (token.startsWith('mock-')) {
|
|
return 'anonymous';
|
|
}
|
|
|
|
// Parse real JWT token for user info
|
|
try {
|
|
const payload = this.parseJWT(token);
|
|
return payload.sub || payload.user_id || payload.email || 'anonymous';
|
|
} catch (error) {
|
|
console.warn('Failed to parse JWT token:', error);
|
|
return 'anonymous';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get stored JWT token from localStorage/sessionStorage
|
|
* @returns {string|null} Stored JWT token
|
|
*/
|
|
getStoredToken() {
|
|
// Try localStorage first (persistent), then sessionStorage (session-only)
|
|
return localStorage.getItem('insertr_auth_token') ||
|
|
sessionStorage.getItem('insertr_auth_token') ||
|
|
null;
|
|
}
|
|
|
|
/**
|
|
* Store JWT token for future requests
|
|
* @param {string} token - JWT token from OAuth provider
|
|
* @param {boolean} persistent - Whether to use localStorage (true) or sessionStorage (false)
|
|
*/
|
|
setStoredToken(token, persistent = true) {
|
|
const storage = persistent ? localStorage : sessionStorage;
|
|
storage.setItem('insertr_auth_token', token);
|
|
|
|
// Clear the other storage to avoid conflicts
|
|
const otherStorage = persistent ? sessionStorage : localStorage;
|
|
otherStorage.removeItem('insertr_auth_token');
|
|
}
|
|
|
|
/**
|
|
* Clear stored authentication token
|
|
*/
|
|
clearStoredToken() {
|
|
localStorage.removeItem('insertr_auth_token');
|
|
sessionStorage.removeItem('insertr_auth_token');
|
|
}
|
|
|
|
/**
|
|
* Generate mock JWT token for development/testing
|
|
* @returns {string} Mock JWT token
|
|
*/
|
|
getMockToken() {
|
|
// First check if we have a stored mock JWT token
|
|
const storedMockToken = localStorage.getItem('insertr_mock_token');
|
|
if (storedMockToken && !this.isTokenExpired(storedMockToken)) {
|
|
return storedMockToken;
|
|
}
|
|
|
|
// If no valid stored token, fetch a new one from the API
|
|
this.fetchMockTokenAsync();
|
|
|
|
// Return a temporary token while we fetch the real one
|
|
const user = 'dev-user';
|
|
const timestamp = Date.now();
|
|
const random = Math.random().toString(36).substr(2, 9);
|
|
return `mock-${user}-${timestamp}-${random}`;
|
|
}
|
|
|
|
/**
|
|
* Fetch a real mock JWT token from the development API
|
|
*/
|
|
async fetchMockTokenAsync() {
|
|
try {
|
|
const authUrl = this.baseUrl.replace('/api/content', '/api/auth/token');
|
|
const response = await fetch(authUrl);
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.token) {
|
|
localStorage.setItem('insertr_mock_token', data.token);
|
|
localStorage.setItem('insertr_mock_token_expires', Date.now() + (data.expires_in * 1000));
|
|
console.log('🔐 Mock JWT token fetched successfully');
|
|
}
|
|
} else {
|
|
console.warn('Failed to fetch mock token, using fallback');
|
|
}
|
|
} catch (error) {
|
|
console.warn('Error fetching mock token:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse JWT token payload
|
|
* @param {string} token - JWT token
|
|
* @returns {object} Parsed payload
|
|
*/
|
|
parseJWT(token) {
|
|
if (token.startsWith('mock-')) {
|
|
// Return mock payload for development tokens
|
|
return {
|
|
sub: 'anonymous',
|
|
user_id: 'anonymous',
|
|
email: 'anonymous@localhost',
|
|
iss: 'insertr-dev',
|
|
exp: Date.now() + 24 * 60 * 60 * 1000 // 24 hours from now
|
|
};
|
|
}
|
|
|
|
try {
|
|
// Parse real JWT token
|
|
const parts = token.split('.');
|
|
if (parts.length !== 3) {
|
|
throw new Error('Invalid JWT format');
|
|
}
|
|
|
|
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
|
return payload;
|
|
} catch (error) {
|
|
throw new Error(`Failed to parse JWT token: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if JWT token is expired
|
|
* @param {string} token - JWT token
|
|
* @returns {boolean} True if token is expired
|
|
*/
|
|
isTokenExpired(token) {
|
|
try {
|
|
// Check localStorage expiration for mock tokens
|
|
if (token.startsWith('mock-') && token === localStorage.getItem('insertr_mock_token')) {
|
|
const expires = localStorage.getItem('insertr_mock_token_expires');
|
|
if (expires && Date.now() > parseInt(expires)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Parse JWT expiration
|
|
const payload = this.parseJWT(token);
|
|
const now = Math.floor(Date.now() / 1000);
|
|
return payload.exp && payload.exp < now;
|
|
} catch (error) {
|
|
// If we can't parse the token, consider it expired
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize OAuth flow with provider (Google, GitHub, etc.)
|
|
* @param {string} provider - OAuth provider ('google', 'github', etc.)
|
|
* @returns {Promise<boolean>} Success status
|
|
*/
|
|
async initiateOAuth(provider = 'google') {
|
|
// This will be implemented when we add real OAuth integration
|
|
console.log(`🔐 OAuth flow with ${provider} not yet implemented`);
|
|
console.log('💡 For now, using mock authentication in development');
|
|
|
|
// Store a mock token for development
|
|
const mockToken = this.getMockToken();
|
|
this.setStoredToken(mockToken, true);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Handle OAuth callback after user returns from provider
|
|
* @param {URLSearchParams} urlParams - URL parameters from OAuth callback
|
|
* @returns {Promise<boolean>} Success status
|
|
*/
|
|
async handleOAuthCallback(urlParams) {
|
|
// This will be implemented when we add real OAuth integration
|
|
const code = urlParams.get('code');
|
|
const state = urlParams.get('state');
|
|
|
|
if (code) {
|
|
console.log('🔐 OAuth callback received, exchanging code for token...');
|
|
// TODO: Exchange authorization code for JWT token
|
|
// const token = await this.exchangeCodeForToken(code, state);
|
|
// this.setStoredToken(token, true);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Handle missing site_id configuration
|
|
* @returns {string} Site ID or throws error
|
|
*/
|
|
handleMissingSiteId() {
|
|
console.error('❌ No site_id configured for Insertr.');
|
|
console.log('💡 Add data-site-id attribute to your script tag:');
|
|
console.log(' <script src="insertr.js" data-site-id="mysite"></script>');
|
|
console.log('');
|
|
console.log('🚀 For demos under demos/, site_id is auto-derived from directory name.');
|
|
console.log('🏭 For production sites, you must explicitly set your site_id.');
|
|
|
|
// Return a placeholder that will cause API calls to fail gracefully
|
|
return '__MISSING_SITE_ID__';
|
|
}
|
|
|
|
/**
|
|
* Get current file path from URL for consistent ID generation
|
|
* @returns {string} File path like "index.html", "about.html"
|
|
*/
|
|
getCurrentFilePath() {
|
|
const path = window.location.pathname;
|
|
if (path === '/' || path === '') {
|
|
return 'index.html';
|
|
}
|
|
// Remove leading slash: "/about.html" → "about.html"
|
|
return path.replace(/^\//, '');
|
|
}
|
|
} |