Implementing API - WIP frontend

This commit is contained in:
2026-07-26 11:37:03 +02:00
parent e989594b96
commit 8b1d3288c0
12 changed files with 2222 additions and 519 deletions
+710
View File
@@ -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>&lt;path&gt;</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>">&ltNew Entry&gt</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>
-53
View File
@@ -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 => ({"<": "&lt;", ">": "&gt;", "/": "&#47;"})[rpl])}')">${name}</a>${post}`
: ref.reference.replaceAll(
/[\<\>]/g,
rpl => ({"<": "&lt", ">": "&gt"})[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
};
+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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();
})();
-59
View File
@@ -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};
+186
View File
@@ -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();
}