MC versioning overhaul: calendar scheme + DB-backed versions (REQ-2026-04-16-mc-versioning)
Migration 145: mc_versions table with java_version, paper/forge/neoforge/fabric support flags, seeded with 11 known versions for immediate use. New: src/services/mcVersionSync.js - javaVersionForMC() handles both 1.x.y legacy and 26.x.y calendar schemes - Daily 3AM pg-boss cron fetches Paper API + Modrinth /tag/game_version, upserts mc_versions with correct loader flags and Java version mapping - getVersions(filter) for DB-backed lookups with loader filtering - semver.rcompare for proper version sorting across both schemes Routes: GET /admin/modpack-installer/mc-versions — JSON endpoint for dynamic version dropdowns, filterable by loader (paper/forge/neoforge/fabric), with hardcoded fallback if DB is unavailable. Views updated: - _vanilla_form.ejs: MC version dropdown loaded dynamically (Paper-only filter), Java auto-select reads java_version from DB row - index.ejs: search filter version dropdown loaded dynamically from mc_versions - _pack_details.ejs: client-side Java logic handles major >= 26 index.js: registers mc-version-sync cron via pg-boss after queue init. package.json: added semver ^7.6.3. ACTIVE_CONTEXT updated.
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
Local Nitro (Windows 11) at `C:\Users\mkrau\firefrost-services`. Git remote: Gitea. Identity: `Claude Code <claude@firefrostgaming.com>`.
|
||||
|
||||
## Current Focus
|
||||
Bridge queue empty. Seven features shipped tonight, all pending deploy by Michael.
|
||||
Bridge queue empty. Eight features shipped tonight, all pending deploy by Michael.
|
||||
|
||||
## Session 2026-04-16
|
||||
|
||||
@@ -35,6 +35,15 @@ Bridge queue empty. Seven features shipped tonight, all pending deploy by Michae
|
||||
- `index.ejs`: "New Vanilla / Paper Server" button loads vanilla form directly (skips pack search)
|
||||
- No migrations. **Deploy:** standard restart only, ensure `standard-plugins/1.21.1/` populated in NextCloud before first vanilla install
|
||||
|
||||
- **MC Versioning Overhaul** (REQ-2026-04-16-mc-versioning)
|
||||
- Migration 145: `mc_versions` table with loader support flags, seeded with 11 known versions
|
||||
- `mcVersionSync.js`: new `javaVersionForMC()` handles both legacy 1.x.y and calendar 26.x.y schemes; daily 3AM pg-boss cron syncs from Paper API + Modrinth `/tag/game_version`; `getVersions(filter)` for DB-backed lookups
|
||||
- `/mc-versions` endpoint returns cached versions (filterable by loader), with hardcoded fallback
|
||||
- `_vanilla_form.ejs` + `index.ejs` search filter: replaced hardcoded version dropdowns with dynamic fetch from mc_versions
|
||||
- `_pack_details.ejs`: updated client-side Java auto-select for calendar versioning
|
||||
- Added `semver ^7.6.3` to package.json
|
||||
- **Deploy:** `npm install` (semver) + run migration 145 + restart
|
||||
|
||||
- **Discord Action Log — Issue #1** (`49f8f79`, +263 lines)
|
||||
- Migration 142: `discord_action_log` table
|
||||
- `discordActionLog.js` service (silent-fail logAction)
|
||||
|
||||
28
services/arbiter-3.0/migrations/145_mc_versions.sql
Normal file
28
services/arbiter-3.0/migrations/145_mc_versions.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Migration 145: mc_versions table (REQ-2026-04-16-mc-versioning)
|
||||
-- Caches available MC versions with loader support flags.
|
||||
-- Refreshed daily at 3AM via pg-boss cron job.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mc_versions (
|
||||
version VARCHAR(20) PRIMARY KEY,
|
||||
java_version INT NOT NULL DEFAULT 21,
|
||||
paper_supported BOOLEAN DEFAULT FALSE,
|
||||
forge_supported BOOLEAN DEFAULT FALSE,
|
||||
neoforge_supported BOOLEAN DEFAULT FALSE,
|
||||
fabric_supported BOOLEAN DEFAULT FALSE,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Seed with known versions so installer works before first cron run
|
||||
INSERT INTO mc_versions (version, java_version, paper_supported, forge_supported, neoforge_supported, fabric_supported) VALUES
|
||||
('26.1.2', 21, true, false, true, true),
|
||||
('26.1.1', 21, true, false, true, true),
|
||||
('26.1', 21, true, false, true, true),
|
||||
('1.21.4', 21, true, false, true, true),
|
||||
('1.21.1', 21, true, true, true, true),
|
||||
('1.20.4', 17, true, true, false, true),
|
||||
('1.20.1', 17, true, true, false, true),
|
||||
('1.19.4', 17, true, true, false, true),
|
||||
('1.19.2', 17, true, true, false, true),
|
||||
('1.18.2', 17, true, true, false, true),
|
||||
('1.16.5', 8, true, true, false, true)
|
||||
ON CONFLICT (version) DO NOTHING;
|
||||
@@ -25,6 +25,7 @@
|
||||
"node-cron": "^3.0.3",
|
||||
"passport": "^0.7.0",
|
||||
"pg-boss": "^10.1.5",
|
||||
"semver": "^7.6.3",
|
||||
"passport-discord": "^0.1.4",
|
||||
"pg": "^8.11.3",
|
||||
"socket.io-client": "^4.7.5",
|
||||
|
||||
@@ -161,6 +161,7 @@ const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_BOT_TOKEN)
|
||||
// Initialize pg-boss job queue (Task #101 — Modpack Installer)
|
||||
const PgBoss = require('pg-boss');
|
||||
const { handleInstallJob } = require('./services/modpackInstaller');
|
||||
const { registerCronJob: registerVersionSync } = require('./services/mcVersionSync');
|
||||
(async () => {
|
||||
try {
|
||||
const boss = new PgBoss({
|
||||
@@ -174,6 +175,7 @@ const { handleInstallJob } = require('./services/modpackInstaller');
|
||||
await boss.work('modpack-installs', { teamConcurrency: 2 }, handleInstallJob);
|
||||
app.locals.pgBoss = boss;
|
||||
console.log('✅ pg-boss job queue initialized (modpack-installs concurrency: 2)');
|
||||
await registerVersionSync(boss);
|
||||
} catch (err) {
|
||||
console.error('⚠️ pg-boss init failed (installer will not work):', err.message);
|
||||
}
|
||||
|
||||
@@ -18,11 +18,12 @@ const axios = require('axios');
|
||||
const db = require('../../database');
|
||||
const { getProvider, listProviders } = require('../../services/providerApi');
|
||||
const { estimateRam, slugify } = require('../../services/modpackInstaller');
|
||||
const { javaVersionForMC, getVersions: getMCVersions } = require('../../services/mcVersionSync');
|
||||
|
||||
const PANEL_URL = process.env.PANEL_URL || 'https://panel.firefrostgaming.com';
|
||||
const PANEL_ADMIN_KEY = process.env.PANEL_ADMIN_KEY || '';
|
||||
|
||||
// Aikar G1GC flags template — {RAM} replaced with allocation in MB
|
||||
// Aikar G1GC flags template
|
||||
const AIKAR_FLAGS = '-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 ' +
|
||||
'-XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC -XX:+AlwaysPreTouch ' +
|
||||
'-XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=40 -XX:G1HeapRegionSize=8M ' +
|
||||
@@ -32,15 +33,6 @@ const AIKAR_FLAGS = '-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMill
|
||||
'-XX:MaxTenuringThreshold=1 -Dusing.aikars.flags=https://mcflags.emc.gs ' +
|
||||
'-Daikars.new.flags=true';
|
||||
|
||||
function javaVersionForMC(mcVersion) {
|
||||
if (!mcVersion) return 21;
|
||||
const parts = mcVersion.split('.').map(Number);
|
||||
const minor = parts[1] || 0;
|
||||
if (minor <= 16) return 8;
|
||||
if (minor <= 20) return 17;
|
||||
return 21;
|
||||
}
|
||||
|
||||
// ─── Node resource usage (JSON for form) ────────────────────────────────────
|
||||
router.get('/node-info', async (req, res) => {
|
||||
try {
|
||||
@@ -99,6 +91,29 @@ router.get('/next-port', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── MC versions (JSON for dynamic dropdowns) ──────────────────────────────
|
||||
router.get('/mc-versions', async (req, res) => {
|
||||
try {
|
||||
const filter = {};
|
||||
if (req.query.paper) filter.paper = true;
|
||||
if (req.query.forge) filter.forge = true;
|
||||
if (req.query.neoforge) filter.neoforge = true;
|
||||
if (req.query.fabric) filter.fabric = true;
|
||||
const versions = await getMCVersions(filter);
|
||||
res.json(versions);
|
||||
} catch (err) {
|
||||
console.error('[Installer] mc-versions error:', err.message);
|
||||
// Fallback to hardcoded list if DB is down
|
||||
res.json([
|
||||
{ version: '26.1.2', java_version: 21 },
|
||||
{ version: '1.21.1', java_version: 21 },
|
||||
{ version: '1.20.1', java_version: 17 },
|
||||
{ version: '1.18.2', java_version: 17 },
|
||||
{ version: '1.16.5', java_version: 8 }
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Main page ──────────────────────────────────────────────────────────────
|
||||
router.get('/', (req, res) => {
|
||||
res.render('admin/modpack-installer/index', {
|
||||
|
||||
181
services/arbiter-3.0/src/services/mcVersionSync.js
Normal file
181
services/arbiter-3.0/src/services/mcVersionSync.js
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* MC Version Sync — daily refresh of mc_versions table from Paper + Modrinth APIs.
|
||||
* REQ-2026-04-16-mc-versioning
|
||||
*
|
||||
* Runs as a pg-boss cron job at 3AM daily. Also callable on-demand from admin.
|
||||
*
|
||||
* Data sources:
|
||||
* 1. Paper API → paper_supported versions
|
||||
* 2. Modrinth /tag/game_version → all MC versions + loader tags
|
||||
*
|
||||
* Java version mapping handles both legacy (1.x.y) and new calendar (26.x.y) schemes.
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const semver = require('semver');
|
||||
const db = require('../database');
|
||||
|
||||
/**
|
||||
* Determine Java version for any MC version string.
|
||||
* Handles both 1.x.y legacy and 26.x.y calendar scheme.
|
||||
*/
|
||||
function javaVersionForMC(mcVersion) {
|
||||
if (!mcVersion) return 21;
|
||||
const major = parseInt(mcVersion.split('.')[0]);
|
||||
|
||||
// New 2026+ calendar scheme — all Java 21
|
||||
if (major >= 26) return 21;
|
||||
|
||||
// Legacy 1.x.y scheme
|
||||
if (major === 1) {
|
||||
const minor = parseInt(mcVersion.split('.')[1]) || 0;
|
||||
if (minor >= 21) return 21;
|
||||
if (minor >= 17) return 17;
|
||||
return 8;
|
||||
}
|
||||
|
||||
return 21; // Frostwall fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Paper-supported versions from Paper API.
|
||||
*/
|
||||
async function fetchPaperVersions() {
|
||||
try {
|
||||
const resp = await axios.get('https://api.papermc.io/v2/projects/paper', { timeout: 10000 });
|
||||
return new Set(resp.data.versions || []);
|
||||
} catch (err) {
|
||||
console.error('[MCVersionSync] Paper API error:', err.message);
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all MC versions + loader tags from Modrinth.
|
||||
*/
|
||||
async function fetchModrinthVersions() {
|
||||
try {
|
||||
const resp = await axios.get('https://api.modrinth.com/v2/tag/game_version', {
|
||||
headers: { 'User-Agent': 'FirefrostGaming/Arbiter (contact@firefrostgaming.com)' },
|
||||
timeout: 10000
|
||||
});
|
||||
// Filter to releases only (skip snapshots)
|
||||
return (resp.data || []).filter(v => v.version_type === 'release');
|
||||
} catch (err) {
|
||||
console.error('[MCVersionSync] Modrinth API error:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort MC version strings properly — handles both 1.x.y and 26.x.y.
|
||||
* Uses semver.rcompare where possible, falls back to string compare.
|
||||
*/
|
||||
function sortVersions(versions) {
|
||||
return versions.sort((a, b) => {
|
||||
// Coerce to semver-compatible (pad to 3 parts)
|
||||
const pad = v => {
|
||||
const parts = v.split('.');
|
||||
while (parts.length < 3) parts.push('0');
|
||||
return parts.join('.');
|
||||
};
|
||||
const sa = semver.valid(semver.coerce(pad(a)));
|
||||
const sb = semver.valid(semver.coerce(pad(b)));
|
||||
if (sa && sb) return semver.rcompare(sa, sb);
|
||||
return b.localeCompare(a);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full sync. Upserts mc_versions from Paper + Modrinth data.
|
||||
* Returns { synced, paperCount, total }.
|
||||
*/
|
||||
async function syncVersions() {
|
||||
console.log('[MCVersionSync] Starting version sync...');
|
||||
|
||||
const [paperVersions, modrinthVersions] = await Promise.all([
|
||||
fetchPaperVersions(),
|
||||
fetchModrinthVersions()
|
||||
]);
|
||||
|
||||
// Build unified version set
|
||||
const allVersions = new Set();
|
||||
for (const v of paperVersions) allVersions.add(v);
|
||||
for (const v of modrinthVersions) allVersions.add(v.version);
|
||||
|
||||
// We only care about "major" releases (x.y.z or x.y), skip pre-releases/snapshots
|
||||
const releaseVersions = [...allVersions].filter(v => /^\d+\.\d+(\.\d+)?$/.test(v));
|
||||
const sorted = sortVersions(releaseVersions);
|
||||
|
||||
// Determine loader support from Modrinth tags (best-effort)
|
||||
const modrinthMap = new Map();
|
||||
for (const v of modrinthVersions) {
|
||||
modrinthMap.set(v.version, v);
|
||||
}
|
||||
|
||||
let synced = 0;
|
||||
for (const version of sorted) {
|
||||
const java = javaVersionForMC(version);
|
||||
const paper = paperVersions.has(version);
|
||||
// Modrinth doesn't give per-version loader flags in the tag endpoint,
|
||||
// so we infer from version range heuristics
|
||||
const major = parseInt(version.split('.')[0]);
|
||||
const minor = parseInt(version.split('.')[1]) || 0;
|
||||
const forge = major === 1 && minor >= 7;
|
||||
const neoforge = (major === 1 && minor >= 21) || major >= 26;
|
||||
const fabric = (major === 1 && minor >= 14) || major >= 26;
|
||||
|
||||
await db.query(`
|
||||
INSERT INTO mc_versions (version, java_version, paper_supported, forge_supported, neoforge_supported, fabric_supported, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
ON CONFLICT (version) DO UPDATE SET
|
||||
java_version = EXCLUDED.java_version,
|
||||
paper_supported = EXCLUDED.paper_supported,
|
||||
forge_supported = EXCLUDED.forge_supported,
|
||||
neoforge_supported = EXCLUDED.neoforge_supported,
|
||||
fabric_supported = EXCLUDED.fabric_supported,
|
||||
updated_at = NOW()
|
||||
`, [version, java, paper, forge, neoforge, fabric]);
|
||||
synced++;
|
||||
}
|
||||
|
||||
console.log(`[MCVersionSync] Done. Synced ${synced} versions (${paperVersions.size} Paper-supported).`);
|
||||
return { synced, paperCount: paperVersions.size, total: sorted.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached versions from DB, optionally filtered by loader.
|
||||
*/
|
||||
async function getVersions(filter = {}) {
|
||||
let where = 'WHERE 1=1';
|
||||
if (filter.paper) where += ' AND paper_supported = true';
|
||||
if (filter.forge) where += ' AND forge_supported = true';
|
||||
if (filter.neoforge) where += ' AND neoforge_supported = true';
|
||||
if (filter.fabric) where += ' AND fabric_supported = true';
|
||||
|
||||
const result = await db.query(`
|
||||
SELECT * FROM mc_versions ${where}
|
||||
ORDER BY
|
||||
CASE WHEN version ~ '^[0-9]+\\.' THEN CAST(SPLIT_PART(version, '.', 1) AS INT) ELSE 0 END DESC,
|
||||
CASE WHEN version ~ '^[0-9]+\\.[0-9]+' THEN CAST(SPLIT_PART(version, '.', 2) AS INT) ELSE 0 END DESC,
|
||||
CASE WHEN version ~ '^[0-9]+\\.[0-9]+\\.[0-9]+' THEN CAST(SPLIT_PART(version, '.', 3) AS INT) ELSE 0 END DESC
|
||||
`);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the 3AM daily cron job with pg-boss.
|
||||
*/
|
||||
async function registerCronJob(boss) {
|
||||
try {
|
||||
await boss.schedule('mc-version-sync', '0 3 * * *');
|
||||
await boss.work('mc-version-sync', async () => {
|
||||
await syncVersions();
|
||||
});
|
||||
console.log('✅ MC version sync cron registered (daily 3AM)');
|
||||
} catch (err) {
|
||||
console.error('[MCVersionSync] Failed to register cron:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { javaVersionForMC, syncVersions, getVersions, registerCronJob };
|
||||
@@ -190,9 +190,10 @@
|
||||
|
||||
function onVersionChange(sel) {
|
||||
var mc = sel.options[sel.selectedIndex].dataset.mc || '';
|
||||
var parts = mc.split('.').map(Number);
|
||||
var minor = parts[1] || 0;
|
||||
var java = minor <= 16 ? 8 : minor <= 20 ? 17 : 21;
|
||||
var major = parseInt(mc.split('.')[0]) || 0;
|
||||
var minor = parseInt(mc.split('.')[1]) || 0;
|
||||
// Calendar scheme (26+) → Java 21; Legacy 1.x → 8/17/21
|
||||
var java = major >= 26 ? 21 : minor <= 16 ? 8 : minor <= 20 ? 17 : 21;
|
||||
document.getElementById('dd-java').value = java;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,17 +21,12 @@
|
||||
<input type="hidden" name="spawnType" value="vanilla">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- MC version -->
|
||||
<!-- MC version (loaded dynamically from mc_versions table, Paper-only) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Minecraft Version</label>
|
||||
<select name="versionId" id="vf-mcversion" required onchange="vfUpdateJava(this)"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm">
|
||||
<option value="1.21.1" data-mc="1.21.1" selected>1.21.1 (latest)</option>
|
||||
<option value="1.20.4" data-mc="1.20.4">1.20.4</option>
|
||||
<option value="1.20.1" data-mc="1.20.1">1.20.1</option>
|
||||
<option value="1.19.4" data-mc="1.19.4">1.19.4</option>
|
||||
<option value="1.18.2" data-mc="1.18.2">1.18.2</option>
|
||||
<option value="1.16.5" data-mc="1.16.5">1.16.5</option>
|
||||
<option value="">Loading versions...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -195,6 +190,26 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Load MC versions dynamically (Paper-supported only)
|
||||
fetch('/admin/modpack-installer/mc-versions?paper=1', { headers: { 'HX-Request': 'true' } })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(versions) {
|
||||
var sel = document.getElementById('vf-mcversion');
|
||||
sel.innerHTML = '';
|
||||
versions.forEach(function(v, i) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = v.version;
|
||||
opt.dataset.mc = v.version;
|
||||
opt.dataset.java = v.java_version;
|
||||
opt.textContent = v.version + (i === 0 ? ' (latest)' : '');
|
||||
if (i === 0) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (versions.length > 0) {
|
||||
document.getElementById('vf-java').value = versions[0].java_version;
|
||||
}
|
||||
});
|
||||
|
||||
// Load node stats
|
||||
fetch('/admin/modpack-installer/node-info', { headers: { 'HX-Request': 'true' } })
|
||||
.then(function(r) { return r.json(); })
|
||||
@@ -220,9 +235,15 @@
|
||||
}
|
||||
|
||||
function vfUpdateJava(sel) {
|
||||
var mc = sel.options[sel.selectedIndex].dataset.mc || '';
|
||||
var parts = mc.split('.').map(Number);
|
||||
var minor = parts[1] || 0;
|
||||
document.getElementById('vf-java').value = minor <= 16 ? 8 : minor <= 20 ? 17 : 21;
|
||||
var opt = sel.options[sel.selectedIndex];
|
||||
if (opt && opt.dataset.java) {
|
||||
document.getElementById('vf-java').value = opt.dataset.java;
|
||||
} else {
|
||||
// Fallback: derive from version string
|
||||
var mc = (opt && opt.dataset.mc) || '';
|
||||
var major = parseInt(mc.split('.')[0]) || 0;
|
||||
var minor = parseInt(mc.split('.')[1]) || 0;
|
||||
document.getElementById('vf-java').value = major >= 26 ? 21 : minor <= 16 ? 8 : minor <= 20 ? 17 : 21;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -52,11 +52,7 @@
|
||||
onkeydown="if(event.key==='Enter')doSearch()">
|
||||
<select id="filter-version" class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-white">
|
||||
<option value="">Any MC version</option>
|
||||
<option value="1.21.1">1.21.1</option>
|
||||
<option value="1.20.1">1.20.1</option>
|
||||
<option value="1.19.2">1.19.2</option>
|
||||
<option value="1.18.2">1.18.2</option>
|
||||
<option value="1.16.5">1.16.5</option>
|
||||
<!-- Populated dynamically from mc_versions table -->
|
||||
</select>
|
||||
<button onclick="doSearch()" class="bg-cyan-600 hover:bg-cyan-700 text-white px-4 py-2 rounded text-sm">Search</button>
|
||||
</div>
|
||||
@@ -69,6 +65,19 @@
|
||||
<script>
|
||||
var selectedProvider = '';
|
||||
|
||||
// Load MC versions for search filter dropdown
|
||||
fetch('/admin/modpack-installer/mc-versions', { headers: { 'HX-Request': 'true' } })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(versions) {
|
||||
var sel = document.getElementById('filter-version');
|
||||
versions.slice(0, 20).forEach(function(v) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = v.version;
|
||||
opt.textContent = v.version;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
});
|
||||
|
||||
function selectProvider(id) {
|
||||
selectedProvider = id;
|
||||
document.querySelectorAll('.provider-card').forEach(function(el) {
|
||||
|
||||
Reference in New Issue
Block a user