- Add class-based template comparison to differentiate styling variants - Implement template deduplication based on structure + class signatures - Add GetCollectionTemplate method to repository interface and implementations - Fix collection item creation by replacing unimplemented CreateCollectionItemAtomic - Add template selection modal with auto-default selection in frontend - Generate meaningful template names from distinctive CSS classes - Fix unique constraint violations with timestamp-based collection item IDs - Add collection templates API endpoint for frontend template fetching - Update simple demo with featured/compact/dark testimonial variants for testing
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 available templates for a collection
|
|
* @param {string} collectionId - Collection ID
|
|
* @returns {Promise<Array>} Array of collection templates
|
|
*/
|
|
async getCollectionTemplates(collectionId) {
|
|
try {
|
|
const collectionsUrl = this.getCollectionsUrl();
|
|
const response = await fetch(`${collectionsUrl}/${collectionId}/templates?site_id=${this.siteId}`);
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
return result.templates || [];
|
|
} else {
|
|
console.warn(`⚠️ Failed to fetch collection templates (${response.status}): ${collectionId}`);
|
|
return [];
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch collection templates:', collectionId, error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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(/^\//, '');
|
|
}
|
|
} |