diff --git a/handlers/chat.go b/handlers/chat.go index 5f2bda3..6abfae4 100644 --- a/handlers/chat.go +++ b/handlers/chat.go @@ -223,12 +223,21 @@ func ChatHandler(app *App) http.HandlerFunc { } allUsernamesJSON, _ := json.Marshal(allUsernames) + // Get user preferences for markdown preview default + prefs, err := models.GetUserPreferences(app.DB, userID) + if err != nil { + log.Printf("Error fetching user preferences: %v", err) + // Create default if not found + prefs, _ = models.CreateDefaultPreferences(app.DB, userID) + } + data := struct { PageData - Board models.Board - Messages []models.ChatMessage - AllUsernames template.JS - CurrentUsername string + Board models.Board + Messages []models.ChatMessage + AllUsernames template.JS + CurrentUsername string + MarkdownPreviewDefault string }{ PageData: PageData{ Title: "ThreadR Chat - " + board.Name, @@ -239,10 +248,11 @@ func ChatHandler(app *App) http.HandlerFunc { StaticPath: app.Config.ThreadrDir + "/static", CurrentURL: r.URL.Path, }, - Board: *board, - Messages: messages, - AllUsernames: template.JS(allUsernamesJSON), - CurrentUsername: currentUsername, + Board: *board, + Messages: messages, + AllUsernames: template.JS(allUsernamesJSON), + CurrentUsername: currentUsername, + MarkdownPreviewDefault: prefs.MarkdownPreviewDefault, } if err := app.Tmpl.ExecuteTemplate(w, "chat", data); err != nil { log.Printf("Error executing template in ChatHandler: %v", err) diff --git a/static/app.js b/static/app.js index 3f374b0..aefdf51 100644 --- a/static/app.js +++ b/static/app.js @@ -371,3 +371,118 @@ function showDraftIndicator(content, timestamp, onRestore, onDiscard) { return indicator; } + +// ============================================ +// Markdown Preview Functions +// ============================================ + +// Escape HTML to prevent XSS +function escapeHTML(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +// Process inline markdown (bold, italic, code, mentions) +function processInlineMarkdown(line) { + // Inline code first (to avoid processing markdown inside code) + line = line.replace(/`([^`]+)`/g, '$1'); + + // Bold: **text** or __text__ + line = line.replace(/\*\*([^\*]+)\*\*/g, '$1'); + line = line.replace(/__([^_]+)__/g, '$1'); + + // Italic: *text* or _text_ + line = line.replace(/\*([^\*]+)\*/g, '$1'); + line = line.replace(/_([^_]+)_/g, '$1'); + + // Mentions: @username + line = line.replace(/@(\w+)/g, '@$1'); + + return line; +} + +// Render markdown to HTML (matching Go implementation) +function renderMarkdownPreview(content) { + let html = ''; + + // Extract code blocks first and replace with placeholders + const codeBlocks = []; + let codeBlockCounter = 0; + + content = content.replace(/```(\w*)\n([\s\S]*?)\n```/g, (match, lang, code) => { + const escapedCode = escapeHTML(code); + let renderedBlock; + if (lang) { + renderedBlock = `
${escapedCode}
`; + } else { + renderedBlock = `
${escapedCode}
`; + } + const placeholder = ``; + codeBlocks[codeBlockCounter] = renderedBlock; + codeBlockCounter++; + return placeholder; + }); + + // Process lines + const lines = content.split('\n'); + let inList = false; + + for (const line of lines) { + const trimmedLine = line.trim(); + + // Headers + if (trimmedLine.startsWith('### ')) { + if (inList) { html += '\n'; inList = false; } + html += '

' + processInlineMarkdown(trimmedLine.substring(4)) + '

\n'; + continue; + } else if (trimmedLine.startsWith('## ')) { + if (inList) { html += '\n'; inList = false; } + html += '

' + processInlineMarkdown(trimmedLine.substring(3)) + '

\n'; + continue; + } else if (trimmedLine.startsWith('# ')) { + if (inList) { html += '\n'; inList = false; } + html += '

' + processInlineMarkdown(trimmedLine.substring(2)) + '

\n'; + continue; + } + + // Lists + if (trimmedLine.startsWith('* ') || trimmedLine.startsWith('- ')) { + if (!inList) { + html += '\n'; + inList = false; + } + + // Regular paragraphs + if (trimmedLine !== '') { + html += '

' + processInlineMarkdown(trimmedLine) + '

\n'; + } else { + html += '\n'; + } + } + + // Close list if still open + if (inList) { + html += '\n'; + } + + // Replace code block placeholders + codeBlocks.forEach((block, index) => { + html = html.replace(``, block); + }); + + // Clean up excessive newlines + html = html.replace(/\n{3,}/g, '\n\n'); + + return html; +} diff --git a/templates/pages/chat.html b/templates/pages/chat.html index 2adba73..28eff8b 100644 --- a/templates/pages/chat.html +++ b/templates/pages/chat.html @@ -247,6 +247,99 @@ padding: 6px 12px; font-size: 0.9em; } + .markdown-tabs { + display: flex; + gap: 0; + margin-bottom: 0; + border-bottom: 1px solid #001858; + } + .markdown-tab { + flex: 1; + padding: 8px 16px; + border: none; + background-color: #f3d2c1; + color: #001858; + font-family: monospace; + font-size: 0.9em; + cursor: pointer; + border-right: 1px solid #001858; + transition: background-color 0.2s ease; + } + .markdown-tab:last-child { + border-right: none; + } + .markdown-tab:hover { + background-color: #fef6e4; + } + .markdown-tab.active { + background-color: #8bd3dd; + color: #001858; + font-weight: bold; + } + .markdown-content-container { + position: relative; + min-height: 50px; + } + .chat-input textarea, + .markdown-preview { + display: none; + resize: none; + height: 50px; + margin-bottom: 8px; + font-size: 0.9em; + width: 100%; + box-sizing: border-box; + } + .chat-input textarea.markdown-visible, + .markdown-preview.markdown-visible { + display: block; + } + .markdown-preview { + border: 1px solid #001858; + border-radius: 5px; + padding: 8px; + background-color: #fef6e4; + color: #001858; + min-height: 50px; + max-height: 200px; + overflow-y: auto; + font-family: monospace; + } + .markdown-preview h1 { + font-size: 1.5em; + margin: 0.5em 0; + } + .markdown-preview h2 { + font-size: 1.3em; + margin: 0.5em 0; + } + .markdown-preview h3 { + font-size: 1.1em; + margin: 0.5em 0; + } + .markdown-preview p { + margin: 0.5em 0; + } + .markdown-preview ul { + margin: 0.5em 0; + padding-left: 2em; + } + .markdown-preview code { + background-color: #f3d2c1; + padding: 2px 4px; + border-radius: 3px; + } + .markdown-preview pre { + background-color: #f3d2c1; + padding: 8px; + border-radius: 5px; + overflow-x: auto; + margin: 0.5em 0; + } + .markdown-preview pre code { + background-color: transparent; + padding: 0; + } .post-actions { position: absolute; top: 5px; @@ -410,6 +503,29 @@ border-color: #f582ae; } } + .markdown-tab { + background-color: #555; + color: #fef6e4; + border-color: #fef6e4; + } + .markdown-tab:hover { + background-color: #666; + } + .markdown-tab.active { + background-color: #8bd3dd; + color: #001858; + } + .markdown-preview { + background-color: #333; + color: #fef6e4; + border-color: #fef6e4; + } + .markdown-preview code { + background-color: #555; + } + .markdown-preview pre { + background-color: #555; + } } @@ -466,7 +582,14 @@ Replying to - +
+ + +
+
+ +
+
@@ -829,6 +952,57 @@ } }); + // Markdown preview functionality + let currentTab = '{{.MarkdownPreviewDefault}}' || 'edit'; + let previewUpdateTimeout; + + function switchMarkdownTab(tab) { + currentTab = tab; + const textarea = document.getElementById('chat-input-text'); + const preview = document.getElementById('chat-preview'); + const tabs = document.querySelectorAll('.markdown-tab'); + + // Update tab styling + tabs.forEach(t => { + if (t.dataset.tab === tab) { + t.classList.add('active'); + } else { + t.classList.remove('active'); + } + }); + + // Show/hide content + if (tab === 'edit') { + textarea.classList.add('markdown-visible'); + preview.classList.remove('markdown-visible'); + textarea.focus(); + } else { + textarea.classList.remove('markdown-visible'); + preview.classList.add('markdown-visible'); + updatePreview(); + } + } + + function updatePreview() { + const textarea = document.getElementById('chat-input-text'); + const preview = document.getElementById('chat-preview'); + const content = textarea.value; + + if (content.trim() === '') { + preview.innerHTML = '

Nothing to preview. Type some markdown in the Edit tab.

'; + } else { + preview.innerHTML = renderMarkdownPreview(content); + } + } + + // Update preview on input (debounced) + document.getElementById('chat-input-text').addEventListener('input', () => { + if (currentTab === 'preview') { + clearTimeout(previewUpdateTimeout); + previewUpdateTimeout = setTimeout(updatePreview, 300); + } + }); + window.onload = function() { connectWebSocket(); const messagesContainer = document.getElementById('chat-messages'); @@ -906,6 +1080,9 @@ originalSendMessage(); }; + // Initialize markdown preview tab based on user preference + switchMarkdownTab(currentTab); + // Initial check for scroll position checkScrollPosition(); };