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>