Skip to content

Commit 5cff7c3

Browse files
committed
Blog: Add Like buttons
1 parent 5d4cc7c commit 5cff7c3

4 files changed

Lines changed: 235 additions & 0 deletions

File tree

apps/api-server/src/index.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { pruneStaleDevices, shouldAlert, type DeviceSet } from './device-trackin
2121
type Bindings = {
2222
// KV namespace for license code -> full key mappings
2323
LICENSE_CODES: KVNamespace
24+
// KV namespace for blog post likes
25+
BLOG_LIKES: KVNamespace
2426
// Analytics Engine for device count tracking (fair use monitoring)
2527
DEVICE_COUNTS: AnalyticsEngineDataset
2628
// D1 database for telemetry persistence (crash reports, downloads, update checks)
@@ -868,6 +870,80 @@ app.get('/download/:version/:arch', async (c) => {
868870
return c.redirect(`https://github.com/vdavid/cmdr/releases/download/v${version}/Cmdr_${version}_${arch}.dmg`, 302)
869871
})
870872

873+
// --- Blog likes ---
874+
875+
type LikesData = { count: number; hashes: string[] }
876+
877+
const likesAllowedOrigins = new Set(['https://getcmdr.com', 'https://www.getcmdr.com'])
878+
879+
function likesCors(c: { req: { header: (name: string) => string | undefined }; header: (name: string, value: string) => void }) {
880+
const origin = c.req.header('origin')
881+
if (origin && likesAllowedOrigins.has(origin)) {
882+
c.header('Access-Control-Allow-Origin', origin)
883+
c.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
884+
c.header('Access-Control-Allow-Headers', 'Content-Type')
885+
c.header('Vary', 'Origin')
886+
}
887+
}
888+
889+
async function hashIpForLikes(ip: string): Promise<string> {
890+
const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode('cmdr-likes:' + ip))
891+
return [...new Uint8Array(buffer)].slice(0, 8).map((b) => b.toString(16).padStart(2, '0')).join('')
892+
}
893+
894+
async function getLikesData(kv: KVNamespace, slug: string): Promise<LikesData> {
895+
const raw = await kv.get(`likes:${slug}`)
896+
if (!raw) return { count: 0, hashes: [] }
897+
return JSON.parse(raw) as LikesData
898+
}
899+
900+
app.options('/likes/:slug', (c) => {
901+
likesCors(c)
902+
return c.body(null, 204)
903+
})
904+
905+
app.get('/likes/:slug', async (c) => {
906+
likesCors(c)
907+
const slug = c.req.param('slug')
908+
const ip = c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? 'unknown'
909+
const ipHash = await hashIpForLikes(ip)
910+
const data = await getLikesData(c.env.BLOG_LIKES, slug)
911+
return c.json({ count: data.count, liked: data.hashes.includes(ipHash) })
912+
})
913+
914+
app.post('/likes/:slug', async (c) => {
915+
likesCors(c)
916+
const slug = c.req.param('slug')
917+
const ip = c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? 'unknown'
918+
const ipHash = await hashIpForLikes(ip)
919+
const data = await getLikesData(c.env.BLOG_LIKES, slug)
920+
921+
if (!data.hashes.includes(ipHash)) {
922+
data.hashes.push(ipHash)
923+
data.count = data.hashes.length
924+
await c.env.BLOG_LIKES.put(`likes:${slug}`, JSON.stringify(data))
925+
}
926+
927+
return c.json({ count: data.count, liked: true })
928+
})
929+
930+
app.delete('/likes/:slug', async (c) => {
931+
likesCors(c)
932+
const slug = c.req.param('slug')
933+
const ip = c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for') ?? 'unknown'
934+
const ipHash = await hashIpForLikes(ip)
935+
const data = await getLikesData(c.env.BLOG_LIKES, slug)
936+
937+
const idx = data.hashes.indexOf(ipHash)
938+
if (idx !== -1) {
939+
data.hashes.splice(idx, 1)
940+
data.count = data.hashes.length
941+
await c.env.BLOG_LIKES.put(`likes:${slug}`, JSON.stringify(data))
942+
}
943+
944+
return c.json({ count: data.count, liked: false })
945+
})
946+
871947
export { app }
872948

873949
// --- Scheduled handler (cron) ---

apps/api-server/wrangler.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ zone_name = "getcmdr.com"
2222
binding = "LICENSE_CODES"
2323
id = "64418f5cb33e4c629fe0ccf51be76399"
2424

25+
# KV namespace for blog post likes (separate from license data)
26+
[[kv_namespaces]]
27+
binding = "BLOG_LIKES"
28+
id = "4ff902a7cb084fca97e02c7758ba913e"
29+
2530
# Analytics Engine for device count tracking (fair use monitoring)
2631
[[analytics_engine_datasets]]
2732
binding = "DEVICE_COUNTS"
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
---
2+
3+
---
4+
5+
<div class="like-section">
6+
<button class="like-button" aria-label="Like this article">
7+
<svg class="like-heart" viewBox="0 0 24 24" width="20" height="20">
8+
<path
9+
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
10+
></path>
11+
</svg>
12+
<span class="like-count"></span>
13+
</button>
14+
<span class="like-error"></span>
15+
</div>
16+
17+
<script is:inline>
18+
;(function () {
19+
const section = document.querySelector('.like-section')
20+
const button = section.querySelector('.like-button')
21+
const heart = button.querySelector('.like-heart')
22+
const countEl = button.querySelector('.like-count')
23+
const errorEl = section.querySelector('.like-error')
24+
const slug = window.location.pathname.replace(/^\/blog\//, '').replace(/\/$/, '')
25+
const apiBase = 'https://api.getcmdr.com'
26+
const storageKey = `liked:${slug}`
27+
const countKey = `like-count:${slug}`
28+
const isDev = location.hostname === 'localhost' || location.hostname === '127.0.0.1'
29+
30+
// Restore state instantly from localStorage
31+
const cachedCount = localStorage.getItem(countKey)
32+
if (localStorage.getItem(storageKey)) {
33+
heart.classList.add('liked')
34+
}
35+
if (cachedCount) {
36+
countEl.textContent = cachedCount
37+
}
38+
39+
if (isDev) return
40+
41+
// Fetch current count and server-side liked status
42+
fetch(`${apiBase}/likes/${encodeURIComponent(slug)}`)
43+
.then(r => r.json())
44+
.then(data => {
45+
updateCount(data.count)
46+
if (data.liked) {
47+
heart.classList.add('liked')
48+
localStorage.setItem(storageKey, '1')
49+
} else {
50+
heart.classList.remove('liked')
51+
localStorage.removeItem(storageKey)
52+
}
53+
})
54+
.catch(() => {})
55+
56+
function updateCount(count) {
57+
countEl.textContent = count > 0 ? count : ''
58+
if (count > 0) {
59+
localStorage.setItem(countKey, String(count))
60+
} else {
61+
localStorage.removeItem(countKey)
62+
}
63+
}
64+
65+
button.addEventListener('click', () => {
66+
const isLiked = heart.classList.contains('liked')
67+
const prevCount = countEl.textContent
68+
69+
// Optimistic update
70+
heart.classList.toggle('liked')
71+
errorEl.textContent = ''
72+
if (isLiked) {
73+
localStorage.removeItem(storageKey)
74+
} else {
75+
localStorage.setItem(storageKey, '1')
76+
}
77+
78+
fetch(`${apiBase}/likes/${encodeURIComponent(slug)}`, { method: isLiked ? 'DELETE' : 'POST' })
79+
.then(r => {
80+
if (!r.ok) throw new Error()
81+
return r.json()
82+
})
83+
.then(data => updateCount(data.count))
84+
.catch(() => {
85+
// Rollback
86+
heart.classList.toggle('liked')
87+
countEl.textContent = prevCount
88+
if (isLiked) {
89+
localStorage.setItem(storageKey, '1')
90+
} else {
91+
localStorage.removeItem(storageKey)
92+
}
93+
errorEl.textContent = 'Something went wrong — try again later.'
94+
})
95+
})
96+
})()
97+
</script>
98+
99+
<style>
100+
.like-section {
101+
margin-top: 2rem;
102+
margin-bottom: 1rem;
103+
}
104+
105+
.like-button {
106+
display: inline-flex;
107+
align-items: center;
108+
gap: 0.4em;
109+
background: none;
110+
border: none;
111+
padding: 0;
112+
cursor: pointer;
113+
font-family: var(--font-sans);
114+
font-size: 0.875rem;
115+
color: var(--color-text-tertiary);
116+
transition: color var(--duration-normal) var(--ease-out-expo);
117+
}
118+
119+
.like-button:hover {
120+
color: var(--color-accent);
121+
}
122+
123+
.like-heart {
124+
fill: none;
125+
stroke: currentColor;
126+
stroke-width: 2;
127+
transition:
128+
fill var(--duration-normal) var(--ease-out-expo),
129+
color var(--duration-normal) var(--ease-out-expo);
130+
}
131+
132+
.like-heart.liked {
133+
fill: var(--color-accent);
134+
color: var(--color-accent);
135+
}
136+
137+
.like-count {
138+
line-height: 20px;
139+
}
140+
141+
.like-error:empty {
142+
display: none;
143+
}
144+
145+
.like-error {
146+
display: block;
147+
margin-top: 0.3em;
148+
font-family: var(--font-sans);
149+
font-size: 0.75rem;
150+
color: var(--color-text-tertiary);
151+
}
152+
</style>

apps/website/src/pages/blog/[slug].astro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
import BlogLayout from '../../layouts/BlogLayout.astro'
3+
import LikeButton from '../../components/LikeButton.astro'
34
import BlogNewsletterCta from '../../components/BlogNewsletterCta.astro'
45
import Remark42Comments from '../../components/Remark42Comments.astro'
56
import { getCollection, render } from 'astro:content'
@@ -23,6 +24,7 @@ const { Content } = await render(post)
2324
ogImage={`/og/${post.id}.png`}
2425
>
2526
<Content />
27+
<LikeButton />
2628
<BlogNewsletterCta />
2729
<Remark42Comments />
2830
</BlogLayout>

0 commit comments

Comments
 (0)