localradar / services /admin_html.py
GitHub Actions
LocalRadar Deployment (Clean State)
6d2410d
Raw
History Blame Contribute Delete
16.6 kB
ADMIN_HTML_CONTENT = """<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LocalRadar - Admin Analytics Dashboard</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- FontAwesome Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Outfit', sans-serif;
background-color: #0f172a;
color: #f1f5f9;
}
</style>
</head>
<body class="min-h-screen flex flex-col">
<!-- 1. Password Gate Screen -->
<div id="loginGate" class="flex-1 flex items-center justify-center p-6">
<div class="bg-slate-800 rounded-3xl p-8 max-w-sm w-full shadow-2xl border border-slate-700/50 text-center">
<div class="w-16 h-16 bg-indigo-500/10 rounded-full flex items-center justify-center mx-auto mb-6">
<i class="fa-solid fa-lock text-indigo-400 text-3xl"></i>
</div>
<h1 class="text-2xl font-bold mb-2">Admin Portal</h1>
<p class="text-slate-400 text-sm mb-6">๊ด€๋ฆฌ์ž ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.</p>
<div class="mb-4 text-left">
<label class="block text-slate-300 text-xs font-semibold mb-2" for="password">Password</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-slate-500">
<i class="fa-solid fa-key"></i>
</span>
<input type="password" id="password" class="w-full bg-slate-900 border border-slate-700 rounded-xl py-3 pl-10 pr-4 text-sm focus:outline-none focus:border-indigo-500 transition-colors" placeholder="๋น„๋ฐ€๋ฒˆํ˜ธ ์ž…๋ ฅ" onkeydown="if(event.key === 'Enter') login()">
</div>
<p id="errorMsg" class="text-rose-500 text-xs mt-2 hidden">๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.</p>
</div>
<button onclick="login()" class="w-full bg-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 text-white font-semibold py-3 rounded-xl transition-all shadow-lg shadow-indigo-600/30">
๋กœ๊ทธ์ธ
</button>
</div>
</div>
<!-- 2. Main Dashboard (Initially Hidden) -->
<div id="dashboard" class="hidden flex-1 flex flex-col">
<!-- Header -->
<header class="border-b border-slate-800 bg-slate-900/50 backdrop-blur-md sticky top-0 z-10 px-6 py-4 flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-indigo-600/10 rounded-xl flex items-center justify-center">
<i class="fa-solid fa-chart-line text-indigo-400 text-xl"></i>
</div>
<h1 class="text-xl font-bold">LocalRadar Admin</h1>
</div>
<div class="flex items-center space-x-4">
<button onclick="refreshData()" class="p-2.5 bg-slate-800 hover:bg-slate-700 active:bg-slate-900 rounded-xl text-slate-300 hover:text-white transition-all">
<i class="fa-solid fa-rotate"></i>
</button>
<button onclick="logout()" class="px-4 py-2 bg-rose-600/10 hover:bg-rose-600 text-rose-400 hover:text-white rounded-xl text-sm font-semibold transition-all">
<i class="fa-solid fa-right-from-bracket mr-1.5"></i> ๋กœ๊ทธ์•„์›ƒ
</button>
</div>
</header>
<!-- Content -->
<main class="flex-1 p-6 max-w-6xl w-full mx-auto space-y-8">
<!-- Cards Grid -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Card 1 -->
<div class="bg-slate-800 rounded-2xl p-6 shadow-xl border border-slate-700/30 flex items-center space-x-4">
<div class="p-4 bg-emerald-500/10 rounded-xl">
<i class="fa-solid fa-users text-emerald-400 text-2xl"></i>
</div>
<div>
<div class="flex items-center space-x-2 text-slate-400 text-xs">
<span>Active Users (5m)</span>
<span class="w-2.5 h-2.5 bg-emerald-500 rounded-full animate-pulse"></span>
</div>
<h2 id="activeUsers" class="text-3xl font-bold mt-1">0</h2>
</div>
</div>
<!-- Card 2 -->
<div class="bg-slate-800 rounded-2xl p-6 shadow-xl border border-slate-700/30 flex items-center space-x-4">
<div class="p-4 bg-indigo-500/10 rounded-xl">
<i class="fa-solid fa-calendar-day text-indigo-400 text-2xl"></i>
</div>
<div>
<p class="text-slate-400 text-xs">Today's Views</p>
<h2 id="todayViews" class="text-3xl font-bold mt-1">0</h2>
</div>
</div>
<!-- Card 3 -->
<div class="bg-slate-800 rounded-2xl p-6 shadow-xl border border-slate-700/30 flex items-center space-x-4">
<div class="p-4 bg-amber-500/10 rounded-xl">
<i class="fa-solid fa-chart-simple text-amber-400 text-2xl"></i>
</div>
<div>
<p class="text-slate-400 text-xs">Total Views</p>
<h2 id="totalViews" class="text-3xl font-bold mt-1">0</h2>
</div>
</div>
</div>
<!-- Detailed Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Languages Distribution -->
<div class="bg-slate-800 rounded-3xl p-6 shadow-xl border border-slate-700/30">
<h3 class="text-lg font-bold mb-6 flex items-center">
<i class="fa-solid fa-globe text-indigo-400 mr-2.5"></i> User Languages
</h3>
<div id="languagesList" class="space-y-4">
<!-- Filled dynamically -->
</div>
</div>
<!-- Top Visited Events -->
<div class="bg-slate-800 rounded-3xl p-6 shadow-xl border border-slate-700/30">
<h3 class="text-lg font-bold mb-6 flex items-center">
<i class="fa-solid fa-fire text-amber-400 mr-2.5"></i> Top Visited Events
</h3>
<div id="topEventsList" class="space-y-3">
<!-- Filled dynamically -->
</div>
</div>
</div>
<!-- Funnel Section -->
<div class="bg-slate-800 rounded-3xl p-6 shadow-xl border border-slate-700/30">
<h3 class="text-lg font-bold mb-6 flex items-center">
<i class="fa-solid fa-filter text-indigo-400 mr-2.5"></i> SEO & App Acquisition Funnel
</h3>
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="border-b border-slate-700 text-slate-400 text-sm font-semibold">
<th class="py-3 px-4">Language</th>
<th class="py-3 px-4">SEO Page Views</th>
<th class="py-3 px-4">SEO Clicks (CTA)</th>
<th class="py-3 px-4">App Conversions</th>
<th class="py-3 px-4">Conversion Rate</th>
</tr>
</thead>
<tbody id="funnelRows" class="text-sm divide-y divide-slate-800">
<!-- Filled dynamically -->
</tbody>
</table>
</div>
</div>
</main>
</div>
<!-- Scripts -->
<script>
const flagMap = {
'ko': '๐Ÿ‡ฐ๐Ÿ‡ท KO',
'en': '๐Ÿ‡บ๐Ÿ‡ธ EN',
'es': '๐Ÿ‡ช๐Ÿ‡ธ ES',
'zh': '๐Ÿ‡จ๐Ÿ‡ณ ZH',
'ja': '๐Ÿ‡ฏ๐Ÿ‡ต JA',
'ru': '๐Ÿ‡ท๐Ÿ‡บ RU',
'fr': '๐Ÿ‡ซ๐Ÿ‡ท FR'
};
// Check if already authenticated on load
window.onload = function() {
const token = localStorage.getItem('admin_token');
if (token === 'localradar2026') {
showDashboard();
}
};
function login() {
const pwd = document.getElementById('password').value;
const errorMsg = document.getElementById('errorMsg');
if (pwd === 'localradar2026') {
localStorage.setItem('admin_token', pwd);
errorMsg.classList.add('hidden');
showDashboard();
} else {
errorMsg.classList.remove('hidden');
}
}
function logout() {
localStorage.removeItem('admin_token');
document.getElementById('password').value = '';
document.getElementById('loginGate').classList.remove('hidden');
document.getElementById('dashboard').classList.add('hidden');
}
function showDashboard() {
document.getElementById('loginGate').classList.add('hidden');
document.getElementById('dashboard').classList.remove('hidden');
refreshData();
// Auto refresh every 8 seconds
setInterval(refreshData, 8000);
}
function refreshData() {
fetch('/analytics/stats')
.then(res => res.json())
.then(data => {
// Update main cards
document.getElementById('activeUsers').innerText = data.active_users || 0;
document.getElementById('todayViews').innerText = data.today_views || 0;
document.getElementById('totalViews').innerText = data.total_views || 0;
// Update Languages
const langsDiv = document.getElementById('languagesList');
langsDiv.innerHTML = '';
const totalLangs = Object.values(data.languages || {}).reduce((a, b) => a + b, 0);
const sortedLangs = Object.entries(data.languages || {}).sort((a, b) => b[1] - a[1]);
if (sortedLangs.length === 0) {
langsDiv.innerHTML = '<p class="text-slate-500 text-sm">๊ธฐ๋ก๋œ ์–ธ์–ด ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</p>';
} else {
sortedLangs.forEach(([lang, count]) => {
const pct = totalLangs > 0 ? (count / totalLangs * 100).toFixed(1) : 0;
const flag = flagMap[lang.toLowerCase()] || lang.toUpperCase();
langsDiv.innerHTML += `
<div>
<div class="flex justify-between text-sm mb-1.5 font-semibold">
<span>${flag}</span>
<span class="text-slate-400">${count} (${pct}%)</span>
</div>
<div class="w-full bg-slate-900 rounded-full h-2.5">
<div class="bg-indigo-500 h-2.5 rounded-full" style="width: ${pct}%"></div>
</div>
</div>
`;
});
}
// Update Funnel Table
const funnelRows = document.getElementById('funnelRows');
funnelRows.innerHTML = '';
const funnelData = data.funnel || {};
const sortedFunnel = Object.entries(funnelData).sort((a, b) => b[1].seo_views - a[1].seo_views);
if (sortedFunnel.length === 0) {
funnelRows.innerHTML = '<tr><td colspan="5" class="py-4 text-center text-slate-500">๊ธฐ๋ก๋œ ํผ๋„ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</td></tr>';
} else {
sortedFunnel.forEach(([lang, stats]) => {
const views = stats.seo_views || 0;
const clicks = stats.seo_clicks || 0;
const convs = stats.app_conversions || 0;
const rate = views > 0 ? ((convs / views) * 100).toFixed(1) : '0.0';
const flag = flagMap[lang.toLowerCase()] || lang.toUpperCase();
funnelRows.innerHTML += `
<tr class="hover:bg-slate-900/30 transition-colors border-b border-slate-800">
<td class="py-3 px-4 font-semibold text-white">${flag}</td>
<td class="py-3 px-4 text-slate-300">${views.toLocaleString()}</td>
<td class="py-3 px-4 text-slate-300">${clicks.toLocaleString()}</td>
<td class="py-3 px-4 text-emerald-400 font-semibold">${convs.toLocaleString()}</td>
<td class="py-3 px-4">
<div class="flex items-center space-x-2">
<span class="font-bold text-indigo-400">${rate}%</span>
<div class="w-20 bg-slate-900 rounded-full h-1.5 shrink-0 hidden sm:block">
<div class="bg-indigo-500 h-1.5 rounded-full" style="width: ${Math.min(parseFloat(rate), 100)}%"></div>
</div>
</div>
</td>
</tr>
`;
});
}
// Update Top Events
const eventsDiv = document.getElementById('topEventsList');
eventsDiv.innerHTML = '';
const topEvents = data.top_events || [];
if (topEvents.length === 0) {
eventsDiv.innerHTML = '<p class="text-slate-500 text-sm">๊ธฐ๋ก๋œ ์ด๋ฒคํŠธ ์กฐํšŒ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</p>';
} else {
topEvents.forEach((item, idx) => {
eventsDiv.innerHTML += `
<div class="flex items-center justify-between p-3.5 bg-slate-900/40 border border-slate-700/20 rounded-xl">
<div class="flex items-center space-x-3 truncate">
<span class="w-7 h-7 bg-indigo-500/10 rounded-full flex items-center justify-center text-xs font-bold text-indigo-400">
${idx + 1}
</span>
<div class="truncate">
<div class="font-semibold text-sm truncate" title="${item.title}">${item.title}</div>
<div class="text-slate-500 text-[10px] truncate">${item.languages || ''}</div>
</div>
</div>
<span class="px-2.5 py-1 bg-indigo-500/15 text-indigo-400 text-xs font-bold rounded-lg shrink-0">
${item.count} views
</span>
</div>
`;
});
}
})
.catch(err => {
console.error("๋ฐ์ดํ„ฐ ๋กœ๋“œ ์˜ค๋ฅ˜: ", err);
});
}
</script>
</body>
</html>
"""