From 0e66579f3685d723c408ed3b5d8e7508f27a607d Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Sun, 8 Mar 2026 15:27:42 +0100 Subject: [PATCH] feat(where): add OwnYourSwarm checkins page and metadata --- .env.example | 6 +- .github/workflows/deploy.yml | 6 + _data/whereCheckins.js | 353 +++++++++++++++++++++++++++++++ _includes/layouts/base.njk | 2 + slashes.njk | 8 + theme/_data/whereCheckins.js | 353 +++++++++++++++++++++++++++++++ theme/_includes/layouts/base.njk | 2 + theme/slashes.njk | 11 +- theme/where.njk | 131 ++++++++++++ where.njk | 131 ++++++++++++ 10 files changed, 1001 insertions(+), 2 deletions(-) create mode 100644 _data/whereCheckins.js create mode 100644 theme/_data/whereCheckins.js create mode 100644 theme/where.njk create mode 100644 where.njk diff --git a/.env.example b/.env.example index d214757..d7a5d6a 100644 --- a/.env.example +++ b/.env.example @@ -25,4 +25,8 @@ ACTIVITYPUB_HANDLE= AUTHOR_AVATAR=/images/avatar.jpg AUTHOR_TITLE= AUTHOR_PRONOUN= -SITE_LOCALE=de \ No newline at end of file +SITE_LOCALE=de + +# --- Where page (OwnYourSwarm/Swarm checkins) --- +OWNYOURSWARM_FEED_URL= +OWNYOURSWARM_FEED_TOKEN= \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 31909e4..80f3ebb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -40,6 +40,8 @@ jobs: AUTHOR_TITLE: ${{ secrets.AUTHOR_TITLE }} AUTHOR_PRONOUN: ${{ secrets.AUTHOR_PRONOUN }} SITE_LOCALE: ${{ secrets.SITE_LOCALE }} + OWNYOURSWARM_FEED_URL: ${{ secrets.OWNYOURSWARM_FEED_URL }} + OWNYOURSWARM_FEED_TOKEN: ${{ secrets.OWNYOURSWARM_FEED_TOKEN }} run: | { printf 'SITE_URL=%s\n' "$SITE_URL" @@ -59,6 +61,8 @@ jobs: printf 'AUTHOR_TITLE=%s\n' "$AUTHOR_TITLE" printf 'AUTHOR_PRONOUN=%s\n' "$AUTHOR_PRONOUN" printf 'SITE_LOCALE=%s\n' "$SITE_LOCALE" + printf 'OWNYOURSWARM_FEED_URL=%s\n' "$OWNYOURSWARM_FEED_URL" + printf 'OWNYOURSWARM_FEED_TOKEN=%s\n' "$OWNYOURSWARM_FEED_TOKEN" } > .env - name: Build site @@ -76,6 +80,8 @@ jobs: GITHUB_USERNAME: ${{ secrets.GH_USERNAME }} MASTODON_INSTANCE: ${{ secrets.MASTODON_INSTANCE }} MASTODON_USER: ${{ secrets.MASTODON_USER }} + OWNYOURSWARM_FEED_URL: ${{ secrets.OWNYOURSWARM_FEED_URL }} + OWNYOURSWARM_FEED_TOKEN: ${{ secrets.OWNYOURSWARM_FEED_TOKEN }} - name: Deploy via SCP uses: appleboy/scp-action@v0.1.7 diff --git a/_data/whereCheckins.js b/_data/whereCheckins.js new file mode 100644 index 0000000..dcf17a1 --- /dev/null +++ b/_data/whereCheckins.js @@ -0,0 +1,353 @@ +/** + * Where/Checkin data + * + * Fetches h-entry checkins from an OwnYourSwarm-connected endpoint. + * Expected payload: MF2 JSON with h-entry objects containing `checkin` and/or `location`. + */ + +import EleventyFetch from "@11ty/eleventy-fetch"; + +const FEED_URL = process.env.OWNYOURSWARM_FEED_URL || "https://ownyourswarm.p3k.io/"; +const FEED_TOKEN = process.env.OWNYOURSWARM_FEED_TOKEN || ""; + +function first(value) { + if (Array.isArray(value)) return value[0]; + return value; +} + +function asText(value) { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number") return String(value); + if (typeof value === "object") { + if (typeof value.value === "string") return value.value; + if (typeof value.text === "string") return value.text; + } + return ""; +} + +function asNumber(value) { + const raw = first(value); + const num = Number(raw); + return Number.isFinite(num) ? num : null; +} + +function joinLocation(locality, region, country) { + return [locality, region, country].filter(Boolean).join(", "); +} + +function buildCandidateUrls(baseUrl) { + const raw = (baseUrl || "").trim(); + if (!raw) return []; + + const urls = [raw]; + + try { + const parsed = new URL(raw); + const pathWithoutSlash = parsed.pathname.replace(/\/$/, ""); + const basePath = `${parsed.origin}${pathWithoutSlash}`; + + const withFormat = new URL(parsed.toString()); + withFormat.searchParams.set("format", "json"); + urls.push(withFormat.toString()); + + const withOutput = new URL(parsed.toString()); + withOutput.searchParams.set("output", "json"); + urls.push(withOutput.toString()); + + if (pathWithoutSlash) { + urls.push(`${basePath}.json`); + urls.push(`${basePath}/checkins.json`); + urls.push(`${basePath}/feed.json`); + urls.push(`${basePath}/api/checkins`); + } else { + urls.push(`${parsed.origin}/checkins.json`); + urls.push(`${parsed.origin}/feed.json`); + urls.push(`${parsed.origin}/api/checkins`); + } + } catch { + // If URL parsing fails, we still try the raw URL above. + } + + return [...new Set(urls)]; +} + +async function fetchJson(url) { + const headers = FEED_TOKEN ? { Authorization: `Bearer ${FEED_TOKEN}` } : {}; + const fetchOptions = Object.keys(headers).length ? { headers } : undefined; + + try { + return await EleventyFetch(url, { + duration: "15m", + type: "json", + fetchOptions, + }); + } catch (jsonError) { + // Some endpoints serve JSON with an incorrect content type. Retry as text. + const text = await EleventyFetch(url, { + duration: "15m", + type: "text", + fetchOptions, + }); + const trimmed = text.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { + throw jsonError; + } + return JSON.parse(trimmed); + } +} + +function looksLikeCheckinEntry(entry) { + if (!entry || typeof entry !== "object") return false; + const type = Array.isArray(entry.type) ? entry.type : []; + if (type.includes("h-entry")) { + const props = entry.properties || {}; + return Boolean(props.checkin || props.location); + } + return false; +} + +function extractEntries(payload) { + const queue = []; + if (Array.isArray(payload)) { + queue.push(...payload); + } else if (payload && typeof payload === "object") { + queue.push(payload); + } + + const entries = []; + + while (queue.length) { + const item = queue.shift(); + if (!item || typeof item !== "object") continue; + + if (looksLikeCheckinEntry(item)) { + entries.push(item); + } + + if (Array.isArray(item.items)) queue.push(...item.items); + if (Array.isArray(item.children)) queue.push(...item.children); + if (item.data && Array.isArray(item.data.items)) queue.push(...item.data.items); + } + + return entries; +} + +function uniqueStrings(values) { + return [...new Set(values.filter(Boolean))]; +} + +function parsePersonCard(card) { + if (!card || typeof card !== "object") return null; + const props = card.properties || {}; + + const urls = Array.isArray(props.url) + ? props.url.map((url) => asText(url)).filter(Boolean) + : []; + const photos = Array.isArray(props.photo) + ? props.photo.map((photo) => asText(photo)).filter(Boolean) + : []; + + return { + name: asText(first(props.name)), + url: urls[0] || "", + urls, + photo: photos[0] || "", + }; +} + +function parseCategory(categoryValues) { + const tags = []; + const people = []; + + for (const value of categoryValues) { + if (typeof value === "string") { + tags.push(value.trim()); + continue; + } + + if (!value || typeof value !== "object") continue; + const type = Array.isArray(value.type) ? value.type : []; + if (type.includes("h-card")) { + const person = parsePersonCard(value); + if (person && (person.name || person.url)) { + people.push(person); + } + } + } + + return { + tags: uniqueStrings(tags), + people, + }; +} + +function normalizeCheckin(entry) { + const props = entry.properties || {}; + + if (!props.checkin && !props.location) { + return null; + } + + const checkinCard = first(props.checkin); + const locationCard = first(props.location); + + const checkinProps = + checkinCard && typeof checkinCard === "object" && checkinCard.properties + ? checkinCard.properties + : {}; + const locationProps = + locationCard && typeof locationCard === "object" && locationCard.properties + ? locationCard.properties + : {}; + + const venueUrlsRaw = Array.isArray(checkinProps.url) + ? checkinProps.url + : checkinProps.url + ? [checkinProps.url] + : []; + const venueUrls = venueUrlsRaw.map((url) => asText(url)).filter(Boolean); + + const name = asText(first(checkinProps.name)) || "Unknown place"; + const venueUrl = venueUrls[0] || asText(checkinCard?.value); + const venueWebsiteUrl = venueUrls[1] || ""; + const venueSocialUrl = venueUrls[2] || ""; + + const locality = asText(first(checkinProps.locality)) || asText(first(locationProps.locality)); + const region = asText(first(checkinProps.region)) || asText(first(locationProps.region)); + const country = + asText(first(checkinProps["country-name"])) || + asText(first(locationProps["country-name"])); + const postalCode = + asText(first(checkinProps["postal-code"])) || + asText(first(locationProps["postal-code"])); + + const latitude = + asNumber(checkinProps.latitude) ?? asNumber(locationProps.latitude) ?? asNumber(props.latitude); + const longitude = + asNumber(checkinProps.longitude) ?? asNumber(locationProps.longitude) ?? asNumber(props.longitude); + + const published = asText(first(props.published)); + const syndication = asText(first(props.syndication)); + const visibility = asText(first(props.visibility)).toLowerCase(); + + const categoryValues = Array.isArray(props.category) ? props.category : []; + const category = parseCategory(categoryValues); + + const checkedInByCard = first(props["checked-in-by"]); + const checkedInBy = parsePersonCard(checkedInByCard); + + const photos = Array.isArray(props.photo) + ? props.photo.map((photo) => asText(photo)).filter(Boolean) + : []; + + const mapUrl = + latitude !== null && longitude !== null + ? `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}` + : ""; + + const coordinatesText = + latitude !== null && longitude !== null + ? `${latitude.toFixed(5)}, ${longitude.toFixed(5)}` + : ""; + + const locationText = joinLocation(locality, region, country); + const timestamp = published ? Date.parse(published) || 0 : 0; + const id = syndication || `${published}-${name}-${coordinatesText}`; + + return { + id, + published, + timestamp, + syndication, + visibility, + isPrivate: visibility === "private", + name, + photos, + tags: category.tags, + taggedPeople: category.people, + checkedInBy, + venueUrl, + venueWebsiteUrl, + venueSocialUrl, + locality, + region, + country, + postalCode, + locationText, + latitude, + longitude, + coordinatesText, + mapUrl, + }; +} + +function normalizeCheckins(entries) { + const seen = new Set(); + const checkins = []; + + for (const entry of entries) { + const normalized = normalizeCheckin(entry); + if (!normalized) continue; + if (seen.has(normalized.id)) continue; + seen.add(normalized.id); + checkins.push(normalized); + } + + return checkins.sort((a, b) => b.timestamp - a.timestamp); +} + +export default async function () { + const checkedAt = new Date().toISOString(); + const candidateUrls = buildCandidateUrls(FEED_URL); + const errors = []; + + for (const url of candidateUrls) { + try { + console.log(`[whereCheckins] Fetching: ${url}`); + const payload = await fetchJson(url); + const entries = extractEntries(payload); + const checkins = normalizeCheckins(entries); + + if (checkins.length > 0) { + const withCoordinates = checkins.filter( + (item) => item.latitude !== null && item.longitude !== null + ).length; + + return { + feedUrl: url, + checkins, + source: "ownyourswarm", + available: true, + checkedAt, + triedUrls: candidateUrls, + errors, + stats: { + total: checkins.length, + withCoordinates, + }, + }; + } + + errors.push(`No checkin h-entry objects found at ${url}`); + } catch (error) { + const message = `[whereCheckins] Unable to use ${url}: ${error.message}`; + console.log(message); + errors.push(message); + } + } + + return { + feedUrl: FEED_URL, + checkins: [], + source: "unavailable", + available: false, + checkedAt, + triedUrls: candidateUrls, + errors, + stats: { + total: 0, + withCoordinates: 0, + }, + }; +} diff --git a/_includes/layouts/base.njk b/_includes/layouts/base.njk index 053c598..8a53e50 100644 --- a/_includes/layouts/base.njk +++ b/_includes/layouts/base.njk @@ -187,6 +187,7 @@ {% if blogrollStatus and blogrollStatus.source == "indiekit" %}Blogroll{% endif %} {% if podrollStatus and podrollStatus.source == "indiekit" %}Podroll{% endif %} {% if newsActivity and newsActivity.source == "indiekit" %}News{% endif %} + Where All Pages @@ -253,6 +254,7 @@ {% if blogrollStatus and blogrollStatus.source == "indiekit" %}Blogroll{% endif %} {% if podrollStatus and podrollStatus.source == "indiekit" %}Podroll{% endif %} {% if newsActivity and newsActivity.source == "indiekit" %}News{% endif %} + Where All Pages diff --git a/slashes.njk b/slashes.njk index ff0d74e..387ce52 100644 --- a/slashes.njk +++ b/slashes.njk @@ -205,6 +205,14 @@ eleventyImport:

Social interactions (likes, reposts, replies)

+
  • +
    +

    + /where +

    +
    +

    Location check-ins from OwnYourSwarm

    +
  • diff --git a/theme/_data/whereCheckins.js b/theme/_data/whereCheckins.js new file mode 100644 index 0000000..dcf17a1 --- /dev/null +++ b/theme/_data/whereCheckins.js @@ -0,0 +1,353 @@ +/** + * Where/Checkin data + * + * Fetches h-entry checkins from an OwnYourSwarm-connected endpoint. + * Expected payload: MF2 JSON with h-entry objects containing `checkin` and/or `location`. + */ + +import EleventyFetch from "@11ty/eleventy-fetch"; + +const FEED_URL = process.env.OWNYOURSWARM_FEED_URL || "https://ownyourswarm.p3k.io/"; +const FEED_TOKEN = process.env.OWNYOURSWARM_FEED_TOKEN || ""; + +function first(value) { + if (Array.isArray(value)) return value[0]; + return value; +} + +function asText(value) { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number") return String(value); + if (typeof value === "object") { + if (typeof value.value === "string") return value.value; + if (typeof value.text === "string") return value.text; + } + return ""; +} + +function asNumber(value) { + const raw = first(value); + const num = Number(raw); + return Number.isFinite(num) ? num : null; +} + +function joinLocation(locality, region, country) { + return [locality, region, country].filter(Boolean).join(", "); +} + +function buildCandidateUrls(baseUrl) { + const raw = (baseUrl || "").trim(); + if (!raw) return []; + + const urls = [raw]; + + try { + const parsed = new URL(raw); + const pathWithoutSlash = parsed.pathname.replace(/\/$/, ""); + const basePath = `${parsed.origin}${pathWithoutSlash}`; + + const withFormat = new URL(parsed.toString()); + withFormat.searchParams.set("format", "json"); + urls.push(withFormat.toString()); + + const withOutput = new URL(parsed.toString()); + withOutput.searchParams.set("output", "json"); + urls.push(withOutput.toString()); + + if (pathWithoutSlash) { + urls.push(`${basePath}.json`); + urls.push(`${basePath}/checkins.json`); + urls.push(`${basePath}/feed.json`); + urls.push(`${basePath}/api/checkins`); + } else { + urls.push(`${parsed.origin}/checkins.json`); + urls.push(`${parsed.origin}/feed.json`); + urls.push(`${parsed.origin}/api/checkins`); + } + } catch { + // If URL parsing fails, we still try the raw URL above. + } + + return [...new Set(urls)]; +} + +async function fetchJson(url) { + const headers = FEED_TOKEN ? { Authorization: `Bearer ${FEED_TOKEN}` } : {}; + const fetchOptions = Object.keys(headers).length ? { headers } : undefined; + + try { + return await EleventyFetch(url, { + duration: "15m", + type: "json", + fetchOptions, + }); + } catch (jsonError) { + // Some endpoints serve JSON with an incorrect content type. Retry as text. + const text = await EleventyFetch(url, { + duration: "15m", + type: "text", + fetchOptions, + }); + const trimmed = text.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { + throw jsonError; + } + return JSON.parse(trimmed); + } +} + +function looksLikeCheckinEntry(entry) { + if (!entry || typeof entry !== "object") return false; + const type = Array.isArray(entry.type) ? entry.type : []; + if (type.includes("h-entry")) { + const props = entry.properties || {}; + return Boolean(props.checkin || props.location); + } + return false; +} + +function extractEntries(payload) { + const queue = []; + if (Array.isArray(payload)) { + queue.push(...payload); + } else if (payload && typeof payload === "object") { + queue.push(payload); + } + + const entries = []; + + while (queue.length) { + const item = queue.shift(); + if (!item || typeof item !== "object") continue; + + if (looksLikeCheckinEntry(item)) { + entries.push(item); + } + + if (Array.isArray(item.items)) queue.push(...item.items); + if (Array.isArray(item.children)) queue.push(...item.children); + if (item.data && Array.isArray(item.data.items)) queue.push(...item.data.items); + } + + return entries; +} + +function uniqueStrings(values) { + return [...new Set(values.filter(Boolean))]; +} + +function parsePersonCard(card) { + if (!card || typeof card !== "object") return null; + const props = card.properties || {}; + + const urls = Array.isArray(props.url) + ? props.url.map((url) => asText(url)).filter(Boolean) + : []; + const photos = Array.isArray(props.photo) + ? props.photo.map((photo) => asText(photo)).filter(Boolean) + : []; + + return { + name: asText(first(props.name)), + url: urls[0] || "", + urls, + photo: photos[0] || "", + }; +} + +function parseCategory(categoryValues) { + const tags = []; + const people = []; + + for (const value of categoryValues) { + if (typeof value === "string") { + tags.push(value.trim()); + continue; + } + + if (!value || typeof value !== "object") continue; + const type = Array.isArray(value.type) ? value.type : []; + if (type.includes("h-card")) { + const person = parsePersonCard(value); + if (person && (person.name || person.url)) { + people.push(person); + } + } + } + + return { + tags: uniqueStrings(tags), + people, + }; +} + +function normalizeCheckin(entry) { + const props = entry.properties || {}; + + if (!props.checkin && !props.location) { + return null; + } + + const checkinCard = first(props.checkin); + const locationCard = first(props.location); + + const checkinProps = + checkinCard && typeof checkinCard === "object" && checkinCard.properties + ? checkinCard.properties + : {}; + const locationProps = + locationCard && typeof locationCard === "object" && locationCard.properties + ? locationCard.properties + : {}; + + const venueUrlsRaw = Array.isArray(checkinProps.url) + ? checkinProps.url + : checkinProps.url + ? [checkinProps.url] + : []; + const venueUrls = venueUrlsRaw.map((url) => asText(url)).filter(Boolean); + + const name = asText(first(checkinProps.name)) || "Unknown place"; + const venueUrl = venueUrls[0] || asText(checkinCard?.value); + const venueWebsiteUrl = venueUrls[1] || ""; + const venueSocialUrl = venueUrls[2] || ""; + + const locality = asText(first(checkinProps.locality)) || asText(first(locationProps.locality)); + const region = asText(first(checkinProps.region)) || asText(first(locationProps.region)); + const country = + asText(first(checkinProps["country-name"])) || + asText(first(locationProps["country-name"])); + const postalCode = + asText(first(checkinProps["postal-code"])) || + asText(first(locationProps["postal-code"])); + + const latitude = + asNumber(checkinProps.latitude) ?? asNumber(locationProps.latitude) ?? asNumber(props.latitude); + const longitude = + asNumber(checkinProps.longitude) ?? asNumber(locationProps.longitude) ?? asNumber(props.longitude); + + const published = asText(first(props.published)); + const syndication = asText(first(props.syndication)); + const visibility = asText(first(props.visibility)).toLowerCase(); + + const categoryValues = Array.isArray(props.category) ? props.category : []; + const category = parseCategory(categoryValues); + + const checkedInByCard = first(props["checked-in-by"]); + const checkedInBy = parsePersonCard(checkedInByCard); + + const photos = Array.isArray(props.photo) + ? props.photo.map((photo) => asText(photo)).filter(Boolean) + : []; + + const mapUrl = + latitude !== null && longitude !== null + ? `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}` + : ""; + + const coordinatesText = + latitude !== null && longitude !== null + ? `${latitude.toFixed(5)}, ${longitude.toFixed(5)}` + : ""; + + const locationText = joinLocation(locality, region, country); + const timestamp = published ? Date.parse(published) || 0 : 0; + const id = syndication || `${published}-${name}-${coordinatesText}`; + + return { + id, + published, + timestamp, + syndication, + visibility, + isPrivate: visibility === "private", + name, + photos, + tags: category.tags, + taggedPeople: category.people, + checkedInBy, + venueUrl, + venueWebsiteUrl, + venueSocialUrl, + locality, + region, + country, + postalCode, + locationText, + latitude, + longitude, + coordinatesText, + mapUrl, + }; +} + +function normalizeCheckins(entries) { + const seen = new Set(); + const checkins = []; + + for (const entry of entries) { + const normalized = normalizeCheckin(entry); + if (!normalized) continue; + if (seen.has(normalized.id)) continue; + seen.add(normalized.id); + checkins.push(normalized); + } + + return checkins.sort((a, b) => b.timestamp - a.timestamp); +} + +export default async function () { + const checkedAt = new Date().toISOString(); + const candidateUrls = buildCandidateUrls(FEED_URL); + const errors = []; + + for (const url of candidateUrls) { + try { + console.log(`[whereCheckins] Fetching: ${url}`); + const payload = await fetchJson(url); + const entries = extractEntries(payload); + const checkins = normalizeCheckins(entries); + + if (checkins.length > 0) { + const withCoordinates = checkins.filter( + (item) => item.latitude !== null && item.longitude !== null + ).length; + + return { + feedUrl: url, + checkins, + source: "ownyourswarm", + available: true, + checkedAt, + triedUrls: candidateUrls, + errors, + stats: { + total: checkins.length, + withCoordinates, + }, + }; + } + + errors.push(`No checkin h-entry objects found at ${url}`); + } catch (error) { + const message = `[whereCheckins] Unable to use ${url}: ${error.message}`; + console.log(message); + errors.push(message); + } + } + + return { + feedUrl: FEED_URL, + checkins: [], + source: "unavailable", + available: false, + checkedAt, + triedUrls: candidateUrls, + errors, + stats: { + total: 0, + withCoordinates: 0, + }, + }; +} diff --git a/theme/_includes/layouts/base.njk b/theme/_includes/layouts/base.njk index cf5c196..a6a4401 100644 --- a/theme/_includes/layouts/base.njk +++ b/theme/_includes/layouts/base.njk @@ -166,6 +166,7 @@

  • {% endif %} +
  • +
    +

    + /where +

    +
    +

    Location check-ins from OwnYourSwarm

    +
  • {% endif %} diff --git a/theme/where.njk b/theme/where.njk new file mode 100644 index 0000000..1bf9c6d --- /dev/null +++ b/theme/where.njk @@ -0,0 +1,131 @@ +--- +layout: layouts/base.njk +title: Where +permalink: /where/ +withSidebar: true +--- +{% set checkins = whereCheckins.checkins or [] %} + +
    +
    +

    Where

    +

    + Recent check-ins from Swarm via OwnYourSwarm, rendered from check-in metadata. +

    + {% if whereCheckins.available %} +

    + Loaded {{ whereCheckins.stats.total }} check-ins{% if whereCheckins.stats.withCoordinates %} ({{ whereCheckins.stats.withCoordinates }} with coordinates){% endif %}. +

    + {% endif %} +
    + + {% if checkins.length %} +
    + {% for checkin in checkins %} +
    +
    +
    + +
    + +
    +

    + {% if checkin.venueUrl %} + {{ checkin.name }} + {% else %} + {{ checkin.name }} + {% endif %} +

    + + {% if checkin.locationText or checkin.postalCode %} +

    + {{ checkin.locationText }}{% if checkin.locationText and checkin.postalCode %}, {% endif %}{{ checkin.postalCode }} +

    + {% endif %} + +
    + {% if checkin.published %} + + {% endif %} + {% if checkin.coordinatesText %} + {{ checkin.coordinatesText }} + {% endif %} + {% if checkin.isPrivate %} + Private + {% endif %} + {% if checkin.checkedInBy and (checkin.checkedInBy.name or checkin.checkedInBy.url) %} + + Checked in by {% if checkin.checkedInBy.url %}{{ checkin.checkedInBy.name or checkin.checkedInBy.url }}{% else %}{{ checkin.checkedInBy.name }}{% endif %} + + {% endif %} +
    + + {% if checkin.tags and checkin.tags.length %} +
    + {% for tag in checkin.tags | head(8) %} + #{{ tag }} + {% endfor %} +
    + {% endif %} + + {% if checkin.taggedPeople and checkin.taggedPeople.length %} +
    + {% for person in checkin.taggedPeople | head(6) %} + {% if person.url %} + {{ person.name or person.url }} + {% else %} + {{ person.name }} + {% endif %} + {% endfor %} +
    + {% endif %} + +
    + {% if checkin.syndication %} + Swarm + {% endif %} + {% if checkin.mapUrl %} + Map + {% endif %} + {% if checkin.venueWebsiteUrl %} + Website + {% endif %} + {% if checkin.venueSocialUrl %} + Social + {% endif %} +
    + + {% if checkin.photos and checkin.photos.length %} +
    + {% for photo in checkin.photos | head(3) %} + + Check-in photo + + {% endfor %} +
    + {% endif %} +
    +
    +
    + {% endfor %} +
    + {% else %} +
    +

    No check-ins available yet

    +

    + This page expects an endpoint that returns MF2 JSON h-entry checkins with properties like published, checkin, location, category, and checked-in-by. +

    + {% if whereCheckins.feedUrl %} +

    + Current feed URL: {{ whereCheckins.feedUrl }} +

    + {% endif %} +

    + Configure OWNYOURSWARM_FEED_URL (and optionally OWNYOURSWARM_FEED_TOKEN) in your environment or deploy secrets. +

    +
    + {% endif %} +
    diff --git a/where.njk b/where.njk new file mode 100644 index 0000000..1bf9c6d --- /dev/null +++ b/where.njk @@ -0,0 +1,131 @@ +--- +layout: layouts/base.njk +title: Where +permalink: /where/ +withSidebar: true +--- +{% set checkins = whereCheckins.checkins or [] %} + +
    +
    +

    Where

    +

    + Recent check-ins from Swarm via OwnYourSwarm, rendered from check-in metadata. +

    + {% if whereCheckins.available %} +

    + Loaded {{ whereCheckins.stats.total }} check-ins{% if whereCheckins.stats.withCoordinates %} ({{ whereCheckins.stats.withCoordinates }} with coordinates){% endif %}. +

    + {% endif %} +
    + + {% if checkins.length %} +
    + {% for checkin in checkins %} +
    +
    +
    + +
    + +
    +

    + {% if checkin.venueUrl %} + {{ checkin.name }} + {% else %} + {{ checkin.name }} + {% endif %} +

    + + {% if checkin.locationText or checkin.postalCode %} +

    + {{ checkin.locationText }}{% if checkin.locationText and checkin.postalCode %}, {% endif %}{{ checkin.postalCode }} +

    + {% endif %} + +
    + {% if checkin.published %} + + {% endif %} + {% if checkin.coordinatesText %} + {{ checkin.coordinatesText }} + {% endif %} + {% if checkin.isPrivate %} + Private + {% endif %} + {% if checkin.checkedInBy and (checkin.checkedInBy.name or checkin.checkedInBy.url) %} + + Checked in by {% if checkin.checkedInBy.url %}{{ checkin.checkedInBy.name or checkin.checkedInBy.url }}{% else %}{{ checkin.checkedInBy.name }}{% endif %} + + {% endif %} +
    + + {% if checkin.tags and checkin.tags.length %} +
    + {% for tag in checkin.tags | head(8) %} + #{{ tag }} + {% endfor %} +
    + {% endif %} + + {% if checkin.taggedPeople and checkin.taggedPeople.length %} +
    + {% for person in checkin.taggedPeople | head(6) %} + {% if person.url %} + {{ person.name or person.url }} + {% else %} + {{ person.name }} + {% endif %} + {% endfor %} +
    + {% endif %} + +
    + {% if checkin.syndication %} + Swarm + {% endif %} + {% if checkin.mapUrl %} + Map + {% endif %} + {% if checkin.venueWebsiteUrl %} + Website + {% endif %} + {% if checkin.venueSocialUrl %} + Social + {% endif %} +
    + + {% if checkin.photos and checkin.photos.length %} +
    + {% for photo in checkin.photos | head(3) %} + + Check-in photo + + {% endfor %} +
    + {% endif %} +
    +
    +
    + {% endfor %} +
    + {% else %} +
    +

    No check-ins available yet

    +

    + This page expects an endpoint that returns MF2 JSON h-entry checkins with properties like published, checkin, location, category, and checked-in-by. +

    + {% if whereCheckins.feedUrl %} +

    + Current feed URL: {{ whereCheckins.feedUrl }} +

    + {% endif %} +

    + Configure OWNYOURSWARM_FEED_URL (and optionally OWNYOURSWARM_FEED_TOKEN) in your environment or deploy secrets. +

    +
    + {% endif %} +