feat(where): add OwnYourSwarm checkins page and metadata
This commit is contained in:
+5
-1
@@ -25,4 +25,8 @@ ACTIVITYPUB_HANDLE=
|
|||||||
AUTHOR_AVATAR=/images/avatar.jpg
|
AUTHOR_AVATAR=/images/avatar.jpg
|
||||||
AUTHOR_TITLE=
|
AUTHOR_TITLE=
|
||||||
AUTHOR_PRONOUN=
|
AUTHOR_PRONOUN=
|
||||||
SITE_LOCALE=de
|
SITE_LOCALE=de
|
||||||
|
|
||||||
|
# --- Where page (OwnYourSwarm/Swarm checkins) ---
|
||||||
|
OWNYOURSWARM_FEED_URL=
|
||||||
|
OWNYOURSWARM_FEED_TOKEN=
|
||||||
@@ -40,6 +40,8 @@ jobs:
|
|||||||
AUTHOR_TITLE: ${{ secrets.AUTHOR_TITLE }}
|
AUTHOR_TITLE: ${{ secrets.AUTHOR_TITLE }}
|
||||||
AUTHOR_PRONOUN: ${{ secrets.AUTHOR_PRONOUN }}
|
AUTHOR_PRONOUN: ${{ secrets.AUTHOR_PRONOUN }}
|
||||||
SITE_LOCALE: ${{ secrets.SITE_LOCALE }}
|
SITE_LOCALE: ${{ secrets.SITE_LOCALE }}
|
||||||
|
OWNYOURSWARM_FEED_URL: ${{ secrets.OWNYOURSWARM_FEED_URL }}
|
||||||
|
OWNYOURSWARM_FEED_TOKEN: ${{ secrets.OWNYOURSWARM_FEED_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
{
|
{
|
||||||
printf 'SITE_URL=%s\n' "$SITE_URL"
|
printf 'SITE_URL=%s\n' "$SITE_URL"
|
||||||
@@ -59,6 +61,8 @@ jobs:
|
|||||||
printf 'AUTHOR_TITLE=%s\n' "$AUTHOR_TITLE"
|
printf 'AUTHOR_TITLE=%s\n' "$AUTHOR_TITLE"
|
||||||
printf 'AUTHOR_PRONOUN=%s\n' "$AUTHOR_PRONOUN"
|
printf 'AUTHOR_PRONOUN=%s\n' "$AUTHOR_PRONOUN"
|
||||||
printf 'SITE_LOCALE=%s\n' "$SITE_LOCALE"
|
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
|
} > .env
|
||||||
|
|
||||||
- name: Build site
|
- name: Build site
|
||||||
@@ -76,6 +80,8 @@ jobs:
|
|||||||
GITHUB_USERNAME: ${{ secrets.GH_USERNAME }}
|
GITHUB_USERNAME: ${{ secrets.GH_USERNAME }}
|
||||||
MASTODON_INSTANCE: ${{ secrets.MASTODON_INSTANCE }}
|
MASTODON_INSTANCE: ${{ secrets.MASTODON_INSTANCE }}
|
||||||
MASTODON_USER: ${{ secrets.MASTODON_USER }}
|
MASTODON_USER: ${{ secrets.MASTODON_USER }}
|
||||||
|
OWNYOURSWARM_FEED_URL: ${{ secrets.OWNYOURSWARM_FEED_URL }}
|
||||||
|
OWNYOURSWARM_FEED_TOKEN: ${{ secrets.OWNYOURSWARM_FEED_TOKEN }}
|
||||||
|
|
||||||
- name: Deploy via SCP
|
- name: Deploy via SCP
|
||||||
uses: appleboy/scp-action@v0.1.7
|
uses: appleboy/scp-action@v0.1.7
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -187,6 +187,7 @@
|
|||||||
{% if blogrollStatus and blogrollStatus.source == "indiekit" %}<a href="/blogroll/" role="menuitem">Blogroll</a>{% endif %}
|
{% if blogrollStatus and blogrollStatus.source == "indiekit" %}<a href="/blogroll/" role="menuitem">Blogroll</a>{% endif %}
|
||||||
{% if podrollStatus and podrollStatus.source == "indiekit" %}<a href="/podroll/" role="menuitem">Podroll</a>{% endif %}
|
{% if podrollStatus and podrollStatus.source == "indiekit" %}<a href="/podroll/" role="menuitem">Podroll</a>{% endif %}
|
||||||
{% if newsActivity and newsActivity.source == "indiekit" %}<a href="/news/" role="menuitem">News</a>{% endif %}
|
{% if newsActivity and newsActivity.source == "indiekit" %}<a href="/news/" role="menuitem">News</a>{% endif %}
|
||||||
|
<a href="/where/" role="menuitem">Where</a>
|
||||||
<a href="/slashes/" role="menuitem">All Pages</a>
|
<a href="/slashes/" role="menuitem">All Pages</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,6 +254,7 @@
|
|||||||
{% if blogrollStatus and blogrollStatus.source == "indiekit" %}<a href="/blogroll/">Blogroll</a>{% endif %}
|
{% if blogrollStatus and blogrollStatus.source == "indiekit" %}<a href="/blogroll/">Blogroll</a>{% endif %}
|
||||||
{% if podrollStatus and podrollStatus.source == "indiekit" %}<a href="/podroll/">Podroll</a>{% endif %}
|
{% if podrollStatus and podrollStatus.source == "indiekit" %}<a href="/podroll/">Podroll</a>{% endif %}
|
||||||
{% if newsActivity and newsActivity.source == "indiekit" %}<a href="/news/">News</a>{% endif %}
|
{% if newsActivity and newsActivity.source == "indiekit" %}<a href="/news/">News</a>{% endif %}
|
||||||
|
<a href="/where/">Where</a>
|
||||||
<a href="/slashes/">All Pages</a>
|
<a href="/slashes/">All Pages</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -205,6 +205,14 @@ eleventyImport:
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-surface-600 dark:text-surface-400 mt-2">Social interactions (likes, reposts, replies)</p>
|
<p class="text-surface-600 dark:text-surface-400 mt-2">Social interactions (likes, reposts, replies)</p>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="h-entry post-card">
|
||||||
|
<div class="post-header">
|
||||||
|
<h3 class="text-xl font-semibold">
|
||||||
|
<a href="/where/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/where</a>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-surface-600 dark:text-surface-400 mt-2">Location check-ins from OwnYourSwarm</p>
|
||||||
|
</li>
|
||||||
<li class="h-entry post-card">
|
<li class="h-entry post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<h3 class="text-xl font-semibold">
|
<h3 class="text-xl font-semibold">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -166,6 +166,7 @@
|
|||||||
<div class="nav-dropdown-menu" x-show="open" x-transition x-cloak>
|
<div class="nav-dropdown-menu" x-show="open" x-transition x-cloak>
|
||||||
<a href="/slashes/">All Pages</a>
|
<a href="/slashes/">All Pages</a>
|
||||||
<a href="/cv/">/cv</a>
|
<a href="/cv/">/cv</a>
|
||||||
|
<a href="/where/">/where</a>
|
||||||
{% for item in collections.pages %}
|
{% for item in collections.pages %}
|
||||||
<a href="{{ item.url }}">/{{ item.fileSlug }}</a>
|
<a href="{{ item.url }}">/{{ item.fileSlug }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -252,6 +253,7 @@
|
|||||||
<div class="mobile-nav-submenu" x-show="slashOpen" x-collapse>
|
<div class="mobile-nav-submenu" x-show="slashOpen" x-collapse>
|
||||||
<a href="/slashes/">All Pages</a>
|
<a href="/slashes/">All Pages</a>
|
||||||
<a href="/cv/">/cv</a>
|
<a href="/cv/">/cv</a>
|
||||||
|
<a href="/where/">/where</a>
|
||||||
{% for item in collections.pages %}
|
{% for item in collections.pages %}
|
||||||
<a href="{{ item.url }}">/{{ item.fileSlug }}</a>
|
<a href="{{ item.url }}">/{{ item.fileSlug }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
+10
-1
@@ -63,7 +63,8 @@ eleventyImport:
|
|||||||
(newsActivity and newsActivity.source == "indiekit") or
|
(newsActivity and newsActivity.source == "indiekit") or
|
||||||
(youtubeChannel and youtubeChannel.source == "indiekit") or
|
(youtubeChannel and youtubeChannel.source == "indiekit") or
|
||||||
(blogrollStatus and blogrollStatus.source == "indiekit") or
|
(blogrollStatus and blogrollStatus.source == "indiekit") or
|
||||||
(podrollStatus and podrollStatus.source == "indiekit") %}
|
(podrollStatus and podrollStatus.source == "indiekit") or
|
||||||
|
(whereCheckins and whereCheckins.source != "error") %}
|
||||||
{% if hasActivityPages %}
|
{% if hasActivityPages %}
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h2 class="text-lg font-semibold text-surface-800 dark:text-surface-200 mb-4">Activity Feeds</h2>
|
<h2 class="text-lg font-semibold text-surface-800 dark:text-surface-200 mb-4">Activity Feeds</h2>
|
||||||
@@ -138,6 +139,14 @@ eleventyImport:
|
|||||||
<p class="text-surface-600 dark:text-surface-400 mt-2">YouTube channel</p>
|
<p class="text-surface-600 dark:text-surface-400 mt-2">YouTube channel</p>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<li class="post-card">
|
||||||
|
<div class="post-header">
|
||||||
|
<h3 class="text-xl font-semibold">
|
||||||
|
<a href="/where/" class="text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/where</a>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-surface-600 dark:text-surface-400 mt-2">Location check-ins from OwnYourSwarm</p>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
---
|
||||||
|
layout: layouts/base.njk
|
||||||
|
title: Where
|
||||||
|
permalink: /where/
|
||||||
|
withSidebar: true
|
||||||
|
---
|
||||||
|
{% set checkins = whereCheckins.checkins or [] %}
|
||||||
|
|
||||||
|
<div class="where-page">
|
||||||
|
<header class="mb-6 sm:mb-8">
|
||||||
|
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">Where</h1>
|
||||||
|
<p class="text-surface-600 dark:text-surface-400">
|
||||||
|
Recent check-ins from Swarm via OwnYourSwarm, rendered from check-in metadata.
|
||||||
|
</p>
|
||||||
|
{% if whereCheckins.available %}
|
||||||
|
<p class="text-xs text-surface-600 dark:text-surface-400 mt-2">
|
||||||
|
Loaded {{ whereCheckins.stats.total }} check-ins{% if whereCheckins.stats.withCoordinates %} ({{ whereCheckins.stats.withCoordinates }} with coordinates){% endif %}.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if checkins.length %}
|
||||||
|
<section class="space-y-4" aria-label="Recent check-ins">
|
||||||
|
{% for checkin in checkins %}
|
||||||
|
<article class="p-4 sm:p-5 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm">
|
||||||
|
<div class="flex items-start gap-3 sm:gap-4">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-emerald-100 dark:bg-emerald-900/40 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<svg class="w-5 h-5 text-emerald-700 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a2 2 0 01-2.828 0l-4.243-4.243a8 8 0 1111.314 0z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h2 class="text-lg sm:text-xl font-semibold text-surface-900 dark:text-surface-100">
|
||||||
|
{% if checkin.venueUrl %}
|
||||||
|
<a href="{{ checkin.venueUrl }}" class="hover:text-emerald-600 dark:hover:text-emerald-400" target="_blank" rel="noopener">{{ checkin.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ checkin.name }}
|
||||||
|
{% endif %}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{% if checkin.locationText or checkin.postalCode %}
|
||||||
|
<p class="text-sm text-surface-600 dark:text-surface-400 mt-1">
|
||||||
|
{{ checkin.locationText }}{% if checkin.locationText and checkin.postalCode %}, {% endif %}{{ checkin.postalCode }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="text-xs text-surface-600 dark:text-surface-400 mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
{% if checkin.published %}
|
||||||
|
<time class="font-mono" datetime="{{ checkin.published }}">{{ checkin.published | date("MMM d, yyyy") }}</time>
|
||||||
|
{% endif %}
|
||||||
|
{% if checkin.coordinatesText %}
|
||||||
|
<span class="px-2 py-0.5 rounded-full bg-surface-100 dark:bg-surface-700">{{ checkin.coordinatesText }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if checkin.isPrivate %}
|
||||||
|
<span class="px-2 py-0.5 rounded-full bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-300">Private</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if checkin.checkedInBy and (checkin.checkedInBy.name or checkin.checkedInBy.url) %}
|
||||||
|
<span class="px-2 py-0.5 rounded-full bg-surface-100 dark:bg-surface-700">
|
||||||
|
Checked in by {% if checkin.checkedInBy.url %}<a href="{{ checkin.checkedInBy.url }}" class="hover:text-emerald-600 dark:hover:text-emerald-400" target="_blank" rel="noopener">{{ checkin.checkedInBy.name or checkin.checkedInBy.url }}</a>{% else %}{{ checkin.checkedInBy.name }}{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if checkin.tags and checkin.tags.length %}
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
||||||
|
{% for tag in checkin.tags | head(8) %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-1 rounded-full bg-indigo-100 dark:bg-indigo-900/30 text-indigo-800 dark:text-indigo-300">#{{ tag }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if checkin.taggedPeople and checkin.taggedPeople.length %}
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2 text-xs">
|
||||||
|
{% for person in checkin.taggedPeople | head(6) %}
|
||||||
|
{% if person.url %}
|
||||||
|
<a href="{{ person.url }}" class="inline-flex items-center px-2.5 py-1 rounded-full bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-600" target="_blank" rel="noopener">{{ person.name or person.url }}</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-1 rounded-full bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300">{{ person.name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
||||||
|
{% if checkin.syndication %}
|
||||||
|
<a href="{{ checkin.syndication }}" class="inline-flex items-center px-2.5 py-1 rounded-full bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-600" target="_blank" rel="noopener">Swarm</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if checkin.mapUrl %}
|
||||||
|
<a href="{{ checkin.mapUrl }}" class="inline-flex items-center px-2.5 py-1 rounded-full bg-emerald-100/70 dark:bg-emerald-900/30 text-emerald-800 dark:text-emerald-300 hover:bg-emerald-200/70 dark:hover:bg-emerald-900/50" target="_blank" rel="noopener">Map</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if checkin.venueWebsiteUrl %}
|
||||||
|
<a href="{{ checkin.venueWebsiteUrl }}" class="inline-flex items-center px-2.5 py-1 rounded-full bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-600" target="_blank" rel="noopener">Website</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if checkin.venueSocialUrl %}
|
||||||
|
<a href="{{ checkin.venueSocialUrl }}" class="inline-flex items-center px-2.5 py-1 rounded-full bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-600" target="_blank" rel="noopener">Social</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if checkin.photos and checkin.photos.length %}
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
{% for photo in checkin.photos | head(3) %}
|
||||||
|
<a href="{{ photo }}" target="_blank" rel="noopener" class="block">
|
||||||
|
<img src="{{ photo }}" alt="Check-in photo" class="w-16 h-16 sm:w-20 sm:h-20 rounded-lg object-cover border border-surface-200 dark:border-surface-700" loading="lazy" eleventy:ignore>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<section class="p-4 sm:p-5 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm" aria-live="polite">
|
||||||
|
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-2">No check-ins available yet</h2>
|
||||||
|
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2">
|
||||||
|
This page expects an endpoint that returns MF2 JSON h-entry checkins with properties like <code>published</code>, <code>checkin</code>, <code>location</code>, <code>category</code>, and <code>checked-in-by</code>.
|
||||||
|
</p>
|
||||||
|
{% if whereCheckins.feedUrl %}
|
||||||
|
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2">
|
||||||
|
Current feed URL: <code>{{ whereCheckins.feedUrl }}</code>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-sm text-surface-600 dark:text-surface-400">
|
||||||
|
Configure <code>OWNYOURSWARM_FEED_URL</code> (and optionally <code>OWNYOURSWARM_FEED_TOKEN</code>) in your environment or deploy secrets.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
---
|
||||||
|
layout: layouts/base.njk
|
||||||
|
title: Where
|
||||||
|
permalink: /where/
|
||||||
|
withSidebar: true
|
||||||
|
---
|
||||||
|
{% set checkins = whereCheckins.checkins or [] %}
|
||||||
|
|
||||||
|
<div class="where-page">
|
||||||
|
<header class="mb-6 sm:mb-8">
|
||||||
|
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">Where</h1>
|
||||||
|
<p class="text-surface-600 dark:text-surface-400">
|
||||||
|
Recent check-ins from Swarm via OwnYourSwarm, rendered from check-in metadata.
|
||||||
|
</p>
|
||||||
|
{% if whereCheckins.available %}
|
||||||
|
<p class="text-xs text-surface-600 dark:text-surface-400 mt-2">
|
||||||
|
Loaded {{ whereCheckins.stats.total }} check-ins{% if whereCheckins.stats.withCoordinates %} ({{ whereCheckins.stats.withCoordinates }} with coordinates){% endif %}.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if checkins.length %}
|
||||||
|
<section class="space-y-4" aria-label="Recent check-ins">
|
||||||
|
{% for checkin in checkins %}
|
||||||
|
<article class="p-4 sm:p-5 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm">
|
||||||
|
<div class="flex items-start gap-3 sm:gap-4">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-emerald-100 dark:bg-emerald-900/40 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<svg class="w-5 h-5 text-emerald-700 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a2 2 0 01-2.828 0l-4.243-4.243a8 8 0 1111.314 0z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h2 class="text-lg sm:text-xl font-semibold text-surface-900 dark:text-surface-100">
|
||||||
|
{% if checkin.venueUrl %}
|
||||||
|
<a href="{{ checkin.venueUrl }}" class="hover:text-emerald-600 dark:hover:text-emerald-400" target="_blank" rel="noopener">{{ checkin.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ checkin.name }}
|
||||||
|
{% endif %}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{% if checkin.locationText or checkin.postalCode %}
|
||||||
|
<p class="text-sm text-surface-600 dark:text-surface-400 mt-1">
|
||||||
|
{{ checkin.locationText }}{% if checkin.locationText and checkin.postalCode %}, {% endif %}{{ checkin.postalCode }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="text-xs text-surface-600 dark:text-surface-400 mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
{% if checkin.published %}
|
||||||
|
<time class="font-mono" datetime="{{ checkin.published }}">{{ checkin.published | date("MMM d, yyyy") }}</time>
|
||||||
|
{% endif %}
|
||||||
|
{% if checkin.coordinatesText %}
|
||||||
|
<span class="px-2 py-0.5 rounded-full bg-surface-100 dark:bg-surface-700">{{ checkin.coordinatesText }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if checkin.isPrivate %}
|
||||||
|
<span class="px-2 py-0.5 rounded-full bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-300">Private</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if checkin.checkedInBy and (checkin.checkedInBy.name or checkin.checkedInBy.url) %}
|
||||||
|
<span class="px-2 py-0.5 rounded-full bg-surface-100 dark:bg-surface-700">
|
||||||
|
Checked in by {% if checkin.checkedInBy.url %}<a href="{{ checkin.checkedInBy.url }}" class="hover:text-emerald-600 dark:hover:text-emerald-400" target="_blank" rel="noopener">{{ checkin.checkedInBy.name or checkin.checkedInBy.url }}</a>{% else %}{{ checkin.checkedInBy.name }}{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if checkin.tags and checkin.tags.length %}
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
||||||
|
{% for tag in checkin.tags | head(8) %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-1 rounded-full bg-indigo-100 dark:bg-indigo-900/30 text-indigo-800 dark:text-indigo-300">#{{ tag }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if checkin.taggedPeople and checkin.taggedPeople.length %}
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2 text-xs">
|
||||||
|
{% for person in checkin.taggedPeople | head(6) %}
|
||||||
|
{% if person.url %}
|
||||||
|
<a href="{{ person.url }}" class="inline-flex items-center px-2.5 py-1 rounded-full bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-600" target="_blank" rel="noopener">{{ person.name or person.url }}</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-1 rounded-full bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300">{{ person.name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
||||||
|
{% if checkin.syndication %}
|
||||||
|
<a href="{{ checkin.syndication }}" class="inline-flex items-center px-2.5 py-1 rounded-full bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-600" target="_blank" rel="noopener">Swarm</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if checkin.mapUrl %}
|
||||||
|
<a href="{{ checkin.mapUrl }}" class="inline-flex items-center px-2.5 py-1 rounded-full bg-emerald-100/70 dark:bg-emerald-900/30 text-emerald-800 dark:text-emerald-300 hover:bg-emerald-200/70 dark:hover:bg-emerald-900/50" target="_blank" rel="noopener">Map</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if checkin.venueWebsiteUrl %}
|
||||||
|
<a href="{{ checkin.venueWebsiteUrl }}" class="inline-flex items-center px-2.5 py-1 rounded-full bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-600" target="_blank" rel="noopener">Website</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if checkin.venueSocialUrl %}
|
||||||
|
<a href="{{ checkin.venueSocialUrl }}" class="inline-flex items-center px-2.5 py-1 rounded-full bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-600" target="_blank" rel="noopener">Social</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if checkin.photos and checkin.photos.length %}
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
{% for photo in checkin.photos | head(3) %}
|
||||||
|
<a href="{{ photo }}" target="_blank" rel="noopener" class="block">
|
||||||
|
<img src="{{ photo }}" alt="Check-in photo" class="w-16 h-16 sm:w-20 sm:h-20 rounded-lg object-cover border border-surface-200 dark:border-surface-700" loading="lazy" eleventy:ignore>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<section class="p-4 sm:p-5 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm" aria-live="polite">
|
||||||
|
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-2">No check-ins available yet</h2>
|
||||||
|
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2">
|
||||||
|
This page expects an endpoint that returns MF2 JSON h-entry checkins with properties like <code>published</code>, <code>checkin</code>, <code>location</code>, <code>category</code>, and <code>checked-in-by</code>.
|
||||||
|
</p>
|
||||||
|
{% if whereCheckins.feedUrl %}
|
||||||
|
<p class="text-sm text-surface-600 dark:text-surface-400 mb-2">
|
||||||
|
Current feed URL: <code>{{ whereCheckins.feedUrl }}</code>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-sm text-surface-600 dark:text-surface-400">
|
||||||
|
Configure <code>OWNYOURSWARM_FEED_URL</code> (and optionally <code>OWNYOURSWARM_FEED_TOKEN</code>) in your environment or deploy secrets.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user