This tutorial shows how to add a “like” button to your Hugo blog posts using Google Sheets as a free, serverless backend. Users can click to like posts, and the count persists across sessions. 總之,換個方法可以按文章讚了。

人類前言

失序了,AI 做太好了。

想說 LikeCoin for blog 已死 (反正我也不是靠那個賺錢,目前主要可能還是廣告),要改個方法實現小互動。因為此網站是架在 github page 上的靜態網頁無法儲存動態資訊,想說可以用 google sheet 當作後端紀錄愛心數。用了破英文丟給 github copilot 的 Claude Opus 4.5 做。沒想到 one-shot 一次到位。

prompt (非常之破的英文,勿鞭)
help plan to add like functionality by using google sheet. the key is the post url and value is total likes of the specific post. update the like when clicking and fetch like count when load the article

成果

成果

OuO

連這篇文章也直接請它整理

整理文章

整理文章

OuO

回顧 2023 年還在痛苦的使用 (無法接受新事物的現代人),雖然兩年多但也算進步頗快了。有了 AI 我已成廢人。

Overview

The solution consists of three parts:

  1. Google Sheets - Stores the URL and like count for each post
  2. Google Apps Script - Provides a REST API to read/write like counts
  3. Hugo Template - Renders the like button and handles user interaction

Features

  • ❤️ Animated heart button with like count
  • 🔄 Fetches count on page load
  • 🔒 Prevents duplicate likes using localStorage
  • 🌙 Dark mode support
  • 🔔 Toast notifications
  • 💰 Completely free (uses Google Sheets as database)

Step 1: Create Google Sheet

  1. Go to Google Sheets and create a new spreadsheet
  2. Rename the first sheet tab to Likes (important: exact name required)
  3. Add headers in the first row:
    • Cell A1: url
    • Cell B1: likes

Your sheet should look like this:

urllikes

Step 2: Set up Google Apps Script

  1. In your Google Sheet, go to Extensions > Apps Script
  2. Delete any existing code in the editor
  3. Paste the following code:
const SHEET_NAME = 'Likes';

function doGet(e) {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
  const url = e.parameter.url;
  
  if (!url) {
    return createJsonResponse({ error: 'Missing url parameter' });
  }
  
  const data = sheet.getDataRange().getValues();
  for (let i = 1; i < data.length; i++) {
    if (data[i][0] === url) {
      return createJsonResponse({ url: url, likes: data[i][1] });
    }
  }
  
  return createJsonResponse({ url: url, likes: 0 });
}

function doPost(e) {
  const lock = LockService.getScriptLock();
  try {
    lock.waitLock(10000);
  } catch (e) {
    return createJsonResponse({ error: 'Could not obtain lock' });
  }
  
  try {
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
    const url = e.parameter.url;
    
    if (!url) {
      return createJsonResponse({ error: 'Missing url parameter' });
    }
    
    const data = sheet.getDataRange().getValues();
    for (let i = 1; i < data.length; i++) {
      if (data[i][0] === url) {
        const newLikes = (parseInt(data[i][1]) || 0) + 1;
        sheet.getRange(i + 1, 2).setValue(newLikes);
        return createJsonResponse({ url: url, likes: newLikes });
      }
    }
    
    // New entry - URL not found, add it
    sheet.appendRow([url, 1]);
    return createJsonResponse({ url: url, likes: 1 });
  } finally {
    lock.releaseLock();
  }
}

function createJsonResponse(data) {
  return ContentService.createTextOutput(JSON.stringify(data))
    .setMimeType(ContentService.MimeType.JSON);
}
  1. Click Save (Ctrl+S) and name the project (e.g., “Blog Likes”)

How the Script Works

  • doGet(e): Handles GET requests to fetch the current like count for a URL
  • doPost(e): Handles POST requests to increment the like count
  • LockService: Prevents race conditions when multiple users like simultaneously

Step 3: Deploy as Web App

  1. Click Deploy > New deployment
  2. Click the gear icon ⚙️ next to “Select type” and choose Web app
  3. Configure the deployment:
    • Description: Blog Like Counter (or any name)
    • Execute as: Me
    • Who has access: Anyone
  4. Click Deploy
  5. Click Authorize access and follow the prompts to grant permissions
  6. Copy the Web app URL - it looks like:
    https://script.google.com/macros/s/AKfycbw.../exec
    

⚠️ Important: Every time you modify the Apps Script code, you need to create a New deployment for changes to take effect.

Step 4: Create Hugo Template

Create a new partial template at layouts/partials/like-button.html:

{{- if .Site.Params.likeSheetUrl -}}
<div id="like-button-container" class="like-button-wrapper">
  <button id="like-btn" class="like-btn" onclick="toggleLike()" title="Like this post">
    <i id="like-icon" class="far fa-heart"></i>
    <span id="like-count">0</span>
  </button>
</div>

<style>
.like-button-wrapper {
  display: flex;
  justify-content: center;
  margin: 1.5rem 0;
}

.like-btn {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.6rem 1.2rem;
  font-size: 1rem;
  border: 2px solid #e74c3c;
  border-radius: 2rem;
  background: transparent;
  color: #e74c3c;
  cursor: pointer;
  transition: all 0.3s ease;
  font-family: inherit;
}

.like-btn:hover {
  background: rgba(231, 76, 60, 0.1);
  transform: scale(1.05);
}

.like-btn.liked {
  background: #e74c3c;
  color: white;
}

.like-btn.liked i {
  animation: heartBeat 0.3s ease-in-out;
}

.like-btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

@keyframes heartBeat {
  0% { transform: scale(1); }
  50% { transform: scale(1.3); }
  100% { transform: scale(1); }
}

/* Dark mode support */
.dark .like-btn {
  border-color: #ff6b6b;
  color: #ff6b6b;
}

.dark .like-btn:hover {
  background: rgba(255, 107, 107, 0.1);
}

.dark .like-btn.liked {
  background: #ff6b6b;
  color: white;
}
</style>

<script>
(function() {
  const LIKE_SHEET_URL = '{{ .Site.Params.likeSheetUrl }}';
  const POST_URL = '{{ .Permalink }}';
  const STORAGE_KEY = 'liked_posts';

  function hasLiked() {
    try {
      const likedPosts = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
      return likedPosts.includes(POST_URL);
    } catch (e) {
      return false;
    }
  }

  function saveLikedState() {
    try {
      const likedPosts = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
      if (!likedPosts.includes(POST_URL)) {
        likedPosts.push(POST_URL);
        localStorage.setItem(STORAGE_KEY, JSON.stringify(likedPosts));
      }
    } catch (e) {
      console.error('Failed to save liked state:', e);
    }
  }

  async function fetchLikeCount() {
    try {
      const response = await fetch(`${LIKE_SHEET_URL}?url=${encodeURIComponent(POST_URL)}`);
      const data = await response.json();
      document.getElementById('like-count').textContent = data.likes || 0;
    } catch (e) {
      console.error('Failed to fetch like count:', e);
    }
  }

  function updateButtonState() {
    const btn = document.getElementById('like-btn');
    const icon = document.getElementById('like-icon');
    if (hasLiked()) {
      btn.classList.add('liked');
      icon.classList.remove('far');
      icon.classList.add('fas');
    }
  }

  window.toggleLike = async function() {
    const btn = document.getElementById('like-btn');
    const icon = document.getElementById('like-icon');
    const countEl = document.getElementById('like-count');

    if (btn.disabled) return;

    if (hasLiked()) {
      // Optional: show notification that user already liked
      return;
    }

    btn.disabled = true;

    try {
      await fetch(`${LIKE_SHEET_URL}?url=${encodeURIComponent(POST_URL)}`, {
        method: 'POST',
        mode: 'no-cors'
      });

      // Optimistically update UI
      const currentCount = parseInt(countEl.textContent) || 0;
      countEl.textContent = currentCount + 1;

      btn.classList.add('liked');
      icon.classList.remove('far');
      icon.classList.add('fas');

      saveLikedState();

      // Refetch accurate count
      setTimeout(fetchLikeCount, 1000);

    } catch (e) {
      console.error('Failed to like post:', e);
    } finally {
      btn.disabled = false;
    }
  };

  document.addEventListener('DOMContentLoaded', function() {
    fetchLikeCount();
    updateButtonState();
  });
})();
</script>
{{- end -}}

Step 5: Include the Template

Add the like button to your single post template. Edit your layouts/_default/single.html or layouts/partials/footer.html:

{{- partial "like-button.html" . -}}

Step 6: Configure Hugo

Add the Web App URL to your config.toml:

[params]
likeSheetUrl = "https://script.google.com/macros/s/YOUR_SCRIPT_ID/exec"

Replace YOUR_SCRIPT_ID with your actual deployment URL from Step 3.

How It Works

Data Flow

┌─────────────┐     GET ?url=...     ┌─────────────────┐     Read      ┌──────────────┐
│   Browser   │ ──────────────────▶  │  Apps Script    │ ────────────▶ │ Google Sheet │
│  (Hugo Site)│ ◀──────────────────  │  (Web App)      │ ◀──────────── │   (Likes)    │
└─────────────┘    { likes: 42 }     └─────────────────┘    Data       └──────────────┘
       │ POST ?url=...
┌─────────────────┐
│  Increment +1   │
│  Save to Sheet  │
└─────────────────┘

Client-Side Storage

The browser’s localStorage tracks which posts the user has liked:

// Example localStorage data
{
  "liked_posts": [
    "https://example.com/posts/article-1",
    "https://example.com/posts/article-2"
  ]
}

This prevents users from liking the same post multiple times (per browser).

Troubleshooting

CORS Issues

Google Apps Script has limited CORS support. The implementation uses mode: 'no-cors' for POST requests, which means:

  • Response content cannot be read
  • UI updates optimistically and re-fetches afterward

Like Count Shows 0

  1. Verify the Web App URL is correct in config.toml
  2. Check browser console for errors
  3. Test the API directly: https://your-script-url/exec?url=test

Changes Not Taking Effect

After modifying the Apps Script:

  1. Go to Deploy > Manage deployments
  2. Click Create new deployment (not edit existing)
  3. Update the URL in config.toml if it changed

Conclusion

This solution provides a simple, free way to add like functionality to a Hugo blog. While it’s not suitable for high-traffic sites (Google Sheets has API limits), it works well for personal blogs and small sites.

For production use, consider:

  • Adding rate limiting
  • Using a proper database (Firebase, Supabase, etc.)
  • Implementing server-side validation

References

本文部分內容由Claude Opus 4.5協助生成,作者具備相關專業能力,對 AI 產出內容進行審核與把關,並對文章的正確性負最終責任。若文中有錯誤之處,敬請不吝指正,作者將虛心接受指教並儘速修正。

  • ⊛ Back to top
  • ⊛ Go to bottom