Implementing API - WIP frontend
This commit is contained in:
@@ -0,0 +1,710 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VMD Editor</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.panel {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.step {
|
||||
margin-bottom: 20px;
|
||||
padding-left: 15px;
|
||||
border-left: 3px solid #6d4aff;
|
||||
}
|
||||
.step-title {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
select,
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
select:focus,
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #6d4aff;
|
||||
}
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
button {
|
||||
background: #6d4aff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
button:hover {
|
||||
background: #5a3fd4;
|
||||
}
|
||||
button.secondary {
|
||||
background: #6c757d;
|
||||
}
|
||||
button.secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
button.danger {
|
||||
background: #dc3545;
|
||||
}
|
||||
button.danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
.status {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
}
|
||||
.status.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
display: block;
|
||||
}
|
||||
.status.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
display: block;
|
||||
}
|
||||
.preview {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
.form-row > * {
|
||||
flex: 1;
|
||||
}
|
||||
.breadcrumb {
|
||||
background: #e9ecef;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.breadcrumb span {
|
||||
color: #6d4aff;
|
||||
font-weight: 600;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
}
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.type-destinations {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
.type-routes {
|
||||
background: #17a2b8;
|
||||
color: white;
|
||||
}
|
||||
.type-utilities {
|
||||
background: #ffc107;
|
||||
color: black;
|
||||
}
|
||||
.type-other {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>VMD Reference Editor</h1>
|
||||
|
||||
<div id="breadcrumb" class="breadcrumb hidden">
|
||||
Navigation: <span id="breadcrumb-path"></span>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<form id="vmdForm">
|
||||
<!-- Step 1: Type Selection -->
|
||||
<div class="step">
|
||||
<div class="step-title">1. Entry Type</div>
|
||||
<select id="typeSelect" name="type" required>
|
||||
<option value="">Select type...</option>
|
||||
<option value="destinations">Destination</option>
|
||||
<option value="routes">Route</option>
|
||||
<option value="utilities">Utility</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Country Selection -->
|
||||
<div class="step hidden" id="countryStep">
|
||||
<div class="step-title">2. Country</div>
|
||||
<select id="existingCountrySelect" onchange="handleCountrySelect()">
|
||||
<option value="">Select existing country...</option>
|
||||
<!-- Populated dynamically -->
|
||||
</select>
|
||||
<select id="countryDropdown" class="hidden" onchange="handleNewCountry()">
|
||||
<option value="">Select new country...</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
id="newCountryInput"
|
||||
class="hidden"
|
||||
placeholder="Enter 2-letter country code..."
|
||||
maxlength="2"
|
||||
style="text-transform: uppercase" />
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Entry Name -->
|
||||
<div class="step hidden" id="nameStep">
|
||||
<div class="step-title">3. Entry Name</div>
|
||||
<select
|
||||
id="existingEntrySelect"
|
||||
onchange="handleExistingEntry()"
|
||||
class="hidden">
|
||||
<option value="">Select existing entry...</option>
|
||||
</select>
|
||||
<select id="newEntryType" onchange="toggleNewEntryInput()" class="hidden">
|
||||
<option value="">Select what to create...</option>
|
||||
<option value="folder">Folder</option>
|
||||
<option value="file">File</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
id="typedNameInput"
|
||||
class="hidden"
|
||||
placeholder="Type.Name (e.g., City.Oslo or Capital.København)" />
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Subentry (optional) -->
|
||||
<div class="step hidden" id="subentryStep">
|
||||
<div class="step-title">4. Subentry (Optional)</div>
|
||||
<select
|
||||
id="parentSelect"
|
||||
onchange="handleParentSelect()"
|
||||
style="margin-bottom: 10px">
|
||||
<option value="">No parent - create root entry</option>
|
||||
<!-- Populated dynamically from existing entries -->
|
||||
</select>
|
||||
<select id="subentryAction" onchange="toggleSubentryCreate()">
|
||||
<option value="none">No subentry</option>
|
||||
<option value="create">Create new subentry</option>
|
||||
<option value="edit">Edit existing subentry</option>
|
||||
</select>
|
||||
<select id="subentrySelect" class="hidden">
|
||||
<option value="">Select subentry...</option>
|
||||
</select>
|
||||
<div id="subentryCreateFields" class="hidden">
|
||||
<select id="subentryType">
|
||||
<option value="">What type of subentry?</option>
|
||||
<option value="folder">Folder</option>
|
||||
<option value="file">File</option>
|
||||
</select>
|
||||
<input type="text" id="subentryTypedName" placeholder="Type.Name" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 5: Properties -->
|
||||
<div class="step hidden" id="propertiesStep">
|
||||
<p style="font-size: 12px; color: #666; margin-top: 5px">
|
||||
Typed names format: <code>Type.Name</code>. Valid types:
|
||||
<span class="type-badge type-destinations">City</span>
|
||||
<span class="type-badge type-destinations">Capital</span>
|
||||
<span class="type-badge type-destinations">Town</span>
|
||||
<span class="type-badge type-routes">Gravel</span>
|
||||
<span class="type-badge type-routes">Asphalt</span>
|
||||
<span class="type-badge type-routes">Dirt</span>
|
||||
<span class="type-badge type-utilities">Toilet</span>
|
||||
</p>
|
||||
<div class="step-title">5. Properties</div>
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label>Geographic ($)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="propGeo"
|
||||
placeholder="$divisions:DK,Hovedstaden" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Transport (~)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="propTrans"
|
||||
placeholder="~train:<city>;city,..." />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label>POI/Culture (¤)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="propPoi"
|
||||
placeholder="¤museum:price;type" />#tags">
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
id="tagsInput"
|
||||
placeholder="#tags (one per line) - e.g. #Museum #Lake #Playground"
|
||||
style="width: 100%; min-height: 60px"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Step 6: Content -->
|
||||
<div class="step hidden" id="contentStep">
|
||||
<div class="step-title">6. Markdown Content</div>
|
||||
<textarea
|
||||
id="markdownContent"
|
||||
placeholder="Enter markdown content here..."
|
||||
style="width: 100%; height: 200px; font-family: monospace"></textarea>
|
||||
<p style="font-size: 12px; color: #666; margin-top: 5px">
|
||||
Use sections: <code>---LANG:XX</code>, <code>---INFO:GENERAL</code>,
|
||||
<code>---MODE:BIKE</code><br />
|
||||
References: <code><path></code> (destinations),
|
||||
<code>{path}</code> (routes), <code>(path)</code> (utilities)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px">
|
||||
<button type="submit" id="saveBtn">Save VMD</button>
|
||||
<button type="button" class="secondary" id="loadBtn">Load Existing</button>
|
||||
<button type="button" class="danger" id="deleteBtn" class="hidden">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="previewPanel">
|
||||
<h2>Preview</h2>
|
||||
<div id="preview" class="preview">Complete the form to see VMD preview</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import {reference2json, loadVMDFile, listCountries, listEntries} from "./vmd.mjs";
|
||||
|
||||
let currentData = null;
|
||||
let existingCountries = [];
|
||||
let existingEntries = new Map();
|
||||
|
||||
// Initialize on load
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
async function loadExistingCountries() {
|
||||
try {
|
||||
existingCountries = await listCountries(
|
||||
document.getElementById("typeSelect").value
|
||||
);
|
||||
const countrySelect = document.getElementById("existingCountrySelect");
|
||||
countrySelect.innerHTML =
|
||||
'<option value="">Select existing country...</option>';
|
||||
existingCountries.forEach(country => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = country;
|
||||
opt.textContent = `${country.toUpperCase()} - ${getCountryName(country)}`;
|
||||
countrySelect.appendChild(opt);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Could not load countries:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function getCountryName(code) {
|
||||
const names = {
|
||||
DK: "Denmark",
|
||||
NO: "Norway",
|
||||
SE: "Sweden",
|
||||
FI: "Finland",
|
||||
IS: "Iceland",
|
||||
DE: "Germany",
|
||||
NL: "Netherlands",
|
||||
BE: "Belgium",
|
||||
FR: "France",
|
||||
IT: "Italy",
|
||||
ES: "Spain",
|
||||
PT: "Portugal",
|
||||
GB: "United Kingdom",
|
||||
CH: "Switzerland",
|
||||
AT: "Austria",
|
||||
PL: "Poland",
|
||||
CZ: "Czech Republic",
|
||||
HU: "Hungary",
|
||||
HR: "Croatia",
|
||||
SI: "Slovenia",
|
||||
SK: "Slovakia",
|
||||
LT: "Lithuania",
|
||||
LV: "Latvia",
|
||||
EE: "Estonia",
|
||||
};
|
||||
return names[code.toUpperCase()] || code.toUpperCase();
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
const form = document.getElementById("vmdForm");
|
||||
|
||||
document.getElementById("typeSelect").addEventListener("change", e => {
|
||||
if (e.target.value) {
|
||||
document.getElementById("countryStep").classList.remove("hidden");
|
||||
loadExistingCountries(e.target.value);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("saveBtn").addEventListener("click", handleSubmit);
|
||||
document.getElementById("loadBtn").addEventListener("click", handleLoad);
|
||||
document.getElementById("deleteBtn").addEventListener("click", handleDelete);
|
||||
}
|
||||
|
||||
window.handleCountrySelect = async function () {
|
||||
const select = document.getElementById("existingCountrySelect");
|
||||
const val = select.value;
|
||||
|
||||
if (!val) return;
|
||||
|
||||
// Hide new country inputs
|
||||
document.getElementById("countryDropdown").classList.add("hidden");
|
||||
document.getElementById("newCountryInput").classList.add("hidden");
|
||||
|
||||
// Load entries for this country
|
||||
await loadCountryEntries(val);
|
||||
document.getElementById("nameStep").classList.remove("hidden");
|
||||
};
|
||||
|
||||
window.handleNewCountry = function () {
|
||||
const dropdown = document.getElementById("countryDropdown");
|
||||
const val = dropdown.value;
|
||||
|
||||
if (val === "<NEW>") {
|
||||
dropdown.classList.add("hidden");
|
||||
document.getElementById("newCountryInput").classList.remove("hidden");
|
||||
document.getElementById("newCountryInput").focus();
|
||||
} else if (val) {
|
||||
document.getElementById("newCountryInput").classList.add("hidden");
|
||||
loadCountryEntries(val);
|
||||
document.getElementById("nameStep").classList.remove("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
async function loadCountryEntries(country) {
|
||||
try {
|
||||
existingEntries = await listEntries(
|
||||
document.getElementById("typeSelect").value,
|
||||
country
|
||||
);
|
||||
const entrySelect = document.getElementById("existingEntrySelect");
|
||||
entrySelect.innerHTML =
|
||||
'<option value="">Select existing entry...</option><option value="<NEW>"><New Entry></option>';
|
||||
|
||||
existingEntries.forEach(entry => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = entry.reference;
|
||||
opt.textContent = `${entry.name}`;
|
||||
entrySelect.appendChild(opt);
|
||||
});
|
||||
|
||||
entrySelect.classList.remove("hidden");
|
||||
document.getElementById("newEntryType").classList.add("hidden");
|
||||
document.getElementById("typedNameInput").classList.add("hidden");
|
||||
} catch (err) {
|
||||
console.warn("Could not load entries:", err);
|
||||
}
|
||||
}
|
||||
|
||||
window.handleExistingEntry = async function () {
|
||||
const select = document.getElementById("existingEntrySelect");
|
||||
const val = select.value;
|
||||
|
||||
if (val === "<NEW>") {
|
||||
select.classList.add("hidden");
|
||||
document.getElementById("newEntryType").classList.remove("hidden");
|
||||
document.getElementById("typedNameInput").classList.remove("hidden");
|
||||
} else if (val) {
|
||||
await loadEntry(val);
|
||||
}
|
||||
};
|
||||
|
||||
window.toggleNewEntryInput = function () {
|
||||
const val = document.getElementById("newEntryType").value;
|
||||
if (val) {
|
||||
document.getElementById("typedNameInput").classList.remove("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
window.handleParentSelect = async function () {
|
||||
const parent = document.getElementById("parentSelect").value;
|
||||
const subentries = parent ? await listChildren(parent) : [];
|
||||
|
||||
const subSelect = document.getElementById("subentrySelect");
|
||||
subSelect.innerHTML = '<option value="">Select subentry...</option>';
|
||||
subentries.forEach(entry => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = entry.reference;
|
||||
opt.textContent = entry.name;
|
||||
subSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
subSelect.classList.toggle(
|
||||
"hidden",
|
||||
document.getElementById("subentryAction").value === "create"
|
||||
);
|
||||
};
|
||||
|
||||
window.toggleSubentryCreate = function () {
|
||||
const action = document.getElementById("subentryAction").value;
|
||||
if (action === "create") {
|
||||
document.getElementById("subentrySelect").classList.add("hidden");
|
||||
document.getElementById("subentryCreateFields").classList.remove("hidden");
|
||||
} else if (action === "edit") {
|
||||
document.getElementById("subentrySelect").classList.remove("hidden");
|
||||
document.getElementById("subentryCreateFields").classList.add("hidden");
|
||||
} else {
|
||||
document.getElementById("subentrySelect").classList.add("hidden");
|
||||
document.getElementById("subentryCreateFields").classList.add("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
async function loadEntry(ref) {
|
||||
try {
|
||||
currentData = await loadVMDFile(ref);
|
||||
populateForm(currentData);
|
||||
updatePreview();
|
||||
showStatus("Loaded successfully!");
|
||||
} catch (err) {
|
||||
showStatus("Failed to load: " + err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function populateForm(data) {
|
||||
const type = data.Reference?.type || "other";
|
||||
const ref = data.Reference?.reference || "";
|
||||
|
||||
document.getElementById("typeSelect").value = type;
|
||||
|
||||
const countryMatch = ref.match(/^([A-Z]{2})\//i);
|
||||
if (countryMatch) {
|
||||
document.getElementById("existingCountrySelect").value = countryMatch[1];
|
||||
}
|
||||
|
||||
const nameMatch = ref.match(/\/(.+)$/);
|
||||
if (nameMatch) {
|
||||
const name = nameMatch[1];
|
||||
const typedMatch = name.match(/^(.+?)\.(.+)$/);
|
||||
if (typedMatch) {
|
||||
document.getElementById("typedNameInput").value = name;
|
||||
} else {
|
||||
document.getElementById("typedNameInput").value = name;
|
||||
}
|
||||
}
|
||||
|
||||
// Fill content if available
|
||||
const lang = "DA",
|
||||
section = "INFO",
|
||||
subsection = "ABOUT";
|
||||
document.getElementById("markdownContent").value =
|
||||
data.Content[lang][section][subsection];
|
||||
// Show all steps
|
||||
document.getElementById("countryStep").classList.remove("hidden");
|
||||
document.getElementById("nameStep").classList.remove("hidden");
|
||||
document.getElementById("propertiesStep").classList.remove("hidden");
|
||||
document.getElementById("contentStep").classList.remove("hidden");
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
if (e) e.preventDefault();
|
||||
|
||||
try {
|
||||
const type = document.getElementById("typeSelect").value;
|
||||
const country =
|
||||
document.getElementById("newCountryInput").value ||
|
||||
document.getElementById("existingCountrySelect").value;
|
||||
const typedName = document.getElementById("typedNameInput").value;
|
||||
|
||||
if (!country || !typedName) {
|
||||
throw new Error("Please complete country and name fields");
|
||||
}
|
||||
|
||||
const reference = `${country}/${typedName}`;
|
||||
const path = `${type}/${country}/${typedName}.vmd`;
|
||||
|
||||
// Build VMD data structure
|
||||
const data = {
|
||||
Reference: {type: type, reference: reference, path: path},
|
||||
Content: {},
|
||||
Properties: {},
|
||||
};
|
||||
|
||||
// Add properties
|
||||
const geoProp = document.getElementById("propGeo").value;
|
||||
const transProp = document.getElementById("propTrans").value;
|
||||
const poiProp = document.getElementById("propPoi").value;
|
||||
const tags = document
|
||||
.getElementById("tagsInput")
|
||||
.value.split("\n")
|
||||
.filter(t => t.trim())
|
||||
.map(t => t.trim().replace(/^#/, "#"));
|
||||
|
||||
if (geoProp) data.Properties["$"] = geoProp;
|
||||
if (transProp) data.Properties["~"] = transProp;
|
||||
if (poiProp) data.Properties["¤"] = poiProp;
|
||||
if (tags.length) data.Tags = tags;
|
||||
|
||||
// Add content
|
||||
const mdContent = document.getElementById("markdownContent").value;
|
||||
if (mdContent.trim()) {
|
||||
data["---"] = {content: mdContent};
|
||||
}
|
||||
|
||||
// Save
|
||||
const saved = await saveVMDReference(reference, data);
|
||||
showStatus("VMD saved successfully!", false);
|
||||
currentData = saved;
|
||||
updatePreview();
|
||||
} catch (err) {
|
||||
showStatus("Error: " + err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoad() {
|
||||
const ref = getInputReference();
|
||||
if (!ref) {
|
||||
showStatus("Please complete type, country, and name fields first", true);
|
||||
return;
|
||||
}
|
||||
await loadEntry(ref);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
const ref = getInputReference();
|
||||
if (!ref) return;
|
||||
|
||||
if (!confirm(`Delete ${ref}? This cannot be undone.`)) return;
|
||||
|
||||
try {
|
||||
const {deleteVMDReference} = await import("./vmdAPI.js");
|
||||
await deleteVMDReference(ref);
|
||||
showStatus("Deleted successfully!", false);
|
||||
currentData = null;
|
||||
updatePreview();
|
||||
} catch (err) {
|
||||
showStatus("Failed to delete: " + err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function getInputReference() {
|
||||
const type = document.getElementById("typeSelect").value;
|
||||
const country =
|
||||
document.getElementById("newCountryInput").value ||
|
||||
document.getElementById("existingCountrySelect").value;
|
||||
const typedName = document.getElementById("typedNameInput").value;
|
||||
|
||||
if (!type || !country || !typedName) return null;
|
||||
|
||||
return `${country}/${typedName}`;
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
if (!currentData) {
|
||||
document.getElementById("preview").textContent =
|
||||
"Complete the form to see VMD preview";
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = [];
|
||||
const data = currentData;
|
||||
|
||||
// Properties
|
||||
if (data.Properties["$"]) preview.push(data.Properties["$"]);
|
||||
if (data.Properties["~"]) preview.push(data.Properties["~"]);
|
||||
if (data.Properties["¤"]) preview.push(data.Properties["¤"]);
|
||||
|
||||
// Tags
|
||||
if (data.Tags) {
|
||||
data.Tags.forEach(tag => preview.push(tag));
|
||||
}
|
||||
|
||||
// Content
|
||||
if (data["---"]) {
|
||||
preview.push("---");
|
||||
preview.push(data["---"].content || "");
|
||||
}
|
||||
|
||||
// Language sections would go here if implemented
|
||||
|
||||
document.getElementById("preview").textContent = preview.join("\n");
|
||||
}
|
||||
|
||||
function showStatus(message, isError = false) {
|
||||
const statusDiv = document.getElementById("status");
|
||||
statusDiv.textContent = message;
|
||||
statusDiv.className = "status " + (isError ? "error" : "success");
|
||||
setTimeout(() => {
|
||||
statusDiv.className = "status";
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Helper functions that need API implementation
|
||||
window.listCountries = async function () {
|
||||
// Placeholder - would call API endpoint
|
||||
return ["DK", "NO", "SE"];
|
||||
};
|
||||
|
||||
window.listEntries = async function (country, type) {
|
||||
// Placeholder - would call API endpoint
|
||||
return [
|
||||
{reference: `${country}/City.Oslo`, name: "Oslo", type},
|
||||
{reference: `${country}/Capital.København`, name: "København", type},
|
||||
];
|
||||
};
|
||||
|
||||
window.listChildren = async function (parent) {
|
||||
// Placeholder - would call API endpoint
|
||||
return [];
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,53 +0,0 @@
|
||||
const updateHTML = data => {
|
||||
fileoutput.innerText = "Bad or empty file";
|
||||
|
||||
QUICKR.replaceChildren(
|
||||
...Object.values(data.Connections.Bike).flatMap(v =>
|
||||
v.map(
|
||||
/** @param {{reference: string, path?: string}} ref */ ref => {
|
||||
const a = document.createElement("a");
|
||||
const [_, pre, reff, name, post] =
|
||||
ref.reference.match(/(.*)(\<.+\/(.+)\>)(.*)/) || [];
|
||||
|
||||
a.innerHTML = reff
|
||||
? `${pre}<a href="#${reff}" onclick="getFile('${reff.replaceAll(/<|>|\//g, rpl => ({"<": "<", ">": ">", "/": "/"})[rpl])}')">${name}</a>${post}`
|
||||
: ref.reference.replaceAll(
|
||||
/[\<\>]/g,
|
||||
rpl => ({"<": "<", ">": ">"})[rpl]
|
||||
);
|
||||
if (ref.path) {
|
||||
a.href = `#${ref.reference}`;
|
||||
a.onclick = () => getFile(ref.reference);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const languages = Object.keys(data.Content);
|
||||
LANGSO.replaceChildren(
|
||||
...languages.map(lang => {
|
||||
const option = document.createElement("OPTION");
|
||||
option.value = lang;
|
||||
option.innerHTML = lang;
|
||||
return option;
|
||||
})
|
||||
);
|
||||
if (LANGSO.children.length === 0)
|
||||
LANGSO.innerHTML = "<option value='NO FILE LOADED' disabled>NO FILE LOADED</option>";
|
||||
LANGSO.onchange = ev => {
|
||||
if (!data.Content[ev.target.value]) return;
|
||||
fileoutput.innerHTML = [
|
||||
`<h1>${data.Content[ev.target.value].Title}</h1>`,
|
||||
...(data.Content[ev.target.value].INFO
|
||||
? Object.keys(data.Content[ev.target.value].INFO).map(type => {
|
||||
const content = data.Content[ev.target.value].INFO[type].split(/\r?\n/, 2);
|
||||
if (content.length === 1) return `<p>${content.shift()}</p>`;
|
||||
return `<h2>${content.shift().replace("## ", "")}</h2><p>${content.shift()}</p>`;
|
||||
})
|
||||
: []),
|
||||
].join("");
|
||||
};
|
||||
LANGSO.onchange({target: LANGSO}); // Dirty hack to trigger onchange
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import {requestVMDReference} from "./requestVMDReference.mjs";
|
||||
import {reference2json} from "./vmd.mjs";
|
||||
import * as L from "leaflet";
|
||||
//import "leaflet-providers";
|
||||
|
||||
@@ -89,7 +89,9 @@ class Graph {
|
||||
this.edges = new Map();
|
||||
this.canvas = document.createElement("div");
|
||||
this.canvas.id = "map";
|
||||
document.body.appendChild(this.canvas);
|
||||
document
|
||||
.getElementById("map-container")
|
||||
.replaceChild(this.canvas, document.getElementById("map"));
|
||||
this.renderer = L.canvas({padding: 5});
|
||||
this.map = L.map(this.canvas, {renderer: this.renderer, preferCanvas: true}).setView(
|
||||
[60, 0],
|
||||
@@ -282,7 +284,7 @@ class Graph {
|
||||
async deepAdd(iref, depth = 1) {
|
||||
let element = this.nodes.get(iref);
|
||||
if (!element) {
|
||||
element = await requestVMDReference(iref);
|
||||
element = await reference2json(iref);
|
||||
switch (element.Reference.type) {
|
||||
case "destinations":
|
||||
element = new Node(element);
|
||||
|
||||
+154
-2
@@ -3,7 +3,159 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Document</title>
|
||||
<title>Route Explorer - Outdoor Adventures</title>
|
||||
<style>
|
||||
:root {
|
||||
--box-color: rgba(156, 156, 156, 0.21);
|
||||
--box-header: rgb(201, 219, 1);
|
||||
--box-text: rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background: linear-gradient(135deg, #72da29 0%, #173606 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
padding: 3rem 0;
|
||||
color: var(--box-header);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 1.3rem;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
background: var(--box-color);
|
||||
border-radius: 20px;
|
||||
padding: 3rem;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin: 3rem 0;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--box-color);
|
||||
color: var(--box-text);
|
||||
padding: 2rem;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--box-header);
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
background: var(--box-header);
|
||||
color: var(--box-text);
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 50px;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
color: white;
|
||||
padding: 2rem 0;
|
||||
margin-top: 3rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body></body>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Velout</h1>
|
||||
<p class="tagline">Become an adventurer</p>
|
||||
</header>
|
||||
|
||||
<main class="hero-section">
|
||||
<h2>Dont let plans stop you from going on adventures</h2>
|
||||
<p style="margin: 1rem 0; font-size: 1.1rem">
|
||||
Select a starting point, and an end point and get a route for your adventure
|
||||
whether you're bikepacking, hiking, kayaking, etc. or a combination. Customize
|
||||
daily distance required services along the way and other critical considerations
|
||||
on multiday adventures. Get a detailed overview of destinations, attactions and
|
||||
areas you pass through or near.
|
||||
</p>
|
||||
<a href="/map" class="cta-button">Open map</a>
|
||||
</main>
|
||||
|
||||
<section class="features">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🌍️</div>
|
||||
<h3 class="feature-title">Worldwide</h3>
|
||||
<p>
|
||||
Community driven data collection, contribute what you know and access what
|
||||
others knows.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🥈</div>
|
||||
<h3 class="feature-title">Prepare</h3>
|
||||
<p>
|
||||
You can not plan for everything, but you can prepare. Get alternative places
|
||||
for breaks and overnights in you plan and adjust minimum and maximum
|
||||
distance and elevation for one or multiple days.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p>Built with passion for outdoor adventures</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+230
-5
@@ -8,11 +8,102 @@
|
||||
body {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
height: 100%;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#map-container {
|
||||
grid-column: 1 / 2;
|
||||
grid-row: 1 / 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#map {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
margin: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#file-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
#plan-container {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 1 / 2;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
#file-content h1,
|
||||
#file-content h2,
|
||||
#file-content h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
#file-content p {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.plan-item {
|
||||
background: white;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.plan-item h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.add-to-plan-btn {
|
||||
background: #6d4aff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.add-to-plan-btn:hover {
|
||||
background: #5a3fd6;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: #999;
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/node_modules/leaflet/dist/leaflet.css" />
|
||||
@@ -29,10 +120,144 @@
|
||||
<script src="graph.mjs" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module">
|
||||
import {Graph} from "./graph.mjs"
|
||||
<div class="container">
|
||||
<div id="map-container">
|
||||
<div id="map"></div>
|
||||
<div id="file-content">
|
||||
<div class="empty-state">Click on a destination marker to view details</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="plan-container">
|
||||
<h3>My Plan</h3>
|
||||
<div id="plan-items">
|
||||
<div class="empty-state">No destinations added yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script defer type="module">
|
||||
import {Graph} from "./graph.mjs";
|
||||
|
||||
const planItems = [];
|
||||
|
||||
function displayContent(data) {
|
||||
const contentDiv = document.getElementById("file-content");
|
||||
|
||||
if (!data || !data.Content) {
|
||||
contentDiv.innerHTML = '<div class="empty-state">No content available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = "";
|
||||
|
||||
// Show global content first if available
|
||||
if (data.Content.global) {
|
||||
html += `<h1>${data.Content.global.Title || "Untitled"}</h1>`;
|
||||
for (const [section, subsections] of Object.entries(data.Content.global)) {
|
||||
if (section === "Title") continue;
|
||||
html += `<h2>${section}</h2>`;
|
||||
if (typeof subsections === "object") {
|
||||
for (const [sub, content] of Object.entries(subsections)) {
|
||||
html += `<h3>${sub}</h3><p>${content}</p>`;
|
||||
}
|
||||
} else {
|
||||
html += `<p>${subsections}</p>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show language-specific content
|
||||
for (const [lang, content] of Object.entries(data.Content)) {
|
||||
if (lang === "global") continue;
|
||||
|
||||
html += `<h2>${lang}</h2>`;
|
||||
if (content.Title) {
|
||||
html += `<h1>${content.Title}</h1>`;
|
||||
}
|
||||
|
||||
for (const [section, subsections] of Object.entries(content)) {
|
||||
if (section === "Title") continue;
|
||||
html += `<h3>${section}</h3>`;
|
||||
if (typeof subsections === "object") {
|
||||
for (const [sub, text] of Object.entries(subsections)) {
|
||||
html += `<h4>${sub}</h4><p>${text}</p>`;
|
||||
}
|
||||
} else {
|
||||
html += `<p>${subsections}</p>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add "Add to Plan" button
|
||||
const ref = data.Reference?.reference || "unknown";
|
||||
const name =
|
||||
data.Content?.global?.Title ||
|
||||
Object.values(data.Content)?.[0]?.Title ||
|
||||
"Destination";
|
||||
|
||||
html += `
|
||||
<button class="add-to-plan-btn" onclick="addToPlan('${ref}', '${name}')">
|
||||
Add to Plan
|
||||
</button>
|
||||
`;
|
||||
|
||||
contentDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
window.addToPlan = function (ref, name) {
|
||||
if (planItems.some(item => item.ref === ref)) {
|
||||
alert("Already in plan!");
|
||||
return;
|
||||
}
|
||||
|
||||
planItems.push({ref, name});
|
||||
updatePlanDisplay();
|
||||
};
|
||||
|
||||
function updatePlanDisplay() {
|
||||
const planDiv = document.getElementById("plan-items");
|
||||
|
||||
if (planItems.length === 0) {
|
||||
planDiv.innerHTML = '<div class="empty-state">No destinations added yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = "";
|
||||
for (const item of planItems) {
|
||||
html += `
|
||||
<div class="plan-item">
|
||||
<h4>${item.name}</h4>
|
||||
<small>${item.ref}</small>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
planDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const network = new Graph();
|
||||
|
||||
// Override the click handler to display content instead of loading connected pins
|
||||
const originalGet = network.get.bind(network);
|
||||
|
||||
network.get = async function (ref) {
|
||||
const element = await originalGet(ref);
|
||||
|
||||
// If this is a node (destination), display its content
|
||||
if (element && element.data && element.data.Content) {
|
||||
displayContent(element.data);
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
// Also override the Node.draw method to use our custom click handler
|
||||
const originalNodeDraw = network.nodesConstructor
|
||||
? network.nodesConstructor.prototype.draw
|
||||
: null;
|
||||
|
||||
// Reinitialize with our custom behavior
|
||||
const t = await network.get("<NO/Lunde>");
|
||||
network.draw();
|
||||
})();
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Module for requesting VMD references from the server API.
|
||||
* Handles HTTP POST requests to retrieve reference data.
|
||||
*
|
||||
* @module requestVMDReference
|
||||
*
|
||||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Request a VMD reference from the server endpoint.
|
||||
* Sends a POST request with the reference identifier and returns parsed JSON data.
|
||||
*
|
||||
* @async
|
||||
* @function requestVMDReference
|
||||
*
|
||||
* @param {string} ref - The reference identifier to look up. Must be a non-empty string.
|
||||
*
|
||||
* @returns {Promise<Object>} Parsed JSON response containing the VMD reference data
|
||||
*
|
||||
* @throws {TypeError} When ref is not a valid string
|
||||
* @throws {Error} When network request fails or server returns non-success status
|
||||
*
|
||||
* @property {number} response.status - HTTP status code
|
||||
* @property {boolean} response.ok - Indicates successful response (2xx)
|
||||
*
|
||||
* @example
|
||||
* try {
|
||||
* const data = await requestVMDReference("NO_Mjolkevegen_5");
|
||||
* // Use data
|
||||
* } catch (error) {
|
||||
* // Handle fail
|
||||
* }
|
||||
*/
|
||||
async function requestVMDReference(ref) {
|
||||
if (typeof ref !== "string" || !ref.trim())
|
||||
throw new TypeError("Reference must be a non-empty string");
|
||||
|
||||
const endpoint = "/"; //"/api/vmd/reference";
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {"Content-Type": "vmd/reference"},
|
||||
method: "POST",
|
||||
body: ref,
|
||||
});
|
||||
|
||||
if (!response.ok)
|
||||
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError && error.message.includes("fetch"))
|
||||
throw new Error("Network error: Unable to reach server");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export {requestVMDReference};
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Module for requesting VMD references from the server API.
|
||||
* Handles HTTP requests to retrieve and save reference data.
|
||||
*
|
||||
* @module vmd
|
||||
*
|
||||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Request a VMD reference from the server endpoint (GET).
|
||||
* Sends a GET request with the reference identifier and returns parsed JSON data.
|
||||
*
|
||||
* @async
|
||||
* @function reference2json
|
||||
*
|
||||
* @param {string} ref - The reference identifier to look up. Must be a non-empty string.
|
||||
*
|
||||
* @returns {Promise<Object>} Parsed JSON response containing the VMD reference data
|
||||
*
|
||||
* @throws {TypeError} When ref is not a valid string
|
||||
* @throws {Error} When network request fails or server returns non-success status
|
||||
*/
|
||||
export async function reference2json(ref) {
|
||||
if (typeof ref !== "string" || !ref.trim())
|
||||
throw new TypeError("Reference must be a non-empty string");
|
||||
|
||||
const endpoint = `/api/vmd/reference/`;
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {"Accept": "application/json", "Content-Type": "vmd/reference"},
|
||||
body: ref,
|
||||
});
|
||||
|
||||
if (!response.ok)
|
||||
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError && error.message.includes("fetch"))
|
||||
throw new Error("Network error: Unable to reach server");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// /**
|
||||
// * Save a VMD reference to the server endpoint (POST/PUT).
|
||||
// * Sends a POST request with the VMD data and returns the saved data.
|
||||
// *
|
||||
// * @async
|
||||
// * @function saveVMDReference
|
||||
// *
|
||||
// * @param {string} ref - The reference identifier. Must be a non-empty string.
|
||||
// * @param {Object} data - The VMD data object to save
|
||||
// *
|
||||
// * @returns {Promise<Object>} The saved VMD data
|
||||
// *
|
||||
// * @throws {TypeError} When ref is not a valid string or data is not an object
|
||||
// * @throws {Error} When network request fails or server returns non-success status
|
||||
// */
|
||||
// export async function saveVMDReference(ref, data) {
|
||||
// if (typeof ref !== "string" || !ref.trim())
|
||||
// throw new TypeError("Reference must be a non-empty string");
|
||||
|
||||
// if (!data || typeof data !== "object") throw new TypeError("Data must be a valid object");
|
||||
|
||||
// const endpoint = "/api/vmd/reference";
|
||||
|
||||
// try {
|
||||
// const response = await fetch(endpoint, {
|
||||
// method: "POST",
|
||||
// headers: {"Content-Type": "application/json"},
|
||||
// body: JSON.stringify({reference: ref, data}),
|
||||
// });
|
||||
|
||||
// if (!response.ok)
|
||||
// throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
||||
|
||||
// return await response.json();
|
||||
// } catch (error) {
|
||||
// if (error instanceof TypeError && error.message.includes("fetch"))
|
||||
// throw new Error("Network error: Unable to reach server");
|
||||
// throw error;
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* Save a VMD reference to the server endpoint (POST/PUT).
|
||||
* Sends a POST request with the VMD data and returns the saved data.
|
||||
*
|
||||
* @async
|
||||
* @function loadVMDFile
|
||||
*
|
||||
* @param {string} ref - The reference identifier. Must be a non-empty string.
|
||||
*
|
||||
* @returns {Promise<Object>} The saved VMD data
|
||||
*
|
||||
* @throws {TypeError} When ref is not a valid string or data is not an object
|
||||
* @throws {Error} When network request fails or server returns non-success status
|
||||
*/
|
||||
export async function loadVMDFile(ref) {
|
||||
if (typeof ref !== "string" || !ref.trim())
|
||||
throw new TypeError("Reference must be a non-empty string");
|
||||
|
||||
const endpoint = "/api/vmd/entry";
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: ref,
|
||||
});
|
||||
|
||||
if (!response.ok)
|
||||
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError && error.message.includes("fetch"))
|
||||
throw new Error("Network error: Unable to reach server");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side wrapper for VMD API endpoints.
|
||||
* Exports functions that communicate with the server API.
|
||||
*
|
||||
* @module vmdAPI
|
||||
*/
|
||||
|
||||
/**
|
||||
* List all countries with VMD entries.
|
||||
*
|
||||
* @async
|
||||
* @function listCountries
|
||||
*
|
||||
* @returns {Promise<Array>} Array of country codes
|
||||
*/
|
||||
export async function listCountries(type) {
|
||||
const url = new URL("/api/vmd/countries", window.location.origin);
|
||||
if (type) url.searchParams.set("type", type);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* List entries within a country.
|
||||
*
|
||||
* @async
|
||||
* @function listEntries
|
||||
*
|
||||
* @param {string} country - Country code
|
||||
* @param {string} [type] - Entry type filter
|
||||
*
|
||||
* @returns {Promise<Array>} Array of entry objects
|
||||
*/
|
||||
export async function listEntries(type, country) {
|
||||
const url = new URL("/api/vmd/entries", window.location.origin);
|
||||
url.searchParams.set("type", type);
|
||||
url.searchParams.set("country", country);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* List child entries under a parent.
|
||||
*
|
||||
* @async
|
||||
* @function listChildren
|
||||
*
|
||||
* @param {string} parent - Parent reference
|
||||
*
|
||||
* @returns {Promise<Array>} Array of child entry objects
|
||||
*/
|
||||
export async function listChildren(parent) {
|
||||
const response = await fetch(`/api/vmd/children/${encodeURIComponent(parent)}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
Reference in New Issue
Block a user