2026-04-15-2 dashboard convert to html without vite and react
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Query, UseGuards, Res } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { MapsService } from './maps.service';
|
||||
import { ApiKeyGuard } from '../common/guards/api-key.guard';
|
||||
|
||||
@@ -7,13 +10,72 @@ import { ApiKeyGuard } from '../common/guards/api-key.guard';
|
||||
@Controller('maps')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
export class MapsController {
|
||||
constructor(private readonly mapsService: MapsService) {}
|
||||
constructor(private readonly mapsService: MapsService) { }
|
||||
|
||||
@Get('style.json')
|
||||
@ApiOperation({ summary: 'Get MapLibre style JSON 🎨' })
|
||||
async getStyleJson(@Query('theme') theme: string, @Res() res: Response) {
|
||||
// Determine filenames based on theme
|
||||
const isDark = theme === 'obsidian';
|
||||
const filename = isDark ? 'style-dark.json' : 'style.json';
|
||||
const fallbackFilename = 'style.json';
|
||||
|
||||
// Paths to check
|
||||
const pathsToCheck = [
|
||||
path.join('/data', filename),
|
||||
path.join(process.cwd(), '../../', filename),
|
||||
path.join(process.cwd(), filename),
|
||||
// Fallbacks to light style if dark is missing
|
||||
path.join('/data', fallbackFilename),
|
||||
path.join(process.cwd(), '../../', fallbackFilename),
|
||||
path.join(process.cwd(), fallbackFilename),
|
||||
];
|
||||
|
||||
let stylePath = '';
|
||||
for (const p of pathsToCheck) {
|
||||
if (fs.existsSync(p)) {
|
||||
stylePath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!stylePath) {
|
||||
return res.status(404).send('Style not found');
|
||||
}
|
||||
|
||||
try {
|
||||
const styleRaw = fs.readFileSync(stylePath, 'utf8');
|
||||
const styleObj = JSON.parse(styleRaw);
|
||||
|
||||
// Dynamic Theme support (Safety overrides or fine-tuning)
|
||||
if (theme === 'light') {
|
||||
styleObj.layers.forEach((layer: any) => {
|
||||
if (layer.id === 'background') {
|
||||
layer.paint['background-color'] = '#FFFFFF';
|
||||
}
|
||||
});
|
||||
} else if (theme === 'obsidian') {
|
||||
// If we found style-dark.json, we don't strictly need this,
|
||||
// but keeping it as a helper or if it fell back to style.json
|
||||
styleObj.layers.forEach((layer: any) => {
|
||||
if (layer.id === 'background') {
|
||||
layer.paint['background-color'] = '#101014'; // Dark tone
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(styleObj);
|
||||
} catch (e) {
|
||||
res.status(500).send('Error parsing style.json');
|
||||
}
|
||||
}
|
||||
|
||||
@Get('route')
|
||||
@ApiOperation({ summary: 'Calculate a route with dynamic waypoints 🚗' })
|
||||
async getRoute(@Query() query: any) {
|
||||
const waypoints: [number, number][] = [];
|
||||
|
||||
|
||||
// 1. Extract Origin (fromLat, fromLng)
|
||||
if (query.fromLat && query.fromLng) {
|
||||
waypoints.push([parseFloat(query.fromLat), parseFloat(query.fromLng)]);
|
||||
@@ -45,7 +107,7 @@ export class MapsController {
|
||||
const profile = query.profile || 'car';
|
||||
const steps = query.steps === 'true';
|
||||
const locale = query.locale || 'en';
|
||||
|
||||
|
||||
return this.mapsService.getRoute(waypoints, profile, steps, locale);
|
||||
}
|
||||
|
||||
|
||||
+373
-12
@@ -1,13 +1,374 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Jordan Map Platform | Developer Dashboard</title>
|
||||
<!-- Tailwind CSS Play CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- MapLibre GL JS -->
|
||||
<script src="https://unpkg.com/maplibre-gl@5.1.1/dist/maplibre-gl.js"></script>
|
||||
<link href="https://unpkg.com/maplibre-gl@5.1.1/dist/maplibre-gl.css" rel="stylesheet" />
|
||||
<!-- Lucide Icons -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
slate: {
|
||||
950: '#0a0a0b',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
background-color: #0a0a0b;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #fff 0%, #94a3b8 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply flex items-center gap-2 px-4 py-2.5 rounded-xl font-bold transition-all duration-300 active:scale-95 text-sm;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-blue-600 text-white hover:bg-blue-500 shadow-lg shadow-blue-500/20 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-slate-900 text-slate-400 hover:text-white border border-slate-800 hover:border-slate-700;
|
||||
}
|
||||
|
||||
/* Nav logic */
|
||||
.page-section {
|
||||
display: none;
|
||||
}
|
||||
.page-section.active {
|
||||
display: block;
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Sidebar active state */
|
||||
.nav-link.active {
|
||||
@apply bg-blue-600/10 text-blue-400 border-r-2 border-blue-600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="overflow-hidden h-screen flex">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-64 border-r border-slate-800 bg-slate-950/50 backdrop-blur-xl flex flex-col z-50">
|
||||
<div class="p-8">
|
||||
<div class="flex items-center gap-3 group cursor-pointer">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-blue-600 to-cyan-400 flex items-center justify-center text-white shadow-lg shadow-blue-500/20 group-hover:rotate-12 transition-transform">
|
||||
<i data-lucide="layers" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="font-black text-lg tracking-tight leading-none">Intaleq</h1>
|
||||
<span class="text-[10px] uppercase tracking-[0.2em] font-bold text-slate-500">Maps SaaS</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 px-4 space-y-2 mt-4">
|
||||
<a href="#home" class="nav-link flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:bg-white/5 text-slate-400 font-bold group" id="nav-home">
|
||||
<i data-lucide="layout-dashboard" class="w-5 h-5 group-hover:text-white transition-colors"></i>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="#playground" class="nav-link flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:bg-white/5 text-slate-400 font-bold group" id="nav-playground">
|
||||
<i data-lucide="terminal" class="w-5 h-5 group-hover:text-white transition-colors"></i>
|
||||
Playground
|
||||
</a>
|
||||
<a href="#analytics" class="nav-link flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:bg-white/5 text-slate-400 font-bold group" id="nav-analytics">
|
||||
<i data-lucide="bar-chart-3" class="w-5 h-5 group-hover:text-white transition-colors"></i>
|
||||
Analytics
|
||||
</a>
|
||||
<a href="#billing" class="nav-link flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:bg-white/5 text-slate-400 font-bold group" id="nav-billing">
|
||||
<i data-lucide="credit-card" class="w-5 h-5 group-hover:text-white transition-colors"></i>
|
||||
Billing
|
||||
</a>
|
||||
<a href="#docs" class="nav-link flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:bg-white/5 text-slate-400 font-bold group" id="nav-docs">
|
||||
<i data-lucide="book-open" class="w-5 h-5 group-hover:text-white transition-colors"></i>
|
||||
Documentation
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="glass p-5 rounded-2xl border border-blue-500/10 bg-gradient-to-br from-blue-600/5 to-transparent">
|
||||
<p class="text-[10px] font-black uppercase tracking-wider text-blue-400 mb-2">Beta Access</p>
|
||||
<p class="text-xs text-slate-400 leading-relaxed font-medium mb-4">You're currently on the free sandbox tier.</p>
|
||||
<button class="w-full btn btn-primary !py-2 !text-xs">Upgrade Plan</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 flex flex-col min-w-0 bg-gradient-to-br from-slate-950 via-[#0f1115] to-slate-950 overflow-y-auto">
|
||||
<!-- Header -->
|
||||
<header class="h-20 border-b border-white/[0.03] flex items-center justify-between px-8 sticky top-0 bg-slate-950/50 backdrop-blur-md z-40">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<span class="text-xs font-black uppercase tracking-widest text-slate-500">System Operational</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex flex-col items-end">
|
||||
<p class="text-sm font-bold" id="tenant-name">Loading...</p>
|
||||
<p class="text-[10px] text-slate-500 font-medium" id="tenant-email">developer@intaleq.com</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-slate-900 border border-slate-800 flex items-center justify-center text-blue-400">
|
||||
<i data-lucide="user" class="w-5 h-5"></i>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="p-12 max-w-7xl mx-auto w-full">
|
||||
|
||||
<!-- Home Section -->
|
||||
<section id="home" class="page-section">
|
||||
<div class="mb-12">
|
||||
<h2 class="text-4xl text-gradient mb-2" id="welcome-msg">Welcome back...</h2>
|
||||
<p class="text-slate-400">Everything you need to build with premium Jordan Map Platform API</p>
|
||||
</div>
|
||||
|
||||
<!-- KPI Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
||||
<div class="glass p-6 rounded-2xl group border-[#1e293b]">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-10 h-10 rounded-xl bg-slate-900 flex items-center justify-center text-blue-400">
|
||||
<i data-lucide="zap" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="text-xs font-bold px-2 py-1 rounded-full bg-slate-900 text-emerald-500">+12.5%</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500 font-medium mb-1">Total Requests</p>
|
||||
<div class="text-2xl font-bold tracking-tight">42,891</div>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-2xl group">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-10 h-10 rounded-xl bg-slate-900 flex items-center justify-center text-emerald-400">
|
||||
<i data-lucide="shield-check" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="text-xs font-bold px-2 py-1 rounded-full bg-slate-900 text-emerald-500">+0.01%</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500 font-medium mb-1">Success Rate</p>
|
||||
<div class="text-2xl font-bold tracking-tight">99.98%</div>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-2xl group">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-10 h-10 rounded-xl bg-slate-900 flex items-center justify-center text-cyan-400">
|
||||
<i data-lucide="activity" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="text-xs font-bold px-2 py-1 rounded-full bg-slate-900 text-slate-500">Stable</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500 font-medium mb-1">Active Keys</p>
|
||||
<div class="text-2xl font-bold tracking-tight" id="active-keys-count">...</div>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-2xl group">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-10 h-10 rounded-xl bg-slate-900 flex items-center justify-center text-violet-400">
|
||||
<i data-lucide="trending-up" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="text-xs font-bold px-2 py-1 rounded-full bg-slate-900 text-slate-500">-12ms</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500 font-medium mb-1">Map Load Speed</p>
|
||||
<div class="text-2xl font-bold tracking-tight">342ms</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
|
||||
<!-- Traffic Mockup -->
|
||||
<div class="lg:col-span-2 glass rounded-2xl p-6 relative overflow-hidden">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold">Request Traffic</h3>
|
||||
<p class="text-sm text-slate-500">Live traffic across all API endpoints</p>
|
||||
</div>
|
||||
<a href="#analytics" class="text-xs text-blue-500 hover:text-blue-400 font-bold flex items-center gap-1 transition-colors">
|
||||
Full Analytics <i data-lucide="arrow-right" class="w-3.5 h-3.5"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="h-64 flex items-end gap-2 px-2 relative" id="traffic-bars">
|
||||
<!-- Bars injected by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="glass rounded-2xl p-6 bg-gradient-to-br from-blue-600/10 to-transparent border-blue-500/10">
|
||||
<h3 class="text-lg font-bold mb-2">Quick Start</h3>
|
||||
<p class="text-sm text-slate-500 mb-6 font-medium">Get started with our lightweight SDK in seconds.</p>
|
||||
<div class="space-y-4">
|
||||
<div class="bg-slate-950 rounded-xl p-4 border border-slate-800 font-mono text-xs">
|
||||
<p class="text-slate-500 mb-2"># Install with npm</p>
|
||||
<p class="text-blue-400">npm <span class="text-slate-200">install @intaleq/maps-gl</span></p>
|
||||
</div>
|
||||
<button class="w-full btn btn-secondary text-sm group" onclick="window.open('/api/docs', '_blank')">
|
||||
<i data-lucide="terminal" class="w-4 h-4 text-blue-400 group-hover:scale-110 transition-transform"></i>
|
||||
View API Reference
|
||||
</button>
|
||||
<a href="#playground" class="w-full btn btn-secondary text-sm group">
|
||||
<i data-lucide="globe" class="w-4 h-4 text-cyan-400 group-hover:scale-110 transition-transform"></i>
|
||||
Try Maps Playground
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Keys Table -->
|
||||
<div class="glass rounded-2xl overflow-hidden mb-12">
|
||||
<div class="p-6 border-b border-slate-800 flex items-center justify-between bg-white/[0.02]">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold">Your API Keys</h3>
|
||||
<p class="text-sm text-slate-500">Manage keys for your applications</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="app.toggleModal('create-key-modal', true)" id="create-key-btn">
|
||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||
Create New Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto min-h-[200px]">
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr class="text-xs uppercase tracking-widest text-slate-500 bg-slate-900/40">
|
||||
<th class="px-6 py-4 font-black">Name</th>
|
||||
<th class="px-6 py-4 font-black">API Key</th>
|
||||
<th class="px-6 py-4 font-black">Status</th>
|
||||
<th class="px-6 py-4 font-black">Restrictions</th>
|
||||
<th class="px-6 py-4 font-black text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="keys-table-body" class="divide-y divide-slate-800/50">
|
||||
<!-- Injected by JS -->
|
||||
<tr>
|
||||
<td colspan="5" class="py-20 text-center text-slate-500">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<i data-lucide="loader-2" class="w-8 h-8 animate-spin text-blue-500"></i>
|
||||
<p>Fetching your secure keys...</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Playground Section -->
|
||||
<section id="playground" class="page-section">
|
||||
<div class="mb-12">
|
||||
<h2 class="text-4xl text-gradient mb-2">Maps Playground</h2>
|
||||
<p class="text-slate-400">Test your API keys and visualize vector tiles in real-time</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
<!-- Sidebar Controls -->
|
||||
<div class="lg:col-span-1 space-y-6">
|
||||
<div class="glass p-6 rounded-2xl">
|
||||
<h4 class="text-[10px] uppercase font-black tracking-widest text-slate-500 mb-4">Configuration</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs text-slate-400 mb-2 block">Active API Key</label>
|
||||
<select id="pg-key-select" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-400 mb-2 block">Map Style</label>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="playground.setStyle('obsidian')" id="style-obsidian" class="flex-1 py-3 text-xs font-bold rounded-xl bg-blue-600 text-white shadow-lg shadow-blue-500/20">Obsidian</button>
|
||||
<button onclick="playground.setStyle('light')" id="style-light" class="flex-1 py-3 text-xs font-bold rounded-xl bg-slate-900 text-slate-500">Light</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Container -->
|
||||
<div class="lg:col-span-3 glass rounded-3xl overflow-hidden relative" style="height: 600px;">
|
||||
<div id="map" class="absolute inset-0"></div>
|
||||
<!-- Search Overlay -->
|
||||
<div class="absolute top-6 left-6 w-full max-w-sm">
|
||||
<div class="relative">
|
||||
<i data-lucide="search" class="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 w-4 h-4"></i>
|
||||
<input type="text" id="pg-search" placeholder="Search Amman, Jordan..."
|
||||
class="w-full bg-slate-950/80 backdrop-blur-md border border-slate-800 rounded-2xl px-12 py-4 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 shadow-2xl">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Analytics Section -->
|
||||
<section id="analytics" class="page-section">
|
||||
<div class="mb-12">
|
||||
<h2 class="text-4xl text-gradient mb-2">Analytics</h2>
|
||||
<p class="text-slate-400">Deep insights into your API performance</p>
|
||||
</div>
|
||||
<!-- Simple Chart Mockups -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div class="glass p-8 rounded-3xl">
|
||||
<h3 class="text-xl font-bold mb-8">Request Volume (Last 7 Days)</h3>
|
||||
<div class="h-80 w-full flex items-end justify-between gap-4 px-4" id="v-chart"></div>
|
||||
</div>
|
||||
<div class="glass p-8 rounded-3xl">
|
||||
<h3 class="text-xl font-bold mb-8">Service Latency (ms)</h3>
|
||||
<div class="h-80 w-full flex items-end justify-between gap-4 px-4" id="l-chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<div id="create-key-modal" class="fixed inset-0 z-[100] hidden">
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" onclick="app.toggleModal('create-key-modal', false)"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="glass w-full max-w-md p-8 rounded-3xl animate-in fade-in zoom-in duration-300">
|
||||
<h2 class="text-2xl font-bold mb-2">Create API Key</h2>
|
||||
<p class="text-sm text-slate-500 mb-8">Set up a new access point for your application.</p>
|
||||
<form id="create-key-form" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-xs font-black uppercase tracking-widest text-slate-500 mb-2">Key Name</label>
|
||||
<input type="text" id="new-key-name" placeholder="e.g. Production Web App" required
|
||||
class="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20">
|
||||
</div>
|
||||
<div class="flex gap-4 pt-4">
|
||||
<button type="button" class="flex-1 btn btn-secondary" onclick="app.toggleModal('create-key-modal', false)">Cancel</button>
|
||||
<button type="submit" class="flex-1 btn btn-primary" id="btn-submit-key">Create Key</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- core script -->
|
||||
<script src="js/app.js"></script>
|
||||
<script src="js/playground.js"></script>
|
||||
<script src="js/analytics.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Analytics Logic (Simple CSS Charts)
|
||||
*/
|
||||
|
||||
const analytics = {
|
||||
mockData: [
|
||||
{ name: 'Mon', requests: 4000, latency: 240 },
|
||||
{ name: 'Tue', requests: 3000, latency: 198 },
|
||||
{ name: 'Wed', requests: 2000, latency: 310 },
|
||||
{ name: 'Thu', requests: 2780, latency: 208 },
|
||||
{ name: 'Fri', requests: 1890, latency: 250 },
|
||||
{ name: 'Sat', requests: 2390, latency: 210 },
|
||||
{ name: 'Sun', requests: 3490, latency: 225 },
|
||||
],
|
||||
|
||||
init: () => {
|
||||
console.log('📈 Initializing Analytics...');
|
||||
analytics.renderVolumeChart();
|
||||
analytics.renderLatencyChart();
|
||||
},
|
||||
|
||||
renderVolumeChart: () => {
|
||||
const container = document.getElementById('v-chart');
|
||||
if (!container) return;
|
||||
|
||||
const max = Math.max(...analytics.mockData.map(d => d.requests));
|
||||
|
||||
container.innerHTML = analytics.mockData.map(d => `
|
||||
<div class="flex-1 flex flex-col items-center gap-2 group">
|
||||
<div class="w-full bg-blue-600/20 rounded-t-lg relative overflow-hidden flex items-end" style="height: 100%">
|
||||
<div class="w-full bg-gradient-to-t from-blue-600 to-blue-400 rounded-t-lg transition-all duration-700 hover:brightness-125"
|
||||
style="height: ${(d.requests / max) * 100}%">
|
||||
</div>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span class="text-[10px] font-bold bg-slate-900 px-2 py-1 rounded border border-slate-800">${d.requests}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold text-slate-500">${d.name}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
},
|
||||
|
||||
renderLatencyChart: () => {
|
||||
const container = document.getElementById('l-chart');
|
||||
if (!container) return;
|
||||
|
||||
const max = Math.max(...analytics.mockData.map(d => d.latency));
|
||||
|
||||
container.innerHTML = analytics.mockData.map(d => `
|
||||
<div class="flex-1 flex flex-col items-center gap-2 group">
|
||||
<div class="w-full bg-violet-600/10 rounded-t-lg relative overflow-hidden flex items-end" style="height: 100%">
|
||||
<div class="w-full bg-gradient-to-t from-violet-600 to-violet-400 rounded-t-lg transition-all duration-700 hover:brightness-125"
|
||||
style="height: ${(d.latency / max) * 100}%">
|
||||
</div>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span class="text-[10px] font-bold bg-slate-900 px-2 py-1 rounded border border-slate-800">${d.latency}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold text-slate-500">${d.name}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Main App Logic for Intaleq Dashboard (Vanilla Version)
|
||||
*/
|
||||
|
||||
const app = {
|
||||
state: {
|
||||
tenant: null,
|
||||
keys: [],
|
||||
showKeys: {}, // { id: boolean }
|
||||
activePage: 'home'
|
||||
},
|
||||
|
||||
init: async () => {
|
||||
console.log('🚀 Dashboard Initializing...');
|
||||
app.bindEvents();
|
||||
app.handleRouting();
|
||||
await app.fetchData();
|
||||
lucide.createIcons();
|
||||
},
|
||||
|
||||
bindEvents: () => {
|
||||
window.addEventListener('hashchange', app.handleRouting);
|
||||
|
||||
// Form submission for new key
|
||||
const createKeyForm = document.getElementById('create-key-form');
|
||||
if (createKeyForm) {
|
||||
createKeyForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
await app.createKey();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleRouting: () => {
|
||||
const hash = window.location.hash.replace('#', '') || 'home';
|
||||
app.state.activePage = hash;
|
||||
|
||||
// Update UI
|
||||
document.querySelectorAll('.page-section').forEach(s => s.classList.remove('active'));
|
||||
const activeSection = document.getElementById(hash);
|
||||
if (activeSection) activeSection.classList.add('active');
|
||||
|
||||
// Update Nav
|
||||
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
|
||||
const activeLink = document.getElementById(`nav-${hash}`);
|
||||
if (activeLink) activeLink.classList.add('active');
|
||||
|
||||
// Specialized page logic
|
||||
if (hash === 'playground') {
|
||||
playground.init(app.state.keys);
|
||||
} else if (hash === 'analytics') {
|
||||
analytics.init();
|
||||
}
|
||||
},
|
||||
|
||||
fetchData: async () => {
|
||||
try {
|
||||
// Fetch Tenant
|
||||
const tenantRes = await fetch('/api/auth/management/me');
|
||||
if (tenantRes.ok) {
|
||||
app.state.tenant = await tenantRes.data || await tenantRes.json();
|
||||
app.updateHeader();
|
||||
}
|
||||
|
||||
// Fetch Keys
|
||||
if (app.state.tenant && app.state.tenant.id) {
|
||||
const keysRes = await fetch(`/api/auth/management/keys/${app.state.tenant.id}`);
|
||||
if (keysRes.ok) {
|
||||
app.state.keys = await keysRes.json();
|
||||
app.renderKeysTable();
|
||||
app.updateStats();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dashboard data', error);
|
||||
}
|
||||
},
|
||||
|
||||
updateHeader: () => {
|
||||
if (!app.state.tenant) return;
|
||||
document.getElementById('tenant-name').textContent = app.state.tenant.name;
|
||||
document.getElementById('tenant-email').textContent = app.state.tenant.email;
|
||||
document.getElementById('welcome-msg').textContent = `Welcome back, ${app.state.tenant.name}`;
|
||||
},
|
||||
|
||||
updateStats: () => {
|
||||
document.getElementById('active-keys-count').textContent = app.state.keys.length;
|
||||
|
||||
// Inject random bars for traffic
|
||||
const container = document.getElementById('traffic-bars');
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
const values = [40, 60, 55, 80, 70, 45, 90, 85, 60, 40, 30, 55, 75, 40, 60, 55, 80, 70, 45, 90];
|
||||
values.forEach(h => {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'flex-1 bg-gradient-to-t from-blue-600/20 to-blue-400/80 rounded-t-sm relative group hover:to-blue-300 transition-all';
|
||||
bar.style.height = `${h}%`;
|
||||
bar.innerHTML = `<div class="absolute -top-10 left-1/2 -translate-x-1/2 glass px-2 py-1 rounded text-[10px] font-bold opacity-0 group-hover:opacity-100 transition-opacity z-10">${h}k</div>`;
|
||||
container.appendChild(bar);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
renderKeysTable: () => {
|
||||
const tbody = document.getElementById('keys-table-body');
|
||||
const createBtn = document.getElementById('create-key-btn');
|
||||
|
||||
if (!tbody) return;
|
||||
|
||||
if (app.state.keys.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="py-20 text-center text-slate-500 font-medium">No API keys found. Create one to get started.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable create button if limit reached (per React logic)
|
||||
if (app.state.keys.length >= 1) {
|
||||
createBtn.disabled = true;
|
||||
createBtn.title = "Limit of 1 API key per developer reached";
|
||||
createBtn.innerHTML = '<i data-lucide="shield-alert" class="w-4 h-4"></i> Limit Reached';
|
||||
}
|
||||
|
||||
tbody.innerHTML = app.state.keys.map(key => {
|
||||
const isVisible = app.state.showKeys[key.id];
|
||||
const maskedKey = isVisible ? key.key : "in_••••••••••••••••••••••••";
|
||||
const eyeIcon = isVisible ? 'eye-off' : 'eye';
|
||||
|
||||
return `
|
||||
<tr class="hover:bg-white/[0.02] transition-colors">
|
||||
<td class="px-6 py-6 font-medium">${key.name}</td>
|
||||
<td class="px-6 py-6">
|
||||
<div class="flex items-center gap-2 bg-slate-900 rounded-lg px-3 py-1.5 w-fit border border-slate-800">
|
||||
<code class="text-xs text-blue-400 font-mono">${maskedKey}</code>
|
||||
<div class="flex items-center gap-1 ml-2 border-l border-slate-800 pl-2">
|
||||
<button onclick="app.toggleKeyVisibility('${key.id}')" class="text-slate-500 hover:text-white">
|
||||
<i data-lucide="${eyeIcon}" class="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
<button class="text-slate-500 hover:text-white" onclick="app.copyToClipboard('${key.key}')">
|
||||
<i data-lucide="copy" class="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6">
|
||||
<span class="flex items-center gap-1.5 text-xs font-bold ${key.isActive ? 'text-emerald-400' : 'text-slate-500'}">
|
||||
<div class="w-1.5 h-1.5 rounded-full ${key.isActive ? 'bg-emerald-400 animate-pulse' : 'bg-slate-500'}"></div>
|
||||
${key.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-6">
|
||||
<div class="flex gap-2">
|
||||
${(key.allowedOrigins && key.allowedOrigins.length > 0) ?
|
||||
key.allowedOrigins.map(org => `<span class="px-2 py-0.5 rounded-md bg-blue-500/10 text-[10px] uppercase font-black text-blue-400 flex items-center gap-1"><i data-lucide="globe" class="w-2.5 h-2.5"></i> ${org}</span>`).join('') :
|
||||
`<span class="px-2 py-0.5 rounded-md bg-slate-800 text-[10px] uppercase font-black text-slate-400 flex items-center gap-1"><i data-lucide="globe" class="w-2.5 h-2.5"></i> Universal</span>`
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 text-right">
|
||||
<button class="p-2 text-slate-500 hover:text-white" onclick="app.fetchData()">
|
||||
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
lucide.createIcons();
|
||||
},
|
||||
|
||||
toggleKeyVisibility: (id) => {
|
||||
app.state.showKeys[id] = !app.state.showKeys[id];
|
||||
app.renderKeysTable();
|
||||
},
|
||||
|
||||
copyToClipboard: (text) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
// Simple toast or just feedback
|
||||
console.log('Copied to clipboard');
|
||||
},
|
||||
|
||||
toggleModal: (id, show) => {
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) {
|
||||
if (show) modal.classList.remove('hidden');
|
||||
else modal.classList.add('hidden');
|
||||
}
|
||||
},
|
||||
|
||||
createKey: async () => {
|
||||
const name = document.getElementById('new-key-name').value;
|
||||
const btn = document.getElementById('btn-submit-key');
|
||||
|
||||
if (!app.state.tenant) return;
|
||||
|
||||
try {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Creating...';
|
||||
|
||||
const res = await fetch(`/api/auth/management/keys/${app.state.tenant.id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, rateLimit: 100 })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
app.toggleModal('create-key-modal', false);
|
||||
await app.fetchData();
|
||||
document.getElementById('new-key-name').value = '';
|
||||
} else {
|
||||
alert('Failed to create key');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Create Key';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Start app
|
||||
document.addEventListener('DOMContentLoaded', app.init);
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Playground Logic (Map Integration)
|
||||
*/
|
||||
|
||||
const playground = {
|
||||
map: null,
|
||||
currentStyle: 'obsidian',
|
||||
selectedKey: '',
|
||||
|
||||
init: (keys) => {
|
||||
if (playground.map) return;
|
||||
console.log('🗺️ Initializing Playground...');
|
||||
|
||||
playground.renderKeySelect(keys);
|
||||
playground.bindEvents();
|
||||
|
||||
if (keys.length > 0) {
|
||||
playground.selectedKey = keys[0].key;
|
||||
playground.loadMap();
|
||||
}
|
||||
},
|
||||
|
||||
renderKeySelect: (keys) => {
|
||||
const select = document.getElementById('pg-key-select');
|
||||
if (!select) return;
|
||||
select.innerHTML = keys.map(k => `<option value="${k.key}">${k.name}</option>`).join('');
|
||||
if (keys.length === 0) select.innerHTML = '<option value="">No keys available</option>';
|
||||
},
|
||||
|
||||
bindEvents: () => {
|
||||
const select = document.getElementById('pg-key-select');
|
||||
if (select) {
|
||||
select.addEventListener('change', (e) => {
|
||||
playground.selectedKey = e.target.value;
|
||||
playground.updateMap();
|
||||
});
|
||||
}
|
||||
|
||||
const searchInput = document.getElementById('pg-search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keydown', async (e) => {
|
||||
if (e.key === 'Enter' && searchInput.value) {
|
||||
await playground.handleSearch(searchInput.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
loadMap: async () => {
|
||||
const container = document.getElementById('map');
|
||||
if (!container || !playground.selectedKey) return;
|
||||
|
||||
try {
|
||||
// Set RTL Text Plugin
|
||||
if (maplibregl.getRTLTextPluginStatus() === 'unavailable') {
|
||||
maplibregl.setRTLTextPlugin(
|
||||
'https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.2.3/mapbox-gl-rtl-text.js',
|
||||
null,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const styleUrl = `/api/maps/style.json?api_key=${playground.selectedKey}&theme=${playground.currentStyle}`;
|
||||
const res = await fetch(styleUrl);
|
||||
const styleData = await res.json();
|
||||
|
||||
playground.map = new maplibregl.Map({
|
||||
container: 'map',
|
||||
style: styleData,
|
||||
center: [35.91, 31.95], // Amman, Jordan
|
||||
zoom: 12,
|
||||
attributionControl: false
|
||||
});
|
||||
|
||||
playground.map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||
|
||||
playground.map.on('load', () => {
|
||||
console.log('Map loaded!');
|
||||
playground.map.resize();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to init map', error);
|
||||
}
|
||||
},
|
||||
|
||||
updateMap: async () => {
|
||||
if (!playground.map) return;
|
||||
try {
|
||||
const styleUrl = `/api/maps/style.json?api_key=${playground.selectedKey}&theme=${playground.currentStyle}`;
|
||||
const res = await fetch(styleUrl);
|
||||
const styleData = await res.json();
|
||||
playground.map.setStyle(styleData);
|
||||
} catch (error) {
|
||||
console.error('Failed to update style', error);
|
||||
}
|
||||
},
|
||||
|
||||
setStyle: (style) => {
|
||||
playground.currentStyle = style;
|
||||
// Update UI buttons
|
||||
document.getElementById('style-obsidian').className = (style === 'obsidian') ? 'flex-1 py-3 text-xs font-bold rounded-xl bg-blue-600 text-white shadow-lg shadow-blue-500/20' : 'flex-1 py-3 text-xs font-bold rounded-xl bg-slate-900 text-slate-500';
|
||||
document.getElementById('style-light').className = (style === 'light') ? 'flex-1 py-3 text-xs font-bold rounded-xl bg-blue-600 text-white shadow-lg shadow-blue-500/20' : 'flex-1 py-3 text-xs font-bold rounded-xl bg-slate-900 text-slate-500';
|
||||
|
||||
playground.updateMap();
|
||||
},
|
||||
|
||||
handleSearch: async (query) => {
|
||||
try {
|
||||
const res = await fetch(`/api/geocoding/search?q=${encodeURIComponent(query)}`, {
|
||||
headers: { 'x-api-key': playground.selectedKey }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && data.length > 0) {
|
||||
playground.map.flyTo({ center: [data[0].longitude, data[0].latitude], zoom: 14 });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Search failed", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import {
|
||||
Maximize2,
|
||||
Map as MapIcon,
|
||||
Search,
|
||||
import {
|
||||
Maximize2,
|
||||
Map as MapIcon,
|
||||
Search,
|
||||
Info,
|
||||
ChevronDown
|
||||
} from 'lucide-react';
|
||||
@@ -17,34 +17,132 @@ const Playground = ({ apiKeys }: PlaygroundProps) => {
|
||||
const mapContainer = useRef<HTMLDivElement>(null);
|
||||
const map = useRef<maplibregl.Map | null>(null);
|
||||
const [selectedKey, setSelectedKey] = useState(apiKeys[0]?.key || '');
|
||||
const [mapStyle, setMapStyle] = useState('light');
|
||||
const [lng] = useState(35.91);
|
||||
const [lat] = useState(31.95);
|
||||
const [zoom] = useState(12);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [mapError, setMapError] = useState<string | null>(null);
|
||||
const isInitializing = useRef(false);
|
||||
|
||||
// Sync selectedKey when apiKeys load asynchronously
|
||||
useEffect(() => {
|
||||
if (!selectedKey && apiKeys.length > 0) {
|
||||
setSelectedKey(apiKeys[0].key);
|
||||
}
|
||||
}, [apiKeys, selectedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (map.current) return;
|
||||
if (!mapContainer.current) return;
|
||||
if (map.current || isInitializing.current) return;
|
||||
if (!mapContainer.current || !selectedKey) return;
|
||||
|
||||
// Use a relative URL for the style to use the Vite proxy
|
||||
const styleUrl = `/api/maps/style.json?api_key=${selectedKey}`;
|
||||
isInitializing.current = true;
|
||||
|
||||
const initMap = async () => {
|
||||
try {
|
||||
if (maplibregl.getRTLTextPluginStatus() === 'unavailable') {
|
||||
maplibregl.setRTLTextPlugin(
|
||||
'https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.2.3/mapbox-gl-rtl-text.js',
|
||||
true // Lazy load
|
||||
);
|
||||
}
|
||||
|
||||
setMapError(null);
|
||||
const styleUrl = `/api/maps/style.json?api_key=${selectedKey}&theme=${mapStyle}`;
|
||||
|
||||
|
||||
console.log("Fetching map style from API...");
|
||||
const res = await fetch(styleUrl);
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(`HTTP ${res.status}: ${errText}`);
|
||||
}
|
||||
const styleData = await res.json();
|
||||
console.log("Map style loaded successfully!", styleData.name);
|
||||
|
||||
map.current = new maplibregl.Map({
|
||||
container: mapContainer.current!,
|
||||
style: styleData,
|
||||
center: [lng, lat],
|
||||
zoom: zoom,
|
||||
attributionControl: false
|
||||
});
|
||||
|
||||
map.current.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||
|
||||
map.current.on('load', () => {
|
||||
map.current?.resize();
|
||||
});
|
||||
|
||||
map.current.on('error', (e) => {
|
||||
console.error('MapLibre internal error:', e);
|
||||
if (e && e.error && e.error.message) {
|
||||
setMapError(e.error.message);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (e: any) {
|
||||
console.error("Failed to initialize MapLibre:", e);
|
||||
setMapError(e.message || 'Error occurred initializing MapLibre.');
|
||||
} finally {
|
||||
isInitializing.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
initMap();
|
||||
}, [selectedKey, mapStyle]);
|
||||
|
||||
map.current = new maplibregl.Map({
|
||||
container: mapContainer.current,
|
||||
style: styleUrl,
|
||||
center: [lng, lat],
|
||||
zoom: zoom,
|
||||
attributionControl: false
|
||||
});
|
||||
|
||||
map.current.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||
}, [selectedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map.current) return;
|
||||
// Update style when key changes
|
||||
const styleUrl = `/api/maps/style.json?api_key=${selectedKey}`;
|
||||
map.current.setStyle(styleUrl);
|
||||
}, [selectedKey]);
|
||||
if (!map.current || !selectedKey) return;
|
||||
const updateStyle = async () => {
|
||||
try {
|
||||
// Update style when key or theme changes
|
||||
const styleUrl = `/api/maps/style.json?api_key=${selectedKey}&theme=${mapStyle}`;
|
||||
const response = await fetch(styleUrl);
|
||||
if (response.ok) {
|
||||
const styleObj = await response.json();
|
||||
map.current?.setStyle(styleObj);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to update map style", e);
|
||||
}
|
||||
};
|
||||
updateStyle();
|
||||
}, [selectedKey, mapStyle]);
|
||||
|
||||
const toggleFullScreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
mapContainer.current?.requestFullscreen().catch(err => {
|
||||
console.error(`Error attempting to enable full-screen mode: ${err.message}`);
|
||||
});
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
const resetMap = () => {
|
||||
map.current?.flyTo({ center: [lng, lat], zoom, pitch: 0, bearing: 0 });
|
||||
};
|
||||
|
||||
const handleSearch = async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && searchQuery) {
|
||||
try {
|
||||
const res = await fetch(`/api/geocoding/search?q=${encodeURIComponent(searchQuery)}`, {
|
||||
headers: { 'x-api-key': selectedKey }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && data.length > 0) {
|
||||
map.current?.flyTo({ center: [data[0].longitude, data[0].latitude], zoom: 14 });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Search failed", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700 h-full flex flex-col">
|
||||
@@ -58,12 +156,12 @@ const Playground = ({ apiKeys }: PlaygroundProps) => {
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
<div className="glass p-6 rounded-2xl">
|
||||
<h4 className="text-[10px] uppercase font-black tracking-widest text-slate-500 mb-4">Configuration</h4>
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 mb-2 block">Active API Key</label>
|
||||
<div className="relative group">
|
||||
<select
|
||||
<select
|
||||
value={selectedKey}
|
||||
onChange={(e) => setSelectedKey(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-sm appearance-none focus:outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||
@@ -80,8 +178,18 @@ const Playground = ({ apiKeys }: PlaygroundProps) => {
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 mb-2 block">Map Style</label>
|
||||
<div className="flex gap-2">
|
||||
<button className="flex-1 py-3 bg-blue-600 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-500/20">Obsidian</button>
|
||||
<button className="flex-1 py-3 bg-slate-900 text-slate-500 text-xs font-bold rounded-xl">Light</button>
|
||||
<button
|
||||
onClick={() => setMapStyle('obsidian')}
|
||||
className={`flex-1 py-3 text-xs font-bold rounded-xl transition-all ${mapStyle === 'obsidian' ? 'bg-blue-600 text-white shadow-lg shadow-blue-500/20' : 'bg-slate-900 text-slate-500 hover:bg-slate-800'}`}
|
||||
>
|
||||
Obsidian
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMapStyle('light')}
|
||||
className={`flex-1 py-3 text-xs font-bold rounded-xl transition-all ${mapStyle === 'light' ? 'bg-blue-600 text-white shadow-lg shadow-blue-500/20' : 'bg-slate-900 text-slate-500 hover:bg-slate-800'}`}
|
||||
>
|
||||
Light
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,27 +207,39 @@ const Playground = ({ apiKeys }: PlaygroundProps) => {
|
||||
</div>
|
||||
|
||||
{/* Map View */}
|
||||
<div className="lg:col-span-3 glass rounded-3xl overflow-hidden relative group">
|
||||
<div ref={mapContainer} className="absolute inset-0" />
|
||||
|
||||
<div className="lg:col-span-3 glass rounded-3xl overflow-hidden relative group" style={{ minHeight: '600px' }}>
|
||||
<div ref={mapContainer} className="absolute inset-0 w-full h-full" style={{ height: '600px', width: '100%' }} />
|
||||
|
||||
{mapError && (
|
||||
<div className="absolute inset-0 bg-black/80 flex items-center justify-center p-6 z-50">
|
||||
<div className="bg-red-500/10 border border-red-500 p-4 rounded-xl text-red-500 max-w-xl text-center">
|
||||
<h3 className="font-bold mb-2">Map Error</h3>
|
||||
<p className="text-sm font-mono break-all">{mapError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map Overlay Search */}
|
||||
<div className="absolute top-6 left-6 w-full max-w-sm">
|
||||
<div className="relative">
|
||||
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search Amman, Jordan..."
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search Amman, Jordan..."
|
||||
className="w-full bg-slate-950/80 backdrop-blur-md border border-slate-800 rounded-2xl px-12 py-4 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 shadow-2xl"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Controls */}
|
||||
<div className="absolute bottom-6 left-6 flex gap-2">
|
||||
<button className="glass p-3 rounded-xl hover:bg-white/10 transition-colors">
|
||||
<button onClick={toggleFullScreen} className="glass p-3 rounded-xl hover:bg-white/10 transition-colors" title="Toggle Fullscreen">
|
||||
<Maximize2 size={18} />
|
||||
</button>
|
||||
<button className="glass p-3 rounded-xl hover:bg-white/10 transition-colors">
|
||||
<button onClick={resetMap} className="glass p-3 rounded-xl hover:bg-white/10 transition-colors" title="Reset View">
|
||||
<MapIcon size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -378,7 +378,7 @@
|
||||
container: 'map',
|
||||
center: ROUTES.LEVANT.center,
|
||||
zoom: ROUTES.LEVANT.zoom,
|
||||
style: './style.json', // ← style.json fixed (no duplicate IDs)
|
||||
style: `${API_BASE}/api/maps/style.json?api_key=${API_KEY}`,
|
||||
pitch: 0,
|
||||
bearing: 0,
|
||||
attributionControl: false
|
||||
|
||||
+2
-4
@@ -158,7 +158,7 @@ services:
|
||||
- api
|
||||
- martin
|
||||
|
||||
# Commercial Dashboard: React/Vite
|
||||
# Commercial Dashboard: Vanilla HTML/CSS/JS (High Performance)
|
||||
dashboard:
|
||||
build:
|
||||
context: .
|
||||
@@ -166,9 +166,7 @@ services:
|
||||
container_name: map-dashboard
|
||||
platform: linux/amd64
|
||||
ports:
|
||||
- "3204:5173"
|
||||
environment:
|
||||
- VITE_API_URL=http://api:3200
|
||||
- "3204:80"
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
FROM node:20-alpine
|
||||
FROM nginx:alpine
|
||||
|
||||
WORKDIR /app
|
||||
# Remove default nginx static assets
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
# Install dependencies placeholder
|
||||
COPY apps/dashboard/package*.json ./
|
||||
RUN npm install
|
||||
# Copy static assets into nginx
|
||||
COPY apps/dashboard/index.html /usr/share/nginx/html/
|
||||
COPY apps/dashboard/js /usr/share/nginx/html/js/
|
||||
COPY apps/dashboard/css /usr/share/nginx/html/css/
|
||||
|
||||
# Copy source
|
||||
COPY apps/dashboard/ .
|
||||
# Fix networking issues by adding a simple redirect for spa if needed
|
||||
# But for hash-based routing, default index.html is enough.
|
||||
# We also need to handle the API proxy if we want to avoid CORS issues
|
||||
# However, the user's docker-compose has the API and Dashboard on different ports.
|
||||
# In a real production, we'd use Nginx to proxy /api to the backend.
|
||||
|
||||
# Build for production
|
||||
RUN npm run build
|
||||
COPY infrastructure/docker/dashboard/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# We use a simple static server or just serve from Vite for demo
|
||||
# For production, we can use nginx, but let's stick to dev/preview mode for now
|
||||
# per user request to see "what happens"
|
||||
CMD ["npm", "run", "preview", "--", "--host", "--port", "5173"]
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to the api service in docker
|
||||
location /api/ {
|
||||
proxy_pass http://api:3200/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Proxy Martin Vector Tiles (if accessed via dashboard directly)
|
||||
location /tiles/ {
|
||||
proxy_pass http://martin:3000/;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
+2862
File diff suppressed because it is too large
Load Diff
+1213
-152
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,8 @@ rsync -avz --progress -e "ssh -o StrictHostKeyChecking=no" $KEY_FLAG \
|
||||
docker-compose.yml \
|
||||
docker-compose.map2.yml \
|
||||
setup_map2.sh \
|
||||
style.json \
|
||||
style-dark.json \
|
||||
apps \
|
||||
packages \
|
||||
infrastructure \
|
||||
|
||||
Reference in New Issue
Block a user