feat(where): align checkin parsing and simplify where layout
This commit is contained in:
+169
-50
@@ -2,16 +2,24 @@
|
|||||||
* Where/Checkin data
|
* Where/Checkin data
|
||||||
*
|
*
|
||||||
* Reads local check-ins created by this site's Micropub endpoint.
|
* Reads local check-ins created by this site's Micropub endpoint.
|
||||||
* A post is treated as a check-in when frontmatter includes checkin/location
|
* Supports OwnYourSwarm JSON mode and simple mode payloads once they are
|
||||||
* metadata, coordinates, or a checkin-like category.
|
* written to local markdown content.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import matter from "gray-matter";
|
import matter from "gray-matter";
|
||||||
import { readdirSync, readFileSync } from "node:fs";
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||||
import { extname, join, relative } from "node:path";
|
import { extname, join, relative } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const CONTENT_DIR = fileURLToPath(new URL("../content", import.meta.url));
|
function resolveContentDir() {
|
||||||
|
const candidates = ["../content", "../../content"].map((value) =>
|
||||||
|
fileURLToPath(new URL(value, import.meta.url))
|
||||||
|
);
|
||||||
|
return candidates.find((dirPath) => existsSync(dirPath)) || candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTENT_DIR = resolveContentDir();
|
||||||
|
const SWARM_HOST = "swarmapp.com";
|
||||||
|
|
||||||
function first(value) {
|
function first(value) {
|
||||||
if (Array.isArray(value)) return value[0];
|
if (Array.isArray(value)) return value[0];
|
||||||
@@ -42,14 +50,14 @@ function asNumber(value) {
|
|||||||
return Number.isFinite(num) ? num : null;
|
return Number.isFinite(num) ? num : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function joinLocation(locality, region, country) {
|
|
||||||
return [locality, region, country].filter(Boolean).join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueStrings(values) {
|
function uniqueStrings(values) {
|
||||||
return [...new Set(values.filter(Boolean))];
|
return [...new Set(values.filter(Boolean))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function joinLocation(locality, region, country) {
|
||||||
|
return [locality, region, country].filter(Boolean).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
function toRelativePath(filePath) {
|
function toRelativePath(filePath) {
|
||||||
return relative(CONTENT_DIR, filePath).replace(/\\/g, "/");
|
return relative(CONTENT_DIR, filePath).replace(/\\/g, "/");
|
||||||
}
|
}
|
||||||
@@ -72,15 +80,61 @@ function walkMarkdownFiles(dirPath) {
|
|||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toPropertiesObject(value) {
|
||||||
|
if (!value || typeof value !== "object") return {};
|
||||||
|
if (value.properties && typeof value.properties === "object") {
|
||||||
|
return value.properties;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEntryProperties(frontmatter) {
|
||||||
|
return toPropertiesObject(frontmatter.properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSwarmUrl(url) {
|
||||||
|
return asText(url).toLowerCase().includes(SWARM_HOST);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGeoUri(value) {
|
||||||
|
const raw = asText(value).trim();
|
||||||
|
const match = raw.match(/^geo:\s*([-+]?\d+(?:\.\d+)?),\s*([-+]?\d+(?:\.\d+)?)/i);
|
||||||
|
if (!match) {
|
||||||
|
return {
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const latitude = Number(match[1]);
|
||||||
|
const longitude = Number(match[2]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
latitude: Number.isFinite(latitude) ? latitude : null,
|
||||||
|
longitude: Number.isFinite(longitude) ? longitude : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSimpleModeVenueName(contentValue) {
|
||||||
|
const text = asText(contentValue).trim();
|
||||||
|
if (!text) return "";
|
||||||
|
|
||||||
|
const match = text.match(/^Checked in (?:at|to)\s+(.+?)(?:\.\s|$)/i);
|
||||||
|
return match ? match[1].trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
function parsePersonCard(card) {
|
function parsePersonCard(card) {
|
||||||
if (!card || typeof card !== "object") return null;
|
if (!card || typeof card !== "object") return null;
|
||||||
const props = card.properties || {};
|
const props = toPropertiesObject(card);
|
||||||
|
|
||||||
const urls = asArray(props.url).map((url) => asText(url)).filter(Boolean);
|
const urls = asArray(props.url).map((url) => asText(url)).filter(Boolean);
|
||||||
const photos = asArray(props.photo).map((photo) => asText(photo)).filter(Boolean);
|
const photos = asArray(props.photo).map((photo) => asText(photo)).filter(Boolean);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: asText(first(asArray(props.name))),
|
name:
|
||||||
|
asText(first(asArray(props.name))) ||
|
||||||
|
asText(props.firstName) ||
|
||||||
|
"",
|
||||||
url: urls[0] || "",
|
url: urls[0] || "",
|
||||||
urls,
|
urls,
|
||||||
photo: photos[0] || "",
|
photo: photos[0] || "",
|
||||||
@@ -98,9 +152,15 @@ function parseCategory(categoryValues) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!value || typeof value !== "object") continue;
|
if (!value || typeof value !== "object") continue;
|
||||||
const type = Array.isArray(value.type) ? value.type : [];
|
|
||||||
|
|
||||||
if (type.includes("h-card")) {
|
const type = Array.isArray(value.type) ? value.type : [];
|
||||||
|
const looksLikePersonTag =
|
||||||
|
type.includes("h-card") ||
|
||||||
|
Boolean(value.properties) ||
|
||||||
|
value.name !== undefined ||
|
||||||
|
value.url !== undefined;
|
||||||
|
|
||||||
|
if (looksLikePersonTag) {
|
||||||
const person = parsePersonCard(value);
|
const person = parsePersonCard(value);
|
||||||
if (person && (person.name || person.url)) {
|
if (person && (person.name || person.url)) {
|
||||||
people.push(person);
|
people.push(person);
|
||||||
@@ -121,101 +181,159 @@ function parseCategory(categoryValues) {
|
|||||||
function isCheckinFrontmatter(frontmatter, relativePath) {
|
function isCheckinFrontmatter(frontmatter, relativePath) {
|
||||||
if (relativePath === "pages/where.md") return false;
|
if (relativePath === "pages/where.md") return false;
|
||||||
|
|
||||||
const categories = asArray(frontmatter.category)
|
const properties = getEntryProperties(frontmatter);
|
||||||
|
|
||||||
|
const checkinValue =
|
||||||
|
properties.checkin ??
|
||||||
|
frontmatter.checkin ??
|
||||||
|
frontmatter["check-in"];
|
||||||
|
|
||||||
|
const syndicationValues = asArray(properties.syndication ?? frontmatter.syndication)
|
||||||
|
.map((value) => asText(value))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const categories = asArray(properties.category ?? frontmatter.category)
|
||||||
.map((value) => asText(value).toLowerCase())
|
.map((value) => asText(value).toLowerCase())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const hasCheckinField = frontmatter.checkin !== undefined || frontmatter["check-in"] !== undefined;
|
const locationValue = properties.location ?? frontmatter.location;
|
||||||
const hasLocationField = frontmatter.location !== undefined;
|
const geoCoords = parseGeoUri(first(asArray(locationValue)));
|
||||||
const hasCoordinates = frontmatter.latitude !== undefined || frontmatter.longitude !== undefined;
|
|
||||||
const hasCheckinCategory = categories.includes("where") || categories.includes("checkin") || categories.includes("swarm");
|
|
||||||
|
|
||||||
return hasCheckinField || hasLocationField || hasCoordinates || hasCheckinCategory;
|
const hasCheckinField = checkinValue !== undefined;
|
||||||
|
const hasSwarmSyndication = syndicationValues.some((url) => isSwarmUrl(url));
|
||||||
|
const hasLocationField = locationValue !== undefined;
|
||||||
|
const hasCoordinates =
|
||||||
|
properties.latitude !== undefined ||
|
||||||
|
properties.longitude !== undefined ||
|
||||||
|
frontmatter.latitude !== undefined ||
|
||||||
|
frontmatter.longitude !== undefined ||
|
||||||
|
(geoCoords.latitude !== null && geoCoords.longitude !== null);
|
||||||
|
|
||||||
|
const hasCheckinCategory =
|
||||||
|
categories.includes("where") ||
|
||||||
|
categories.includes("checkin") ||
|
||||||
|
categories.includes("swarm");
|
||||||
|
|
||||||
|
return hasCheckinField || hasSwarmSyndication || (hasCheckinCategory && (hasLocationField || hasCoordinates));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCheckin(frontmatter, relativePath) {
|
function normalizeCheckin(frontmatter, relativePath) {
|
||||||
const checkinValue = first(asArray(frontmatter.checkin ?? frontmatter["check-in"]));
|
const properties = getEntryProperties(frontmatter);
|
||||||
const locationValue = first(asArray(frontmatter.location));
|
|
||||||
|
|
||||||
const checkinProps =
|
const checkinValue = first(
|
||||||
checkinValue && typeof checkinValue === "object" && checkinValue.properties
|
asArray(properties.checkin ?? frontmatter.checkin ?? frontmatter["check-in"])
|
||||||
? checkinValue.properties
|
);
|
||||||
: {};
|
const locationValue = first(asArray(properties.location ?? frontmatter.location));
|
||||||
const locationProps =
|
|
||||||
locationValue && typeof locationValue === "object" && locationValue.properties
|
const checkinProps = toPropertiesObject(checkinValue);
|
||||||
? locationValue.properties
|
const locationProps = toPropertiesObject(locationValue);
|
||||||
: {};
|
const locationGeo = parseGeoUri(locationValue);
|
||||||
|
|
||||||
const venueUrlsFromCard = asArray(checkinProps.url).map((url) => asText(url)).filter(Boolean);
|
const venueUrlsFromCard = asArray(checkinProps.url).map((url) => asText(url)).filter(Boolean);
|
||||||
const venueUrlFromSimpleMode = typeof checkinValue === "string" ? checkinValue : "";
|
const venueUrlFromSimpleMode =
|
||||||
const venueUrls = venueUrlFromSimpleMode
|
typeof checkinValue === "string" ? checkinValue : asText(checkinValue?.value);
|
||||||
? [venueUrlFromSimpleMode, ...venueUrlsFromCard]
|
const venueUrls = uniqueStrings(
|
||||||
: venueUrlsFromCard;
|
venueUrlFromSimpleMode ? [venueUrlFromSimpleMode, ...venueUrlsFromCard] : venueUrlsFromCard
|
||||||
|
);
|
||||||
|
|
||||||
const venueUrl = venueUrls[0] || "";
|
const venueUrl = venueUrls[0] || "";
|
||||||
const venueWebsiteUrl = venueUrls[1] || "";
|
const venueWebsiteUrl = venueUrls[1] || "";
|
||||||
const venueSocialUrl = venueUrls[2] || "";
|
const venueSocialUrl = venueUrls[2] || "";
|
||||||
|
|
||||||
|
const contentValue = first(asArray(properties.content ?? frontmatter.content));
|
||||||
|
const simpleModeVenueName = extractSimpleModeVenueName(contentValue);
|
||||||
|
|
||||||
const name =
|
const name =
|
||||||
asText(first(asArray(checkinProps.name))) ||
|
asText(first(asArray(checkinProps.name))) ||
|
||||||
|
simpleModeVenueName ||
|
||||||
asText(frontmatter.title) ||
|
asText(frontmatter.title) ||
|
||||||
"Unknown place";
|
"Unknown place";
|
||||||
|
|
||||||
const locality =
|
const locality =
|
||||||
asText(first(asArray(checkinProps.locality))) ||
|
asText(first(asArray(checkinProps.locality))) ||
|
||||||
asText(first(asArray(locationProps.locality))) ||
|
asText(first(asArray(locationProps.locality))) ||
|
||||||
|
asText(properties.locality) ||
|
||||||
asText(frontmatter.locality);
|
asText(frontmatter.locality);
|
||||||
const region =
|
const region =
|
||||||
asText(first(asArray(checkinProps.region))) ||
|
asText(first(asArray(checkinProps.region))) ||
|
||||||
asText(first(asArray(locationProps.region))) ||
|
asText(first(asArray(locationProps.region))) ||
|
||||||
|
asText(properties.region) ||
|
||||||
asText(frontmatter.region);
|
asText(frontmatter.region);
|
||||||
const country =
|
const country =
|
||||||
asText(first(asArray(checkinProps["country-name"]))) ||
|
asText(first(asArray(checkinProps["country-name"]))) ||
|
||||||
asText(first(asArray(locationProps["country-name"]))) ||
|
asText(first(asArray(locationProps["country-name"]))) ||
|
||||||
|
asText(properties["country-name"]) ||
|
||||||
asText(frontmatter["country-name"]);
|
asText(frontmatter["country-name"]);
|
||||||
const postalCode =
|
const postalCode =
|
||||||
asText(first(asArray(checkinProps["postal-code"]))) ||
|
asText(first(asArray(checkinProps["postal-code"]))) ||
|
||||||
asText(first(asArray(locationProps["postal-code"]))) ||
|
asText(first(asArray(locationProps["postal-code"]))) ||
|
||||||
|
asText(properties["postal-code"]) ||
|
||||||
asText(frontmatter["postal-code"]);
|
asText(frontmatter["postal-code"]);
|
||||||
|
|
||||||
const latitude =
|
const latitude =
|
||||||
asNumber(checkinProps.latitude) ??
|
asNumber(checkinProps.latitude) ??
|
||||||
asNumber(locationProps.latitude) ??
|
asNumber(locationProps.latitude) ??
|
||||||
asNumber(frontmatter.latitude);
|
asNumber(properties.latitude) ??
|
||||||
|
asNumber(frontmatter.latitude) ??
|
||||||
|
locationGeo.latitude;
|
||||||
const longitude =
|
const longitude =
|
||||||
asNumber(checkinProps.longitude) ??
|
asNumber(checkinProps.longitude) ??
|
||||||
asNumber(locationProps.longitude) ??
|
asNumber(locationProps.longitude) ??
|
||||||
asNumber(frontmatter.longitude);
|
asNumber(properties.longitude) ??
|
||||||
|
asNumber(frontmatter.longitude) ??
|
||||||
|
locationGeo.longitude;
|
||||||
|
|
||||||
const published =
|
const published =
|
||||||
asText(first(asArray(frontmatter.published))) ||
|
asText(first(asArray(properties.published ?? frontmatter.published))) ||
|
||||||
asText(frontmatter.date);
|
asText(frontmatter.date);
|
||||||
|
|
||||||
const syndicationUrls = asArray(frontmatter.syndication)
|
const syndicationUrls = asArray(properties.syndication ?? frontmatter.syndication)
|
||||||
.map((url) => asText(url))
|
.map((url) => asText(url))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const syndication =
|
const syndication =
|
||||||
syndicationUrls.find((url) => url.includes("swarmapp.com")) ||
|
syndicationUrls.find((url) => isSwarmUrl(url)) ||
|
||||||
syndicationUrls[0] ||
|
syndicationUrls[0] ||
|
||||||
"";
|
"";
|
||||||
|
|
||||||
const visibility = asText(frontmatter.visibility).toLowerCase();
|
const visibility = asText(
|
||||||
|
first(asArray(properties.visibility ?? frontmatter.visibility))
|
||||||
|
).toLowerCase();
|
||||||
|
|
||||||
const categoryValues = asArray(frontmatter.category);
|
const categoryValues = asArray(properties.category ?? frontmatter.category);
|
||||||
const category = parseCategory(categoryValues);
|
const category = parseCategory(categoryValues);
|
||||||
|
|
||||||
const checkedInByValue = first(asArray(frontmatter["checked-in-by"] ?? frontmatter.checkedInBy));
|
const checkedInByValue = first(
|
||||||
|
asArray(
|
||||||
|
properties["checked-in-by"] ??
|
||||||
|
frontmatter["checked-in-by"] ??
|
||||||
|
frontmatter.checkedInBy
|
||||||
|
)
|
||||||
|
);
|
||||||
const checkedInBy = parsePersonCard(checkedInByValue);
|
const checkedInBy = parsePersonCard(checkedInByValue);
|
||||||
|
|
||||||
const photos = asArray(frontmatter.photo)
|
const addedPhotos =
|
||||||
.map((photo) => {
|
frontmatter.add && typeof frontmatter.add === "object"
|
||||||
if (typeof photo === "string") return photo;
|
? asArray(frontmatter.add.photo)
|
||||||
if (photo && typeof photo === "object") {
|
: [];
|
||||||
return asText(photo.url || photo.value || photo.src || "");
|
const photoValues = [
|
||||||
}
|
...asArray(properties.photo ?? frontmatter.photo),
|
||||||
return "";
|
...addedPhotos,
|
||||||
})
|
];
|
||||||
.filter(Boolean);
|
const photos = uniqueStrings(
|
||||||
|
photoValues
|
||||||
|
.map((photo) => {
|
||||||
|
if (typeof photo === "string") return photo;
|
||||||
|
if (photo && typeof photo === "object") {
|
||||||
|
return asText(photo.url || photo.value || photo.src || "");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!checkinValue && !syndication && latitude === null && longitude === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const mapUrl =
|
const mapUrl =
|
||||||
latitude !== null && longitude !== null
|
latitude !== null && longitude !== null
|
||||||
@@ -265,6 +383,7 @@ function normalizeCheckins(items) {
|
|||||||
const checkins = [];
|
const checkins = [];
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
if (!item) continue;
|
||||||
if (seen.has(item.id)) continue;
|
if (seen.has(item.id)) continue;
|
||||||
seen.add(item.id);
|
seen.add(item.id);
|
||||||
checkins.push(item);
|
checkins.push(item);
|
||||||
@@ -310,7 +429,7 @@ export default async function () {
|
|||||||
if (!isCheckinFrontmatter(frontmatter, relativePath)) continue;
|
if (!isCheckinFrontmatter(frontmatter, relativePath)) continue;
|
||||||
|
|
||||||
const checkin = normalizeCheckin(frontmatter, relativePath);
|
const checkin = normalizeCheckin(frontmatter, relativePath);
|
||||||
items.push(checkin);
|
if (checkin) items.push(checkin);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(`[whereCheckins] Skipped ${relativePath}: ${error.message}`);
|
errors.push(`[whereCheckins] Skipped ${relativePath}: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,10 @@ withSidebar: true
|
|||||||
{{ content | safe }}
|
{{ content | safe }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% 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>
|
</header>
|
||||||
|
|
||||||
{% if checkins.length %}
|
{% if checkins.length %}
|
||||||
|
<h2 class="text-sm uppercase tracking-wide text-surface-500 dark:text-surface-400 mb-3">Latest check-ins</h2>
|
||||||
<section class="space-y-4" aria-label="Recent check-ins">
|
<section class="space-y-4" aria-label="Recent check-ins">
|
||||||
{% for checkin in checkins %}
|
{% 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">
|
<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">
|
||||||
@@ -35,7 +31,7 @@ withSidebar: true
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h2 class="text-lg sm:text-xl font-semibold text-surface-900 dark:text-surface-100">
|
<h2 class="text-base sm:text-lg font-semibold text-surface-900 dark:text-surface-100">
|
||||||
{% if checkin.venueUrl %}
|
{% if checkin.venueUrl %}
|
||||||
<a href="{{ checkin.venueUrl }}" class="hover:text-emerald-600 dark:hover:text-emerald-400" target="_blank" rel="noopener">{{ checkin.name }}</a>
|
<a href="{{ checkin.venueUrl }}" class="hover:text-emerald-600 dark:hover:text-emerald-400" target="_blank" rel="noopener">{{ checkin.name }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -87,9 +83,6 @@ withSidebar: true
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
<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 %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
|
|||||||
+169
-50
@@ -2,16 +2,24 @@
|
|||||||
* Where/Checkin data
|
* Where/Checkin data
|
||||||
*
|
*
|
||||||
* Reads local check-ins created by this site's Micropub endpoint.
|
* Reads local check-ins created by this site's Micropub endpoint.
|
||||||
* A post is treated as a check-in when frontmatter includes checkin/location
|
* Supports OwnYourSwarm JSON mode and simple mode payloads once they are
|
||||||
* metadata, coordinates, or a checkin-like category.
|
* written to local markdown content.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import matter from "gray-matter";
|
import matter from "gray-matter";
|
||||||
import { readdirSync, readFileSync } from "node:fs";
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||||
import { extname, join, relative } from "node:path";
|
import { extname, join, relative } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const CONTENT_DIR = fileURLToPath(new URL("../content", import.meta.url));
|
function resolveContentDir() {
|
||||||
|
const candidates = ["../content", "../../content"].map((value) =>
|
||||||
|
fileURLToPath(new URL(value, import.meta.url))
|
||||||
|
);
|
||||||
|
return candidates.find((dirPath) => existsSync(dirPath)) || candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTENT_DIR = resolveContentDir();
|
||||||
|
const SWARM_HOST = "swarmapp.com";
|
||||||
|
|
||||||
function first(value) {
|
function first(value) {
|
||||||
if (Array.isArray(value)) return value[0];
|
if (Array.isArray(value)) return value[0];
|
||||||
@@ -42,14 +50,14 @@ function asNumber(value) {
|
|||||||
return Number.isFinite(num) ? num : null;
|
return Number.isFinite(num) ? num : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function joinLocation(locality, region, country) {
|
|
||||||
return [locality, region, country].filter(Boolean).join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueStrings(values) {
|
function uniqueStrings(values) {
|
||||||
return [...new Set(values.filter(Boolean))];
|
return [...new Set(values.filter(Boolean))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function joinLocation(locality, region, country) {
|
||||||
|
return [locality, region, country].filter(Boolean).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
function toRelativePath(filePath) {
|
function toRelativePath(filePath) {
|
||||||
return relative(CONTENT_DIR, filePath).replace(/\\/g, "/");
|
return relative(CONTENT_DIR, filePath).replace(/\\/g, "/");
|
||||||
}
|
}
|
||||||
@@ -72,15 +80,61 @@ function walkMarkdownFiles(dirPath) {
|
|||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toPropertiesObject(value) {
|
||||||
|
if (!value || typeof value !== "object") return {};
|
||||||
|
if (value.properties && typeof value.properties === "object") {
|
||||||
|
return value.properties;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEntryProperties(frontmatter) {
|
||||||
|
return toPropertiesObject(frontmatter.properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSwarmUrl(url) {
|
||||||
|
return asText(url).toLowerCase().includes(SWARM_HOST);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGeoUri(value) {
|
||||||
|
const raw = asText(value).trim();
|
||||||
|
const match = raw.match(/^geo:\s*([-+]?\d+(?:\.\d+)?),\s*([-+]?\d+(?:\.\d+)?)/i);
|
||||||
|
if (!match) {
|
||||||
|
return {
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const latitude = Number(match[1]);
|
||||||
|
const longitude = Number(match[2]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
latitude: Number.isFinite(latitude) ? latitude : null,
|
||||||
|
longitude: Number.isFinite(longitude) ? longitude : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSimpleModeVenueName(contentValue) {
|
||||||
|
const text = asText(contentValue).trim();
|
||||||
|
if (!text) return "";
|
||||||
|
|
||||||
|
const match = text.match(/^Checked in (?:at|to)\s+(.+?)(?:\.\s|$)/i);
|
||||||
|
return match ? match[1].trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
function parsePersonCard(card) {
|
function parsePersonCard(card) {
|
||||||
if (!card || typeof card !== "object") return null;
|
if (!card || typeof card !== "object") return null;
|
||||||
const props = card.properties || {};
|
const props = toPropertiesObject(card);
|
||||||
|
|
||||||
const urls = asArray(props.url).map((url) => asText(url)).filter(Boolean);
|
const urls = asArray(props.url).map((url) => asText(url)).filter(Boolean);
|
||||||
const photos = asArray(props.photo).map((photo) => asText(photo)).filter(Boolean);
|
const photos = asArray(props.photo).map((photo) => asText(photo)).filter(Boolean);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: asText(first(asArray(props.name))),
|
name:
|
||||||
|
asText(first(asArray(props.name))) ||
|
||||||
|
asText(props.firstName) ||
|
||||||
|
"",
|
||||||
url: urls[0] || "",
|
url: urls[0] || "",
|
||||||
urls,
|
urls,
|
||||||
photo: photos[0] || "",
|
photo: photos[0] || "",
|
||||||
@@ -98,9 +152,15 @@ function parseCategory(categoryValues) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!value || typeof value !== "object") continue;
|
if (!value || typeof value !== "object") continue;
|
||||||
const type = Array.isArray(value.type) ? value.type : [];
|
|
||||||
|
|
||||||
if (type.includes("h-card")) {
|
const type = Array.isArray(value.type) ? value.type : [];
|
||||||
|
const looksLikePersonTag =
|
||||||
|
type.includes("h-card") ||
|
||||||
|
Boolean(value.properties) ||
|
||||||
|
value.name !== undefined ||
|
||||||
|
value.url !== undefined;
|
||||||
|
|
||||||
|
if (looksLikePersonTag) {
|
||||||
const person = parsePersonCard(value);
|
const person = parsePersonCard(value);
|
||||||
if (person && (person.name || person.url)) {
|
if (person && (person.name || person.url)) {
|
||||||
people.push(person);
|
people.push(person);
|
||||||
@@ -121,101 +181,159 @@ function parseCategory(categoryValues) {
|
|||||||
function isCheckinFrontmatter(frontmatter, relativePath) {
|
function isCheckinFrontmatter(frontmatter, relativePath) {
|
||||||
if (relativePath === "pages/where.md") return false;
|
if (relativePath === "pages/where.md") return false;
|
||||||
|
|
||||||
const categories = asArray(frontmatter.category)
|
const properties = getEntryProperties(frontmatter);
|
||||||
|
|
||||||
|
const checkinValue =
|
||||||
|
properties.checkin ??
|
||||||
|
frontmatter.checkin ??
|
||||||
|
frontmatter["check-in"];
|
||||||
|
|
||||||
|
const syndicationValues = asArray(properties.syndication ?? frontmatter.syndication)
|
||||||
|
.map((value) => asText(value))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const categories = asArray(properties.category ?? frontmatter.category)
|
||||||
.map((value) => asText(value).toLowerCase())
|
.map((value) => asText(value).toLowerCase())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const hasCheckinField = frontmatter.checkin !== undefined || frontmatter["check-in"] !== undefined;
|
const locationValue = properties.location ?? frontmatter.location;
|
||||||
const hasLocationField = frontmatter.location !== undefined;
|
const geoCoords = parseGeoUri(first(asArray(locationValue)));
|
||||||
const hasCoordinates = frontmatter.latitude !== undefined || frontmatter.longitude !== undefined;
|
|
||||||
const hasCheckinCategory = categories.includes("where") || categories.includes("checkin") || categories.includes("swarm");
|
|
||||||
|
|
||||||
return hasCheckinField || hasLocationField || hasCoordinates || hasCheckinCategory;
|
const hasCheckinField = checkinValue !== undefined;
|
||||||
|
const hasSwarmSyndication = syndicationValues.some((url) => isSwarmUrl(url));
|
||||||
|
const hasLocationField = locationValue !== undefined;
|
||||||
|
const hasCoordinates =
|
||||||
|
properties.latitude !== undefined ||
|
||||||
|
properties.longitude !== undefined ||
|
||||||
|
frontmatter.latitude !== undefined ||
|
||||||
|
frontmatter.longitude !== undefined ||
|
||||||
|
(geoCoords.latitude !== null && geoCoords.longitude !== null);
|
||||||
|
|
||||||
|
const hasCheckinCategory =
|
||||||
|
categories.includes("where") ||
|
||||||
|
categories.includes("checkin") ||
|
||||||
|
categories.includes("swarm");
|
||||||
|
|
||||||
|
return hasCheckinField || hasSwarmSyndication || (hasCheckinCategory && (hasLocationField || hasCoordinates));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCheckin(frontmatter, relativePath) {
|
function normalizeCheckin(frontmatter, relativePath) {
|
||||||
const checkinValue = first(asArray(frontmatter.checkin ?? frontmatter["check-in"]));
|
const properties = getEntryProperties(frontmatter);
|
||||||
const locationValue = first(asArray(frontmatter.location));
|
|
||||||
|
|
||||||
const checkinProps =
|
const checkinValue = first(
|
||||||
checkinValue && typeof checkinValue === "object" && checkinValue.properties
|
asArray(properties.checkin ?? frontmatter.checkin ?? frontmatter["check-in"])
|
||||||
? checkinValue.properties
|
);
|
||||||
: {};
|
const locationValue = first(asArray(properties.location ?? frontmatter.location));
|
||||||
const locationProps =
|
|
||||||
locationValue && typeof locationValue === "object" && locationValue.properties
|
const checkinProps = toPropertiesObject(checkinValue);
|
||||||
? locationValue.properties
|
const locationProps = toPropertiesObject(locationValue);
|
||||||
: {};
|
const locationGeo = parseGeoUri(locationValue);
|
||||||
|
|
||||||
const venueUrlsFromCard = asArray(checkinProps.url).map((url) => asText(url)).filter(Boolean);
|
const venueUrlsFromCard = asArray(checkinProps.url).map((url) => asText(url)).filter(Boolean);
|
||||||
const venueUrlFromSimpleMode = typeof checkinValue === "string" ? checkinValue : "";
|
const venueUrlFromSimpleMode =
|
||||||
const venueUrls = venueUrlFromSimpleMode
|
typeof checkinValue === "string" ? checkinValue : asText(checkinValue?.value);
|
||||||
? [venueUrlFromSimpleMode, ...venueUrlsFromCard]
|
const venueUrls = uniqueStrings(
|
||||||
: venueUrlsFromCard;
|
venueUrlFromSimpleMode ? [venueUrlFromSimpleMode, ...venueUrlsFromCard] : venueUrlsFromCard
|
||||||
|
);
|
||||||
|
|
||||||
const venueUrl = venueUrls[0] || "";
|
const venueUrl = venueUrls[0] || "";
|
||||||
const venueWebsiteUrl = venueUrls[1] || "";
|
const venueWebsiteUrl = venueUrls[1] || "";
|
||||||
const venueSocialUrl = venueUrls[2] || "";
|
const venueSocialUrl = venueUrls[2] || "";
|
||||||
|
|
||||||
|
const contentValue = first(asArray(properties.content ?? frontmatter.content));
|
||||||
|
const simpleModeVenueName = extractSimpleModeVenueName(contentValue);
|
||||||
|
|
||||||
const name =
|
const name =
|
||||||
asText(first(asArray(checkinProps.name))) ||
|
asText(first(asArray(checkinProps.name))) ||
|
||||||
|
simpleModeVenueName ||
|
||||||
asText(frontmatter.title) ||
|
asText(frontmatter.title) ||
|
||||||
"Unknown place";
|
"Unknown place";
|
||||||
|
|
||||||
const locality =
|
const locality =
|
||||||
asText(first(asArray(checkinProps.locality))) ||
|
asText(first(asArray(checkinProps.locality))) ||
|
||||||
asText(first(asArray(locationProps.locality))) ||
|
asText(first(asArray(locationProps.locality))) ||
|
||||||
|
asText(properties.locality) ||
|
||||||
asText(frontmatter.locality);
|
asText(frontmatter.locality);
|
||||||
const region =
|
const region =
|
||||||
asText(first(asArray(checkinProps.region))) ||
|
asText(first(asArray(checkinProps.region))) ||
|
||||||
asText(first(asArray(locationProps.region))) ||
|
asText(first(asArray(locationProps.region))) ||
|
||||||
|
asText(properties.region) ||
|
||||||
asText(frontmatter.region);
|
asText(frontmatter.region);
|
||||||
const country =
|
const country =
|
||||||
asText(first(asArray(checkinProps["country-name"]))) ||
|
asText(first(asArray(checkinProps["country-name"]))) ||
|
||||||
asText(first(asArray(locationProps["country-name"]))) ||
|
asText(first(asArray(locationProps["country-name"]))) ||
|
||||||
|
asText(properties["country-name"]) ||
|
||||||
asText(frontmatter["country-name"]);
|
asText(frontmatter["country-name"]);
|
||||||
const postalCode =
|
const postalCode =
|
||||||
asText(first(asArray(checkinProps["postal-code"]))) ||
|
asText(first(asArray(checkinProps["postal-code"]))) ||
|
||||||
asText(first(asArray(locationProps["postal-code"]))) ||
|
asText(first(asArray(locationProps["postal-code"]))) ||
|
||||||
|
asText(properties["postal-code"]) ||
|
||||||
asText(frontmatter["postal-code"]);
|
asText(frontmatter["postal-code"]);
|
||||||
|
|
||||||
const latitude =
|
const latitude =
|
||||||
asNumber(checkinProps.latitude) ??
|
asNumber(checkinProps.latitude) ??
|
||||||
asNumber(locationProps.latitude) ??
|
asNumber(locationProps.latitude) ??
|
||||||
asNumber(frontmatter.latitude);
|
asNumber(properties.latitude) ??
|
||||||
|
asNumber(frontmatter.latitude) ??
|
||||||
|
locationGeo.latitude;
|
||||||
const longitude =
|
const longitude =
|
||||||
asNumber(checkinProps.longitude) ??
|
asNumber(checkinProps.longitude) ??
|
||||||
asNumber(locationProps.longitude) ??
|
asNumber(locationProps.longitude) ??
|
||||||
asNumber(frontmatter.longitude);
|
asNumber(properties.longitude) ??
|
||||||
|
asNumber(frontmatter.longitude) ??
|
||||||
|
locationGeo.longitude;
|
||||||
|
|
||||||
const published =
|
const published =
|
||||||
asText(first(asArray(frontmatter.published))) ||
|
asText(first(asArray(properties.published ?? frontmatter.published))) ||
|
||||||
asText(frontmatter.date);
|
asText(frontmatter.date);
|
||||||
|
|
||||||
const syndicationUrls = asArray(frontmatter.syndication)
|
const syndicationUrls = asArray(properties.syndication ?? frontmatter.syndication)
|
||||||
.map((url) => asText(url))
|
.map((url) => asText(url))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const syndication =
|
const syndication =
|
||||||
syndicationUrls.find((url) => url.includes("swarmapp.com")) ||
|
syndicationUrls.find((url) => isSwarmUrl(url)) ||
|
||||||
syndicationUrls[0] ||
|
syndicationUrls[0] ||
|
||||||
"";
|
"";
|
||||||
|
|
||||||
const visibility = asText(frontmatter.visibility).toLowerCase();
|
const visibility = asText(
|
||||||
|
first(asArray(properties.visibility ?? frontmatter.visibility))
|
||||||
|
).toLowerCase();
|
||||||
|
|
||||||
const categoryValues = asArray(frontmatter.category);
|
const categoryValues = asArray(properties.category ?? frontmatter.category);
|
||||||
const category = parseCategory(categoryValues);
|
const category = parseCategory(categoryValues);
|
||||||
|
|
||||||
const checkedInByValue = first(asArray(frontmatter["checked-in-by"] ?? frontmatter.checkedInBy));
|
const checkedInByValue = first(
|
||||||
|
asArray(
|
||||||
|
properties["checked-in-by"] ??
|
||||||
|
frontmatter["checked-in-by"] ??
|
||||||
|
frontmatter.checkedInBy
|
||||||
|
)
|
||||||
|
);
|
||||||
const checkedInBy = parsePersonCard(checkedInByValue);
|
const checkedInBy = parsePersonCard(checkedInByValue);
|
||||||
|
|
||||||
const photos = asArray(frontmatter.photo)
|
const addedPhotos =
|
||||||
.map((photo) => {
|
frontmatter.add && typeof frontmatter.add === "object"
|
||||||
if (typeof photo === "string") return photo;
|
? asArray(frontmatter.add.photo)
|
||||||
if (photo && typeof photo === "object") {
|
: [];
|
||||||
return asText(photo.url || photo.value || photo.src || "");
|
const photoValues = [
|
||||||
}
|
...asArray(properties.photo ?? frontmatter.photo),
|
||||||
return "";
|
...addedPhotos,
|
||||||
})
|
];
|
||||||
.filter(Boolean);
|
const photos = uniqueStrings(
|
||||||
|
photoValues
|
||||||
|
.map((photo) => {
|
||||||
|
if (typeof photo === "string") return photo;
|
||||||
|
if (photo && typeof photo === "object") {
|
||||||
|
return asText(photo.url || photo.value || photo.src || "");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!checkinValue && !syndication && latitude === null && longitude === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const mapUrl =
|
const mapUrl =
|
||||||
latitude !== null && longitude !== null
|
latitude !== null && longitude !== null
|
||||||
@@ -265,6 +383,7 @@ function normalizeCheckins(items) {
|
|||||||
const checkins = [];
|
const checkins = [];
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
if (!item) continue;
|
||||||
if (seen.has(item.id)) continue;
|
if (seen.has(item.id)) continue;
|
||||||
seen.add(item.id);
|
seen.add(item.id);
|
||||||
checkins.push(item);
|
checkins.push(item);
|
||||||
@@ -310,7 +429,7 @@ export default async function () {
|
|||||||
if (!isCheckinFrontmatter(frontmatter, relativePath)) continue;
|
if (!isCheckinFrontmatter(frontmatter, relativePath)) continue;
|
||||||
|
|
||||||
const checkin = normalizeCheckin(frontmatter, relativePath);
|
const checkin = normalizeCheckin(frontmatter, relativePath);
|
||||||
items.push(checkin);
|
if (checkin) items.push(checkin);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(`[whereCheckins] Skipped ${relativePath}: ${error.message}`);
|
errors.push(`[whereCheckins] Skipped ${relativePath}: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,10 @@ withSidebar: true
|
|||||||
{{ content | safe }}
|
{{ content | safe }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% 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>
|
</header>
|
||||||
|
|
||||||
{% if checkins.length %}
|
{% if checkins.length %}
|
||||||
|
<h2 class="text-sm uppercase tracking-wide text-surface-500 dark:text-surface-400 mb-3">Latest check-ins</h2>
|
||||||
<section class="space-y-4" aria-label="Recent check-ins">
|
<section class="space-y-4" aria-label="Recent check-ins">
|
||||||
{% for checkin in checkins %}
|
{% 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">
|
<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">
|
||||||
@@ -35,7 +31,7 @@ withSidebar: true
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h2 class="text-lg sm:text-xl font-semibold text-surface-900 dark:text-surface-100">
|
<h2 class="text-base sm:text-lg font-semibold text-surface-900 dark:text-surface-100">
|
||||||
{% if checkin.venueUrl %}
|
{% if checkin.venueUrl %}
|
||||||
<a href="{{ checkin.venueUrl }}" class="hover:text-emerald-600 dark:hover:text-emerald-400" target="_blank" rel="noopener">{{ checkin.name }}</a>
|
<a href="{{ checkin.venueUrl }}" class="hover:text-emerald-600 dark:hover:text-emerald-400" target="_blank" rel="noopener">{{ checkin.name }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -87,9 +83,6 @@ withSidebar: true
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
<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 %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
|
|||||||
+2
-9
@@ -12,14 +12,10 @@ withSidebar: true
|
|||||||
<p class="text-surface-600 dark:text-surface-400">
|
<p class="text-surface-600 dark:text-surface-400">
|
||||||
Recent check-ins captured by this site via Micropub.
|
Recent check-ins captured by this site via Micropub.
|
||||||
</p>
|
</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>
|
</header>
|
||||||
|
|
||||||
{% if checkins.length %}
|
{% if checkins.length %}
|
||||||
|
<h2 class="text-sm uppercase tracking-wide text-surface-500 dark:text-surface-400 mb-3">Latest check-ins</h2>
|
||||||
<section class="space-y-4" aria-label="Recent check-ins">
|
<section class="space-y-4" aria-label="Recent check-ins">
|
||||||
{% for checkin in checkins %}
|
{% 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">
|
<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">
|
||||||
@@ -32,7 +28,7 @@ withSidebar: true
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h2 class="text-lg sm:text-xl font-semibold text-surface-900 dark:text-surface-100">
|
<h2 class="text-base sm:text-lg font-semibold text-surface-900 dark:text-surface-100">
|
||||||
{% if checkin.venueUrl %}
|
{% if checkin.venueUrl %}
|
||||||
<a href="{{ checkin.venueUrl }}" class="hover:text-emerald-600 dark:hover:text-emerald-400" target="_blank" rel="noopener">{{ checkin.name }}</a>
|
<a href="{{ checkin.venueUrl }}" class="hover:text-emerald-600 dark:hover:text-emerald-400" target="_blank" rel="noopener">{{ checkin.name }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -84,9 +80,6 @@ withSidebar: true
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
<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 %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
|
|||||||
Reference in New Issue
Block a user