From c1f6c35c12bfc81cfcf1765b00c7d0a09dc129ce Mon Sep 17 00:00:00 2001 From: richarddushime Date: Tue, 29 Jul 2025 01:57:45 +0200 Subject: [PATCH] Adopting Principled Education comments section --- content/adopting/adopting.md | 4 +- data/adopting-review/comments.json | 47 ++++ layouts/shortcodes/ad-comments.html | 311 +++++++++++++++++++++++ netlify.toml | 7 + netlify/functions/comments.js | 120 +++++++++ static/js/comments-backend.js | 379 ++++++++++++++++++++++++++++ static/js/comments.js | 79 ++++++ 7 files changed, 946 insertions(+), 1 deletion(-) create mode 100644 data/adopting-review/comments.json create mode 100644 layouts/shortcodes/ad-comments.html create mode 100644 netlify/functions/comments.js create mode 100644 static/js/comments-backend.js create mode 100644 static/js/comments.js diff --git a/content/adopting/adopting.md b/content/adopting/adopting.md index 9acbb471076..842819a5a0e 100644 --- a/content/adopting/adopting.md +++ b/content/adopting/adopting.md @@ -221,4 +221,6 @@ A great way to adopt the values of FORRT is to advocate in your own institution * E.g., [UK Reproducibility network](https://www.ukrn.org/), [repliCATS](https://replicats.research.unimelb.edu.au/). * If you are a member of a professional society or a teaching union, advocate for the adoption of principles for openness, transparency, and equality in research and education. -



+
+ +{{< ad-comments >}} diff --git a/data/adopting-review/comments.json b/data/adopting-review/comments.json new file mode 100644 index 00000000000..51e63a04050 --- /dev/null +++ b/data/adopting-review/comments.json @@ -0,0 +1,47 @@ +{ + "page": "adopting-review", + "lastUpdated": "2024-01-15T10:00:00.000Z", + "commentCount": 4, + "comments": [ + { + "id": 1705312200000, + "author": "Sarah Johnson", + "email": "sarah@example.com", + "content": "This is such a comprehensive guide! I adopted my dog Luna from a local shelter last year and it was the best decision ever. The process was smooth and the staff was incredibly helpful.", + "timestamp": "2 days ago", + "date": "2024-01-13T08:30:00.000Z", + "commentNumber": 1, + "avatar": "https://ui-avatars.com/api/?name=Sarah%20Johnson&background=3B82F6&color=fff&size=40&rounded=true" + }, + { + "id": 1705315800000, + "author": "Mike Chen", + "email": "mike@example.com", + "content": "Great article! One thing I'd add is to consider fostering first if you're unsure. We fostered our cat Whiskers for a month before officially adopting him. It really helped us make sure we were a good match.", + "timestamp": "1 day ago", + "date": "2024-01-14T09:30:00.000Z", + "commentNumber": 2, + "avatar": "https://ui-avatars.com/api/?name=Mike%20Chen&background=3B82F6&color=fff&size=40&rounded=true" + }, + { + "id": 1705319400000, + "author": "Emma Rodriguez", + "email": "emma@example.com", + "content": "The preparation checklist is spot on! I wish I had seen this before adopting my first pet. Definitely saving this for anyone I know who's considering adoption.", + "timestamp": "18 hours ago", + "date": "2024-01-14T15:45:00.000Z", + "commentNumber": 3, + "avatar": "https://ui-avatars.com/api/?name=Emma%20Rodriguez&background=3B82F6&color=fff&size=40&rounded=true" + }, + { + "id": 1705323000000, + "author": "David Park", + "email": "david@example.com", + "content": "Thank you for mentioning the ongoing support from adoption centers. Our rescue organization has been amazing - they even helped us with training resources when we had some behavioral challenges initially.", + "timestamp": "12 hours ago", + "date": "2024-01-14T21:30:00.000Z", + "commentNumber": 4, + "avatar": "https://ui-avatars.com/api/?name=David%20Park&background=3B82F6&color=fff&size=40&rounded=true" + } + ] +} diff --git a/layouts/shortcodes/ad-comments.html b/layouts/shortcodes/ad-comments.html new file mode 100644 index 00000000000..47446ad6ad3 --- /dev/null +++ b/layouts/shortcodes/ad-comments.html @@ -0,0 +1,311 @@ +
+
+
+ +
+ + +
+
+
+ Your avatar +
+
+ + +
+
+ +
+
+
+
+
+
+ + + + + diff --git a/netlify.toml b/netlify.toml index 2c3fae960ff..4ccb6f37ce0 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,5 +1,6 @@ [build] command = "hugo --gc --minify -b $URL" + functions = "netlify/functions" publish = "public" [build.environment] @@ -24,3 +25,9 @@ for = "index.xml" [headers.values] Content-Type = "application/rss+xml" + +# API redirects for comment system +[[redirects]] + from = "/api/*" + to = "/.netlify/functions/:splat" + status = 200 diff --git a/netlify/functions/comments.js b/netlify/functions/comments.js new file mode 100644 index 00000000000..40ddb1fb786 --- /dev/null +++ b/netlify/functions/comments.js @@ -0,0 +1,120 @@ +const fs = require("fs").promises +const path = require("path") + +// Netlify Function to handle comments +exports.handler = async (event, context) => { + const headers = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Content-Type": "application/json", + } + + // Handle CORS preflight + if (event.httpMethod === "OPTIONS") { + return { + statusCode: 200, + headers, + body: "", + } + } + + const commentsFilePath = path.join(process.cwd(), "data", "adopting-review", "comments.json") + + try { + if (event.httpMethod === "GET") { + // Get comments + try { + const data = await fs.readFile(commentsFilePath, "utf8") + const commentsData = JSON.parse(data) + + return { + statusCode: 200, + headers, + body: JSON.stringify(commentsData), + } + } catch (error) { + // File doesn't exist, return empty structure + const emptyData = { + page: "adopting-review", + lastUpdated: new Date().toISOString(), + commentCount: 0, + comments: [], + } + + return { + statusCode: 200, + headers, + body: JSON.stringify(emptyData), + } + } + } + + if (event.httpMethod === "POST") { + // Add comment + const { action, comment } = JSON.parse(event.body) + + if (action !== "add" || !comment) { + return { + statusCode: 400, + headers, + body: JSON.stringify({ success: false, error: "Invalid request" }), + } + } + + // Load existing comments + let commentsData + try { + const data = await fs.readFile(commentsFilePath, "utf8") + commentsData = JSON.parse(data) + } catch (error) { + // File doesn't exist, create new structure + commentsData = { + page: "adopting-review", + lastUpdated: new Date().toISOString(), + commentCount: 0, + comments: [], + } + } + + // Add new comment + commentsData.comments.push(comment) + commentsData.commentCount = commentsData.comments.length + commentsData.lastUpdated = new Date().toISOString() + + // Ensure directory exists + await fs.mkdir(path.dirname(commentsFilePath), { recursive: true }) + + // Save updated comments + await fs.writeFile(commentsFilePath, JSON.stringify(commentsData, null, 2)) + + return { + statusCode: 200, + headers, + body: JSON.stringify({ + success: true, + message: "Comment added successfully", + commentCount: commentsData.commentCount, + }), + } + } + + return { + statusCode: 405, + headers, + body: JSON.stringify({ success: false, error: "Method not allowed" }), + } + } catch (error) { + console.error("Function error:", error) + + return { + statusCode: 500, + headers, + body: JSON.stringify({ + success: false, + error: "Internal server error", + details: error.message, + }), + } + } +} diff --git a/static/js/comments-backend.js b/static/js/comments-backend.js new file mode 100644 index 00000000000..a9d905c8671 --- /dev/null +++ b/static/js/comments-backend.js @@ -0,0 +1,379 @@ +// Ad Comments Backend System +class AdCommentSystem { + constructor() { + this.comments = [] + this.apiEndpoint = "/.netlify/functions/comments" // Netlify Functions endpoint + this.dataFile = "/data/adopting-review/comments.json" + this.isLoading = false + } + + async init() { + await this.loadComments() + this.bindEvents() + this.render() + } + + async loadComments() { + try { + this.showLoading(true) + + // Try to fetch from API first + const response = await fetch(`${this.apiEndpoint}?action=get`) + + if (response.ok) { + const data = await response.json() + this.comments = data.comments || [] + console.log("Comments loaded from server:", this.comments.length) + } else { + // Fallback to Hugo data file + await this.loadFromHugoData() + } + } catch (error) { + console.log("API not available, trying Hugo data file...") + await this.loadFromHugoData() + } finally { + this.showLoading(false) + } + } + + async loadFromHugoData() { + try { + // Try to load from Hugo's data file + const response = await fetch(this.dataFile) + if (response.ok) { + const data = await response.json() + this.comments = data.comments || [] + console.log("Comments loaded from Hugo data:", this.comments.length) + } + } catch (error) { + console.log("No existing comments found, starting fresh") + this.comments = [] + } + } + + async saveComment(comment) { + try { + this.showLoading(true) + + // Try to save via API + const response = await fetch(this.apiEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + action: "add", + comment: comment, + }), + }) + + if (response.ok) { + const result = await response.json() + if (result.success) { + // Add to local array for immediate display + this.comments.push(comment) + this.showStatus("Comment added successfully!", "success") + return { success: true } + } else { + throw new Error(result.error || "Failed to save comment") + } + } else { + throw new Error("Server error") + } + } catch (error) { + console.error("Error saving comment:", error) + + // Fallback: save to localStorage and show instructions + this.saveToLocalStorage(comment) + this.showStatus("Comment saved locally. Please refresh to see all comments.", "error") + return { success: false, error: error.message } + } finally { + this.showLoading(false) + } + } + + saveToLocalStorage(comment) { + // Add to local array + this.comments.push(comment) + + // Save to localStorage as backup + const backupData = { + page: "adopting-review", + lastUpdated: new Date().toISOString(), + commentCount: this.comments.length, + comments: this.comments, + } + + localStorage.setItem("ad-comments-backup", JSON.stringify(backupData)) + + // Show manual instructions + console.log("Please manually add this comment to data/adopting-review/comments.json:") + console.log(JSON.stringify(backupData, null, 2)) + } + + async addComment() { + const nameInput = document.getElementById("userName") + const emailInput = document.getElementById("userEmail") + const textarea = document.getElementById("newCommentText") + + const name = nameInput.value.trim() + const email = emailInput.value.trim() + const content = textarea.value.trim() + + // Validation + if (!name || !email || !content) { + this.showStatus("Please fill in all fields.", "error") + return + } + + if (!this.isValidEmail(email)) { + this.showStatus("Please enter a valid email address.", "error") + return + } + + // Create comment object + const comment = { + id: Date.now(), + author: name, + email: email, + content: content, + timestamp: this.formatTimestamp(new Date()), + date: new Date().toISOString(), + commentNumber: this.getNextCommentNumber(), + avatar: this.generateAvatar(name), + } + + // Save comment + const result = await this.saveComment(comment) + + if (result.success) { + this.render() + + // Clear form + nameInput.value = "" + emailInput.value = "" + textarea.value = "" + + // Save user info for next time + localStorage.setItem( + "ad-comment-user", + JSON.stringify({ + name: name, + email: email, + }), + ) + } + } + + async getClientIP() { + try { + const response = await fetch("https://api.ipify.org?format=json") + const data = await response.json() + return data.ip + } catch (error) { + return "unknown" + } + } + + isValidEmail(email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(email) + } + + generateAvatar(name) { + const encodedName = encodeURIComponent(name) + return `https://ui-avatars.com/api/?name=${encodedName}&background=3B82F6&color=fff&size=40&rounded=true` + } + + formatTimestamp(date) { + const now = new Date() + const diff = now - date + const minutes = Math.floor(diff / 60000) + + if (minutes < 1) return "just now" + if (minutes < 60) return `${minutes} minute${minutes > 1 ? "s" : ""} ago` + + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours} hour${hours > 1 ? "s" : ""} ago` + + const days = Math.floor(hours / 24) + if (days < 30) return `${days} day${days > 1 ? "s" : ""} ago` + + const months = Math.floor(days / 30) + if (months < 12) return `${months} month${months > 1 ? "s" : ""} ago` + + const years = Math.floor(months / 12) + return `${years} year${years > 1 ? "s" : ""} ago` + } + + getNextCommentNumber() { + return this.comments.length > 0 ? Math.max(...this.comments.map((c) => c.commentNumber || 0)) + 1 : 1 + } + + createCommentHTML(comment) { + return ` +
+
+
+ ${comment.commentNumber} +
+
+ ${comment.author} +
+
+
+
+ ${this.escapeHtml(comment.author)} + ${comment.timestamp} +
+ +
+

${this.escapeHtml(comment.content)}

+
+
+
+ ` + } + + escapeHtml(text) { + const div = document.createElement("div") + div.textContent = text + return div.innerHTML + } + + render() { + const commentsList = document.getElementById("commentsList") + if (!commentsList) return + + if (this.comments.length === 0) { + commentsList.innerHTML = ` +
+
+

+ No comments yet. Be the first to share your thoughts! +

+
+
+ ` + return + } + + // Sort comments by comment number (newest first) + const sortedComments = [...this.comments].sort((a, b) => (b.commentNumber || 0) - (a.commentNumber || 0)) + commentsList.innerHTML = sortedComments.map((comment) => this.createCommentHTML(comment)).join("") + } + + handleReply(commentId) { + const comment = this.comments.find((c) => c.id === commentId) + if (comment) { + const textarea = document.getElementById("newCommentText") + textarea.value = `@${comment.author} ` + textarea.focus() + textarea.scrollIntoView({ behavior: "smooth", block: "center" }) + } + } + + showStatus(message, type) { + const statusContainer = document.getElementById("statusContainer") + if (!statusContainer) return + + statusContainer.innerHTML = "" + + const statusDiv = document.createElement("div") + statusDiv.className = `status-message status-${type}` + statusDiv.textContent = message + + statusContainer.appendChild(statusDiv) + + setTimeout(() => { + if (statusDiv.parentNode) { + statusDiv.remove() + } + }, 5000) + } + + showLoading(isLoading) { + this.isLoading = isLoading + const commentSection = document.querySelector(".comment-section") + const sendButton = document.getElementById("sendComment") + + if (commentSection) { + commentSection.classList.toggle("loading", isLoading) + } + + if (sendButton) { + sendButton.disabled = isLoading + sendButton.innerHTML = isLoading + ? "Saving..." + : ` + + + + SEND` + } + } + + bindEvents() { + const sendButton = document.getElementById("sendComment") + const textarea = document.getElementById("newCommentText") + + if (sendButton) { + sendButton.addEventListener("click", () => this.addComment()) + } + + if (textarea) { + // Allow Enter + Ctrl/Cmd to submit + textarea.addEventListener("keydown", (e) => { + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault() + this.addComment() + } + }) + } + + // Load saved user info + this.loadUserInfo() + } + + loadUserInfo() { + const savedUser = localStorage.getItem("ad-comment-user") + if (savedUser) { + try { + const userData = JSON.parse(savedUser) + const nameInput = document.getElementById("userName") + const emailInput = document.getElementById("userEmail") + + if (nameInput && userData.name) nameInput.value = userData.name + if (emailInput && userData.email) emailInput.value = userData.email + } catch (error) { + console.log("Error loading saved user info:", error) + } + } + } + + // Method to manually load comments from JSON file + async loadFromJSON(jsonData) { + try { + if (typeof jsonData === "string") { + jsonData = JSON.parse(jsonData) + } + + this.comments = jsonData.comments || [] + this.render() + this.showStatus("Comments loaded successfully!", "success") + } catch (error) { + this.showStatus("Error loading comments from JSON.", "error") + console.error("JSON load error:", error) + } + } +} + +// Export for global access +window.AdCommentSystem = AdCommentSystem diff --git a/static/js/comments.js b/static/js/comments.js new file mode 100644 index 00000000000..fb8d65bb3c6 --- /dev/null +++ b/static/js/comments.js @@ -0,0 +1,79 @@ + +// Enhanced comment functionality +class CommentSystem { + constructor() { + this.comments = []; + this.currentUser = { + name: "currentuser", + avatar: "/images/default-avatar.svg" + }; + } + + init() { + this.loadComments(); + this.bindEvents(); + } + + loadComments() { + // Load from localStorage or API + const saved = localStorage.getItem('hugo-comments'); + if (saved) { + this.comments = JSON.parse(saved); + } + this.render(); + } + + saveComments() { + localStorage.setItem('hugo-comments', JSON.stringify(this.comments)); + } + + addComment(content) { + const comment = { + id: Date.now(), + author: this.currentUser.name, + avatar: this.currentUser.avatar, + content: content, + timestamp: this.formatTimestamp(new Date()), + commentNumber: this.getNextCommentNumber() + }; + + this.comments.push(comment); + this.saveComments(); + this.render(); + } + + formatTimestamp(date) { + const now = new Date(); + const diff = now - date; + const minutes = Math.floor(diff / 60000); + + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} hour${hours > 1 ? 's' : ''} ago`; + + const days = Math.floor(hours / 24); + return `${days} day${days > 1 ? 's' : ''} ago`; + } + + getNextCommentNumber() { + return this.comments.length > 0 + ? Math.max(...this.comments.map(c => c.commentNumber)) + 1 + : 1; + } + + render() { + // Render logic here + } + + bindEvents() { + // Event binding logic here + } +} + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + const commentSystem = new CommentSystem(); + commentSystem.init(); +});