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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
labels:
- dependencies
- security

- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
open-pull-requests-limit: 3
labels:
- dependencies
3 changes: 2 additions & 1 deletion .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: npm

# Windows-specific: Clean any existing node_modules to avoid EPERM errors
- name: Clean node_modules (Windows)
Expand Down
26 changes: 16 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: npm

- name: Verify dependency lockfile
run: npm ci --ignore-scripts

- name: Audit dependencies
run: npm audit --audit-level=high

- name: Validate JSON files
run: |
Expand All @@ -23,16 +30,10 @@ jobs:
node -e "JSON.parse(require('fs').readFileSync('$f', 'utf8'))"
done

- name: Check inline script syntax in organization-network-map.html
- name: Check public map JavaScript syntax
run: |
node -e "
const fs = require('fs');
const html = fs.readFileSync('organization-network-map.html', 'utf8');
const match = html.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/);
if (!match) { console.error('Could not find inline script block'); process.exit(1); }
new Function(match[1]);
console.log('Inline script syntax OK');
"
node --check map.js
node --check network-data.js

- name: Check admin-editor/app.js syntax
run: node --check admin-editor/app.js
Expand All @@ -56,3 +57,8 @@ jobs:

- name: Run api/ unit tests (mocked Gist backend)
run: node test/api.test.js

- name: Run public map data tests
run: |
node test/public-assets.test.js
node test/network-data.test.js
12 changes: 10 additions & 2 deletions .github/workflows/static.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Publishes the public map page to GitHub Pages — a second embeddable hosting
# option alongside wherever the map is already hosted. Only the files the map
# actually needs (organization-network-map.html, categories.json,
# categories.legacy.js) are published — NOT the whole repo, since that would
# actually needs (HTML, CSS, JavaScript, and category data) are published —
# NOT the whole repo, since that would
# also publish /admin (a login page with no backend on static Pages) and the
# /api function source as world-readable static files.
#
Expand All @@ -15,6 +15,10 @@ on:
branches: ["main"]
paths:
- "organization-network-map.html"
- "praxium-tokens-v2.css"
- "map.css"
- "map.js"
- "network-data.js"
- "categories.json"
- "categories.legacy.js"
- ".github/workflows/static.yml"
Expand Down Expand Up @@ -50,6 +54,10 @@ jobs:
mkdir -p _site
cp organization-network-map.html _site/
cp organization-network-map.html _site/index.html
cp praxium-tokens-v2.css _site/
cp map.css _site/
cp map.js _site/
cp network-data.js _site/
cp categories.json _site/
cp categories.legacy.js _site/

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,6 @@ out/

# Local testing
*.log
.tmp-chrome/
.cc-history/
Plan.md
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
490 changes: 273 additions & 217 deletions README.md

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions admin-editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@ How to use
- Open `admin-editor/index.html` in your browser (no server required, but for cross-file fetch some browsers block local fetches; use the file input or paste clipboard JSON if needed).
- Paste your current JSON into the left textarea or use the file input to load a file.
- Click `Parse & Preview` to populate the editable list.
- Use `Edit` on any card to change fields, or `Add Organization` to create a new one.
- Use `Edit` on any card to change fields, or `Add Organization` to create a new
one. The form preserves country, contact details, and the public "How Praxium
Connects" impact note.
- Click `Copy JSON` to copy the updated JSON to your clipboard, or `Download JSON` to save locally.
- Paste the copied/downloaded JSON into your Gist to update the map data.

Notes
- This tool is offline-first and does not upload anything to a server.
- Connections are maintained as arrays of numeric IDs; use comma-separated IDs when editing.
- Connections are maintained as arrays of numeric IDs; use comma-separated IDs
when editing. The editor keeps valid relationships reciprocal and removes
references when an organization is deleted.
113 changes: 81 additions & 32 deletions admin-editor/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,36 @@ const template = document.getElementById('org-template');
let CANONICAL_CATEGORIES = [];

// Track if running in Electron
const isElectron = window.electronAPI && window.electronAPI.isElectron;
let currentFilePath = null;

function escapeHtml(str){
const isElectron = window.electronAPI && window.electronAPI.isElectron;
let currentFilePath = null;

function connectionIds(value, selfId) {
return [...new Set(
String(value || '')
.split(',')
.map((entry) => Number(entry.trim()))
.filter((id) => Number.isSafeInteger(id) && id > 0 && id !== selfId)
)];
}

function applyReciprocalConnections(orgId, previousConnections, nextConnections) {
const previous = new Set(previousConnections || []);
const next = new Set(nextConnections || []);

organizationsData.forEach((candidate) => {
if (candidate.id === orgId) return;
const candidateConnections = Array.isArray(candidate.connections)
? candidate.connections
: [];
if (next.has(candidate.id) && !candidateConnections.includes(orgId)) {
candidate.connections = [...candidateConnections, orgId];
} else if (previous.has(candidate.id) && !next.has(candidate.id)) {
candidate.connections = candidateConnections.filter((id) => id !== orgId);
}
});
}

function escapeHtml(str){
if (!str) return '';
return String(str)
.replace(/&/g,'&amp;')
Expand All @@ -33,14 +59,22 @@ function renderList(){

nameDisplay.textContent = org.name || 'Unnamed';

// populate form fields
form.elements.name.value = org.name || '';
form.elements.location.value = org.location || '';
form.elements.lat.value = (org.coordinates && org.coordinates[0]) ? org.coordinates[0] : (org.lat || '');
form.elements.lng.value = (org.coordinates && org.coordinates[1]) ? org.coordinates[1] : (org.lng || '');
form.elements.description.value = org.description || '';
form.elements.website.value = (org.contact && org.contact.website) ? org.contact.website : '';
form.elements.connections.value = (org.connections && org.connections.length) ? org.connections.join(',') : '';
// populate form fields
form.elements.name.value = org.name || '';
form.elements.location.value = org.location || '';
form.elements.country.value = org.country || '';
form.elements.lat.value = (
Array.isArray(org.coordinates) && org.coordinates[0] !== undefined
) ? org.coordinates[0] : (org.lat || '');
form.elements.lng.value = (
Array.isArray(org.coordinates) && org.coordinates[1] !== undefined
) ? org.coordinates[1] : (org.lng || '');
form.elements.description.value = org.description || '';
form.elements.relationship.value = org.relationship || '';
form.elements.website.value = (org.contact && org.contact.website) ? org.contact.website : '';
form.elements.email.value = (org.contact && org.contact.email) ? org.contact.email : '';
form.elements.phone.value = (org.contact && org.contact.phone) ? org.contact.phone : '';
form.elements.connections.value = (org.connections && org.connections.length) ? org.connections.join(',') : '';

// category select + custom handling — populate options from canonical list
const sel = form.elements['category'];
Expand All @@ -53,7 +87,7 @@ function renderList(){
if (Array.isArray(CANONICAL_CATEGORIES) && CANONICAL_CATEGORIES.length) {
CANONICAL_CATEGORIES.forEach(c => addOpt(c, c));
}
addOpt('Other','Other');
if (!CANONICAL_CATEGORIES.includes('Other')) addOpt('Other','Other');
addOpt('custom','Custom...');

// try to match existing category (case-insensitive against canonical)
Expand All @@ -79,11 +113,16 @@ function renderList(){
form.classList.toggle('hidden');
});

deleteBtn.addEventListener('click', () => {
if (!confirm('Delete this organization?')) return;
organizationsData = organizationsData.filter(o => o.id !== org.id);
syncToTextarea();
renderList();
deleteBtn.addEventListener('click', () => {
if (!confirm('Delete this organization?')) return;
organizationsData = organizationsData.filter(o => o.id !== org.id);
organizationsData.forEach((candidate) => {
candidate.connections = Array.isArray(candidate.connections)
? candidate.connections.filter((id) => id !== org.id)
: [];
});
syncToTextarea();
renderList();
});

// show/hide custom input on select change
Expand All @@ -106,20 +145,30 @@ function renderList(){
categoryValue = customVal;
}

const updated = {
id: org.id,
name: form.elements.name.value.trim(),
location: form.elements.location.value.trim(),
coordinates: [ Number(form.elements.lat.value) || 0, Number(form.elements.lng.value) || 0 ],
category: categoryValue,
description: form.elements.description.value.trim(),
contact: { website: form.elements.website.value.trim() },
connections: form.elements.connections.value.split(',').map(s=>Number(s.trim())).filter(Boolean)
};

const idx = organizationsData.findIndex(o=>o.id===org.id);
if (idx > -1) organizationsData[idx] = updated;
syncToTextarea();
const previousConnections = Array.isArray(org.connections) ? org.connections : [];
const nextConnections = connectionIds(form.elements.connections.value, org.id);
const updated = {
...org,
id: org.id,
name: form.elements.name.value.trim(),
location: form.elements.location.value.trim(),
country: form.elements.country.value.trim(),
coordinates: [ Number(form.elements.lat.value) || 0, Number(form.elements.lng.value) || 0 ],
category: categoryValue,
description: form.elements.description.value.trim(),
relationship: form.elements.relationship.value.trim(),
contact: {
website: form.elements.website.value.trim(),
email: form.elements.email.value.trim(),
phone: form.elements.phone.value.trim()
},
connections: nextConnections
};

const idx = organizationsData.findIndex(o=>o.id===org.id);
if (idx > -1) organizationsData[idx] = updated;
applyReciprocalConnections(org.id, previousConnections, nextConnections);
syncToTextarea();
renderList();
};

Expand Down
20 changes: 12 additions & 8 deletions admin-editor/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,21 @@ <h1>Praxium - Allies and Partners: Admin JSON Editor</h1>
</div>
</div>
<form class="org-form hidden">
<label>Name: <input name="name" required></label>
<label>Location: <input name="location"></label>
<label>Latitude: <input name="lat" type="Number" step="any"></label>
<label>Longitude: <input name="lng" type="Number" step="any"></label>
<label>Name: <input name="name" maxlength="200" required></label>
<label>Location: <input name="location" maxlength="300"></label>
<label>Country: <input name="country" maxlength="100"></label>
<label>Latitude: <input name="lat" type="number" step="any"></label>
<label>Longitude: <input name="lng" type="number" step="any"></label>
<label>Category:
<select name="category"></select>
<input name="category-custom" class="hidden" placeholder="Custom category">
</label>
<label>Description: <textarea name="description"></textarea></label>
<label>Website: <input name="website" type="url"></label>
<label>Connections (comma-separated IDs): <input name="connections"></label>
</label>
<label>Description: <textarea name="description" maxlength="500"></textarea></label>
<label>How Praxium Connects: <textarea name="relationship" maxlength="500"></textarea></label>
<label>Website: <input name="website" type="url" maxlength="2048"></label>
<label>Email: <input name="email" type="email" maxlength="254"></label>
<label>Phone: <input name="phone" type="tel" maxlength="50"></label>
<label>Connections (comma-separated IDs): <input name="connections"></label>
<div class="form-actions">
<button type="submit" class="save-btn">Save</button>
<button type="button" class="cancel-btn">Cancel</button>
Expand Down
27 changes: 24 additions & 3 deletions admin/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,16 @@ function populateConnectionsSelect(selectedIds, excludeId) {

function initPreviewMap(lat, lng) {
if (!previewMap) {
previewMap = L.map('preview-map', { attributionControl: false }).setView([lat || 20, lng || 0], lat ? 12 : 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(previewMap);
previewMap = L.map('preview-map').setView([lat || 20, lng || 0], lat ? 12 : 2);
// Esri's keyless community basemap (not OSM's own tile servers) — see map.js
// for why hotlinking tile.openstreetmap.org directly violates OSM's tile
// usage policy, and why CARTO isn't used either (it now requires an API key).
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}', {
attribution:
'Tiles &copy; Esri &mdash; Esri, HERE, Garmin, &copy; OpenStreetMap contributors, and the GIS User Community',
maxZoom: 19,
maxNativeZoom: 16,
}).addTo(previewMap);
previewMarker = L.marker([lat || 20, lng || 0], { draggable: true }).addTo(previewMap);
previewMarker.on('dragend', () => {
const pos = previewMarker.getLatLng();
Expand Down Expand Up @@ -159,10 +167,17 @@ function openForm(org) {
document.getElementById('org-email').value = (org && org.contact && org.contact.email) || '';
document.getElementById('org-phone').value = (org && org.contact && org.contact.phone) || '';
document.getElementById('org-location').value = org ? org.location : '';
document.getElementById('org-country').value = (org && org.country) || '';
document.getElementById('org-lat').value = org ? org.coordinates[0] : '';
document.getElementById('org-lng').value = org ? org.coordinates[1] : '';
document.getElementById('org-description').value = org ? org.description : '';
document.getElementById('description-count').textContent = org ? org.description.length : 0;
document.getElementById('description-count').textContent = (org && org.description)
? org.description.length
: 0;
document.getElementById('org-relationship').value = (org && org.relationship) || '';
document.getElementById('relationship-count').textContent = (org && org.relationship)
? org.relationship.length
: 0;
document.getElementById('geocode-status').textContent = '';
document.getElementById('delete-btn').hidden = !org;

Expand Down Expand Up @@ -192,6 +207,10 @@ document.getElementById('org-description').addEventListener('input', (e) => {
document.getElementById('description-count').textContent = e.target.value.length;
});

document.getElementById('org-relationship').addEventListener('input', (e) => {
document.getElementById('relationship-count').textContent = e.target.value.length;
});

document.getElementById('org-lat').addEventListener('change', updatePreviewMarker);
document.getElementById('org-lng').addEventListener('change', updatePreviewMarker);

Expand Down Expand Up @@ -280,10 +299,12 @@ form.addEventListener('submit', async (e) => {
const org = {
name: document.getElementById('org-name').value.trim(),
location: document.getElementById('org-location').value.trim(),
country: document.getElementById('org-country').value.trim(),
coordinates: [parseFloat(document.getElementById('org-lat').value), parseFloat(document.getElementById('org-lng').value)],
category,
allowCustomCategory,
description: document.getElementById('org-description').value.trim(),
relationship: document.getElementById('org-relationship').value.trim(),
contact: {
website: document.getElementById('org-website').value.trim(),
email: document.getElementById('org-email').value.trim(),
Expand Down
Loading
Loading