From da82d8c8297696275e7eaba0d5234e9206e8fb76 Mon Sep 17 00:00:00 2001 From: Florian Reiff Date: Sat, 28 Mar 2026 15:42:47 +0900 Subject: [PATCH 1/3] feat: add community members map and localization support #68 --- package-lock.json | 67 +++++++++++ package.json | 4 + src/components/Header.astro | 6 +- src/components/Map.jsx | 213 +++++++++++++++++++++++++++++++++++ src/components/MemberMap.jsx | 198 ++++++++++++++++++++++++++++++++ src/data/composite-feed.json | 88 +++++---------- src/data/members.json | 126 +++++++++++++++++++++ src/i18n/ui.ts | 18 +++ src/pages/ja/members.astro | 4 + src/pages/members.astro | 46 ++++++++ 10 files changed, 706 insertions(+), 64 deletions(-) create mode 100644 src/components/Map.jsx create mode 100644 src/components/MemberMap.jsx create mode 100644 src/data/members.json create mode 100644 src/pages/ja/members.astro create mode 100644 src/pages/members.astro diff --git a/package-lock.json b/package-lock.json index 8b6d9c2..da82586 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,13 @@ "@astrojs/react": "^4.4.2", "@astrojs/sitemap": "^3.6.0", "@tailwindcss/vite": "^4.1.17", + "@types/leaflet": "^1.9.21", + "@types/leaflet.markercluster": "^1.5.6", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "astro": "^5.15.8", + "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", "marked": "^17.0.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -1833,6 +1837,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1855,6 +1860,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1877,6 +1883,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1893,6 +1900,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1909,6 +1917,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1925,6 +1934,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1941,6 +1951,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1957,6 +1968,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1973,6 +1985,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1989,6 +2002,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2005,6 +2019,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2021,6 +2036,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2037,6 +2053,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2059,6 +2076,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2081,6 +2099,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2103,6 +2122,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2125,6 +2145,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2147,6 +2168,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2169,6 +2191,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2191,6 +2214,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2213,6 +2237,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -2232,6 +2257,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2251,6 +2277,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2270,6 +2297,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3478,6 +3506,12 @@ "@types/estree": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -3494,6 +3528,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/leaflet.markercluster": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.6.tgz", + "integrity": "sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ==", + "license": "MIT", + "dependencies": { + "@types/leaflet": "^1.9" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -8107,6 +8159,21 @@ "node": ">=0.10" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "license": "MIT", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/package.json b/package.json index 8a060a9..918b936 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,13 @@ "@astrojs/react": "^4.4.2", "@astrojs/sitemap": "^3.6.0", "@tailwindcss/vite": "^4.1.17", + "@types/leaflet": "^1.9.21", + "@types/leaflet.markercluster": "^1.5.6", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "astro": "^5.15.8", + "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", "marked": "^17.0.0", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/src/components/Header.astro b/src/components/Header.astro index 57844d5..2f311c4 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -25,9 +25,9 @@ const homeUrl = lang === "ja" ? "/ja/" : "/";
diff --git a/src/components/Map.jsx b/src/components/Map.jsx new file mode 100644 index 0000000..f581999 --- /dev/null +++ b/src/components/Map.jsx @@ -0,0 +1,213 @@ +/** + * Map – Generic Leaflet map component. + * + * Props: + * geojson {object} GeoJSON FeatureCollection to render (already filtered by caller). + * features {string[]} Feature flags: + * "clusters" – cluster nearby markers with leaflet.markercluster + * "fitBounds" – auto-zoom to fit all visible markers + * "legend" – show a colour-coded legend overlay + * labels {object} i18n strings. Supported key: `legendTitle`. + * getMarkerColour {function} (feature) => CSS colour string. Defaults to grey. + * renderPopup {function} (feature) => HTML string for the Leaflet popup. + * Defaults to a minimal name/coordinates popup. + * legendEntries {Array} [{ label: string, colour: string }] shown in the legend. + * center {[lat,lng]} Initial map centre. Defaults to central Kyoto. + * zoom {number} Initial zoom level. Defaults to 13. + * height {string} CSS height for the map container. Defaults to "480px". + */ + +import { useEffect, useRef } from "react"; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/** Create a circular SVG DivIcon for a given colour. */ +function makeIcon(L, colour) { + const svg = ` + + `; + return L.divIcon({ + html: svg, + className: "", + iconSize: [24, 24], + iconAnchor: [12, 12], + popupAnchor: [0, -14], + }); +} + +function defaultRenderPopup(feature) { + const props = feature.properties ?? {}; + const [lng, lat] = feature.geometry?.coordinates ?? [0, 0]; + return `
+ ${props.name ?? `${lat.toFixed(4)}, ${lng.toFixed(4)}`} +
`; +} + +// ── Component ──────────────────────────────────────────────────────────────── + +export default function Map({ + geojson, + features = ["fitBounds"], + labels = {}, + getMarkerColour = () => "#6B7280", + renderPopup = defaultRenderPopup, + legendEntries = [], + center = [35.005, 135.765], + zoom = 13, + height = "480px", +}) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const layerRef = useRef(null); + + // Keep latest prop callbacks in refs so async renderMarkers always uses + // the current version without those functions being effect dependencies. + const geojsonRef = useRef(geojson); + const getMarkerColourRef = useRef(getMarkerColour); + const renderPopupRef = useRef(renderPopup); + geojsonRef.current = geojson; + getMarkerColourRef.current = getMarkerColour; + renderPopupRef.current = renderPopup; + + const enableClusters = features.includes("clusters"); + const enableFitBounds = features.includes("fitBounds"); + const enableLegend = features.includes("legend"); + + // ── Initialise the map once on mount ────────────────────────────────────── + useEffect(() => { + if (!containerRef.current || mapRef.current) return; + + let L; + let destroyed = false; + + (async () => { + L = (await import("leaflet")).default; + await import("leaflet/dist/leaflet.css"); + + if (destroyed) return; + + // Fix default icon paths broken by bundlers + delete L.Icon.Default.prototype._getIconUrl; + L.Icon.Default.mergeOptions({ + iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", + iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", + shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", + }); + + const map = L.map(containerRef.current, { + center, + zoom, + scrollWheelZoom: false, + }); + + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + attribution: + '© OpenStreetMap contributors', + maxZoom: 19, + }).addTo(map); + + mapRef.current = map; + + // Render the initial set of markers + renderMarkers(L, map); + })(); + + return () => { + destroyed = true; + if (mapRef.current) { + mapRef.current.remove(); + mapRef.current = null; + layerRef.current = null; + } + }; + // center/zoom are intentionally read once at mount time + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ── Re-render markers whenever the caller passes new geojson ────────────── + // (e.g. after a filter is applied in the parent component) + useEffect(() => { + if (!mapRef.current) return; + import("leaflet").then((mod) => renderMarkers(mod.default, mapRef.current)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [geojson]); + + // ── Core marker rendering logic ─────────────────────────────────────────── + async function renderMarkers(L, map) { + if (!map) return; + + // Remove the previous layer + if (layerRef.current) { + map.removeLayer(layerRef.current); + layerRef.current = null; + } + + const featureList = geojsonRef.current?.features ?? []; + + let layer; + if (enableClusters) { + const { default: MC } = await import("leaflet.markercluster"); + await import("leaflet.markercluster/dist/MarkerCluster.css"); + await import("leaflet.markercluster/dist/MarkerCluster.Default.css"); + void MC; // imported for side-effects; augments L.markerClusterGroup + layer = L.markerClusterGroup({ showCoverageOnHover: false }); + } else { + layer = L.layerGroup(); + } + + const bounds = []; + + for (const feature of featureList) { + const [lng, lat] = feature.geometry.coordinates; + const colour = getMarkerColourRef.current(feature); + const icon = makeIcon(L, colour); + const marker = L.marker([lat, lng], { icon }); + + marker.bindPopup(renderPopupRef.current(feature), { maxWidth: 280 }); + layer.addLayer(marker); + bounds.push([lat, lng]); + } + + layer.addTo(map); + layerRef.current = layer; + + if (enableFitBounds && bounds.length > 0) { + if (bounds.length === 1) { + map.setView(bounds[0], 14); + } else { + map.fitBounds(bounds, { padding: [40, 40] }); + } + } + } + + // ── Render ──────────────────────────────────────────────────────────────── + return ( +
+
+ + {enableLegend && legendEntries.length > 0 && ( +
+

+ {labels.legendTitle ?? "Legend"} +

+
    + {legendEntries.map(({ label, colour }) => ( +
  • + + {label} +
  • + ))} +
+
+ )} +
+ ); +} + + + + + diff --git a/src/components/MemberMap.jsx b/src/components/MemberMap.jsx new file mode 100644 index 0000000..dc5b44e --- /dev/null +++ b/src/components/MemberMap.jsx @@ -0,0 +1,198 @@ +/** + * MemberMap - Specialised map for community members. + * + * Handles role-based filtering and passes the filtered GeoJSON to the + * generic component for rendering. + * + * Props: + * geojson {object} GeoJSON FeatureCollection (full, unfiltered member data) + * features {string[]} Feature flags: + * "clusters" - cluster nearby markers + * "roleFilter" - show role/tag filter chips above the map + * "fitBounds" - auto-zoom to fit all visible markers + * "legend" - show a colour-coded role legend + * lang {string} Active locale ("en" | "ja") + * labels {object} i18n label overrides + */ + +import { useMemo, useState } from "react"; +import Map from "./Map.jsx"; + +// -- Role -> colour mapping +const ROLE_COLOURS = { + "Software Engineer": "#3B82F6", + Designer: "#8B5CF6", + Researcher: "#10B981", + Founder: "#F59E0B", + Other: "#6B7280", +}; + +function roleColour(role) { + return ROLE_COLOURS[role] ?? ROLE_COLOURS.Other; +} + +/** Build a Leaflet popup HTML string for a member feature. */ +function buildPopupHtml(props, labels) { + const colour = roleColour(props.role ?? "Other"); + const tags = (props.tags ?? []) + .map( + (t) => + `${t}`, + ) + .join(" "); + + const links = [ + props.github + ? `GitHub` + : null, + props.website + ? `${labels.website ?? "Website"}` + : null, + ] + .filter(Boolean) + .join(" - "); + + return ` +
+
+ + ${props.name ?? labels.unknownMember ?? "Member"} +
+
${props.role ?? ""}
+ ${props.bio ? `

${props.bio}

` : ""} +
${tags}
+ ${links ? `
${links}
` : ""} +
`; +} + +// -- Component + +export default function MemberMap({ + geojson, + features = ["fitBounds", "legend"], + lang = "en", + labels = {}, +}) { + const enableRoleFilter = features.includes("roleFilter"); + void lang; // accepted for API parity with other map pages; unused here today + + // Strip "roleFilter" before forwarding - Map does not know about it + const mapFeatures = useMemo( + () => features.filter((f) => f !== "roleFilter"), + [features], + ); + + // Collect unique roles present in the data + const allRoles = useMemo( + () => [ + ...new Set( + (geojson?.features ?? []).map((f) => f.properties?.role ?? "Other"), + ), + ], + [geojson], + ); + + const [activeRoles, setActiveRoles] = useState(() => new Set(allRoles)); + + // Filtered GeoJSON forwarded to + const filteredGeoJSON = useMemo( + () => ({ + ...geojson, + features: (geojson?.features ?? []).filter((f) => + activeRoles.has(f.properties?.role ?? "Other"), + ), + }), + [geojson, activeRoles], + ); + + // Legend entries from the role colour map (stable reference) + const legendEntries = useMemo( + () => + Object.entries(ROLE_COLOURS).map(([label, colour]) => ({ label, colour })), + [], + ); + + // Callbacks for - Map stores them in refs so stale closure is not an issue + const getMarkerColour = (feature) => + roleColour(feature.properties?.role ?? "Other"); + + const renderPopup = (feature) => + buildPopupHtml(feature.properties ?? {}, labels); + + // -- Filter chip handlers + + function toggleRole(role) { + setActiveRoles((prev) => { + const next = new Set(prev); + if (next.has(role)) { + next.delete(role); + } else { + next.add(role); + } + return next; + }); + } + + function selectAll() { + setActiveRoles(new Set(allRoles)); + } + + // -- Render + + return ( +
+ {/* Role filter chips */} + {enableRoleFilter && ( +
+ + {allRoles.map((role) => { + const active = activeRoles.has(role); + const colour = roleColour(role); + return ( + + ); + })} +
+ )} + + {/* Generic map - receives only the filtered slice of data */} + +
+ ); +} + + + diff --git a/src/data/composite-feed.json b/src/data/composite-feed.json index 18377a8..609c173 100644 --- a/src/data/composite-feed.json +++ b/src/data/composite-feed.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-02-28T09:11:45.481Z", + "generatedAt": "2026-03-28T05:41:12.117Z", "itemsPerFeed": 3, "feeds": [ { @@ -8,43 +8,43 @@ "siteUrl": "https://www.ashryan.io/", "items": [ { - "id": "69964e9ff37e170001ebf954", - "title": "Kyoto Tech Meetup links for February 19, 2026", - "link": "https://www.ashryan.io/kyoto-tech-meetup-links-for-february-19-2026/", - "publishedAt": "2026-02-19T01:09:43.000Z", + "id": "69bb304d91aea700019d55f0", + "title": "Kyoto Tech Meetup links for March 19, 2026", + "link": "https://www.ashryan.io/kyoto-tech-meetup-links-for-march-19-2026/", + "publishedAt": "2026-03-19T01:26:21.000Z", "source": { "name": "Ash Ryan Arnwine", "siteUrl": "https://www.ashryan.io/", "feedUrl": "https://www.ashryan.io/rss/" }, - "summary": "\"Humans are hard\" edition", - "image": "https://images.unsplash.com/photo-1504630083234-14187a9df0f5?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wxMTc3M3wwfDF8c2VhcmNofDQzfHxjb2ZmZWV8ZW58MHx8fHwxNzcxNDYyNjE4fDA&ixlib=rb-4.1.0&q=80&w=2000" + "summary": "As a non-large language model, I have no idea how to summarize the sheer breadth of these links.", + "image": "https://images.unsplash.com/photo-1503481766315-7a586b20f66d?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wxMTc3M3wwfDF8c2VhcmNofDcwfHxjb2ZmZWV8ZW58MHx8fHwxNzczODgzNTI4fDA&ixlib=rb-4.1.0&q=80&w=2000" }, { - "id": "698fc74489aedc0001210e2d", - "title": "Kyoto Tech Meetup links for Feb 14, 2026", - "link": "https://www.ashryan.io/kyoto-tech-meetup-links-for-feb-14-2026/", - "publishedAt": "2026-02-14T02:27:09.000Z", + "id": "69b4b40c68dbec00018c8e81", + "title": "Kyoto Tech Meetup links for March 14, 20206", + "link": "https://www.ashryan.io/kyoto-tech-meetup-links-for-march-14-20206/", + "publishedAt": "2026-03-14T01:43:48.000Z", "source": { "name": "Ash Ryan Arnwine", "siteUrl": "https://www.ashryan.io/", "feedUrl": "https://www.ashryan.io/rss/" }, - "summary": "It's AI all the way down.", - "image": "https://images.unsplash.com/photo-1541167760496-1628856ab772?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wxMTc3M3wwfDF8c2VhcmNofDV8fGNvZmZlZXxlbnwwfHx8fDE3NzEwMTI3NTl8MA&ixlib=rb-4.1.0&q=80&w=2000" + "summary": "MCPs? Skills? Tools? Raw LLM? What's today's AI weather?", + "image": "https://images.unsplash.com/photo-1497935586351-b67a49e012bf?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wxMTc3M3wwfDF8c2VhcmNofDI1fHxjb2ZmZWV8ZW58MHx8fHwxNzczNDUyNDgwfDA&ixlib=rb-4.1.0&q=80&w=2000" }, { - "id": "6980152b82e86e0001227784", - "title": "Kyoto Tech Meetup: January 2026 wrap-up", - "link": "https://www.ashryan.io/kyoto-tech-meetup-january-2026-wrap-up/", - "publishedAt": "2026-02-03T00:12:50.000Z", + "id": "69a8c9851f47ac000159a1d2", + "title": "Kyoto Tech Meetup links for March 5, 2026", + "link": "https://www.ashryan.io/kyoto-tech-meetup-links-for-march-5-2026/", + "publishedAt": "2026-03-05T02:05:48.000Z", "source": { "name": "Ash Ryan Arnwine", "siteUrl": "https://www.ashryan.io/", "feedUrl": "https://www.ashryan.io/rss/" }, - "summary": "Updates from our morning coffee and hack day events last month. Also, this month's schedule.", - "image": "https://www.ashryan.io/content/images/2026/02/IMG_5665--1--1.jpeg" + "summary": "Time management, OpenClaw, cherry blossoms, and more", + "image": "https://images.unsplash.com/photo-1506619216599-9d16d0903dfd?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3wxMTc3M3wwfDF8c2VhcmNofDV8fGNvZmZlZXxlbnwwfHx8fDE3NzI2NzYzMjh8MA&ixlib=rb-4.1.0&q=80&w=2000" } ] }, @@ -98,48 +98,14 @@ "name": "Mere Mortal Dev", "feedUrl": "https://www.youtube.com/@meremortaldev", "siteUrl": "https://www.youtube.com/@meremortaldev", - "items": [ - { - "id": "yt:video:qWBRjtwUhKQ", - "title": "Git Worktree environments for AI agents in the OpenAI Codex app", - "link": "https://www.youtube.com/watch?v=qWBRjtwUhKQ", - "publishedAt": "2026-02-20T10:28:12.000Z", - "source": { - "name": "Mere Mortal Dev", - "siteUrl": "https://www.youtube.com/@meremortaldev", - "feedUrl": "https://www.youtube.com/@meremortaldev" - }, - "summary": "", - "image": "https://i.ytimg.com/vi/qWBRjtwUhKQ/hqdefault.jpg" - }, - { - "id": "yt:video:KHkGzQ2YGE0", - "title": "Prepping for Codex Git Worktrees video", - "link": "https://www.youtube.com/watch?v=KHkGzQ2YGE0", - "publishedAt": "2026-02-12T13:57:29.000Z", - "source": { - "name": "Mere Mortal Dev", - "siteUrl": "https://www.youtube.com/@meremortaldev", - "feedUrl": "https://www.youtube.com/@meremortaldev" - }, - "summary": "", - "image": "https://i.ytimg.com/vi/KHkGzQ2YGE0/hqdefault.jpg" - }, - { - "id": "yt:video:j106xpGv0AI", - "title": "OpenAI Codex app and git worktrees", - "link": "https://www.youtube.com/watch?v=j106xpGv0AI", - "publishedAt": "2026-02-12T03:42:59.000Z", - "source": { - "name": "Mere Mortal Dev", - "siteUrl": "https://www.youtube.com/@meremortaldev", - "feedUrl": "https://www.youtube.com/@meremortaldev" - }, - "summary": "", - "image": "https://i.ytimg.com/vi/j106xpGv0AI/hqdefault.jpg" - } - ] + "items": [], + "error": "HTTP 404 Not Found" } ], - "failedSources": [] + "failedSources": [ + { + "source": "Mere Mortal Dev", + "error": "HTTP 404 Not Found" + } + ] } \ No newline at end of file diff --git a/src/data/members.json b/src/data/members.json new file mode 100644 index 0000000..d4ddfea --- /dev/null +++ b/src/data/members.json @@ -0,0 +1,126 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [135.7625, 34.9953] }, + "properties": { + "name": "Ash", + "role": "Founder", + "tags": ["community", "organizer"], + "bio": "Organiser of Kyoto Tech Meetup. Building community in Kyoto.", + "github": "ashmortar", + "website": "https://kyototechmeetup.com" + } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [135.7760, 35.0116] }, + "properties": { + "name": "Yuki Tanaka", + "role": "Software Engineer", + "tags": ["web", "TypeScript", "React"], + "bio": "Frontend engineer exploring modern web tooling and open-source.", + "github": null, + "website": null + } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [135.7588, 34.9858] }, + "properties": { + "name": "Sofia Rossi", + "role": "Researcher", + "tags": ["AI", "NLP", "academia"], + "bio": "PhD candidate at Kyoto University researching large language models.", + "github": null, + "website": null + } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [135.7811, 35.0262] }, + "properties": { + "name": "Kenji Mori", + "role": "Designer", + "tags": ["UX", "product", "Figma"], + "bio": "Product designer focused on human-centred interfaces for mobile apps.", + "github": null, + "website": null + } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [135.7484, 35.0142] }, + "properties": { + "name": "Lena Braun", + "role": "Software Engineer", + "tags": ["backend", "Rust", "distributed-systems"], + "bio": "Remote backend engineer building reliable infrastructure from Kyoto.", + "github": null, + "website": null + } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [135.7698, 35.0080] }, + "properties": { + "name": "Hiroshi Nakamura", + "role": "Founder", + "tags": ["startup", "fintech", "product"], + "bio": "Early-stage fintech founder iterating on a personal finance app.", + "github": null, + "website": null + } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [135.7650, 35.0200] }, + "properties": { + "name": "Priya Sharma", + "role": "Software Engineer", + "tags": ["AI", "Python", "MLOps"], + "bio": "ML engineer working on applied AI products and developer tooling.", + "github": null, + "website": null + } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [135.7720, 34.9950] }, + "properties": { + "name": "Tom Nguyen", + "role": "Designer", + "tags": ["creative-coding", "generative-art", "p5.js"], + "bio": "Creative technologist blending design and code for interactive experiences.", + "github": null, + "website": null + } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [135.7540, 35.0020] }, + "properties": { + "name": "Aiko Fujita", + "role": "Researcher", + "tags": ["HCI", "robotics", "academia"], + "bio": "Postdoc at NAIST working on human-robot interaction interfaces.", + "github": null, + "website": null + } + }, + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [135.7785, 35.0037] }, + "properties": { + "name": "Marco Bianchi", + "role": "Software Engineer", + "tags": ["web", "Go", "DevOps"], + "bio": "Full-stack engineer and remote worker who moved to Kyoto for the lifestyle.", + "github": null, + "website": null + } + } + ] +} + diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts index 30d5f3e..88b2003 100644 --- a/src/i18n/ui.ts +++ b/src/i18n/ui.ts @@ -147,6 +147,15 @@ export const ui = { "meta.description": "Gatherings for developers, designers, researchers, and founders exploring technology in Kyoto. Conversation-first meetups, community hack days, and collaborations in the city's tech scene.", "meta.imageAlt": "Community meetup in Kyoto for builders and technologists", + + "members.title": "Community Members", + "members.subtitle": "The people behind Kyoto Tech Meetup — engineers, designers, researchers, and founders based in Kyoto.", + "members.map.title": "Member Map", + "members.map.filterAll": "All roles", + "members.map.legendTitle": "Role", + "members.map.website": "Website", + "members.map.unknownMember": "Member", + "nav.members": "Members", }, ja: { "a11y.skipToMain": "メインコンテンツへスキップ", @@ -292,5 +301,14 @@ export const ui = { "京都でテクノロジーを探求するデベロッパー、デザイナー、研究者、ファウンダーのための集まり。会話重視のミートアップ、コミュニティハックデー、コラボレーションを通じて、京都のテックシーンを盛り上げています。", "meta.imageAlt": "京都で開催される、テクノロジーに関心のある人たちのコミュニティミートアップ", + + "members.title": "コミュニティメンバー", + "members.subtitle": "京都在住のエンジニア、デザイナー、研究者、ファウンダーなど、Kyoto Tech Meetupを支える人たち。", + "members.map.title": "メンバーマップ", + "members.map.filterAll": "すべてのロール", + "members.map.legendTitle": "ロール", + "members.map.website": "ウェブサイト", + "members.map.unknownMember": "メンバー", + "nav.members": "メンバー", }, } as const; diff --git a/src/pages/ja/members.astro b/src/pages/ja/members.astro new file mode 100644 index 0000000..d0f89da --- /dev/null +++ b/src/pages/ja/members.astro @@ -0,0 +1,4 @@ +--- +import MembersPage from "../members.astro"; +--- + diff --git a/src/pages/members.astro b/src/pages/members.astro new file mode 100644 index 0000000..1bc6f33 --- /dev/null +++ b/src/pages/members.astro @@ -0,0 +1,46 @@ +--- +import Layout from "../layouts/Layout.astro"; +import Header from "../components/Header.astro"; +import Footer from "../components/Footer.astro"; +import MemberMap from "../components/MemberMap.jsx"; +import { useTranslations } from "../i18n/utils"; +import membersGeoJSON from "../data/members.json"; + +const lang = "en"; +const t = useTranslations(lang); + +const mapLabels = { + filterAll: t("members.map.filterAll"), + legendTitle: t("members.map.legendTitle"), + website: t("members.map.website"), + unknownMember: t("members.map.unknownMember"), +}; +--- + + +
+ +
+ +
+

+ {t("members.map.title")} +

+

+ {t("members.title")} +

+

{t("members.subtitle")}

+
+ + + +
+ +