diff --git a/.gitignore b/.gitignore index bc2cdd3..0e517dd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules _site .DS_Store src/_data/views.json +src/_data/likes.json diff --git a/docker-compose.yml b/docker-compose.yml index 6f7c7c1..f208baf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,8 +14,8 @@ services: - 3000:3000 volumes: # Persist the view count data in a named volume. - - views_data:/app/_data + - data:/app/_data restart: unless-stopped volumes: - views_data: null + data: null networks: {} diff --git a/src/_includes/post.njk b/src/_includes/post.njk index 536889f..2f9e907 100644 --- a/src/_includes/post.njk +++ b/src/_includes/post.njk @@ -21,9 +21,23 @@ layout: "layout.njk" ... + +
+ + 0 +
+ +
{{ content | safe }} +
diff --git a/src/css/style.css b/src/css/style.css index c07b0ca..332c3db 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -182,3 +182,18 @@ article ol { article li { margin-bottom: 0.5rem; } +.like-icon { + cursor: pointer; + transition: color 0.2s ease-in-out; +} +.like-icon:hover { + color: #fff; + transform: scale(1.15); +} +.like-icon.liked { + cursor: pointer; + color: #e2264d; +} +.like-icon.liked:hover { + transform: scale(1.15); +} diff --git a/src/js/like-button.js b/src/js/like-button.js new file mode 100644 index 0000000..1864e11 --- /dev/null +++ b/src/js/like-button.js @@ -0,0 +1,57 @@ +document.addEventListener('DOMContentLoaded', () => { + const likeIcon = document.querySelector('[data-like-button]'); + if (!likeIcon) return; + + const slug = likeIcon.dataset.slug; + const likeCountSpan = document.querySelector(`[data-like-count][data-slug="${slug}"]`); + const storageKey = `liked-${slug}`; + + // Function to update the UI based on liked state and count + const updateUI = (isLiked, count) => { + if (likeCountSpan) { + likeCountSpan.textContent = count; + } + if (isLiked) { + likeIcon.classList.add('liked'); + } else { + likeIcon.classList.remove('liked'); + } + }; + + // Fetch and display the initial like count and set the initial state + const getInitialState = async () => { + try { + const response = await fetch(`/api/likes/${slug}`); + if (!response.ok) return; + const data = await response.json(); + const isLiked = localStorage.getItem(storageKey) === 'true'; + updateUI(isLiked, data.count || 0); + } catch (error) { + console.error('Error fetching likes:', error); + } + }; + + // Add click event listener to the icon + likeIcon.addEventListener('click', async () => { + const isCurrentlyLiked = likeIcon.classList.contains('liked'); + const method = isCurrentlyLiked ? 'DELETE' : 'POST'; + + try { + const response = await fetch(`/api/likes/${slug}`, { method }); + if (!response.ok) return; + const data = await response.json(); + + const isNowLiked = !isCurrentlyLiked; + // Update localStorage to remember the user's choice + localStorage.setItem(storageKey, isNowLiked); + // Update the button and count on the page + updateUI(isNowLiked, data.count); + + } catch (error) { + console.error('Error submitting like/unlike:', error); + } + }); + + // Load the initial state when the page loads + getInitialState(); +}); \ No newline at end of file diff --git a/src/server.js b/src/server.js index ace6419..cb3be7d 100644 --- a/src/server.js +++ b/src/server.js @@ -8,16 +8,24 @@ const port = 3000; app.set('trust proxy', true); -// Path to the file where view counts will be stored +// Path to the file where metadata counts will be stored const dbPath = path.join(__dirname, '_data', 'views.json'); +const likesDbPath = path.join(__dirname, '_data', 'likes.json'); -// Ensure the data directory and the views.json file exist +// Ensure the data directory and the .json files exist fs.ensureFileSync(dbPath); const initialData = fs.readFileSync(dbPath, 'utf8'); if (initialData.length === 0) { fs.writeJsonSync(dbPath, {}); } +fs.ensureFileSync(likesDbPath); +const initialLikesData = fs.readFileSync(likesDbPath, 'utf8'); +if (initialLikesData.length === 0) { + fs.writeJsonSync(likesDbPath, {}); +} + + app.use(cors()); app.use(express.json()); @@ -39,6 +47,20 @@ app.get('/api/views/:slug', async (req, res) => { } }); +// GET: Fetch the like count for a specific post slug +app.get('/api/likes/:slug', async (req, res) => { + try { + const { slug } = req.params; + const likes = await fs.readJson(likesDbPath); + const count = likes[slug] || 0; + res.json({ count }); + } catch (error) { + console.error('Error reading like count:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + + // POST: Increment the view count for a specific post slug app.post('/api/views/:slug', async (req, res) => { try { @@ -53,6 +75,39 @@ app.post('/api/views/:slug', async (req, res) => { } }); +// POST: Increment the like count for a specific post slug +app.post('/api/likes/:slug', async (req, res) => { + try { + const { slug } = req.params; + const likes = await fs.readJson(likesDbPath); + likes[slug] = (likes[slug] || 0) + 1; + await fs.writeJson(likesDbPath, likes); + res.json({ count: likes[slug] }); + } catch (error) { + console.error('Error updating like count:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +// DELETE: Decrement the like count for a specific post slug (unlike) +app.delete('/api/likes/:slug', async (req, res) => { + try { + const { slug } = req.params; + const likes = await fs.readJson(likesDbPath); + // Ensure the count doesn't go below zero + if (likes[slug] > 0) { + likes[slug] -= 1; + } else { + likes[slug] = 0; + } + await fs.writeJson(likesDbPath, likes); + res.json({ count: likes[slug] }); + } catch (error) { + console.error('Error updating like count:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + // --- 404 Handler (Catch-All) --- // This MUST be the last route or middleware added. app.use((req, res, next) => {