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:
2025-09-10 20:19:54 +02:00
parent c572428e45
commit b0c4a33a7c
23 changed files with 1658 additions and 3585 deletions

31
AGENTS.md Normal file
View File

@@ -0,0 +1,31 @@
# AGENTS.md - Developer Guide for Insertr
## Build/Test Commands
- `just dev` - Full-stack development (recommended)
- `just build` - Build entire project (Go binary + JS library)
- `just build-lib` - Build JS library only
- `just test` - Run tests (placeholder, no actual tests yet)
- `just lint` - Run linting (placeholder, no actual linting yet)
- `just air` - Hot reload Go backend only
- `go test ./...` - Run Go tests (when available)
## Code Style Guidelines
### Go (Backend)
- Use standard Go formatting (`gofmt`)
- Package imports: stdlib → third-party → internal
- Error handling: always check and handle errors
- Naming: camelCase for local vars, PascalCase for exported
- Comments: package-level comments, exported functions documented
### JavaScript (Frontend)
- ES6+ modules, no CommonJS
- camelCase for variables/functions, PascalCase for classes
- Use `const`/`let`, avoid `var`
- Prefer template literals over string concatenation
- Export classes/functions explicitly, avoid default exports when multiple exports exist
### Database
- Use `sqlc` for Go database code generation
- SQL files in `db/queries/`, schemas in `db/{sqlite,postgresql}/schema.sql`
- Run `just sqlc` after schema changes

120
TODO.md
View File

@@ -242,3 +242,123 @@ CREATE INDEX IF NOT EXISTS idx_content_site_id ON content(site_id);
- `db/*/setup.sql` - Contains table creation queries (sqlc generates type-safe functions) - `db/*/setup.sql` - Contains table creation queries (sqlc generates type-safe functions)
- `internal/db/database.go` - Manual index/trigger creation using raw SQL - `internal/db/database.go` - Manual index/trigger creation using raw SQL
- **Best Practice**: Use sqlc for what it supports, manual SQL for what it doesn't - **Best Practice**: Use sqlc for what it supports, manual SQL for what it doesn't
## 🔄 **Editor Cache Architecture & Content State Management** (Dec 2024)
### **Current Architecture Issues Identified**
**Problem**: Conflict between preview system and content persistence after save operations.
**Root Cause**: The unified Editor system was designed with preview-first architecture:
- `originalContent` stores DOM state when editing begins
- `clearPreview()` always restores to `originalContent`
- This creates race condition: `applyContent()``clearPreview()` → content reverted
### **✅ Implemented Solution: Post-Save Baseline Update**
**Strategy**: After successful save, update the stored `originalContent` to match the new saved state.
**Implementation** (`lib/src/ui/Editor.js`):
```js
// In save handler:
context.applyContent(content); // Apply new content to DOM
context.updateOriginalContent(); // Update baseline to match current DOM
this.previewer.clearPreview(); // Clear preview (won't revert since baseline matches)
```
**Benefits**:
- ✅ Content persists after save operations
- ✅ Future cancellations restore to saved state, not pre-edit state
- ✅ Maintains clean preview functionality
- ✅ No breaking changes to existing architecture
### **Future Considerations: Draft System Architecture**
**Current State Management**:
- **Browser Cache**: DOM elements store current content state
- **Server Cache**: Database stores persisted content
- **No Intermediate Cache**: Edits are either preview (temporary) or saved (permanent)
**Potential Draft System Design**:
#### **Option 1: LocalStorage Drafts**
```js
// Auto-save drafts locally during editing
const draftKey = `insertr_draft_${contentId}_${siteId}`;
localStorage.setItem(draftKey, JSON.stringify({
content: currentContent,
timestamp: Date.now(),
originalContent: baseline
}));
```
**Benefits**: Offline support, immediate feedback, no server load
**Drawbacks**: Per-browser, no cross-device sync, storage limits
#### **Option 2: Server-Side Drafts**
```js
// Auto-save drafts to server with special draft flag
PUT /api/content/{id}/draft
{
"value": "Draft content...",
"type": "markdown",
"is_draft": true
}
```
**Benefits**: Cross-device sync, unlimited storage, collaborative editing potential
**Drawbacks**: Server complexity, authentication requirements, network dependency
#### **Option 3: Hybrid Draft System**
- **LocalStorage**: Immediate draft saving during typing
- **Server Sync**: Periodic sync of drafts (every 30s or on significant changes)
- **Conflict Resolution**: Handle cases where server content changed while editing draft
### **Cache Invalidation Strategy**
**Current Behavior**:
- Content is cached in DOM until page reload
- No automatic refresh when content changes on server
- No awareness of multi-user editing scenarios
**Recommended Enhancements**:
#### **Option A: Manual Refresh Strategy**
- Add "Refresh Content" button to editor interface
- Check server content before editing (warn if changed)
- Simple conflict resolution (server wins vs local wins vs merge)
#### **Option B: Polling Strategy**
- Poll server every N minutes for content changes
- Show notification if content was updated by others
- Allow user to choose: keep editing, reload, or merge
#### **Option C: WebSocket Strategy**
- Real-time content change notifications
- Live collaborative editing indicators
- Automatic conflict resolution
### **Implementation Priority**
**Phase 1** (Immediate): ✅ **COMPLETED**
- Fix content persistence after save (baseline update approach)
**Phase 2** (Short-term):
- Add LocalStorage draft auto-save during editing
- Implement draft recovery on page reload
- Basic conflict detection (server timestamp vs local timestamp)
**Phase 3** (Long-term):
- Server-side draft support
- Real-time collaboration features
- Advanced conflict resolution
### **Design Principles for Draft System**
1. **Progressive Enhancement**: Site works without drafts, drafts enhance UX
2. **Data Safety**: Never lose user content, even in edge cases
3. **Performance First**: Drafts shouldn't impact site loading for regular visitors
4. **Conflict Transparency**: Always show user what's happening with their content
5. **Graceful Degradation**: Fallback to basic editing if draft system fails
**Note**: Current architecture already supports foundation for all these enhancements through the unified Editor system and API client pattern.

View File

@@ -13,6 +13,7 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/insertr/insertr/internal/api" "github.com/insertr/insertr/internal/api"
"github.com/insertr/insertr/internal/auth"
"github.com/insertr/insertr/internal/db" "github.com/insertr/insertr/internal/db"
) )
@@ -51,8 +52,24 @@ func runServe(cmd *cobra.Command, args []string) {
} }
defer database.Close() defer database.Close()
// Initialize authentication service
authConfig := &auth.AuthConfig{
DevMode: viper.GetBool("dev_mode"),
JWTSecret: viper.GetString("jwt_secret"),
}
// Set default JWT secret if not configured
if authConfig.JWTSecret == "" {
authConfig.JWTSecret = "dev-secret-change-in-production"
if authConfig.DevMode {
log.Printf("🔑 Using default JWT secret for development")
}
}
authService := auth.NewAuthService(authConfig)
// Initialize handlers // Initialize handlers
contentHandler := api.NewContentHandler(database) contentHandler := api.NewContentHandler(database, authService)
// Setup router // Setup router
router := mux.NewRouter() router := mux.NewRouter()

View File

@@ -4,7 +4,7 @@ CREATE TABLE content (
id TEXT NOT NULL, id TEXT NOT NULL,
site_id TEXT NOT NULL, site_id TEXT NOT NULL,
value TEXT NOT NULL, value TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')), type TEXT NOT NULL,
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) NOT NULL, created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) NOT NULL,
updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) NOT NULL, updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) NOT NULL,
last_edited_by TEXT DEFAULT 'system' NOT NULL, last_edited_by TEXT DEFAULT 'system' NOT NULL,

View File

@@ -25,6 +25,15 @@ SET value = sqlc.arg(value), type = sqlc.arg(type), last_edited_by = sqlc.arg(la
WHERE id = sqlc.arg(id) AND site_id = sqlc.arg(site_id) WHERE id = sqlc.arg(id) AND site_id = sqlc.arg(site_id)
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by; RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by;
-- name: UpsertContent :one
INSERT INTO content (id, site_id, value, type, last_edited_by)
VALUES (sqlc.arg(id), sqlc.arg(site_id), sqlc.arg(value), sqlc.arg(type), sqlc.arg(last_edited_by))
ON CONFLICT(id, site_id) DO UPDATE SET
value = EXCLUDED.value,
type = EXCLUDED.type,
last_edited_by = EXCLUDED.last_edited_by
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by;
-- name: DeleteContent :exec -- name: DeleteContent :exec
DELETE FROM content DELETE FROM content
WHERE id = sqlc.arg(id) AND site_id = sqlc.arg(site_id); WHERE id = sqlc.arg(id) AND site_id = sqlc.arg(site_id);

View File

@@ -4,7 +4,7 @@ CREATE TABLE content (
id TEXT NOT NULL, id TEXT NOT NULL,
site_id TEXT NOT NULL, site_id TEXT NOT NULL,
value TEXT NOT NULL, value TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')), type TEXT NOT NULL,
created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL, created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
updated_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL, updated_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
last_edited_by TEXT DEFAULT 'system' NOT NULL, last_edited_by TEXT DEFAULT 'system' NOT NULL,

1
go.mod
View File

@@ -13,6 +13,7 @@ require (
require ( require (
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect github.com/magiconair/properties v1.8.7 // indirect

2
go.sum
View File

@@ -7,6 +7,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=

View File

@@ -11,6 +11,7 @@ import (
"time" "time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/insertr/insertr/internal/auth"
"github.com/insertr/insertr/internal/db" "github.com/insertr/insertr/internal/db"
"github.com/insertr/insertr/internal/db/postgresql" "github.com/insertr/insertr/internal/db/postgresql"
"github.com/insertr/insertr/internal/db/sqlite" "github.com/insertr/insertr/internal/db/sqlite"
@@ -18,13 +19,15 @@ import (
// ContentHandler handles all content-related HTTP requests // ContentHandler handles all content-related HTTP requests
type ContentHandler struct { type ContentHandler struct {
database *db.Database database *db.Database
authService *auth.AuthService
} }
// NewContentHandler creates a new content handler // NewContentHandler creates a new content handler
func NewContentHandler(database *db.Database) *ContentHandler { func NewContentHandler(database *db.Database, authService *auth.AuthService) *ContentHandler {
return &ContentHandler{ return &ContentHandler{
database: database, database: database,
authService: authService,
} }
} }
@@ -173,14 +176,13 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
siteID = "default" // final fallback siteID = "default" // final fallback
} }
// Extract user from request (for now, use X-User-ID header or fallback) // Extract user from request using authentication service
userID := r.Header.Get("X-User-ID") userInfo, authErr := h.authService.ExtractUserFromRequest(r)
if userID == "" && req.CreatedBy != "" { if authErr != nil {
userID = req.CreatedBy http.Error(w, fmt.Sprintf("Authentication error: %v", authErr), http.StatusUnauthorized)
} return
if userID == "" {
userID = "anonymous"
} }
userID := userInfo.ID
var content interface{} var content interface{}
var err error var err error
@@ -219,7 +221,7 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(item) json.NewEncoder(w).Encode(item)
} }
// UpdateContent handles PUT /api/content/{id} // UpdateContent handles PUT /api/content/{id} with upsert functionality
func (h *ContentHandler) UpdateContent(w http.ResponseWriter, r *http.Request) { func (h *ContentHandler) UpdateContent(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
contentID := vars["id"] contentID := vars["id"]
@@ -236,29 +238,70 @@ func (h *ContentHandler) UpdateContent(w http.ResponseWriter, r *http.Request) {
return return
} }
// Extract user from request // Extract user from request using authentication service
userID := r.Header.Get("X-User-ID") userInfo, authErr := h.authService.ExtractUserFromRequest(r)
if userID == "" && req.UpdatedBy != "" { if authErr != nil {
userID = req.UpdatedBy http.Error(w, fmt.Sprintf("Authentication error: %v", authErr), http.StatusUnauthorized)
return
} }
if userID == "" { userID := userInfo.ID
userID = "anonymous"
// Check if content exists for version history (non-blocking)
var existingContent interface{}
var contentExists bool
switch h.database.GetDBType() {
case "sqlite3":
existingContent, _ = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{
ID: contentID,
SiteID: siteID,
})
contentExists = existingContent != nil
case "postgresql":
existingContent, _ = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{
ID: contentID,
SiteID: siteID,
})
contentExists = existingContent != nil
} }
// Get current content for version history and type preservation // Archive existing version before upsert (only if content already exists)
var currentContent interface{} if contentExists {
if err := h.createContentVersion(existingContent); err != nil {
// Log error but don't fail the request - version history is non-critical
fmt.Printf("Warning: Failed to create content version: %v\n", err)
}
}
// Determine content type: use provided type, fallback to existing type, default to "text"
contentType := req.Type
if contentType == "" && contentExists {
contentType = h.getContentType(existingContent)
}
if contentType == "" {
contentType = "text" // default type for new content
}
// Perform upsert operation
var upsertedContent interface{}
var err error var err error
switch h.database.GetDBType() { switch h.database.GetDBType() {
case "sqlite3": case "sqlite3":
currentContent, err = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{ upsertedContent, err = h.database.GetSQLiteQueries().UpsertContent(context.Background(), sqlite.UpsertContentParams{
ID: contentID, ID: contentID,
SiteID: siteID, SiteID: siteID,
Value: req.Value,
Type: contentType,
LastEditedBy: userID,
}) })
case "postgresql": case "postgresql":
currentContent, err = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{ upsertedContent, err = h.database.GetPostgreSQLQueries().UpsertContent(context.Background(), postgresql.UpsertContentParams{
ID: contentID, ID: contentID,
SiteID: siteID, SiteID: siteID,
Value: req.Value,
Type: contentType,
LastEditedBy: userID,
}) })
default: default:
http.Error(w, "Unsupported database type", http.StatusInternalServerError) http.Error(w, "Unsupported database type", http.StatusInternalServerError)
@@ -266,58 +309,11 @@ func (h *ContentHandler) UpdateContent(w http.ResponseWriter, r *http.Request) {
} }
if err != nil { if err != nil {
if err == sql.ErrNoRows { http.Error(w, fmt.Sprintf("Failed to upsert content: %v", err), http.StatusInternalServerError)
http.Error(w, "Content not found", http.StatusNotFound)
return
}
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
return return
} }
// Archive current version before updating item := h.convertToAPIContent(upsertedContent)
err = h.createContentVersion(currentContent)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to create version: %v", err), http.StatusInternalServerError)
return
}
// Determine content type
contentType := req.Type
if contentType == "" {
contentType = h.getContentType(currentContent) // preserve existing type if not specified
}
// Update the content
var updatedContent interface{}
switch h.database.GetDBType() {
case "sqlite3":
updatedContent, err = h.database.GetSQLiteQueries().UpdateContent(context.Background(), sqlite.UpdateContentParams{
Value: req.Value,
Type: contentType,
LastEditedBy: userID,
ID: contentID,
SiteID: siteID,
})
case "postgresql":
updatedContent, err = h.database.GetPostgreSQLQueries().UpdateContent(context.Background(), postgresql.UpdateContentParams{
Value: req.Value,
Type: contentType,
LastEditedBy: userID,
ID: contentID,
SiteID: siteID,
})
default:
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
return
}
if err != nil {
http.Error(w, fmt.Sprintf("Failed to update content: %v", err), http.StatusInternalServerError)
return
}
item := h.convertToAPIContent(updatedContent)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(item) json.NewEncoder(w).Encode(item)
@@ -459,14 +455,13 @@ func (h *ContentHandler) RollbackContent(w http.ResponseWriter, r *http.Request)
return return
} }
// Extract user from request // Extract user from request using authentication service
userID := r.Header.Get("X-User-ID") userInfo, authErr := h.authService.ExtractUserFromRequest(r)
if userID == "" && req.RolledBackBy != "" { if authErr != nil {
userID = req.RolledBackBy http.Error(w, fmt.Sprintf("Authentication error: %v", authErr), http.StatusUnauthorized)
} return
if userID == "" {
userID = "anonymous"
} }
userID := userInfo.ID
// Archive current version before rollback // Archive current version before rollback
var currentContent interface{} var currentContent interface{}

265
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,265 @@
package auth
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
// UserInfo represents authenticated user information
type UserInfo struct {
ID string `json:"sub"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
Provider string `json:"iss,omitempty"`
}
// AuthConfig holds authentication configuration
type AuthConfig struct {
DevMode bool
JWTSecret string
OAuthConfigs map[string]OAuthConfig
}
// OAuthConfig holds OAuth provider configuration
type OAuthConfig struct {
ClientID string
ClientSecret string
RedirectURL string
Scopes []string
}
// AuthService handles authentication operations
type AuthService struct {
config *AuthConfig
}
// NewAuthService creates a new authentication service
func NewAuthService(config *AuthConfig) *AuthService {
return &AuthService{config: config}
}
// ExtractUserFromRequest extracts user information from HTTP request
func (a *AuthService) ExtractUserFromRequest(r *http.Request) (*UserInfo, error) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return &UserInfo{ID: "anonymous"}, nil
}
// Parse Bearer token
if !strings.HasPrefix(authHeader, "Bearer ") {
return nil, fmt.Errorf("invalid authorization header format")
}
token := strings.TrimPrefix(authHeader, "Bearer ")
// Handle mock tokens in development mode
if a.config.DevMode && strings.HasPrefix(token, "mock-") {
return a.parseMockToken(token), nil
}
// Parse real JWT token
return a.parseJWT(token)
}
// parseMockToken handles mock development tokens
func (a *AuthService) parseMockToken(token string) *UserInfo {
// Mock token format: mock-{user}-{timestamp}-{random}
parts := strings.Split(token, "-")
if len(parts) >= 2 {
return &UserInfo{
ID: parts[1], // user part
Email: fmt.Sprintf("%s@localhost", parts[1]),
Name: strings.Title(parts[1]),
Provider: "insertr-dev",
}
}
return &UserInfo{ID: "anonymous"}
}
// parseJWT parses and validates a real JWT token
func (a *AuthService) parseJWT(tokenString string) (*UserInfo, error) {
// Parse the token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Validate signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(a.config.JWTSecret), nil
})
if err != nil {
return nil, fmt.Errorf("failed to parse JWT: %w", err)
}
// Extract claims
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
userInfo := &UserInfo{}
// Extract standard JWT claims
if sub, ok := claims["sub"].(string); ok {
userInfo.ID = sub
}
if email, ok := claims["email"].(string); ok {
userInfo.Email = email
}
if name, ok := claims["name"].(string); ok {
userInfo.Name = name
}
if iss, ok := claims["iss"].(string); ok {
userInfo.Provider = iss
}
// Fallback to alternative claim names
if userInfo.ID == "" {
if userID, ok := claims["user_id"].(string); ok {
userInfo.ID = userID
}
}
// Default to anonymous if no user ID found
if userInfo.ID == "" {
userInfo.ID = "anonymous"
}
return userInfo, nil
}
return nil, fmt.Errorf("invalid JWT claims")
}
// CreateMockJWT creates a mock JWT token for development/testing
func (a *AuthService) CreateMockJWT(userID, email, name string) (string, error) {
if !a.config.DevMode {
return "", fmt.Errorf("mock JWT creation only allowed in development mode")
}
// Create the claims
claims := jwt.MapClaims{
"sub": userID,
"email": email,
"name": name,
"iss": "insertr-dev",
"iat": time.Now().Unix(),
"exp": time.Now().Add(24 * time.Hour).Unix(),
}
// Create token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Sign and get the complete encoded token as a string
tokenString, err := token.SignedString([]byte(a.config.JWTSecret))
if err != nil {
return "", fmt.Errorf("failed to sign JWT: %w", err)
}
return tokenString, nil
}
// ValidateToken validates a JWT token without extracting user info
func (a *AuthService) ValidateToken(tokenString string) error {
_, err := a.parseJWT(tokenString)
return err
}
// RefreshToken creates a new token with extended expiration
func (a *AuthService) RefreshToken(tokenString string) (string, error) {
userInfo, err := a.parseJWT(tokenString)
if err != nil {
return "", fmt.Errorf("cannot refresh invalid token: %w", err)
}
// Create new token with same user info but extended expiration
claims := jwt.MapClaims{
"sub": userInfo.ID,
"email": userInfo.Email,
"name": userInfo.Name,
"iss": userInfo.Provider,
"iat": time.Now().Unix(),
"exp": time.Now().Add(24 * time.Hour).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(a.config.JWTSecret))
}
// IsAuthenticated checks if the request has valid authentication
func (a *AuthService) IsAuthenticated(r *http.Request) bool {
userInfo, err := a.ExtractUserFromRequest(r)
return err == nil && userInfo.ID != "anonymous"
}
// RequireAuth middleware that requires authentication
func (a *AuthService) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userInfo, err := a.ExtractUserFromRequest(r)
if err != nil || userInfo.ID == "anonymous" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
// Add user info to request context for use by handlers
ctx := r.Context()
ctx = ContextWithUser(ctx, userInfo)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
// OAuth flow handlers (to be implemented when adding real OAuth)
// HandleOAuthLogin initiates OAuth flow
func (a *AuthService) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) {
provider := r.URL.Query().Get("provider")
if provider == "" {
provider = "google"
}
// TODO: Implement OAuth initiation
// For now, return mock success in dev mode
if a.config.DevMode {
response := map[string]interface{}{
"message": "OAuth login not yet implemented",
"redirect_url": "/auth/callback?code=mock_code&state=mock_state",
"dev_mode": true,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
return
}
http.Error(w, "OAuth not implemented", http.StatusNotImplemented)
}
// HandleOAuthCallback handles OAuth provider callback
func (a *AuthService) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
_ = r.URL.Query().Get("state") // TODO: validate state parameter
// TODO: Implement OAuth token exchange
// For now, return mock token in dev mode
if a.config.DevMode && code != "" {
mockToken, err := a.CreateMockJWT("dev-user", "dev@localhost", "Development User")
if err != nil {
http.Error(w, "Failed to create mock token", http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"token": mockToken,
"dev_mode": true,
"message": "Mock OAuth callback successful",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
return
}
http.Error(w, "OAuth callback not implemented", http.StatusNotImplemented)
}

29
internal/auth/context.go Normal file
View File

@@ -0,0 +1,29 @@
package auth
import "context"
// Context keys for user information
type contextKey string
const (
userInfoContextKey contextKey = "user_info"
)
// ContextWithUser adds user information to request context
func ContextWithUser(ctx context.Context, user *UserInfo) context.Context {
return context.WithValue(ctx, userInfoContextKey, user)
}
// UserFromContext extracts user information from request context
func UserFromContext(ctx context.Context) (*UserInfo, bool) {
user, ok := ctx.Value(userInfoContextKey).(*UserInfo)
return user, ok
}
// UserIDFromContext extracts user ID from request context
func UserIDFromContext(ctx context.Context) string {
if user, ok := UserFromContext(ctx); ok && user != nil {
return user.ID
}
return "anonymous"
}

View File

@@ -212,3 +212,42 @@ func (q *Queries) UpdateContent(ctx context.Context, arg UpdateContentParams) (C
) )
return i, err return i, err
} }
const upsertContent = `-- name: UpsertContent :one
INSERT INTO content (id, site_id, value, type, last_edited_by)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT(id, site_id) DO UPDATE SET
value = EXCLUDED.value,
type = EXCLUDED.type,
last_edited_by = EXCLUDED.last_edited_by
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by
`
type UpsertContentParams struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
Type string `json:"type"`
LastEditedBy string `json:"last_edited_by"`
}
func (q *Queries) UpsertContent(ctx context.Context, arg UpsertContentParams) (Content, error) {
row := q.db.QueryRowContext(ctx, upsertContent,
arg.ID,
arg.SiteID,
arg.Value,
arg.Type,
arg.LastEditedBy,
)
var i Content
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Value,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastEditedBy,
)
return i, err
}

View File

@@ -26,6 +26,7 @@ type Querier interface {
InitializeSchema(ctx context.Context) error InitializeSchema(ctx context.Context) error
InitializeVersionsTable(ctx context.Context) error InitializeVersionsTable(ctx context.Context) error
UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error) UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error)
UpsertContent(ctx context.Context, arg UpsertContentParams) (Content, error)
} }
var _ Querier = (*Queries)(nil) var _ Querier = (*Queries)(nil)

View File

@@ -212,3 +212,42 @@ func (q *Queries) UpdateContent(ctx context.Context, arg UpdateContentParams) (C
) )
return i, err return i, err
} }
const upsertContent = `-- name: UpsertContent :one
INSERT INTO content (id, site_id, value, type, last_edited_by)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(id, site_id) DO UPDATE SET
value = EXCLUDED.value,
type = EXCLUDED.type,
last_edited_by = EXCLUDED.last_edited_by
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by
`
type UpsertContentParams struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
Value string `json:"value"`
Type string `json:"type"`
LastEditedBy string `json:"last_edited_by"`
}
func (q *Queries) UpsertContent(ctx context.Context, arg UpsertContentParams) (Content, error) {
row := q.db.QueryRowContext(ctx, upsertContent,
arg.ID,
arg.SiteID,
arg.Value,
arg.Type,
arg.LastEditedBy,
)
var i Content
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Value,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.LastEditedBy,
)
return i, err
}

View File

@@ -22,6 +22,7 @@ type Querier interface {
InitializeSchema(ctx context.Context) error InitializeSchema(ctx context.Context) error
InitializeVersionsTable(ctx context.Context) error InitializeVersionsTable(ctx context.Context) error
UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error) UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error)
UpsertContent(ctx context.Context, arg UpsertContentParams) (Content, error)
} }
var _ Querier = (*Queries)(nil) var _ Querier = (*Queries)(nil)

2375
lib/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,7 +34,7 @@ export class ApiClient {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-User-ID': this.getCurrentUser() 'Authorization': `Bearer ${this.getAuthToken()}`
}, },
body: JSON.stringify({ value: content }) body: JSON.stringify({ value: content })
}); });
@@ -64,7 +64,7 @@ export class ApiClient {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-User-ID': this.getCurrentUser() 'Authorization': `Bearer ${this.getAuthToken()}`
}, },
body: JSON.stringify({ body: JSON.stringify({
id: contentId, id: contentId,
@@ -113,7 +113,7 @@ export class ApiClient {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-User-ID': this.getCurrentUser() 'Authorization': `Bearer ${this.getAuthToken()}`
}, },
body: JSON.stringify({ body: JSON.stringify({
version_id: versionId 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() { getCurrentUser() {
// This could be enhanced to get from authentication system const token = this.getAuthToken();
return 'anonymous';
// 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;
} }
} }

View File

@@ -116,8 +116,7 @@ export class InsertrEditor {
} }
} }
// Update element content regardless of API success (optimistic update)
this.updateElementContent(meta.element, formData);
// Close form // Close form
this.formRenderer.closeForm(); this.formRenderer.closeForm();
@@ -127,8 +126,7 @@ export class InsertrEditor {
} catch (error) { } catch (error) {
console.error('❌ Error saving content:', error); console.error('❌ Error saving content:', error);
// Still update the UI even if API fails
this.updateElementContent(meta.element, formData);
this.formRenderer.closeForm(); this.formRenderer.closeForm();
} }
} }
@@ -140,44 +138,15 @@ export class InsertrEditor {
return 'link'; return 'link';
} }
if (tagName === 'p' || tagName === 'div') { // ALL text elements use markdown for consistent editing experience
return 'markdown'; return 'markdown';
}
// Default to text for headings and other elements
return 'text';
} }
handleCancel(meta) { handleCancel(meta) {
console.log('❌ Edit cancelled:', meta.contentId); 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() { addEditorStyles() {
const styles = ` const styles = `
.insertr-editing-hover { .insertr-editing-hover {

492
lib/src/ui/Editor.js Normal file
View File

@@ -0,0 +1,492 @@
/**
* Editor - Handles all content types with markdown-first approach
*/
import { markdownConverter } from '../utils/markdown.js';
import { Previewer } from './Previewer.js';
export class Editor {
constructor() {
this.currentOverlay = null;
this.previewer = new Previewer();
}
/**
* Edit any content element with markdown interface
* @param {Object} meta - Element metadata {element, contentId, contentType}
* @param {string|Object} currentContent - Current content value
* @param {Function} onSave - Save callback
* @param {Function} onCancel - Cancel callback
*/
edit(meta, currentContent, onSave, onCancel) {
const { element } = meta;
// Handle both single elements and groups uniformly
const elements = Array.isArray(element) ? element : [element];
const context = new EditContext(elements, currentContent);
// Close any existing editor
this.close();
// Create editor form
const form = this.createForm(context, meta);
const overlay = this.createOverlay(form);
// Position relative to primary element
this.positionForm(context.primaryElement, overlay);
// Setup event handlers
this.setupEventHandlers(form, overlay, context, { onSave, onCancel });
// Show editor
document.body.appendChild(overlay);
this.currentOverlay = overlay;
// Focus textarea
const textarea = form.querySelector('textarea');
if (textarea) {
setTimeout(() => textarea.focus(), 100);
}
return overlay;
}
/**
* Create editing form for any content type
*/
createForm(context, meta) {
const config = this.getFieldConfig(context);
const currentContent = context.extractContent();
const form = document.createElement('div');
form.className = 'insertr-edit-form';
// Build form HTML
let formHTML = `<div class="insertr-form-header">${config.label}</div>`;
// Markdown textarea (always present)
formHTML += this.createMarkdownField(config, currentContent);
// URL field (for links only)
if (config.includeUrl) {
formHTML += this.createUrlField(currentContent);
}
// Form actions
formHTML += `
<div class="insertr-form-actions">
<button type="button" class="insertr-btn-save">Save</button>
<button type="button" class="insertr-btn-cancel">Cancel</button>
<button type="button" class="insertr-btn-history" data-content-id="${meta.contentId}">View History</button>
</div>
`;
form.innerHTML = formHTML;
return form;
}
/**
* Get field configuration for any element type (markdown-first)
*/
getFieldConfig(context) {
const elementCount = context.elements.length;
const primaryElement = context.primaryElement;
const isLink = primaryElement.tagName.toLowerCase() === 'a';
// Multi-element groups
if (elementCount > 1) {
return {
type: 'markdown',
includeUrl: false,
label: `Group Content (${elementCount} elements)`,
rows: Math.max(8, elementCount * 2),
placeholder: 'Edit all content together using markdown...'
};
}
// Single elements - all get markdown by default
const tag = primaryElement.tagName.toLowerCase();
const baseConfig = {
type: 'markdown',
includeUrl: isLink,
placeholder: 'Enter content using markdown...'
};
// Customize by element type
switch (tag) {
case 'h1':
return { ...baseConfig, label: 'Main Headline', rows: 1, placeholder: 'Enter main headline...' };
case 'h2':
return { ...baseConfig, label: 'Subheading', rows: 1, placeholder: 'Enter subheading...' };
case 'h3': case 'h4': case 'h5': case 'h6':
return { ...baseConfig, label: 'Heading', rows: 2, placeholder: 'Enter heading (markdown supported)...' };
case 'p':
return { ...baseConfig, label: 'Content', rows: 4, placeholder: 'Enter content using markdown...' };
case 'span':
return { ...baseConfig, label: 'Text', rows: 2, placeholder: 'Enter text (markdown supported)...' };
case 'button':
return { ...baseConfig, label: 'Button Text', rows: 1, placeholder: 'Enter button text...' };
case 'a':
return { ...baseConfig, label: 'Link', rows: 2, placeholder: 'Enter link text (markdown supported)...' };
default:
return { ...baseConfig, label: 'Content', rows: 3, placeholder: 'Enter content using markdown...' };
}
}
/**
* Create markdown textarea field
*/
createMarkdownField(config, content) {
const textContent = typeof content === 'object' ? content.text || '' : content;
return `
<div class="insertr-form-group">
<textarea class="insertr-form-textarea insertr-markdown-editor" name="content"
rows="${config.rows}"
placeholder="${config.placeholder}">${this.escapeHtml(textContent)}</textarea>
<div class="insertr-form-help">
Supports Markdown formatting (bold, italic, links, etc.)
</div>
</div>
`;
}
/**
* Create URL field for links
*/
createUrlField(content) {
const url = typeof content === 'object' ? content.url || '' : '';
return `
<div class="insertr-form-group">
<label class="insertr-form-label">Link URL:</label>
<input type="url" class="insertr-form-input" name="url"
value="${this.escapeHtml(url)}"
placeholder="https://example.com">
</div>
`;
}
/**
* Setup event handlers
*/
setupEventHandlers(form, overlay, context, { onSave, onCancel }) {
const textarea = form.querySelector('textarea');
const urlInput = form.querySelector('input[name="url"]');
const saveBtn = form.querySelector('.insertr-btn-save');
const cancelBtn = form.querySelector('.insertr-btn-cancel');
const historyBtn = form.querySelector('.insertr-btn-history');
// Initialize previewer
this.previewer.setActiveContext(context);
// Setup live preview for content changes
if (textarea) {
textarea.addEventListener('input', () => {
const content = this.extractFormData(form);
this.previewer.schedulePreview(context, content);
});
}
// Setup live preview for URL changes (links only)
if (urlInput) {
urlInput.addEventListener('input', () => {
const content = this.extractFormData(form);
this.previewer.schedulePreview(context, content);
});
}
// Save handler
if (saveBtn) {
saveBtn.addEventListener('click', () => {
const content = this.extractFormData(form);
// Apply final content to elements
context.applyContent(content);
// Update stored original content to match current state
// This makes the saved content the new baseline for future edits
context.updateOriginalContent();
// Clear preview styling (won't restore content since original matches current)
this.previewer.clearPreview();
// Callback with the content
onSave(content);
this.close();
});
}
// Cancel handler
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
this.previewer.clearPreview();
onCancel();
this.close();
});
}
// History handler
if (historyBtn) {
historyBtn.addEventListener('click', () => {
const contentId = historyBtn.getAttribute('data-content-id');
console.log('Version history not implemented yet for:', contentId);
// TODO: Implement version history integration
});
}
// ESC key handler
const keyHandler = (e) => {
if (e.key === 'Escape') {
this.previewer.clearPreview();
onCancel();
this.close();
document.removeEventListener('keydown', keyHandler);
}
};
document.addEventListener('keydown', keyHandler);
// Click outside handler
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
this.previewer.clearPreview();
onCancel();
this.close();
}
});
}
/**
* Extract form data consistently
*/
extractFormData(form) {
const textarea = form.querySelector('textarea[name="content"]');
const urlInput = form.querySelector('input[name="url"]');
const content = textarea ? textarea.value : '';
if (urlInput) {
// Link content
return {
text: content,
url: urlInput.value
};
}
// Regular content
return content;
}
/**
* Create overlay with backdrop
*/
createOverlay(form) {
const overlay = document.createElement('div');
overlay.className = 'insertr-form-overlay';
overlay.appendChild(form);
return overlay;
}
/**
* Position form relative to primary element
*/
positionForm(element, overlay) {
const rect = element.getBoundingClientRect();
const form = overlay.querySelector('.insertr-edit-form');
const viewportWidth = window.innerWidth;
// Calculate optimal width
let formWidth;
if (viewportWidth < 768) {
formWidth = Math.min(viewportWidth - 40, 500);
} else {
const minComfortableWidth = 600;
const maxWidth = Math.min(viewportWidth * 0.9, 800);
formWidth = Math.max(minComfortableWidth, Math.min(rect.width * 1.5, maxWidth));
}
form.style.width = `${formWidth}px`;
// Position below element
const top = rect.bottom + window.scrollY + 10;
const centerLeft = rect.left + window.scrollX + (rect.width / 2) - (formWidth / 2);
const minLeft = 20;
const maxLeft = window.innerWidth - formWidth - 20;
const left = Math.max(minLeft, Math.min(centerLeft, maxLeft));
overlay.style.position = 'absolute';
overlay.style.top = `${top}px`;
overlay.style.left = `${left}px`;
overlay.style.zIndex = '10000';
// Ensure visibility
this.ensureModalVisible(overlay);
}
/**
* Ensure modal is visible by scrolling if needed
*/
ensureModalVisible(overlay) {
requestAnimationFrame(() => {
const modal = overlay.querySelector('.insertr-edit-form');
const modalRect = modal.getBoundingClientRect();
const viewportHeight = window.innerHeight;
if (modalRect.bottom > viewportHeight) {
const scrollAmount = modalRect.bottom - viewportHeight + 20;
window.scrollBy({
top: scrollAmount,
behavior: 'smooth'
});
}
});
}
/**
* Close current editor
*/
close() {
if (this.previewer) {
this.previewer.clearPreview();
}
if (this.currentOverlay) {
this.currentOverlay.remove();
this.currentOverlay = null;
}
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
if (typeof text !== 'string') return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
/**
* EditContext - Represents content elements for editing
*/
class EditContext {
constructor(elements, currentContent) {
this.elements = elements;
this.primaryElement = elements[0];
this.originalContent = null;
this.currentContent = currentContent;
}
/**
* Extract content from elements in markdown format
*/
extractContent() {
if (this.elements.length === 1) {
const element = this.elements[0];
// Handle links specially
if (element.tagName.toLowerCase() === 'a') {
return {
text: markdownConverter.htmlToMarkdown(element.innerHTML),
url: element.href
};
}
// Single element - convert to markdown
return markdownConverter.htmlToMarkdown(element.innerHTML);
} else {
// Multiple elements - use group extraction
return markdownConverter.extractGroupMarkdown(this.elements);
}
}
/**
* Apply content to elements from markdown/object
*/
applyContent(content) {
if (this.elements.length === 1) {
const element = this.elements[0];
// Handle links specially
if (element.tagName.toLowerCase() === 'a' && typeof content === 'object') {
element.innerHTML = markdownConverter.markdownToHtml(content.text || '');
if (content.url) {
element.href = content.url;
}
return;
}
// Single element - convert markdown to HTML
const html = markdownConverter.markdownToHtml(content);
element.innerHTML = html;
} else {
// Multiple elements - use group update
markdownConverter.updateGroupElements(this.elements, content);
}
}
/**
* Store original content for preview restoration
*/
storeOriginalContent() {
this.originalContent = this.elements.map(el => ({
innerHTML: el.innerHTML,
href: el.href // Store href for links
}));
}
/**
* Restore original content (for preview cancellation)
*/
restoreOriginalContent() {
if (this.originalContent) {
this.elements.forEach((el, index) => {
if (this.originalContent[index] !== undefined) {
el.innerHTML = this.originalContent[index].innerHTML;
if (this.originalContent[index].href) {
el.href = this.originalContent[index].href;
}
}
});
}
}
/**
* Update original content to match current element state (after save)
* This makes the current content the new baseline for future cancellations
*/
updateOriginalContent() {
this.originalContent = this.elements.map(el => ({
innerHTML: el.innerHTML,
href: el.href // Store href for links
}));
}
/**
* Apply preview styling to all elements
*/
applyPreviewStyling() {
this.elements.forEach(el => {
el.classList.add('insertr-preview-active');
});
// Also apply to containers if they're groups
if (this.primaryElement.classList.contains('insertr-group')) {
this.primaryElement.classList.add('insertr-preview-active');
}
}
/**
* Remove preview styling from all elements
*/
removePreviewStyling() {
this.elements.forEach(el => {
el.classList.remove('insertr-preview-active');
});
// Also remove from containers
if (this.primaryElement.classList.contains('insertr-group')) {
this.primaryElement.classList.remove('insertr-preview-active');
}
}
}

157
lib/src/ui/Previewer.js Normal file
View File

@@ -0,0 +1,157 @@
/**
* Previewer - Handles live preview for all content types
*/
import { markdownConverter } from '../utils/markdown.js';
export class Previewer {
constructor() {
this.previewTimeout = null;
this.activeContext = null;
this.resizeObserver = null;
this.onHeightChange = null;
}
/**
* Set the active editing context for preview
*/
setActiveContext(context) {
this.clearPreview();
this.activeContext = context;
this.startResizeObserver();
}
/**
* Schedule a preview update with debouncing
*/
schedulePreview(context, content) {
// Clear existing timeout
if (this.previewTimeout) {
clearTimeout(this.previewTimeout);
}
// Schedule new preview with 500ms debounce
this.previewTimeout = setTimeout(() => {
this.updatePreview(context, content);
}, 500);
}
/**
* Update preview with new content
*/
updatePreview(context, content) {
// Store original content if first preview
if (!context.originalContent) {
context.storeOriginalContent();
}
// Apply preview content to elements
this.applyPreviewContent(context, content);
context.applyPreviewStyling();
}
/**
* Apply preview content to context elements
*/
applyPreviewContent(context, content) {
if (context.elements.length === 1) {
const element = context.elements[0];
// Handle links specially
if (element.tagName.toLowerCase() === 'a') {
if (typeof content === 'object') {
// Update link text (markdown to HTML)
if (content.text !== undefined) {
const html = markdownConverter.markdownToHtml(content.text);
element.innerHTML = html;
}
// Update link URL
if (content.url !== undefined && content.url.trim()) {
element.href = content.url;
}
} else if (content && content.trim()) {
// Just markdown content for link text
const html = markdownConverter.markdownToHtml(content);
element.innerHTML = html;
}
return;
}
// Regular single element
if (content && content.trim()) {
const html = markdownConverter.markdownToHtml(content);
element.innerHTML = html;
}
} else {
// Multiple elements - use group update
if (content && content.trim()) {
markdownConverter.updateGroupElements(context.elements, content);
}
}
}
/**
* Clear all preview state and restore original content
*/
clearPreview() {
if (this.activeContext) {
this.activeContext.restoreOriginalContent();
this.activeContext.removePreviewStyling();
this.activeContext = null;
}
if (this.previewTimeout) {
clearTimeout(this.previewTimeout);
this.previewTimeout = null;
}
this.stopResizeObserver();
}
/**
* Start observing element size changes for modal repositioning
*/
startResizeObserver() {
this.stopResizeObserver();
if (this.activeContext) {
this.resizeObserver = new ResizeObserver(() => {
// Handle height changes for modal repositioning
if (this.onHeightChange) {
this.onHeightChange(this.activeContext.primaryElement);
}
});
// Observe all elements in the context
this.activeContext.elements.forEach(el => {
this.resizeObserver.observe(el);
});
}
}
/**
* Stop observing element size changes
*/
stopResizeObserver() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
}
/**
* Set callback for height changes (for modal repositioning)
*/
setHeightChangeCallback(callback) {
this.onHeightChange = callback;
}
/**
* Get unique element ID for tracking
*/
getElementId(element) {
if (!element._insertrId) {
element._insertrId = 'insertr_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
return element._insertrId;
}
}

View File

@@ -1,270 +1,35 @@
import { markdownConverter } from '../utils/markdown.js';
import { MarkdownEditor } from './markdown-editor.js';
/** /**
* LivePreviewManager - Handles debounced live preview updates for non-markdown elements * InsertrFormRenderer - Form renderer using markdown-first approach
* Thin wrapper around the Editor system
*/ */
class LivePreviewManager { import { Editor } from './Editor.js';
constructor() {
this.previewTimeouts = new Map();
this.activeElement = null;
this.originalContent = null;
this.originalStyles = null;
this.resizeObserver = null;
this.onHeightChangeCallback = null;
}
schedulePreview(element, newValue, elementType) {
const elementId = this.getElementId(element);
// Clear existing timeout
if (this.previewTimeouts.has(elementId)) {
clearTimeout(this.previewTimeouts.get(elementId));
}
// Schedule new preview update with 500ms debounce
const timeoutId = setTimeout(() => {
this.updatePreview(element, newValue, elementType);
}, 500);
this.previewTimeouts.set(elementId, timeoutId);
}
updatePreview(element, newValue, elementType) {
// Store original content if first preview
if (!this.originalContent && this.activeElement === element) {
this.originalContent = this.extractOriginalContent(element, elementType);
}
// Apply preview styling and content
this.applyPreviewContent(element, newValue, elementType);
// ResizeObserver will automatically detect height changes
}
extractOriginalContent(element, elementType) {
switch (elementType) {
case 'link':
return {
text: element.textContent,
url: element.href
};
default:
return element.textContent;
}
}
applyPreviewContent(element, newValue, elementType) {
// Add preview indicator
element.classList.add('insertr-preview-active');
// Update content based on element type
switch (elementType) {
case 'text':
case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6':
case 'span': case 'button':
if (newValue && newValue.trim()) {
element.textContent = newValue;
}
break;
case 'textarea':
case 'p':
if (newValue && newValue.trim()) {
element.textContent = newValue;
}
break;
case 'link':
if (typeof newValue === 'object') {
if (newValue.text !== undefined && newValue.text.trim()) {
element.textContent = newValue.text;
}
if (newValue.url !== undefined && newValue.url.trim()) {
element.href = newValue.url;
}
} else if (newValue && newValue.trim()) {
element.textContent = newValue;
}
break;
}
}
clearPreview(element) {
if (!element) return;
const elementId = this.getElementId(element);
// Clear any pending preview
if (this.previewTimeouts.has(elementId)) {
clearTimeout(this.previewTimeouts.get(elementId));
this.previewTimeouts.delete(elementId);
}
// Stop ResizeObserver
this.stopResizeObserver();
// Restore original content
if (this.originalContent && element === this.activeElement) {
this.restoreOriginalContent(element);
}
// Remove preview styling
element.classList.remove('insertr-preview-active');
this.activeElement = null;
this.originalContent = null;
}
restoreOriginalContent(element) {
if (!this.originalContent) return;
if (typeof this.originalContent === 'object') {
// Link element
element.textContent = this.originalContent.text;
if (this.originalContent.url) {
element.href = this.originalContent.url;
}
} else {
// Text element
element.textContent = this.originalContent;
}
}
getElementId(element) {
// Create unique ID for element tracking
if (!element._insertrId) {
element._insertrId = 'insertr_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
return element._insertrId;
}
setActiveElement(element) {
this.activeElement = element;
this.originalContent = null;
this.startResizeObserver(element);
}
setHeightChangeCallback(callback) {
this.onHeightChangeCallback = callback;
}
startResizeObserver(element) {
// Clean up existing observer
this.stopResizeObserver();
// Create new ResizeObserver for this element
this.resizeObserver = new ResizeObserver(entries => {
// Use requestAnimationFrame to ensure smooth updates
requestAnimationFrame(() => {
if (this.onHeightChangeCallback && element === this.activeElement) {
this.onHeightChangeCallback(element);
}
});
});
// Start observing the element
this.resizeObserver.observe(element);
}
stopResizeObserver() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
}
}
/**
* InsertrFormRenderer - Professional modal editing forms with live preview
* Enhanced with debounced live preview and comfortable input sizing
*/
export class InsertrFormRenderer { export class InsertrFormRenderer {
constructor(apiClient = null) { constructor(apiClient = null) {
this.apiClient = apiClient; this.apiClient = apiClient;
this.currentOverlay = null; this.editor = new Editor();
this.previewManager = new LivePreviewManager();
this.markdownEditor = new MarkdownEditor();
this.setupStyles(); this.setupStyles();
} }
/** /**
* Create and show edit form for content element * Show edit form for any content element
* @param {Object} meta - Element metadata {element, contentId, contentType} * @param {Object} meta - Element metadata {element, contentId, contentType}
* @param {string} currentContent - Current content value * @param {string|Object} currentContent - Current content value
* @param {Function} onSave - Save callback * @param {Function} onSave - Save callback
* @param {Function} onCancel - Cancel callback * @param {Function} onCancel - Cancel callback
*/ */
showEditForm(meta, currentContent, onSave, onCancel) { showEditForm(meta, currentContent, onSave, onCancel) {
// Close any existing form const { element } = meta;
this.closeForm();
const { element, contentId, contentType } = meta; // Handle insertr-group elements by getting their viable children
const config = this.getFieldConfig(element, contentType);
// Route to unified markdown editor for markdown content
if (config.type === 'markdown') {
return this.markdownEditor.edit(element, onSave, onCancel);
}
// Route to unified markdown editor for group elements
if (element.classList.contains('insertr-group')) { if (element.classList.contains('insertr-group')) {
const children = this.getGroupChildren(element); const children = this.getGroupChildren(element);
return this.markdownEditor.edit(children, onSave, onCancel); const groupMeta = { ...meta, element: children };
return this.editor.edit(groupMeta, currentContent, onSave, onCancel);
} }
// Handle non-markdown elements (text, links, etc.) with legacy system // All other elements use the editor directly
return this.showLegacyEditForm(meta, currentContent, onSave, onCancel); return this.editor.edit(meta, currentContent, onSave, onCancel);
}
/**
* Show legacy edit form for non-markdown elements (text, links, etc.)
*/
showLegacyEditForm(meta, currentContent, onSave, onCancel) {
const { element, contentId, contentType } = meta;
const config = this.getFieldConfig(element, contentType);
// Initialize preview manager for this element
this.previewManager.setActiveElement(element);
// Set up height change callback
this.previewManager.setHeightChangeCallback((changedElement) => {
this.repositionModal(changedElement, overlay);
});
// Create form
const form = this.createEditForm(contentId, config, currentContent);
// Create overlay with backdrop
const overlay = this.createOverlay(form);
// Position form with enhanced sizing
this.positionForm(element, overlay);
// Setup event handlers with live preview
this.setupFormHandlers(form, overlay, element, config, { onSave, onCancel });
// Show form
document.body.appendChild(overlay);
this.currentOverlay = overlay;
// Focus first input
const firstInput = form.querySelector('input, textarea');
if (firstInput) {
setTimeout(() => firstInput.focus(), 100);
}
return overlay;
} }
/** /**
@@ -273,7 +38,7 @@ export class InsertrFormRenderer {
getGroupChildren(groupElement) { getGroupChildren(groupElement) {
const children = []; const children = [];
for (const child of groupElement.children) { for (const child of groupElement.children) {
// Skip elements that don't have text content // Skip elements that don't have meaningful text content
if (child.textContent.trim().length > 0) { if (child.textContent.trim().length > 0) {
children.push(child); children.push(child);
} }
@@ -285,190 +50,28 @@ export class InsertrFormRenderer {
* Close current form * Close current form
*/ */
closeForm() { closeForm() {
// Close markdown editor if active this.editor.close();
this.markdownEditor.close();
// Clear any active legacy previews
if (this.previewManager.activeElement) {
this.previewManager.clearPreview(this.previewManager.activeElement);
}
if (this.currentOverlay) {
this.currentOverlay.remove();
this.currentOverlay = null;
}
} }
/** /**
* Generate field configuration based on element * Show version history modal (placeholder for future implementation)
*/
getFieldConfig(element, contentType) {
const tagName = element.tagName.toLowerCase();
const classList = Array.from(element.classList);
// Default configurations based on element type - using markdown for rich content
const configs = {
h1: { type: 'text', label: 'Headline', maxLength: 60, placeholder: 'Enter headline...' },
h2: { type: 'text', label: 'Subheading', maxLength: 80, placeholder: 'Enter subheading...' },
h3: { type: 'markdown', label: 'Section Title', rows: 2, placeholder: 'Enter title (markdown supported)...' },
h4: { type: 'markdown', label: 'Title', rows: 2, placeholder: 'Enter title (markdown supported)...' },
h5: { type: 'markdown', label: 'Title', rows: 2, placeholder: 'Enter title (markdown supported)...' },
h6: { type: 'markdown', label: 'Title', rows: 2, placeholder: 'Enter title (markdown supported)...' },
p: { type: 'markdown', label: 'Content', rows: 4, placeholder: 'Enter content using markdown...' },
a: { type: 'link', label: 'Link', placeholder: 'Enter link text...', includeUrl: true },
span: { type: 'markdown', label: 'Text', rows: 2, placeholder: 'Enter text (markdown supported)...' },
button: { type: 'text', label: 'Button Text', placeholder: 'Enter button text...' },
};
let config = configs[tagName] || { type: 'text', label: 'Text', placeholder: 'Enter text...' };
// CSS class enhancements
if (classList.includes('lead')) {
config = { ...config, label: 'Lead Paragraph', rows: 4, placeholder: 'Enter lead paragraph...' };
}
// Override with contentType from CLI if specified
if (contentType === 'markdown') {
config = { ...config, type: 'markdown', label: 'Markdown Content', rows: 8 };
}
return config;
}
/**
* Create form HTML structure
*/
createEditForm(contentId, config, currentContent) {
const form = document.createElement('div');
form.className = 'insertr-edit-form';
let formHTML = `<div class="insertr-form-header">${config.label}</div>`;
if (config.type === 'markdown') {
formHTML += this.createMarkdownField(config, currentContent);
} else if (config.type === 'link' && config.includeUrl) {
formHTML += this.createLinkField(config, currentContent);
} else if (config.type === 'textarea') {
formHTML += this.createTextareaField(config, currentContent);
} else {
formHTML += this.createTextField(config, currentContent);
}
// Form buttons
formHTML += `
<div class="insertr-form-actions">
<button type="button" class="insertr-btn-save">Save</button>
<button type="button" class="insertr-btn-cancel">Cancel</button>
<button type="button" class="insertr-btn-history" data-content-id="${contentId}">View History</button>
</div>
`;
form.innerHTML = formHTML;
return form;
}
/**
* Create markdown field with preview
*/
createMarkdownField(config, currentContent) {
return `
<div class="insertr-form-group">
<textarea class="insertr-form-textarea insertr-markdown-editor" name="content"
rows="${config.rows || 8}"
placeholder="${config.placeholder}">${this.escapeHtml(currentContent)}</textarea>
<div class="insertr-form-help">
Supports Markdown formatting (bold, italic, links, etc.)
</div>
</div>
`;
}
/**
* Create link field (text + URL)
*/
createLinkField(config, currentContent) {
const linkText = typeof currentContent === 'object' ? currentContent.text || '' : currentContent;
const linkUrl = typeof currentContent === 'object' ? currentContent.url || '' : '';
return `
<div class="insertr-form-group">
<label class="insertr-form-label">Link Text:</label>
<input type="text" class="insertr-form-input" name="text"
value="${this.escapeHtml(linkText)}"
placeholder="${config.placeholder}"
maxlength="${config.maxLength || 200}">
</div>
<div class="insertr-form-group">
<label class="insertr-form-label">Link URL:</label>
<input type="url" class="insertr-form-input" name="url"
value="${this.escapeHtml(linkUrl)}"
placeholder="https://example.com">
</div>
`;
}
/**
* Create textarea field
*/
createTextareaField(config, currentContent) {
const content = typeof currentContent === 'object' ? currentContent.text || '' : currentContent;
return `
<div class="insertr-form-group">
<textarea class="insertr-form-textarea" name="content"
rows="${config.rows || 3}"
placeholder="${config.placeholder}"
maxlength="${config.maxLength || 1000}">${this.escapeHtml(content)}</textarea>
</div>
`;
}
/**
* Create text input field
*/
createTextField(config, currentContent) {
const content = typeof currentContent === 'object' ? currentContent.text || '' : currentContent;
return `
<div class="insertr-form-group">
<input type="text" class="insertr-form-input" name="content"
value="${this.escapeHtml(content)}"
placeholder="${config.placeholder}"
maxlength="${config.maxLength || 200}">
</div>
`;
}
/**
* Create overlay with backdrop
*/
createOverlay(form) {
const overlay = document.createElement('div');
overlay.className = 'insertr-form-overlay';
overlay.appendChild(form);
return overlay;
}
/**
* Get element ID for preview tracking
*/
getElementId(element) {
return element.id || element.getAttribute('data-content-id') ||
`element-${element.tagName}-${Date.now()}`;
}
/**
* Show version history modal
*/ */
async showVersionHistory(contentId, element, onRestore) { async showVersionHistory(contentId, element, onRestore) {
try { try {
// Get version history from API (we'll need to pass this in) // Get version history from API
const apiClient = this.getApiClient(); const apiClient = this.getApiClient();
if (!apiClient) {
console.warn('No API client configured for version history');
return;
}
const versions = await apiClient.getContentVersions(contentId); const versions = await apiClient.getContentVersions(contentId);
// Create version history modal // Create version history modal
const historyModal = this.createVersionHistoryModal(contentId, versions, onRestore); const historyModal = this.createVersionHistoryModal(contentId, versions, onRestore);
document.body.appendChild(historyModal); document.body.appendChild(historyModal);
// Focus and setup handlers // Setup handlers
this.setupVersionHistoryHandlers(historyModal, contentId); this.setupVersionHistoryHandlers(historyModal, contentId);
} catch (error) { } catch (error) {
@@ -478,7 +81,7 @@ export class InsertrFormRenderer {
} }
/** /**
* Create version history modal * Create version history modal (simplified placeholder)
*/ */
createVersionHistoryModal(contentId, versions, onRestore) { createVersionHistoryModal(contentId, versions, onRestore) {
const modal = document.createElement('div'); const modal = document.createElement('div');
@@ -547,7 +150,6 @@ export class InsertrFormRenderer {
if (await this.confirmRestore()) { if (await this.confirmRestore()) {
await this.restoreVersion(contentId, versionId); await this.restoreVersion(contentId, versionId);
modal.remove(); modal.remove();
// Refresh the current form or close it
this.closeForm(); this.closeForm();
} }
}); });
@@ -626,177 +228,6 @@ export class InsertrFormRenderer {
return this.apiClient || window.insertrAPIClient || null; return this.apiClient || window.insertrAPIClient || null;
} }
/**
* Reposition modal based on current element size and ensure visibility
*/
repositionModal(element, overlay) {
// Wait for next frame to ensure DOM is updated
requestAnimationFrame(() => {
const rect = element.getBoundingClientRect();
const form = overlay.querySelector('.insertr-edit-form');
// Calculate new position below the current element boundaries
const newTop = rect.bottom + window.scrollY + 10;
// Update modal position
overlay.style.top = `${newTop}px`;
// After repositioning, ensure modal is still visible
this.ensureModalVisible(element, overlay);
});
}
/**
* Ensure modal is fully visible by scrolling viewport if necessary
*/
ensureModalVisible(element, overlay) {
// Wait for next frame to ensure DOM is updated
requestAnimationFrame(() => {
const modal = overlay.querySelector('.insertr-edit-form');
const modalRect = modal.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Calculate if modal extends below viewport
const modalBottom = modalRect.bottom;
const viewportBottom = viewportHeight;
if (modalBottom > viewportBottom) {
// Calculate scroll amount needed with some padding
const scrollAmount = modalBottom - viewportBottom + 20;
window.scrollBy({
top: scrollAmount,
behavior: 'smooth'
});
}
});
}
/**
* Setup form event handlers
*/
setupFormHandlers(form, overlay, element, config, { onSave, onCancel }) {
const saveBtn = form.querySelector('.insertr-btn-save');
const cancelBtn = form.querySelector('.insertr-btn-cancel');
const elementType = this.getElementType(element, config);
// Setup live preview for input changes
this.setupLivePreview(form, element, elementType);
if (saveBtn) {
saveBtn.addEventListener('click', () => {
// Clear preview before saving (makes changes permanent)
this.previewManager.clearPreview(element);
const formData = this.extractFormData(form);
onSave(formData);
this.closeForm();
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
// Clear preview to restore original content
this.previewManager.clearPreview(element);
onCancel();
this.closeForm();
});
}
// Version History button
const historyBtn = form.querySelector('.insertr-btn-history');
if (historyBtn) {
historyBtn.addEventListener('click', () => {
const contentId = historyBtn.getAttribute('data-content-id');
this.showVersionHistory(contentId, element, onSave);
});
}
// ESC key to cancel
const keyHandler = (e) => {
if (e.key === 'Escape') {
this.previewManager.clearPreview(element);
onCancel();
this.closeForm();
document.removeEventListener('keydown', keyHandler);
}
};
document.addEventListener('keydown', keyHandler);
// Click outside to cancel
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
this.previewManager.clearPreview(element);
onCancel();
this.closeForm();
}
});
}
setupLivePreview(form, element, elementType) {
// Get all input elements that should trigger preview updates
const inputs = form.querySelectorAll('input, textarea');
inputs.forEach(input => {
input.addEventListener('input', () => {
const newValue = this.extractInputValue(form, elementType);
this.previewManager.schedulePreview(element, newValue, elementType);
});
});
}
extractInputValue(form, elementType) {
// Extract current form values for preview
const textInput = form.querySelector('input[name="text"]');
const urlInput = form.querySelector('input[name="url"]');
const contentInput = form.querySelector('input[name="content"], textarea[name="content"]');
if (textInput && urlInput) {
// Link field
return {
text: textInput.value,
url: urlInput.value
};
} else if (contentInput) {
// Text or textarea field
return contentInput.value;
}
return '';
}
getElementType(element, config) {
// Determine element type for preview handling
if (config.type === 'link') return 'link';
if (config.type === 'markdown') return 'markdown';
if (config.type === 'textarea') return 'textarea';
const tagName = element.tagName.toLowerCase();
return tagName === 'p' ? 'p' : 'text';
}
/**
* Extract form data
*/
extractFormData(form) {
const data = {};
// Handle different field types
const textInput = form.querySelector('input[name="text"]');
const urlInput = form.querySelector('input[name="url"]');
const contentInput = form.querySelector('input[name="content"], textarea[name="content"]');
if (textInput && urlInput) {
// Link field
data.text = textInput.value;
data.url = urlInput.value;
} else if (contentInput) {
// Text or textarea field
data.text = contentInput.value;
}
return data;
}
/** /**
* Escape HTML to prevent XSS * Escape HTML to prevent XSS
*/ */
@@ -808,10 +239,11 @@ export class InsertrFormRenderer {
} }
/** /**
* Setup form styles * Setup form styles (consolidated and simplified)
*/ */
setupStyles() { setupStyles() {
const styles = ` const styles = `
/* Overlay and Form Container */
.insertr-form-overlay { .insertr-form-overlay {
position: absolute; position: absolute;
z-index: 10000; z-index: 10000;
@@ -826,8 +258,11 @@ export class InsertrFormRenderer {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-width: 600px;
max-width: 800px;
} }
/* Form Header */
.insertr-form-header { .insertr-form-header {
font-weight: 600; font-weight: 600;
color: #1f2937; color: #1f2937;
@@ -839,6 +274,7 @@ export class InsertrFormRenderer {
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
/* Form Groups and Fields */
.insertr-form-group { .insertr-form-group {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -874,6 +310,7 @@ export class InsertrFormRenderer {
box-shadow: 0 0 0 3px rgba(0, 124, 186, 0.1); box-shadow: 0 0 0 3px rgba(0, 124, 186, 0.1);
} }
/* Markdown Editor Styling */
.insertr-form-textarea { .insertr-form-textarea {
min-height: 120px; min-height: 120px;
resize: vertical; resize: vertical;
@@ -888,6 +325,7 @@ export class InsertrFormRenderer {
background-color: #f8fafc; background-color: #f8fafc;
} }
/* Form Actions */
.insertr-form-actions { .insertr-form-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -929,6 +367,22 @@ export class InsertrFormRenderer {
background: #4b5563; background: #4b5563;
} }
.insertr-btn-history {
background: #6f42c1;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
font-size: 0.875rem;
}
.insertr-btn-history:hover {
background: #5a359a;
}
.insertr-form-help { .insertr-form-help {
font-size: 0.75rem; font-size: 0.75rem;
color: #6b7280; color: #6b7280;
@@ -960,12 +414,7 @@ export class InsertrFormRenderer {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
} }
/* Enhanced modal sizing for comfortable editing */ /* Responsive Design */
.insertr-edit-form {
min-width: 600px; /* Ensures ~70 character width */
max-width: 800px;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.insertr-edit-form { .insertr-edit-form {
min-width: 90vw; min-width: 90vw;
@@ -979,10 +428,159 @@ export class InsertrFormRenderer {
} }
} }
/* Enhanced input styling for comfortable editing */ /* Version History Modal Styles */
.insertr-form-input { .insertr-version-modal {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace; position: fixed;
letter-spacing: 0.02em; top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10001;
}
.insertr-version-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.insertr-version-content-modal {
background: white;
border-radius: 8px;
max-width: 600px;
width: 100%;
max-height: 80vh;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
}
.insertr-version-header {
padding: 20px 20px 0;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.insertr-version-header h3 {
margin: 0 0 20px;
color: #333;
font-size: 18px;
}
.insertr-btn-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.insertr-btn-close:hover {
color: #333;
}
.insertr-version-list {
overflow-y: auto;
padding: 20px;
flex: 1;
}
.insertr-version-item {
border: 1px solid #e1e5e9;
border-radius: 6px;
padding: 16px;
margin-bottom: 12px;
background: #f8f9fa;
}
.insertr-version-meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
font-size: 13px;
}
.insertr-version-label {
font-weight: 600;
color: #0969da;
}
.insertr-version-date {
color: #656d76;
}
.insertr-version-user {
color: #656d76;
}
.insertr-version-content {
margin-bottom: 12px;
padding: 8px;
background: white;
border-radius: 4px;
font-family: monospace;
font-size: 14px;
color: #24292f;
white-space: pre-wrap;
}
.insertr-version-actions {
display: flex;
gap: 8px;
}
.insertr-btn-restore {
background: #0969da;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
}
.insertr-btn-restore:hover {
background: #0860ca;
}
.insertr-btn-view-diff {
background: #f6f8fa;
color: #24292f;
border: 1px solid #d1d9e0;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
}
.insertr-btn-view-diff:hover {
background: #f3f4f6;
}
.insertr-version-empty {
text-align: center;
color: #656d76;
font-style: italic;
padding: 40px 20px;
} }
`; `;

View File

@@ -1,446 +0,0 @@
/**
* Unified Markdown Editor - Handles both single and multiple element editing
*/
import { markdownConverter } from '../utils/markdown.js';
export class MarkdownEditor {
constructor() {
this.currentOverlay = null;
this.previewManager = new MarkdownPreviewManager();
}
/**
* Edit elements with markdown - unified interface for single or multiple elements
* @param {HTMLElement|HTMLElement[]} elements - Element(s) to edit
* @param {Function} onSave - Save callback
* @param {Function} onCancel - Cancel callback
*/
edit(elements, onSave, onCancel) {
// Normalize to array
const elementArray = Array.isArray(elements) ? elements : [elements];
const context = new MarkdownContext(elementArray);
// Close any existing editor
this.close();
// Create unified editor form
const form = this.createMarkdownForm(context);
const overlay = this.createOverlay(form);
// Position relative to primary element
this.positionForm(context.primaryElement, overlay);
// Setup unified event handlers
this.setupEventHandlers(form, overlay, context, { onSave, onCancel });
// Show editor
document.body.appendChild(overlay);
this.currentOverlay = overlay;
// Focus textarea
const textarea = form.querySelector('textarea');
if (textarea) {
setTimeout(() => textarea.focus(), 100);
}
return overlay;
}
/**
* Create markdown editing form
*/
createMarkdownForm(context) {
const config = this.getMarkdownConfig(context);
const currentContent = context.extractMarkdown();
const form = document.createElement('div');
form.className = 'insertr-edit-form';
form.innerHTML = `
<div class="insertr-form-header">${config.label}</div>
<div class="insertr-form-group">
<textarea class="insertr-form-textarea insertr-markdown-editor" name="content"
rows="${config.rows}"
placeholder="${config.placeholder}">${this.escapeHtml(currentContent)}</textarea>
<div class="insertr-form-help">
Supports Markdown formatting (bold, italic, links, etc.)
</div>
</div>
<div class="insertr-form-actions">
<button type="button" class="insertr-btn-save">Save</button>
<button type="button" class="insertr-btn-cancel">Cancel</button>
</div>
`;
return form;
}
/**
* Get markdown configuration based on context
*/
getMarkdownConfig(context) {
const elementCount = context.elements.length;
if (elementCount === 1) {
const element = context.elements[0];
const tag = element.tagName.toLowerCase();
switch (tag) {
case 'h3': case 'h4': case 'h5': case 'h6':
return {
label: 'Title (Markdown)',
rows: 2,
placeholder: 'Enter title using markdown...'
};
case 'p':
return {
label: 'Content (Markdown)',
rows: 4,
placeholder: 'Enter content using markdown...'
};
case 'span':
return {
label: 'Text (Markdown)',
rows: 2,
placeholder: 'Enter text using markdown...'
};
default:
return {
label: 'Content (Markdown)',
rows: 3,
placeholder: 'Enter content using markdown...'
};
}
} else {
return {
label: `Group Content (${elementCount} elements)`,
rows: Math.max(8, elementCount * 2),
placeholder: 'Edit all content together using markdown...'
};
}
}
/**
* Setup unified event handlers
*/
setupEventHandlers(form, overlay, context, { onSave, onCancel }) {
const textarea = form.querySelector('textarea');
const saveBtn = form.querySelector('.insertr-btn-save');
const cancelBtn = form.querySelector('.insertr-btn-cancel');
// Initialize preview manager
this.previewManager.setActiveContext(context);
// Setup debounced live preview
if (textarea) {
textarea.addEventListener('input', () => {
const markdown = textarea.value;
this.previewManager.schedulePreview(context, markdown);
});
}
// Save handler
if (saveBtn) {
saveBtn.addEventListener('click', () => {
const markdown = textarea.value;
// Update elements with final content
context.applyMarkdown(markdown);
// Clear preview styling
this.previewManager.clearPreview();
// Callback and close
onSave({ text: markdown });
this.close();
});
}
// Cancel handler
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
this.previewManager.clearPreview();
onCancel();
this.close();
});
}
// ESC key handler
const keyHandler = (e) => {
if (e.key === 'Escape') {
this.previewManager.clearPreview();
onCancel();
this.close();
document.removeEventListener('keydown', keyHandler);
}
};
document.addEventListener('keydown', keyHandler);
// Click outside handler
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
this.previewManager.clearPreview();
onCancel();
this.close();
}
});
}
/**
* Create overlay with backdrop
*/
createOverlay(form) {
const overlay = document.createElement('div');
overlay.className = 'insertr-form-overlay';
overlay.appendChild(form);
return overlay;
}
/**
* Position form relative to primary element
*/
positionForm(element, overlay) {
const rect = element.getBoundingClientRect();
const form = overlay.querySelector('.insertr-edit-form');
const viewportWidth = window.innerWidth;
// Calculate optimal width
let formWidth;
if (viewportWidth < 768) {
formWidth = Math.min(viewportWidth - 40, 500);
} else {
const minComfortableWidth = 600;
const maxWidth = Math.min(viewportWidth * 0.9, 800);
formWidth = Math.max(minComfortableWidth, Math.min(rect.width * 1.5, maxWidth));
}
form.style.width = `${formWidth}px`;
// Position below element
const top = rect.bottom + window.scrollY + 10;
const centerLeft = rect.left + window.scrollX + (rect.width / 2) - (formWidth / 2);
const minLeft = 20;
const maxLeft = window.innerWidth - formWidth - 20;
const left = Math.max(minLeft, Math.min(centerLeft, maxLeft));
overlay.style.position = 'absolute';
overlay.style.top = `${top}px`;
overlay.style.left = `${left}px`;
overlay.style.zIndex = '10000';
// Ensure visibility
this.ensureModalVisible(element, overlay);
}
/**
* Ensure modal is visible by scrolling if needed
*/
ensureModalVisible(element, overlay) {
requestAnimationFrame(() => {
const modal = overlay.querySelector('.insertr-edit-form');
const modalRect = modal.getBoundingClientRect();
const viewportHeight = window.innerHeight;
if (modalRect.bottom > viewportHeight) {
const scrollAmount = modalRect.bottom - viewportHeight + 20;
window.scrollBy({
top: scrollAmount,
behavior: 'smooth'
});
}
});
}
/**
* Close current editor
*/
close() {
if (this.previewManager) {
this.previewManager.clearPreview();
}
if (this.currentOverlay) {
this.currentOverlay.remove();
this.currentOverlay = null;
}
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
if (typeof text !== 'string') return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
/**
* Markdown Context - Represents single or multiple elements for editing
*/
class MarkdownContext {
constructor(elements) {
this.elements = elements;
this.primaryElement = elements[0]; // Used for positioning
this.originalContent = null;
}
/**
* Extract markdown content from elements
*/
extractMarkdown() {
if (this.elements.length === 1) {
// Single element - convert its HTML to markdown
return markdownConverter.htmlToMarkdown(this.elements[0].innerHTML);
} else {
// Multiple elements - combine and convert to markdown
return markdownConverter.extractGroupMarkdown(this.elements);
}
}
/**
* Apply markdown content to elements
*/
applyMarkdown(markdown) {
if (this.elements.length === 1) {
// Single element - convert markdown to HTML and apply
const html = markdownConverter.markdownToHtml(markdown);
this.elements[0].innerHTML = html;
} else {
// Multiple elements - use group update logic
markdownConverter.updateGroupElements(this.elements, markdown);
}
}
/**
* Store original content for preview restoration
*/
storeOriginalContent() {
this.originalContent = this.elements.map(el => el.innerHTML);
}
/**
* Restore original content (for preview cancellation)
*/
restoreOriginalContent() {
if (this.originalContent) {
this.elements.forEach((el, index) => {
if (this.originalContent[index] !== undefined) {
el.innerHTML = this.originalContent[index];
}
});
}
}
/**
* Apply preview styling
*/
applyPreviewStyling() {
this.elements.forEach(el => {
el.classList.add('insertr-preview-active');
});
// Also apply to primary element if it's a container
if (this.primaryElement.classList.contains('insertr-group')) {
this.primaryElement.classList.add('insertr-preview-active');
}
}
/**
* Remove preview styling
*/
removePreviewStyling() {
this.elements.forEach(el => {
el.classList.remove('insertr-preview-active');
});
// Also remove from containers
if (this.primaryElement.classList.contains('insertr-group')) {
this.primaryElement.classList.remove('insertr-preview-active');
}
}
}
/**
* Unified Preview Manager for Markdown Content
*/
class MarkdownPreviewManager {
constructor() {
this.previewTimeout = null;
this.activeContext = null;
this.resizeObserver = null;
}
setActiveContext(context) {
this.clearPreview();
this.activeContext = context;
this.startResizeObserver();
}
schedulePreview(context, markdown) {
// Clear existing timeout
if (this.previewTimeout) {
clearTimeout(this.previewTimeout);
}
// Schedule new preview with 500ms debounce
this.previewTimeout = setTimeout(() => {
this.updatePreview(context, markdown);
}, 500);
}
updatePreview(context, markdown) {
// Store original content if first preview
if (!context.originalContent) {
context.storeOriginalContent();
}
// Apply preview content
context.applyMarkdown(markdown);
context.applyPreviewStyling();
}
clearPreview() {
if (this.activeContext) {
this.activeContext.restoreOriginalContent();
this.activeContext.removePreviewStyling();
this.activeContext = null;
}
if (this.previewTimeout) {
clearTimeout(this.previewTimeout);
this.previewTimeout = null;
}
this.stopResizeObserver();
}
startResizeObserver() {
this.stopResizeObserver();
if (this.activeContext) {
this.resizeObserver = new ResizeObserver(() => {
// Handle height changes for modal repositioning
if (this.onHeightChange) {
this.onHeightChange(this.activeContext.primaryElement);
}
});
this.activeContext.elements.forEach(el => {
this.resizeObserver.observe(el);
});
}
}
stopResizeObserver() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
}
setHeightChangeCallback(callback) {
this.onHeightChange = callback;
}
}

View File

@@ -1,33 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Test ID Generation</title>
<script src="demo-site/insertr.js"></script>
</head>
<body>
<section class="hero">
<h1 class="insertr">Transform Your Business with Expert Consulting</h1>
<p class="lead insertr">We help small businesses grow through strategic planning, process optimization, and digital transformation. Our team brings 15+ years of experience to drive your success.</p>
<a href="contact.html" class="btn-primary insertr">Get Started Today</a>
</section>
<script>
document.addEventListener('DOMContentLoaded', function() {
const core = new InsertrCore();
const elements = core.findEnhancedElements();
console.log('Testing ID generation:');
elements.forEach(element => {
const metadata = core.getElementMetadata(element);
console.log(`${element.tagName.toLowerCase()}: "${element.textContent.trim()}" => ${metadata.contentId}`);
});
// Expected IDs from CLI:
console.log('\nExpected from CLI:');
console.log('h1: hero-title-7cfeea');
console.log('p: hero-lead-e47475');
console.log('a: hero-link-76c620');
});
</script>
</body>
</html>