bae705aa97
- 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
728 lines
29 KiB
HTML
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>
|