Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ It shows through the end of `until` (Eastern time), then switches itself off wit
## About card (home page)
`components/AboutCard.vue` holds the Skills, Hobbies and XP tabs. XP is a timeline by year: a slider from 2014 to now shows each year's education, work, volunteer, career-break and achievement entries, with a category icon for each. Swipe left or right across the card to move through the years (a swipe left goes to later years). The mouse wheel works too while the pointer is over the card, or while a keyboard or controller has focus in it: sideways or down goes to later years, and at the first or last year the page scrolls on. While the card is in use the social links below pause (they move as the card changes size): on phones until the card is scrolled away or you tap elsewhere (a tap on a paused link just unpauses it), with a mouse for a second after the press. The data lives in `data/xp.mjs` (`education`, `experience` and `achievements`). Hold the card (or alt-click on desktop) for plain English. In plain English, visitors whose browser prefers another language get a small link to read it in Google Translate (`utils/translate.js`); English readers don't see it. A link to `/#xp` (the Bio page has one) opens the card on XP (`utils/aboutCard.js`).

Kudos: in plain English, each XP entry has a πŸ‘ button with a live count shared by every visitor (`composables/useKudos.js`, refreshed every 15 seconds while the list is showing). The `/api/kudos` Netlify function (`netlify/functions/kudos.mjs`) keeps the counts as one JSON document in a Netlify Blobs store named `kudos`, apart from the site's data, keyed by each entry's id (`xpId` in `data/xp.mjs`, made from the fields that name the entry, so rewording a line keeps its kudos). It counts only known entries, once per visitor per entry per day (by a hash of their connection, which isn't stored; daily records are cleared after a day or two), and only from the site's own pages. Nothing to set up: Netlify provides the store. Where the function isn't running (a local preview), the buttons don't show.
Kudos: in plain English, each XP entry has a πŸ‘ button with a live count shared by every visitor (`components/KudosButton.vue` and `composables/useKudos.js`, refreshed every 15 seconds while the buttons are on screen). Blog posts (at the end of each post) and Curiosity Feed entries have one too, with the id `post-<slug>`. Next to it, β†— Share (`components/ShareButton.vue`, `utils/share.js`) opens the device's share sheet with the post's title and link, or copies the link where there's no share sheet (and says "Link copied"). The `/api/kudos` Netlify function (`netlify/functions/kudos.mjs`) keeps the counts as one JSON document in a Netlify Blobs store named `kudos`, apart from the site's data, keyed by each entry's id (`xpId` in `data/xp.mjs`, made from the fields that name the entry, so rewording a line keeps its kudos). Posts aren't a fixed list (dev.to posts arrive at build time), so a post id must be a plain slug (the dev.to mirror's rule) and the function checks the deployed site has that post (its prerendered `/blog/<slug>/_payload.json` answers 200; kept for 10 minutes) before its first kudos. It counts only known entries, once per visitor per entry per day (by a hash of their connection, which isn't stored; daily records are cleared after a day or two), and only from the site's own pages. Nothing to set up: Netlify provides the store. Where the function isn't running (a local preview), the buttons don't show.

The social links under the card are in `components/Socials.vue`. Tapping Instagram opens a small menu with both accounts: hansel.rocks (main) and hansel.codes (code and software engineering only). A pill gets a menu from its `choices` list (`components/SocialFlipLink.vue`). The addresses are in `utils/outbound.js`. On phones and tablets each link opens a new tab through `/out/<id>` (`pages/out/[id].vue`), which goes on to the link; when the phone hands the link to an app instead, that tab closes itself once you come back, so no blank tab is left behind.

Expand Down
43 changes: 1 addition & 42 deletions components/AboutCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -126,20 +126,7 @@
<span class="xp-entry__count" aria-hidden="true">{{ i + 1 }}/{{ yearEntries.length }}</span>
<span class="visually-hidden">Entry {{ i + 1 }} of {{ yearEntries.length }}, {{ entry.category }}: </span>
<strong>{{ entry.label }}</strong> Β· <TermText :text="entry.text" />
<!-- Live kudos (composables/useKudos.js): the count is in the label, not read out on every refresh -->
<button
v-if="kudos.available.value"
type="button"
:class="['kudos', { 'kudos--given': kudos.given.value.has(entry.id) }]"
:aria-label="kudos.given.value.has(entry.id) ? `Kudos given: ${entry.name}, ${kudos.counts.value[entry.id] ?? 0} in all` : `Give kudos: ${entry.name}, ${kudos.counts.value[entry.id] ?? 0} so far`"
:aria-disabled="kudos.given.value.has(entry.id) ? 'true' : undefined"
@click.stop="kudos.give(entry.id)"
@pointerdown.stop
@pointerup.stop
@keydown.stop
>
<span aria-hidden="true">πŸ‘ {{ kudos.counts.value[entry.id] ?? 0 }}</span>
</button>
<KudosButton :id="entry.id" :name="entry.name" />
</li>
</ul>
<p v-else key="edu-json" class="json json--edu json--xp soft-pulse">{{ yearJson }}</p>
Expand Down Expand Up @@ -949,34 +936,6 @@ cite {
line-height: 1.55;
}

.kudos {
display: inline-flex;
align-items: center;
min-height: 24px;
min-width: 24px;
margin-left: 0.4rem;
padding: 0 0.5rem;
border: 1px solid #475569;
border-radius: 999px;
background: transparent;
color: #e2e8f0;
font-size: 0.75rem;
line-height: 1.4;
white-space: nowrap;
cursor: pointer;
touch-action: manipulation;
}

.kudos:hover,
.kudos:focus-visible {
border-color: #94a3b8;
}

.kudos--given {
border-color: #22c55e;
cursor: default;
}

.edu-list li + li {
margin-top: 0.45rem;
}
Expand Down
59 changes: 59 additions & 0 deletions components/KudosButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<template>
<!-- Live kudos (composables/useKudos.js): the count is in the label, not read out on every
refresh. Presses stop here, so a tap never also counts as a tap on a card around it. -->
<button
v-if="kudos.available.value"
type="button"
:class="['kudos', { 'kudos--given': given }]"
:aria-label="given ? `Kudos given: ${name}, ${count} in all` : `Give kudos: ${name}, ${count} so far`"
:aria-disabled="given ? 'true' : undefined"
@click.stop="kudos.give(id)"
@pointerdown.stop
@pointerup.stop
@keydown.stop
>
<span aria-hidden="true">πŸ‘ {{ count }}</span>
</button>
</template>

<script setup>
const props = defineProps({
// The kudos id: an XP entry's (data/xp.mjs) or `post-<slug>`
id: { type: String, required: true },
// What the kudos is for, in the button's label
name: { type: String, required: true }
})

const kudos = useKudos()
const given = computed(() => kudos.hasGiven(props.id))
const count = computed(() => kudos.counts.value[props.id] ?? 0)
</script>

<style scoped>
.kudos {
display: inline-flex;
align-items: center;
min-height: 24px;
min-width: 24px;
padding: 0 0.5rem;
border: 1px solid #475569;
border-radius: 999px;
background: transparent;
color: #e2e8f0;
font-size: 0.75rem;
line-height: 1.4;
white-space: nowrap;
cursor: pointer;
touch-action: manipulation;
}

.kudos:hover,
.kudos:focus-visible {
border-color: #94a3b8;
}

.kudos--given {
border-color: #22c55e;
cursor: default;
}
</style>
67 changes: 67 additions & 0 deletions components/ShareButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<template>
<span class="share">
<button type="button" class="share__button" :aria-label="`Share: ${title}`" @click.stop="share">
<span aria-hidden="true">{{ result === 'copied' ? 'βœ“ Link copied' : result === 'failed' ? 'Copy failed' : 'β†— Share' }}</span>
</button>
<!-- Says what happened, for screen readers -->
<span class="visually-hidden" role="status">{{ message }}</span>
</span>
</template>

<script setup>
import { shareLink } from '~/utils/share.js'

const props = defineProps({
// The page to share, on this site (e.g. /blog/hello-world)
path: { type: String, required: true },
// What it is, for the share sheet and the button's label
title: { type: String, required: true }
})

const result = ref('')
const message = computed(() => ({ copied: 'Link copied', failed: "Couldn't copy the link" })[result.value] ?? '')
let timer = null

const share = async () => {
clearTimeout(timer)
result.value = await shareLink({ title: props.title, url: new URL(props.path, window.location.origin).href })
timer = setTimeout(() => (result.value = ''), 2500)
}
onBeforeUnmount(() => clearTimeout(timer))
</script>

<style scoped>
.share__button {
display: inline-flex;
align-items: center;
min-height: 24px;
min-width: 24px;
padding: 0 0.5rem;
border: 1px solid #475569;
border-radius: 999px;
background: transparent;
color: #e2e8f0;
font-size: 0.75rem;
line-height: 1.4;
white-space: nowrap;
cursor: pointer;
touch-action: manipulation;
}

.share__button:hover,
.share__button:focus-visible {
border-color: #94a3b8;
}

.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
42 changes: 21 additions & 21 deletions composables/useKudos.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,30 @@
// Kudos for the About card's XP entries (netlify/functions/kudos.mjs). Counts are shared by
// everyone and refreshed while the list is on screen; which entries this browser has already
// given kudos to is remembered locally, so the button shows it. Where the function isn't there
// (local preview), `available` stays false and the buttons don't show.
// Kudos (netlify/functions/kudos.mjs) for the About card's XP entries, blog posts and Curiosity
// Feed entries. Counts are shared by everyone, kept once for the whole app (useState) and
// refreshed while a page's buttons are on screen; which ones this browser has already given
// kudos to is remembered locally, so the button shows it. Where the function isn't there (local
// preview), `available` stays false and the buttons don't show.
const GIVEN_KEY = 'kudos:given'
const REFRESH_MS = 15_000

const readGiven = () => {
try {
const list = JSON.parse(localStorage.getItem(GIVEN_KEY) ?? '[]')
return new Set(Array.isArray(list) ? list.filter(id => typeof id === 'string') : [])
return Array.isArray(list) ? list.filter(id => typeof id === 'string') : []
} catch {
return new Set()
return []
}
}
const saveGiven = given => {
try {
localStorage.setItem(GIVEN_KEY, JSON.stringify([...given]))
localStorage.setItem(GIVEN_KEY, JSON.stringify(given))
} catch {}
}

export const useKudos = () => {
const available = ref(false)
const counts = ref({})
const given = ref(new Set())
let timer = null
const available = useState('kudos:available', () => false)
const counts = useState('kudos:counts', () => ({}))
const given = useState('kudos:given', () => [])
const hasGiven = id => given.value.includes(id)

const refresh = async () => {
try {
Expand All @@ -36,10 +37,10 @@ export const useKudos = () => {
}

const give = async id => {
if (given.value.has(id)) return
if (hasGiven(id)) return
const before = counts.value[id] ?? 0
counts.value = { ...counts.value, [id]: before + 1 }
given.value = new Set([...given.value, id])
given.value = [...given.value, id]
try {
const res = await fetch('/api/kudos', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) })
if (!res.ok) throw new Error(String(res.status))
Expand All @@ -48,17 +49,16 @@ export const useKudos = () => {
saveGiven(given.value)
} catch {
counts.value = { ...counts.value, [id]: before }
const rest = new Set(given.value)
rest.delete(id)
given.value = rest
given.value = given.value.filter(g => g !== id)
}
}

const tick = () => {
if (!document.hidden) refresh()
}
// Live while `active` (the plain-English XP list is showing)
// Live while `active` is true (the buttons are on screen); one timer per caller
const watchActive = active => {
let timer = null
const tick = () => {
if (!document.hidden) refresh()
}
watch(
active,
on => {
Expand All @@ -74,5 +74,5 @@ export const useKudos = () => {
onBeforeUnmount(() => clearInterval(timer))
}

return { available, counts, given, give, watchActive }
return { available, counts, given, hasGiven, give, watchActive }
}
59 changes: 49 additions & 10 deletions netlify/functions/kudos.mjs
Original file line number Diff line number Diff line change
@@ -1,33 +1,68 @@
// GET /api/kudos -> { counts: { [xpId]: n } }: kudos for the About card's XP entries
// POST /api/kudos { id } -> { id, count, counted }: one kudos for one entry (data/xp.mjs)
// GET /api/kudos -> { counts: { [id]: n } }: kudos for the About card's XP entries, blog
// posts and Curiosity Feed entries
// POST /api/kudos { id } -> { id, count, counted }: one kudos for one of them. An id is an XP
// entry's (data/xp.mjs) or `post-<slug>` for a post on the site.
//
// Posts aren't a fixed list (dev.to posts are mirrored in at build time), so a post id has to
// be a plain slug (the dev.to mirror's rule) for a page the deployed site really has: its
// prerendered /blog/<slug>/_payload.json answers 200. The answer is kept for 10 minutes.
//
// The counts are one JSON document ("counts") in the "kudos" Netlify Blobs store, kept apart
// from the site's own data. Each visitor counts once per entry per day: a marker keyed by an
// HMAC of their network (an IPv4 address, or an IPv6 /64: one home or phone connection), the
// entry and the day (so no address is stored) is written with onlyIfNew, and only a write that
// creates it adds to the count. Markers older than yesterday are swept as new kudos come in. The count itself is updated
// with onlyIfMatch on its ETag and retried, so kudos sent at the same moment aren't lost.
// creates it adds to the count. Markers older than yesterday are swept as new kudos come in.
// The count itself is updated with onlyIfMatch on its ETag and retried, so kudos sent at the same
// moment aren't lost.
import { createHmac } from 'node:crypto'
import { getStore } from '@netlify/blobs'
import { XP_IDS } from '../../data/xp.mjs'
import { SLUG } from '../../scripts/lib/devto.mjs'

const MAX_BODY = 1024
const MAX_TRIES = 6
const SWEEP_MAX = 300 // old markers deleted per counted kudos, at most
const COUNTS = 'counts'
const POST = 'post-'
const POST_TTL_MS = 10 * 60_000
const HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-robots-tag': 'noindex' }
const json = (body, status = 200, extra = {}) => new Response(JSON.stringify(body), { status, headers: { ...HEADERS, ...extra } })

// Only known entries, with whole non-negative counts: whatever else the stored document holds
const postSlug = id => (id.startsWith(POST) && SLUG.test(id.slice(POST.length)) ? id.slice(POST.length) : null)
// The shape of an id we count: an XP entry, or a post id with a plain slug (whether that post
// exists is checked before its first kudos, so only real posts' ids are ever stored)
const knownShape = id => XP_IDS.has(id) || postSlug(id) !== null

// Only known entries, with whole positive counts: whatever else the stored document holds
const cleanCounts = data => {
const counts = {}
for (const id of XP_IDS) {
const n = data && typeof data === 'object' && Object.hasOwn(data, id) ? data[id] : 0
if (Number.isSafeInteger(n) && n > 0) counts[id] = n
if (!data || typeof data !== 'object' || Array.isArray(data)) return counts
for (const id of Object.keys(data)) {
const n = data[id]
if (knownShape(id) && Number.isSafeInteger(n) && n > 0) counts[id] = n
}
return counts
}

// Whether the deployed site has the post: its prerendered payload answers 200 (no redirects)
const checkPost = async (slug, siteUrl) => {
try {
const res = await fetch(new URL(`/blog/${slug}/_payload.json`, siteUrl), { redirect: 'manual' })
return res.status === 200
} catch {
return false
}
}
const seenPosts = new Map()
export const cachedPostExists = async (slug, siteUrl, now = Date.now(), check = checkPost) => {
const hit = seenPosts.get(slug)
if (hit && now - hit.at < POST_TTL_MS) return hit.exists
const exists = await check(slug, siteUrl)
if (seenPosts.size > 1000) seenPosts.clear()
seenPosts.set(slug, { exists, at: now })
return exists
}

const addOne = async (store, id) => {
for (let i = 0; i < MAX_TRIES; i++) {
const current = await store.getWithMetadata(COUNTS, { type: 'json' })
Expand Down Expand Up @@ -75,7 +110,7 @@ const sweepOld = async (store, now) => {
}
}

export const handleKudos = async (req, { store, ip = '', secret = '', now = Date.now(), origins = [] }) => {
export const handleKudos = async (req, { store, ip = '', secret = '', now = Date.now(), origins = [], siteUrl = new URL(req.url).origin, postExists = slug => cachedPostExists(slug, siteUrl, now) }) => {
if (req.method === 'GET') {
const current = await store.get(COUNTS, { type: 'json' })
return json({ counts: cleanCounts(current) })
Expand All @@ -94,7 +129,9 @@ export const handleKudos = async (req, { store, ip = '', secret = '', now = Date
} catch {
return json({ error: 'Bad request' }, 400)
}
if (typeof id !== 'string' || !XP_IDS.has(id)) return json({ error: 'Unknown entry' }, 404)
if (typeof id !== 'string' || !knownShape(id)) return json({ error: 'Unknown entry' }, 404)
const slug = postSlug(id)
if (slug !== null && !(await postExists(slug))) return json({ error: 'Unknown entry' }, 404)

const day = new Date(now).toISOString().slice(0, 10)
const visitor = createHmac('sha256', `kudos:${secret}`).update(`${visitorNet(ip)}\n${id}\n${day}`).digest('hex')
Expand All @@ -120,6 +157,8 @@ export default async (req, context) =>
store: getStore({ name: 'kudos', consistency: 'strong' }),
ip: context?.ip ?? '',
secret: process.env.GITHUB_CLIENT_SECRET ?? '',
// This deploy's own address (a preview checks its own posts), else the site's
siteUrl: process.env.DEPLOY_URL || process.env.URL || new URL(req.url).origin,
// The site's own addresses (the request's own origin is always allowed)
origins: [process.env.URL, process.env.DEPLOY_URL, process.env.DEPLOY_PRIME_URL].filter(Boolean).map(u => new URL(u).origin)
})
Expand Down
Loading
Loading