~cytrogen/blog-public

blog-public/js/email-comment.js -rw-r--r-- 7.5 KiB
b922ad66Cytrogen Deploy 2026-04-08 02:26:04 6 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
/**
 * email-comment.js — 客户端邮件评论加载器
 * Fetch /comments/{post-id}.json,用 comment-renderer 的 HTML 结构渲染
 */
class EmailCommentLoader {
  constructor() {
    const isEn = document.documentElement.lang === 'en';
    this.labels = {
      title: isEn ? 'Email Comments' : '邮件评论',
      intro_prefix: isEn ? 'Join the discussion via email: ' : '通过邮件参与讨论:',
      send_aria: isEn ? 'Send a comment via email' : '通过邮件发送评论',
      empty: isEn ? 'No email comments yet.' : '还没有邮件评论。',
      reply: isEn ? 'Reply' : '回复',
      reply_aria: isEn ? "Reply to {name}'s comment" : '回复 {name} 的评论',
      expand: isEn ? 'Show more' : '展开',
      hint: isEn
        ? 'Send an email to the address above to comment. Your name will be shown publicly, but your email address will not.'
        : '发送邮件到上方地址即可评论。你的名字会公开显示,但邮箱地址不会。',
      loading: isEn ? 'Loading comments...' : '加载评论中…',
      error: isEn ? 'Failed to load comments' : '评论加载失败'
    };
    this.FOLD_THRESHOLD = 300;
    this.init();
  }

  init() {
    const containers = document.querySelectorAll('.email-comment-section[data-post-id]');
    if (containers.length === 0) return;
    containers.forEach(container => this.loadComments(container));
  }

  async loadComments(container) {
    const postId = container.getAttribute('data-post-id');
    const blogDomain = container.getAttribute('data-blog-domain');
    if (!postId) return;

    try {
      const response = await fetch(`/comments/${postId}.json`);
      if (!response.ok) {
        if (response.status === 404) {
          // 没有评论数据,渲染空状态
          this.renderEmpty(container, postId, blogDomain);
          return;
        }
        throw new Error(`HTTP ${response.status}`);
      }

      const data = await response.json();
      this.renderSection(container, data, postId, blogDomain);
    } catch (err) {
      console.error('[email-comment] Failed to load:', err);
      this.renderError(container, postId, blogDomain);
    }
  }

  renderSection(container, data, postId, blogDomain) {
    const emailAddr = `post-${postId}@${blogDomain}`;
    const tree = data.tree || [];
    const count = data.count || 0;

    let html = `<h3 class="email-comment-title">${this.labels.title} (<span class="email-comment-count">${count}</span>)</h3>`;
    html += `<p class="email-comment-intro">${this.labels.intro_prefix}<a href="mailto:${this.esc(emailAddr)}" aria-label="${this.esc(this.labels.send_aria)}">${this.esc(emailAddr)}</a></p>`;

    if (tree.length === 0) {
      html += `<p class="email-comment-empty">${this.labels.empty}</p>`;
    } else {
      html += this.renderList(tree, 0, postId, blogDomain);
    }

    html += `<p class="email-comment-hint">${this.labels.hint}</p>`;
    container.innerHTML = html;
    container.classList.remove('email-comment-loading');

    // 绑定折叠按钮事件
    this.bindExpandButtons(container);
  }

  renderList(nodes, depth, postId, blogDomain) {
    if (!nodes || nodes.length === 0) return '';
    let html = `<ol class="email-comment-list" data-depth="${depth}">`;
    for (const node of nodes) {
      html += this.renderNode(node, depth, postId, blogDomain);
    }
    html += '</ol>';
    return html;
  }

  renderNode(node, depth, postId, blogDomain) {
    const replyAddr = `post-${postId}@${blogDomain}`;
    const dateStr = this.formatDate(node.date);
    const authorName = this.esc(node.from?.name || 'Anonymous');
    const bodyHtml = this.renderBody(node.body);
    const isFolded = (node.body || '').length > this.FOLD_THRESHOLD;

    let html = '<li>';
    html += `<article class="email-comment-item" id="comment-${this.simpleHash(node.id)}">`;
    html += `<div class="email-comment-author">`;
    html += `<span class="email-comment-author-name">${authorName}</span>`;
    html += `<span class="email-comment-date">${dateStr}</span>`;
    html += `<a class="email-comment-reply-link" href="mailto:${this.esc(replyAddr)}" aria-label="${this.esc(this.labels.reply_aria.replace('{name}', node.from?.name || 'Anonymous'))}">${this.labels.reply}</a>`;
    html += '</div>';

    if (isFolded) {
      html += `<div class="email-comment-content email-comment-folded">${bodyHtml}`;
      html += `<button class="email-comment-expand" type="button">${this.labels.expand}</button></div>`;
    } else {
      html += `<div class="email-comment-content">${bodyHtml}</div>`;
    }

    if (node.children && node.children.length > 0) {
      html += this.renderList(node.children, depth + 1, postId, blogDomain);
    }

    html += '</article></li>';
    return html;
  }

  renderBody(body) {
    if (!body) return '';
    let text = body
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;');

    // URL 自动链接
    text = text.replace(
      /https?:\/\/[^\s<>"')\]]+/g,
      url => `<a href="${url}" target="_blank" rel="noopener ugc">${url}</a>`
    );

    // \n → <br>
    text = text.replace(/\n/g, '<br>');

    return typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(text) : text;
  }

  renderEmpty(container, postId, blogDomain) {
    const emailAddr = `post-${postId}@${blogDomain}`;
    container.innerHTML = `
      <h3 class="email-comment-title">${this.labels.title} (<span class="email-comment-count">0</span>)</h3>
      <p class="email-comment-intro">${this.labels.intro_prefix}<a href="mailto:${this.esc(emailAddr)}" aria-label="${this.esc(this.labels.send_aria)}">${this.esc(emailAddr)}</a></p>
      <p class="email-comment-empty">${this.labels.empty}</p>
      <p class="email-comment-hint">${this.labels.hint}</p>`;
    container.classList.remove('email-comment-loading');
  }

  renderError(container, postId, blogDomain) {
    const emailAddr = `post-${postId}@${blogDomain}`;
    container.innerHTML = `
      <h3 class="email-comment-title">${this.labels.title}</h3>
      <p class="email-comment-intro">${this.labels.intro_prefix}<a href="mailto:${this.esc(emailAddr)}" aria-label="${this.esc(this.labels.send_aria)}">${this.esc(emailAddr)}</a></p>
      <p class="email-comment-error">${this.labels.error}</p>
      <p class="email-comment-hint">${this.labels.hint}</p>`;
    container.classList.remove('email-comment-loading');
  }

  bindExpandButtons(container) {
    container.querySelectorAll('.email-comment-expand').forEach(btn => {
      btn.addEventListener('click', () => {
        const content = btn.closest('.email-comment-content');
        if (content) {
          content.classList.toggle('email-comment-folded');
          content.classList.toggle('email-comment-expanded');
          btn.style.display = 'none';
        }
      });
    });
  }

  formatDate(dateStr) {
    try {
      return new Date(dateStr).toLocaleDateString('zh-CN');
    } catch {
      return dateStr || '';
    }
  }

  simpleHash(messageId) {
    if (!messageId) return '0';
    let hash = 0;
    const str = messageId.replace(/[<>]/g, '');
    for (let i = 0; i < str.length; i++) {
      hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
    }
    return Math.abs(hash).toString(36);
  }

  esc(str) {
    if (!str) return '';
    return str
      .replace(/&/g, '&amp;')
      .replace(/"/g, '&quot;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;');
  }
}

if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', () => new EmailCommentLoader());
} else {
  new EmailCommentLoader();
}