feat: implement unified editor with content persistence and server-side upsert
- Replace dual update systems with single markdown-first editor architecture - Add server-side upsert to eliminate 404 errors on PUT operations - Fix content persistence race condition between preview and save operations - Remove legacy updateElementContent system entirely - Add comprehensive authentication with JWT scaffolding and dev mode - Implement EditContext.updateOriginalContent() for proper baseline management - Enable markdown formatting in all text elements (h1-h6, p, div, etc) - Clean terminology: remove 'unified' references from codebase Technical changes: * core/editor.js: Remove legacy update system, unify content types as markdown * ui/Editor.js: Add updateOriginalContent() method to fix save persistence * ui/Previewer.js: Clean live preview system for all content types * api/handlers.go: Implement UpsertContent for idempotent PUT operations * auth/*: Complete authentication service with OAuth scaffolding * db/queries/content.sql: Add upsert query with ON CONFLICT handling * Schema: Remove type constraints, rely on server-side validation Result: Clean content editing with persistent saves, no 404 errors, markdown support in all text elements
This commit is contained in:
@@ -34,7 +34,7 @@ export class ApiClient {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-ID': this.getCurrentUser()
|
||||
'Authorization': `Bearer ${this.getAuthToken()}`
|
||||
},
|
||||
body: JSON.stringify({ value: content })
|
||||
});
|
||||
@@ -64,7 +64,7 @@ export class ApiClient {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-ID': this.getCurrentUser()
|
||||
'Authorization': `Bearer ${this.getAuthToken()}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: contentId,
|
||||
@@ -113,7 +113,7 @@ export class ApiClient {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-ID': this.getCurrentUser()
|
||||
'Authorization': `Bearer ${this.getAuthToken()}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
version_id: versionId
|
||||
@@ -133,9 +133,171 @@ export class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get current user (for user attribution)
|
||||
/**
|
||||
* 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() {
|
||||
// This could be enhanced to get from authentication system
|
||||
return 'anonymous';
|
||||
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() {
|
||||
// Create a mock JWT-like token for development
|
||||
// Format: mock-{user}-{timestamp}-{random}
|
||||
const user = 'anonymous';
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substr(2, 9);
|
||||
return `mock-${user}-${timestamp}-${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -116,8 +116,7 @@ export class InsertrEditor {
|
||||
}
|
||||
}
|
||||
|
||||
// Update element content regardless of API success (optimistic update)
|
||||
this.updateElementContent(meta.element, formData);
|
||||
|
||||
|
||||
// Close form
|
||||
this.formRenderer.closeForm();
|
||||
@@ -127,8 +126,7 @@ export class InsertrEditor {
|
||||
} catch (error) {
|
||||
console.error('❌ Error saving content:', error);
|
||||
|
||||
// Still update the UI even if API fails
|
||||
this.updateElementContent(meta.element, formData);
|
||||
|
||||
this.formRenderer.closeForm();
|
||||
}
|
||||
}
|
||||
@@ -140,44 +138,15 @@ export class InsertrEditor {
|
||||
return 'link';
|
||||
}
|
||||
|
||||
if (tagName === 'p' || tagName === 'div') {
|
||||
return 'markdown';
|
||||
}
|
||||
|
||||
// Default to text for headings and other elements
|
||||
return 'text';
|
||||
// ALL text elements use markdown for consistent editing experience
|
||||
return 'markdown';
|
||||
}
|
||||
|
||||
handleCancel(meta) {
|
||||
console.log('❌ Edit cancelled:', meta.contentId);
|
||||
}
|
||||
|
||||
updateElementContent(element, formData) {
|
||||
// Skip updating markdown elements and groups - they're handled by the unified markdown editor
|
||||
if (element.classList.contains('insertr-group') || this.isMarkdownElement(element)) {
|
||||
console.log('🔄 Skipping element update - handled by unified markdown editor');
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.tagName.toLowerCase() === 'a') {
|
||||
// Update link element
|
||||
if (formData.text !== undefined) {
|
||||
element.textContent = formData.text;
|
||||
}
|
||||
if (formData.url !== undefined) {
|
||||
element.setAttribute('href', formData.url);
|
||||
}
|
||||
} else {
|
||||
// Update text content for non-markdown elements
|
||||
element.textContent = formData.text || '';
|
||||
}
|
||||
}
|
||||
|
||||
isMarkdownElement(element) {
|
||||
// Check if element uses markdown based on form config
|
||||
const markdownTags = new Set(['p', 'h3', 'h4', 'h5', 'h6', 'span']);
|
||||
return markdownTags.has(element.tagName.toLowerCase());
|
||||
}
|
||||
addEditorStyles() {
|
||||
const styles = `
|
||||
.insertr-editing-hover {
|
||||
|
||||
Reference in New Issue
Block a user