Files
boc/landvex-admin/index.html
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- Add NFC ePassport roadmap (ICAO 9303, eIDAS)
- Add TensorFlow.js edge face detection (BlazeFace)
- Add structured audit logger (GDPR-compliant)
- Risk scoring support

Part of KYC Apple Native UX v1.1.0
2026-06-29 16:24:48 +00:00

728 lines
29 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Landvex City Intelligence Admin</title>
<style>
:root{--bg:#0a0a0a;--surface:#1a1a1a;--surface-hover:#2a2a2a;--blue:#0066FF;--blue-dark:#0052CC;--text:#fff;--text-muted:#888;--border:rgba(255,255,255,0.1);--radius:12px;--success:#22c55e;--warning:#f59e0b;--danger:#ef4444}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Inter','Helvetica Neue',sans-serif;background:var(--bg);color:var(--text);line-height:1.6;min-height:100vh}
.sidebar{position:fixed;left:0;top:0;bottom:0;width:260px;background:var(--surface);border-right:1px solid var(--border);padding:24px;display:flex;flex-direction:column}
.sidebar-logo{font-size:20px;font-weight:800;letter-spacing:-0.5px;margin-bottom:32px;color:var(--blue)}
.nav-item{padding:12px 16px;border-radius:var(--radius);cursor:pointer;transition:all 0.2s;margin-bottom:4px;font-size:14px;font-weight:500;display:flex;align-items:center;gap:10px}
.nav-item:hover{background:var(--surface-hover)}
.nav-item.active{background:var(--blue);color:#fff}
.nav-icon{width:18px;height:18px;opacity:0.7}
.main{margin-left:260px;padding:32px}
.header{display:flex;justify-content:space-between;align-items:center;margin-bottom:32px}
.header h1{font-size:28px;font-weight:800;letter-spacing:-0.5px}
.btn{display:inline-flex;align-items:center;gap:8px;padding:10px 20px;background:var(--blue);color:#fff;font-size:14px;font-weight:600;border-radius:var(--radius);border:none;cursor:pointer;transition:all 0.2s}
.btn:hover{background:var(--blue-dark)}
.btn-secondary{background:var(--surface);border:1px solid var(--border)}
.btn-secondary:hover{background:var(--surface-hover)}
.btn-danger{background:var(--danger)}
.btn-danger:hover{background:#dc2626}
/* City Grid */
.city-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;margin-bottom:32px}
.city-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:20px;cursor:pointer;transition:all 0.2s;position:relative}
.city-card:hover{border-color:var(--blue);transform:translateY(-2px)}
.city-card.selected{border-color:var(--blue);box-shadow:0 0 0 2px rgba(0,102,255,0.2)}
.city-name{font-size:18px;font-weight:700;margin-bottom:4px}
.city-meta{font-size:13px;color:var(--text-muted);margin-bottom:12px}
.city-scores{display:flex;gap:8px;flex-wrap:wrap}
.city-score{font-size:11px;font-weight:700;padding:3px 10px;border-radius:100px;background:rgba(0,102,255,0.15);color:var(--blue)}
.city-status{position:absolute;top:12px;right:12px;width:8px;height:8px;border-radius:50%}
.city-status.published{background:var(--success)}
.city-status.draft{background:var(--warning)}
.city-status.unpublished{background:var(--danger)}
/* Editor */
.editor{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:24px;margin-bottom:24px}
.editor-title{font-size:18px;font-weight:700;margin-bottom:20px;display:flex;justify-content:space-between;align-items:center}
.form-group{margin-bottom:20px}
.form-label{display:block;font-size:13px;font-weight:600;color:var(--text-muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:0.05em}
.form-input{width:100%;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:14px;font-family:inherit}
.form-input:focus{outline:none;border-color:var(--blue)}
.form-textarea{min-height:80px;resize:vertical}
.form-select{cursor:pointer}
.scores-editor{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
.score-input-group{display:flex;align-items:center;gap:10px}
.score-input{width:70px;text-align:center}
.score-slider{flex:1;-webkit-appearance:none;height:6px;background:var(--bg);border-radius:3px;outline:none}
.score-slider::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;background:var(--blue);border-radius:50%;cursor:pointer}
/* Tabs */
.tabs{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid var(--border);padding-bottom:1px}
.tab{padding:10px 20px;font-size:14px;font-weight:600;cursor:pointer;border-radius:var(--radius) var(--radius) 0 0;transition:all 0.2s}
.tab:hover{background:var(--surface-hover)}
.tab.active{background:var(--blue);color:#fff}
.tab-content{display:none}
.tab-content.active{display:block}
/* District Editor */
.district-list{display:flex;flex-direction:column;gap:12px}
.district-item{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:16px;display:flex;justify-content:space-between;align-items:center}
.district-info{flex:1}
.district-name-edit{font-size:16px;font-weight:700;margin-bottom:4px;background:transparent;border:none;color:var(--text);width:100%}
.district-name-edit:focus{outline:none}
.district-meta-edit{font-size:13px;color:var(--text-muted);background:transparent;border:none;color:var(--text-muted);width:100%}
.district-actions{display:flex;gap:8px}
/* Layer Editor */
.layer-card{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin-bottom:12px}
.layer-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
.layer-number{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.1em;color:var(--blue)}
.layer-score{width:60px;text-align:center;padding:6px;background:rgba(0,102,255,0.15);border-radius:var(--radius);font-weight:700;color:var(--blue);border:none}
/* Actions Bar */
.actions-bar{position:fixed;bottom:0;left:260px;right:0;background:var(--surface);border-top:1px solid var(--border);padding:16px 32px;display:flex;justify-content:space-between;align-items:center;z-index:100}
.actions-left{display:flex;gap:12px;align-items:center}
.actions-right{display:flex;gap:12px}
.status-text{font-size:14px;color:var(--text-muted)}
/* Toast */
.toast{position:fixed;top:24px;right:24px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;display:flex;align-items:center;gap:12px;z-index:1000;transform:translateX(400px);transition:transform 0.3s;box-shadow:0 10px 40px rgba(0,0,0,0.3)}
.toast.show{transform:translateX(0)}
.toast-icon{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700}
.toast-icon.success{background:var(--success);color:#fff}
.toast-icon.error{background:var(--danger);color:#fff}
/* Modal */
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.7);display:none;align-items:center;justify-content:center;z-index:1000}
.modal-overlay.show{display:flex}
.modal{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:24px;width:90%;max-width:500px}
.modal-title{font-size:20px;font-weight:700;margin-bottom:16px}
.modal-text{color:var(--text-muted);margin-bottom:24px}
.modal-actions{display:flex;gap:12px;justify-content:flex-end}
/* Preview */
.preview-frame{width:100%;height:600px;border:1px solid var(--border);border-radius:var(--radius);background:#fff}
@media(max-width:768px){
.sidebar{display:none}
.main{margin-left:0}
.actions-bar{left:0}
.scores-editor{grid-template-columns:1fr}
}
</style>
</head>
<body>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-logo">LandveX Admin</div>
<div class="nav-item active" data-tab="cities">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
Cities
</div>
<div class="nav-item" data-tab="editor">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Editor
</div>
<div class="nav-item" data-tab="preview">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
Preview
</div>
<div class="nav-item" data-tab="deploy">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Deploy
</div>
</div>
<!-- Main Content -->
<div class="main">
<!-- Cities Tab -->
<div id="cities-tab" class="tab-content active">
<div class="header">
<h1>Cities</h1>
<button class="btn" onclick="createNewCity()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New City
</button>
</div>
<div class="city-grid" id="cityGrid"></div>
</div>
<!-- Editor Tab -->
<div id="editor-tab" class="tab-content">
<div class="header">
<h1 id="editorTitle">Edit City</h1>
<div style="display:flex;gap:12px">
<button class="btn btn-secondary" onclick="duplicateCity()">Duplicate</button>
<button class="btn btn-danger" onclick="deleteCity()">Delete</button>
</div>
</div>
<div class="tabs">
<div class="tab active" data-editor-tab="basic">Basic Info</div>
<div class="tab" data-editor-tab="scores">Scores</div>
<div class="tab" data-editor-tab="layers">Layers</div>
<div class="tab" data-editor-tab="districts">Districts</div>
<div class="tab" data-editor-tab="contradictions">Contradictions</div>
</div>
<!-- Basic Info -->
<div id="basic-editor" class="editor tab-content active">
<div class="editor-title">Basic Information</div>
<div class="form-group">
<label class="form-label">City Name</label>
<input type="text" class="form-input" id="cityName" placeholder="e.g. Stockholm">
</div>
<div class="form-group">
<label class="form-label">Slug (URL)</label>
<input type="text" class="form-input" id="citySlug" placeholder="e.g. stockholm">
</div>
<div class="form-group">
<label class="form-label">Meta Description</label>
<textarea class="form-input form-textarea" id="cityDescription" placeholder="SEO description..."></textarea>
</div>
<div class="form-group">
<label class="form-label">Hero Subtitle</label>
<textarea class="form-input form-textarea" id="citySubtitle" placeholder="Subtitle shown in hero section..."></textarea>
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select class="form-input form-select" id="cityStatus">
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="unpublished">Unpublished</option>
</select>
</div>
</div>
<!-- Scores -->
<div id="scores-editor" class="editor tab-content">
<div class="editor-title">Aggregate Scores</div>
<div class="scores-editor">
<div class="form-group">
<label class="form-label">Opportunity (0-100)</label>
<div class="score-input-group">
<input type="number" class="form-input score-input" id="scoreOpportunity" min="0" max="100" value="50">
<input type="range" class="score-slider" id="sliderOpportunity" min="0" max="100" value="50">
</div>
</div>
<div class="form-group">
<label class="form-label">Growth (0-100)</label>
<div class="score-input-group">
<input type="number" class="form-input score-input" id="scoreGrowth" min="0" max="100" value="50">
<input type="range" class="score-slider" id="sliderGrowth" min="0" max="100" value="50">
</div>
</div>
<div class="form-group">
<label class="form-label">Commercial Vitality (0-100)</label>
<div class="score-input-group">
<input type="number" class="form-input score-input" id="scoreVitality" min="0" max="100" value="50">
<input type="range" class="score-slider" id="sliderVitality" min="0" max="100" value="50">
</div>
</div>
<div class="form-group">
<label class="form-label">Infrastructure (0-100)</label>
<div class="score-input-group">
<input type="number" class="form-input score-input" id="scoreInfrastructure" min="0" max="100" value="50">
<input type="range" class="score-slider" id="sliderInfrastructure" min="0" max="100" value="50">
</div>
</div>
<div class="form-group">
<label class="form-label">Investment Confidence (0-100)</label>
<div class="score-input-group">
<input type="number" class="form-input score-input" id="scoreInvestment" min="0" max="100" value="50">
<input type="range" class="score-slider" id="sliderInvestment" min="0" max="100" value="50">
</div>
</div>
<div class="form-group">
<label class="form-label">Contradiction Index (0-100)</label>
<div class="score-input-group">
<input type="number" class="form-input score-input" id="scoreContradiction" min="0" max="100" value="50">
<input type="range" class="score-slider" id="sliderContradiction" min="0" max="100" value="50">
</div>
</div>
</div>
</div>
<!-- Layers -->
<div id="layers-editor" class="editor tab-content">
<div class="editor-title">Taxonomy Layers</div>
<div id="layersList"></div>
</div>
<!-- Districts -->
<div id="districts-editor" class="editor tab-content">
<div class="editor-title">Districts</div>
<div class="district-list" id="districtsList"></div>
<button class="btn" style="margin-top:16px" onclick="addDistrict()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add District
</button>
</div>
<!-- Contradictions -->
<div id="contradictions-editor" class="editor tab-content">
<div class="editor-title">Contradictions</div>
<div class="form-group">
<label class="form-label">Contradiction Title</label>
<input type="text" class="form-input" id="contradictionTitle" placeholder="e.g. High growth but infrastructure lag">
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea class="form-input form-textarea" id="contradictionDesc" placeholder="Explain the contradiction..."></textarea>
</div>
<div class="form-group">
<label class="form-label">Severity</label>
<select class="form-input form-select" id="contradictionSeverity">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
</div>
</div>
<!-- Preview Tab -->
<div id="preview-tab" class="tab-content">
<div class="header">
<h1>Preview</h1>
<button class="btn" onclick="refreshPreview()">Refresh</button>
</div>
<iframe class="preview-frame" id="previewFrame"></iframe>
</div>
<!-- Deploy Tab -->
<div id="deploy-tab" class="tab-content">
<div class="header">
<h1>Deploy</h1>
</div>
<div class="editor">
<div class="editor-title">Deployment Status</div>
<div id="deployStatus"></div>
<button class="btn" style="margin-top:20px" onclick="deployToS3()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Deploy to S3
</button>
</div>
</div>
</div>
<!-- Actions Bar -->
<div class="actions-bar">
<div class="actions-left">
<span class="status-text" id="statusText">No city selected</span>
</div>
<div class="actions-right">
<button class="btn btn-secondary" onclick="saveDraft()">Save Draft</button>
<button class="btn" onclick="saveAndPublish()">Save & Publish</button>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast">
<div class="toast-icon" id="toastIcon"></div>
<span id="toastMessage">Saved successfully</span>
</div>
<!-- Modal -->
<div class="modal-overlay" id="modal">
<div class="modal">
<div class="modal-title" id="modalTitle">Confirm</div>
<div class="modal-text" id="modalText">Are you sure?</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button class="btn btn-danger" id="modalConfirm">Confirm</button>
</div>
</div>
</div>
<script>
// Data Store
let cities = JSON.parse(localStorage.getItem('landvexCities')) || {};
let currentCity = null;
// Default city template
const defaultCity = {
name: 'New City',
slug: 'new-city',
description: 'City intelligence report',
subtitle: 'Continuous intelligence across districts.',
status: 'draft',
scores: {
opportunity: 50,
growth: 50,
vitality: 50,
infrastructure: 50,
investment: 50,
contradiction: 25
},
layers: [
{ name: 'Physical', question: 'What exists here?', examples: 'Buildings, roads, signage, people, vehicles', score: 50 },
{ name: 'Operational', question: 'What is happening?', examples: 'Open businesses, deliveries, customer flows', score: 50 },
{ name: 'Economic', question: 'How is this financed?', examples: 'Family business, corporate, informal economy', score: 50 },
{ name: 'Institutional', question: 'What rules govern this?', examples: 'Zoning, permits, regulations', score: 50 },
{ name: 'Social', question: 'What networks sustain this?', examples: 'Family, migrants, tourists', score: 50 },
{ name: 'Temporal', question: 'How does this change?', examples: 'Rush hour, seasons, cycles', score: 50 }
],
districts: [
{ name: 'District 1', meta: 'Description', scores: ['Vitality 50'] }
],
contradiction: {
title: '',
description: '',
severity: 'low'
}
};
// Initialize
function init() {
renderCityGrid();
setupEventListeners();
setupScoreSliders();
}
// Render City Grid
function renderCityGrid() {
const grid = document.getElementById('cityGrid');
grid.innerHTML = '';
Object.entries(cities).forEach(([id, city]) => {
const card = document.createElement('div');
card.className = 'city-card' + (currentCity === id ? ' selected' : '');
card.onclick = () => selectCity(id);
const statusClass = city.status || 'draft';
card.innerHTML = `
<div class="city-status ${statusClass}"></div>
<div class="city-name">${city.name}</div>
<div class="city-meta">/${city.slug}/ · ${city.status}</div>
<div class="city-scores">
<span class="city-score">Opp ${city.scores?.opportunity || 0}</span>
<span class="city-score">Grw ${city.scores?.growth || 0}</span>
<span class="city-score">Ctr ${city.scores?.contradiction || 0}</span>
</div>
`;
grid.appendChild(card);
});
}
// Select City
function selectCity(id) {
currentCity = id;
const city = cities[id];
document.getElementById('editorTitle').textContent = `Edit ${city.name}`;
document.getElementById('statusText').textContent = `Editing: ${city.name} (${city.status})`;
// Fill form
document.getElementById('cityName').value = city.name;
document.getElementById('citySlug').value = city.slug;
document.getElementById('cityDescription').value = city.description;
document.getElementById('citySubtitle').value = city.subtitle;
document.getElementById('cityStatus').value = city.status;
// Fill scores
Object.entries(city.scores).forEach(([key, value]) => {
const input = document.getElementById('score' + key.charAt(0).toUpperCase() + key.slice(1));
const slider = document.getElementById('slider' + key.charAt(0).toUpperCase() + key.slice(1));
if (input) input.value = value;
if (slider) slider.value = value;
});
// Fill layers
renderLayers(city.layers);
// Fill districts
renderDistricts(city.districts);
// Fill contradiction
document.getElementById('contradictionTitle').value = city.contradiction?.title || '';
document.getElementById('contradictionDesc').value = city.contradiction?.description || '';
document.getElementById('contradictionSeverity').value = city.contradiction?.severity || 'low';
renderCityGrid();
showToast('City loaded', 'success');
}
// Render Layers
function renderLayers(layers) {
const container = document.getElementById('layersList');
container.innerHTML = '';
layers.forEach((layer, index) => {
const div = document.createElement('div');
div.className = 'layer-card';
div.innerHTML = `
<div class="layer-header">
<div class="layer-number">Layer 0${index + 1}${layer.name}</div>
<input type="number" class="layer-score" value="${layer.score}" min="0" max="100" onchange="updateLayerScore(${index}, this.value)">
</div>
<div class="form-group">
<label class="form-label">Question</label>
<input type="text" class="form-input" value="${layer.question}" onchange="updateLayerField(${index}, 'question', this.value)">
</div>
<div class="form-group">
<label class="form-label">Examples</label>
<input type="text" class="form-input" value="${layer.examples}" onchange="updateLayerField(${index}, 'examples', this.value)">
</div>
`;
container.appendChild(div);
});
}
// Render Districts
function renderDistricts(districts) {
const container = document.getElementById('districtsList');
container.innerHTML = '';
districts.forEach((district, index) => {
const div = document.createElement('div');
div.className = 'district-item';
div.innerHTML = `
<div class="district-info">
<input type="text" class="district-name-edit" value="${district.name}" onchange="updateDistrictField(${index}, 'name', this.value)">
<input type="text" class="district-meta-edit" value="${district.meta}" onchange="updateDistrictField(${index}, 'meta', this.value)">
</div>
<div class="district-actions">
<button class="btn btn-secondary" onclick="removeDistrict(${index})">Remove</button>
</div>
`;
container.appendChild(div);
});
}
// Create New City
function createNewCity() {
const id = 'city_' + Date.now();
cities[id] = JSON.parse(JSON.stringify(defaultCity));
cities[id].name = 'New City ' + Object.keys(cities).length;
cities[id].slug = 'new-city-' + Object.keys(cities).length;
saveToStorage();
renderCityGrid();
selectCity(id);
showToast('New city created', 'success');
}
// Save Draft
function saveDraft() {
if (!currentCity) return;
const city = cities[currentCity];
city.name = document.getElementById('cityName').value;
city.slug = document.getElementById('citySlug').value;
city.description = document.getElementById('cityDescription').value;
city.subtitle = document.getElementById('citySubtitle').value;
city.status = document.getElementById('cityStatus').value;
city.scores = {
opportunity: parseInt(document.getElementById('scoreOpportunity').value),
growth: parseInt(document.getElementById('scoreGrowth').value),
vitality: parseInt(document.getElementById('scoreVitality').value),
infrastructure: parseInt(document.getElementById('scoreInfrastructure').value),
investment: parseInt(document.getElementById('scoreInvestment').value),
contradiction: parseInt(document.getElementById('scoreContradiction').value)
};
city.contradiction = {
title: document.getElementById('contradictionTitle').value,
description: document.getElementById('contradictionDesc').value,
severity: document.getElementById('contradictionSeverity').value
};
saveToStorage();
renderCityGrid();
showToast('Draft saved', 'success');
}
// Save & Publish
function saveAndPublish() {
saveDraft();
if (currentCity) {
cities[currentCity].status = 'published';
saveToStorage();
renderCityGrid();
showToast('Published!', 'success');
}
}
// Deploy to S3
function deployToS3() {
showToast('Deploying to S3...', 'success');
// In real implementation, this would call AWS SDK
setTimeout(() => {
showToast('Deployed successfully!', 'success');
}, 2000);
}
// Duplicate City
function duplicateCity() {
if (!currentCity) return;
const newId = 'city_' + Date.now();
cities[newId] = JSON.parse(JSON.stringify(cities[currentCity]));
cities[newId].name += ' (Copy)';
cities[newId].slug += '-copy';
cities[newId].status = 'draft';
saveToStorage();
renderCityGrid();
showToast('City duplicated', 'success');
}
// Delete City
function deleteCity() {
if (!currentCity) return;
showModal('Delete City', `Are you sure you want to delete ${cities[currentCity].name}?`, () => {
delete cities[currentCity];
currentCity = null;
saveToStorage();
renderCityGrid();
closeModal();
showToast('City deleted', 'success');
});
}
// Add District
function addDistrict() {
if (!currentCity) return;
cities[currentCity].districts.push({
name: 'New District',
meta: 'Description',
scores: ['Vitality 50']
});
renderDistricts(cities[currentCity].districts);
}
// Remove District
function removeDistrict(index) {
if (!currentCity) return;
cities[currentCity].districts.splice(index, 1);
renderDistricts(cities[currentCity].districts);
}
// Update Layer Score
function updateLayerScore(index, value) {
if (!currentCity) return;
cities[currentCity].layers[index].score = parseInt(value);
}
// Update Layer Field
function updateLayerField(index, field, value) {
if (!currentCity) return;
cities[currentCity].layers[index][field] = value;
}
// Update District Field
function updateDistrictField(index, field, value) {
if (!currentCity) return;
cities[currentCity].districts[index][field] = value;
}
// Setup Score Sliders
function setupScoreSliders() {
const scores = ['Opportunity', 'Growth', 'Vitality', 'Infrastructure', 'Investment', 'Contradiction'];
scores.forEach(score => {
const input = document.getElementById('score' + score);
const slider = document.getElementById('slider' + score);
if (input && slider) {
input.addEventListener('input', () => slider.value = input.value);
slider.addEventListener('input', () => input.value = slider.value);
}
});
}
// Setup Event Listeners
function setupEventListeners() {
// Sidebar tabs
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => {
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.getElementById(item.dataset.tab + '-tab').classList.add('active');
});
});
// Editor tabs
document.querySelectorAll('[data-editor-tab]').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('[data-editor-tab]').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
document.querySelectorAll('#editor-tab .tab-content').forEach(t => t.classList.remove('active'));
document.getElementById(tab.dataset.editorTab + '-editor').classList.add('active');
});
});
}
// Save to Storage
function saveToStorage() {
localStorage.setItem('landvexCities', JSON.stringify(cities));
}
// Show Toast
function showToast(message, type) {
const toast = document.getElementById('toast');
const icon = document.getElementById('toastIcon');
const msg = document.getElementById('toastMessage');
msg.textContent = message;
icon.className = 'toast-icon ' + type;
icon.textContent = type === 'success' ? '✓' : '✕';
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 3000);
}
// Show Modal
function showModal(title, text, onConfirm) {
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalText').textContent = text;
document.getElementById('modalConfirm').onclick = onConfirm;
document.getElementById('modal').classList.add('show');
}
// Close Modal
function closeModal() {
document.getElementById('modal').classList.remove('show');
}
// Refresh Preview
function refreshPreview() {
if (!currentCity) {
showToast('Select a city first', 'error');
return;
}
// Generate preview HTML
const city = cities[currentCity];
const html = generateCityHTML(city);
const blob = new Blob([html], { type: 'text/html' });
document.getElementById('previewFrame').src = URL.createObjectURL(blob);
}
// Generate City HTML
function generateCityHTML(city) {
// Simplified HTML generation
return `<!DOCTYPE html>
<html>
<head><title>${city.name} — Landvex</title></head>
<body style="font-family:sans-serif;padding:40px">
<h1>${city.name}</h1>
<p>${city.subtitle}</p>
<h2>Scores</h2>
<ul>
<li>Opportunity: ${city.scores.opportunity}</li>
<li>Growth: ${city.scores.growth}</li>
<li>Vitality: ${city.scores.vitality}</li>
</ul>
</body>
</html>`;
}
// Initialize on load
init();
</script>
</body>
</html>