Implementing a Blog Comment Interface with Vue.js
Comment Input Form
Capture user metadata using a reactive object bound to standard input elements. The form collects a display name, contact email, and a optional personal website.
<template>
<section class="comment-draft">
<div class="input-group">
<label for="author">Display Name</label>
<input id="author" v-model="draft.author" placeholder="How should we call you?" required />
</div>
<div class="input-group">
<label for="contact">Email Address</label>
<input id="contact" v-model="draft.contact" type="email" placeholder="For notifications (optional)" />
</div>
<div class="input-group">
<label for="site">Personal Website</label>
<input id="site" v-model="draft.site" placeholder="https://example.com (optional)" />
</div>
<button class="publish-btn" @click="handlePublish">Post Comment</button>
</section>
</template>
Rendering the Thread
Iterate over the fetched dataset using a dedicated component. Pass each record as a prop to isolate rendering logic.
<div class="thread-container">
<CommentNode
v-for="item in threadData"
:key="item.identifier"
:node="item"
/>
</div>
State Management and Data Fetching
Maintain form state and comment lists within the parent component. When refreshing the list after submission, capture the current vertical scroll position and restore it after the DOM updates to prevent jarring viewport jumps.
data() {
return {
postDetails: {},
isFetching: false,
pageSize: 10,
draft: {
author: '',
contact: '',
site: '',
body: '',
postId: null,
parentId: null
},
threadData: []
};
},
methods: {
async loadThread() {
this.isFetching = true;
const scrollPos = window.scrollY;
try {
const response = await api.fetchComments(this.postDetails.id, this.pageSize);
if (response.status === 'success') {
this.threadData = response.payload;
}
} catch (error) {
console.error('Failed to load comments:', error);
} finally {
this.isFetching = false;
this.$nextTick(() => window.scrollTo(0, scrollPos));
}
},
handlePublish() {
// Validate and submit draft
this.loadThread();
}
}
Comment Node Component
Encapsulate individual comment rendering, timestamp formatting, and reply togggling. Use a local identifier to control the visibility of the reply interface without affecting sibling nodes.
<template>
<article class="node-wrapper">
<div class="avatar-section">
<a v-if="node.site && node.contact" :href="node.site" target="_blank" rel="noopener">
<img :src="generateAvatar(node.contact)" alt="User Avatar" loading="lazy" />
</a>
<img v-else src="@/assets/default-avatar.svg" alt="Default" loading="lazy" />
</div>
<div class="content-section">
<header class="meta-header">
<a class="author-link" :href="node.site" target="_blank" rel="noopener">{{ node.author }}</a>
<span class="timestamp">{{ formatTimestamp(node.createdAt) }}</span>
</header>
<p class="message-body">{{ node.body }}</p>
<footer class="action-bar">
<button class="action-btn" @click="toggleReply(node.identifier)">Reply</button>
<span class="vote-count"><i class="icon-up"></i> {{ node.upvotes || 0 }}</span>
</footer>
<div v-if="activeReplyId === node.identifier" class="reply-interface">
<div class="reply-input-area">
<textarea :placeholder="`Responding to @${node.author}...`" rows="3"></textarea>
</div>
<div class="reply-actions">
<button class="submit-reply">Send</button>
</div>
</div>
</div>
</article>
</template>
<script>
import { formatRelativeTime } from '@/utils/dateHelpers';
export default {
name: 'CommentNode',
props: {
node: { type: Object, required: true }
},
data() {
return {
activeReplyId: null
};
},
methods: {
formatTimestamp(ts) {
return formatRelativeTime(ts);
},
generateAvatar(email) {
const hash = email.split('@')[0];
return `https://avatar.service.com/u/${hash}?size=80`;
},
toggleReply(id) {
this.activeReplyId = this.activeReplyId === id ? null : id;
}
}
};
</script>
Interface Styling
Apply scoped styles to structure the layout, handle avatar scaling, and format the repply input area. Flexbox ensures consistent alignment across varying content lengths.
<style scoped>
.node-wrapper {
display: flex;
gap: 1rem;
padding: 1.5rem 0;
border-bottom: 1px solid #e0e0e0;
background: #fafafa;
}
.avatar-section img {
width: 42px;
height: 42px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #ccc;
transition: transform 0.2s ease;
}
.avatar-section img:hover {
transform: scale(1.1);
}
.content-section {
flex: 1;
min-width: 0;
}
.meta-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.author-link {
font-weight: 600;
color: #333;
text-decoration: none;
}
.author-link:hover { color: #0056b3; }
.timestamp { font-size: 0.85rem; color: #888; }
.message-body {
margin: 0.75rem 0;
line-height: 1.5;
word-break: break-word;
color: #222;
}
.action-bar {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.9rem;
color: #666;
}
.action-btn {
background: none;
border: none;
cursor: pointer;
color: #666;
padding: 0;
font-size: inherit;
}
.action-btn:hover { color: #0056b3; }
.reply-interface {
margin-top: 1rem;
padding: 1rem;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.reply-input-area textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
resize: vertical;
font-family: inherit;
}
.reply-actions {
display: flex;
justify-content: flex-end;
margin-top: 0.75rem;
}
.submit-reply {
padding: 0.5rem 1.25rem;
background: #0056b3;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.submit-reply:hover { background: #004494; }
</style>