v1.0.1
PriviMetrics 2
- <?php
-
- declare(strict_types=1);
- require_once __DIR__ . '/functions.php';
- require_once __DIR__ . '/font-options.php';
-
- if (!envIsInstalled()) {
- header('Location: install.php');
- exit;
- }
- require_once __DIR__ . '/storage.php';
-
- requireLogin();
- pmSecurityHeaders();
-
- $storage = new StorageManager();
- $csrfToken = generateCSRFToken();
- $flash = null;
-
- if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'reorder_sites') {
- header('Content-Type: application/json');
- if (!verifyCSRFToken($_POST['csrf_token'] ?? null)) {
- http_response_code(403);
- echo json_encode(['ok' => false, 'error' => 'Invalid security token.']);
- exit;
- }
- $orderedIds = array_map('sanitize', (array) ($_POST['order'] ?? []));
- $ok = $storage->saveSiteOrder($orderedIds);
- echo json_encode(['ok' => $ok]);
- exit;
- }
-
- if ($_SERVER['REQUEST_METHOD'] === 'POST') {
- if (!verifyCSRFToken($_POST['csrf_token'] ?? null)) {
- $flash = ['type' => 'error', 'text' => 'Invalid security token.'];
- } else {
- $action = $_POST['action'] ?? '';
-
- if ($action === 'create_site') {
- $name = sanitize($_POST['name'] ?? '');
- $domain = trim((string) ($_POST['domain'] ?? ''));
- $storageType = in_array($_POST['storage'] ?? '', ['xml', 'mysql', 'pmaf'], true) ? $_POST['storage'] : 'xml';
- $restrictionMode = in_array($_POST['restriction_mode'] ?? '', ['full', 'main', 'none'], true) ? $_POST['restriction_mode'] : 'full';
- $trafficSourcesMode = pmValidFeatureMode($_POST['traffic_sources_mode'] ?? 'inherit');
- $visitorCountriesMode = pmValidFeatureMode($_POST['visitor_countries_mode'] ?? 'inherit');
- $searchQueriesMode = pmValidFeatureMode($_POST['search_queries_mode'] ?? 'inherit');
-
- if ($storageType === 'mysql' && !$storage->isMySQLAvailable()) {
- $flash = ['type' => 'error', 'text' => 'MySQL is not configured. Enable it in Settings or choose XML.'];
- } elseif ($name === '' || !validateDomain($domain)) {
- $flash = ['type' => 'error', 'text' => 'Please provide a valid name and domain (e.g. example.com).'];
- } else {
- $domain = preg_replace('#^https?://#', '', $domain);
- $domain = rtrim($domain, '/');
- $storage->createSite($name, $domain, $storageType, $restrictionMode, $trafficSourcesMode, $visitorCountriesMode, $searchQueriesMode);
- $flash = ['type' => 'success', 'text' => 'Site added successfully.'];
- }
- } elseif ($action === 'update_site') {
- $id = sanitize($_POST['id'] ?? '');
- $name = sanitize($_POST['name'] ?? '');
- $domain = preg_replace('#^https?://#', '', trim((string) ($_POST['domain'] ?? '')));
- $domain = rtrim($domain, '/');
- $restrictionMode = in_array($_POST['restriction_mode'] ?? '', ['full', 'main', 'none'], true) ? $_POST['restriction_mode'] : 'full';
- $trafficSourcesMode = pmValidFeatureMode($_POST['traffic_sources_mode'] ?? 'inherit');
- $visitorCountriesMode = pmValidFeatureMode($_POST['visitor_countries_mode'] ?? 'inherit');
- $searchQueriesMode = pmValidFeatureMode($_POST['search_queries_mode'] ?? 'inherit');
- $active = isset($_POST['active']) ? 1 : 0;
-
- if ($id === '' || $name === '' || !validateDomain($domain)) {
- $flash = ['type' => 'error', 'text' => 'Please provide a valid name and domain.'];
- } else {
- $storage->updateSite($id, [
- 'name' => $name,
- 'domain' => $domain,
- 'restriction_mode' => $restrictionMode,
- 'traffic_sources_mode' => $trafficSourcesMode,
- 'visitor_countries_mode' => $visitorCountriesMode,
- 'search_queries_mode' => $searchQueriesMode,
- 'active' => $active,
- ]);
- $flash = ['type' => 'success', 'text' => 'Site updated successfully.'];
- }
- } elseif ($action === 'delete_site') {
- $id = sanitize($_POST['id'] ?? '');
- if ($id !== '' && $storage->deleteSite($id)) {
- $flash = ['type' => 'success', 'text' => 'Site and its data have been deleted.'];
- }
- }
- }
- }
-
- $sites = $storage->listSites();
- $activeSiteId = sanitize($_GET['site'] ?? ($sites[0]['id'] ?? ''));
- $rangeParam = $_GET['range'] ?? '7d';
- $range = in_array($rangeParam, ['24h', '7d', '30d', '90d', '1y'], true) ? $rangeParam : '7d';
- $viewParam = $_GET['view'] ?? 'overview';
- $view = in_array($viewParam, ['overview', 'sites'], true) ? $viewParam : 'overview';
-
- $dateRange = getDateRange($range);
- $currentSite = null;
- foreach ($sites as $s) {
- if ($s['id'] === $activeSiteId) {
- $currentSite = $s;
- break;
- }
- }
-
- $hits = [];
- $stats = [
- 'total_views' => 0,
- 'unique_visitors' => 0,
- 'top_pages' => [],
- 'top_referrers' => [],
- 'top_utm_sources' => [],
- 'top_countries' => [],
- 'top_searches' => [],
- 'top_devices' => [],
- 'timeline' => [],
- 'timeline_future' => [],
- ];
-
- $featureTrafficSources = pmFeatureEnabledForSite($currentSite, 'traffic_sources');
- $featureVisitorCountries = pmFeatureEnabledForSite($currentSite, 'visitor_countries');
- $featureSearchQueries = pmFeatureEnabledForSite($currentSite, 'search_queries');
- $featureDeviceType = pmFeatureGlobalEnabled('device_type');
-
- if ($currentSite) {
- $hits = $storage->getHits($currentSite, $dateRange['start'], $dateRange['end']);
-
- $uniqueHashes = [];
- $pages = [];
- $referrers = [];
- $utmSources = [];
- $countries = [];
- $searches = [];
- $devices = [];
- $timeline = [];
-
- $bucketFormat = $range === '24h' ? 'H:00' : ($range === '1y' ? 'Y-m' : 'Y-m-d');
-
- foreach ($hits as $hit) {
- $uniqueHashes[$hit['user_hash']] = true;
-
- $page = $hit['page_url'] ?: '(unknown)';
- $pages[$page] = ($pages[$page] ?? 0) + 1;
-
- if ($featureTrafficSources) {
- if (!empty($hit['utm_source'])) {
- $utmSources[$hit['utm_source']] = ($utmSources[$hit['utm_source']] ?? 0) + 1;
- }
- if (!empty($hit['referrer'])) {
- $referrers[$hit['referrer']] = ($referrers[$hit['referrer']] ?? 0) + 1;
- }
- }
-
- if ($featureVisitorCountries) {
- $country = $hit['country'] ?: 'Unknown';
- $countries[$country] = ($countries[$country] ?? 0) + 1;
- }
-
- if ($featureSearchQueries && !empty($hit['search_query'])) {
- $searches[$hit['search_query']] = ($searches[$hit['search_query']] ?? 0) + 1;
- }
-
- if ($featureDeviceType) {
- $device = deviceTypeFromUserAgent($hit['user_agent'] ?? '');
- $devices[$device] = ($devices[$device] ?? 0) + 1;
- }
-
- $bucket = date($bucketFormat, (int) $hit['ts']);
- $timeline[$bucket] = ($timeline[$bucket] ?? 0) + 1;
- }
-
- arsort($pages);
- arsort($referrers);
- arsort($utmSources);
- arsort($countries);
- arsort($searches);
- arsort($devices);
-
- $stats['total_views'] = count($hits);
- $stats['unique_visitors'] = count($uniqueHashes);
- $topListsLimit = max(1, min(50, (int) env('TOP_LISTS_LIMIT', '8')));
- $stats['top_pages'] = array_slice($pages, 0, $topListsLimit, true);
- $stats['top_referrers'] = array_slice($referrers, 0, $topListsLimit, true);
- $stats['top_utm_sources'] = array_slice($utmSources, 0, $topListsLimit, true);
- $stats['top_countries'] = array_slice($countries, 0, $topListsLimit, true);
- $stats['top_searches'] = array_slice($searches, 0, $topListsLimit, true);
- $stats['top_devices'] = $devices;
-
- $filledTimeline = [];
- $timelineFuture = [];
- $nowTs = time();
- if ($range === '24h') {
- $todayStart = strtotime('today 00:00:00');
- for ($h = 0; $h < 24; $h++) {
- $key = sprintf('%02d:00', $h);
- $filledTimeline[$key] = $timeline[$key] ?? 0;
- $timelineFuture[$key] = ($todayStart + $h * 3600) > $nowTs;
- }
- } elseif ($range === '1y') {
- for ($i = 11; $i >= 0; $i--) {
- $key = date('Y-m', strtotime("-{$i} months"));
- $filledTimeline[$key] = $timeline[$key] ?? 0;
- $timelineFuture[$key] = strtotime($key . '-01 00:00:00') > $nowTs;
- }
- } else {
- $days = $range === '7d' ? 7 : ($range === '30d' ? 30 : 90);
- for ($i = $days - 1; $i >= 0; $i--) {
- $key = date('Y-m-d', strtotime("-{$i} days"));
- $filledTimeline[$key] = $timeline[$key] ?? 0;
- $timelineFuture[$key] = strtotime($key . ' 00:00:00') > $nowTs;
- }
- }
- $stats['timeline'] = $filledTimeline;
- $stats['timeline_future'] = $timelineFuture;
- }
-
- $totalHitsAllSites = $storage->countAllHits($sites);
- $siteName = env('SITE_NAME', 'PriviMetrics');
- $theme = env('DEFAULT_THEME', 'dark');
- $baseUrl = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? 'yourdomain.com');
-
- function truncateText(string $text, int $limit = 120): string
- {
- if (mb_strlen($text) <= $limit) {
- return $text;
- }
- return rtrim(mb_substr($text, 0, $limit)) . '...';
- }
-
- function rankMedalSvg(): string
- {
- return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.21 13.89 7 23l5-3 5 3-1.21-9.12"></path><circle cx="12" cy="8" r="7"></circle></svg>';
- }
-
- function deviceTypeFromUserAgent(string $userAgent): string
- {
- $ua = strtolower($userAgent);
- if ($ua === '' || $ua === 'unknown') {
- return 'Unknown';
- }
-
- if (
- str_contains($ua, 'ipad')
- || (str_contains($ua, 'android') && !str_contains($ua, 'mobile'))
- || str_contains($ua, 'tablet')
- || str_contains($ua, 'kindle')
- || str_contains($ua, 'playbook')
- ) {
- return 'Tablet';
- }
-
- if (
- str_contains($ua, 'mobi')
- || str_contains($ua, 'iphone')
- || str_contains($ua, 'ipod')
- || str_contains($ua, 'android')
- || str_contains($ua, 'blackberry')
- || str_contains($ua, 'windows phone')
- ) {
- return 'Mobile';
- }
-
- return 'Desktop';
- }
-
- function countryToIso2(string $country): ?string
- {
- static $map = [
- 'Poland' => 'PL',
- 'Germany' => 'DE',
- 'United States' => 'US',
- 'United Kingdom' => 'GB',
- 'France' => 'FR',
- 'Spain' => 'ES',
- 'Italy' => 'IT',
- 'Netherlands' => 'NL',
- 'Belgium' => 'BE',
- 'Portugal' => 'PT',
- 'Sweden' => 'SE',
- 'Norway' => 'NO',
- 'Denmark' => 'DK',
- 'Finland' => 'FI',
- 'Ireland' => 'IE',
- 'Switzerland' => 'CH',
- 'Austria' => 'AT',
- 'Czech Republic' => 'CZ',
- 'Czechia' => 'CZ',
- 'Slovakia' => 'SK',
- 'Hungary' => 'HU',
- 'Romania' => 'RO',
- 'Bulgaria' => 'BG',
- 'Greece' => 'GR',
- 'Ukraine' => 'UA',
- 'Russia' => 'RU',
- 'Belarus' => 'BY',
- 'Lithuania' => 'LT',
- 'Latvia' => 'LV',
- 'Estonia' => 'EE',
- 'Turkey' => 'TR',
- 'China' => 'CN',
- 'Japan' => 'JP',
- 'South Korea' => 'KR',
- 'India' => 'IN',
- 'Brazil' => 'BR',
- 'Canada' => 'CA',
- 'Mexico' => 'MX',
- 'Australia' => 'AU',
- 'New Zealand' => 'NZ',
- 'South Africa' => 'ZA',
- 'Egypt' => 'EG',
- 'Israel' => 'IL',
- 'United Arab Emirates' => 'AE',
- 'Saudi Arabia' => 'SA',
- 'Indonesia' => 'ID',
- 'Vietnam' => 'VN',
- 'Thailand' => 'TH',
- 'Philippines' => 'PH',
- 'Malaysia' => 'MY',
- 'Singapore' => 'SG',
- 'Pakistan' => 'PK',
- 'Bangladesh' => 'BD',
- 'Argentina' => 'AR',
- 'Chile' => 'CL',
- 'Colombia' => 'CO',
- 'Peru' => 'PE',
- 'Croatia' => 'HR',
- 'Slovenia' => 'SI',
- 'Serbia' => 'RS',
- 'Iceland' => 'IS',
- 'Luxembourg' => 'LU',
- 'Afghanistan' => 'AF',
- 'Albania' => 'AL',
- 'Algeria' => 'DZ',
- 'American Samoa' => 'AS',
- 'Andorra' => 'AD',
- 'Angola' => 'AO',
- 'Armenia' => 'AM',
- 'Azerbaijan' => 'AZ',
- 'Bahamas' => 'BS',
- 'Bahrain' => 'BH',
- 'Belize' => 'BZ',
- 'Bermuda' => 'BM',
- 'Bhutan' => 'BT',
- 'Bolivia' => 'BO',
- 'Bosnia and Herzegovina' => 'BA',
- 'Botswana' => 'BW',
- 'Brunei' => 'BN',
- 'Cambodia' => 'KH',
- 'Cameroon' => 'CM',
- 'Costa Rica' => 'CR',
- 'Cuba' => 'CU',
- 'Cyprus' => 'CY',
- 'Dominican Republic' => 'DO',
- 'Ecuador' => 'EC',
- 'El Salvador' => 'SV',
- 'Eswatini' => 'SZ',
- 'Ethiopia' => 'ET',
- 'Faroe Islands' => 'FO',
- 'French Guiana' => 'GF',
- 'Georgia' => 'GE',
- 'Ghana' => 'GH',
- 'Greenland' => 'GL',
- 'Guatemala' => 'GT',
- 'Haiti' => 'HT',
- 'Honduras' => 'HN',
- 'Hong Kong' => 'HK',
- 'Iran' => 'IR',
- 'Iraq' => 'IQ',
- 'Jamaica' => 'JM',
- 'Jordan' => 'JO',
- 'Kazakhstan' => 'KZ',
- 'Kenya' => 'KE',
- 'Kuwait' => 'KW',
- 'Kyrgyzstan' => 'KG',
- 'Laos' => 'LA',
- 'Lebanon' => 'LB',
- 'Libya' => 'LY',
- 'Macao' => 'MO',
- 'Madagascar' => 'MG',
- 'Malawi' => 'MW',
- 'Maldives' => 'MV',
- 'Malta' => 'MT',
- 'Moldova' => 'MD',
- 'Monaco' => 'MC',
- 'Mongolia' => 'MN',
- 'Morocco' => 'MA',
- 'Mozambique' => 'MZ',
- 'Myanmar' => 'MM',
- 'Namibia' => 'NA',
- 'Nepal' => 'NP',
- 'New Caledonia' => 'NC',
- 'Nicaragua' => 'NI',
- 'Nigeria' => 'NG',
- 'North Korea' => 'KP',
- 'North Macedonia' => 'MK',
- 'Oman' => 'OM',
- 'Panama' => 'PA',
- 'Papua New Guinea' => 'PG',
- 'Paraguay' => 'PY',
- 'Qatar' => 'QA',
- 'Rwanda' => 'RW',
- 'Samoa' => 'WS',
- 'Senegal' => 'SN',
- 'Solomon Islands' => 'SB',
- 'Somalia' => 'SO',
- 'Sri Lanka' => 'LK',
- 'Sudan' => 'SD',
- 'Suriname' => 'SR',
- 'Syria' => 'SY',
- 'Taiwan' => 'TW',
- 'Tajikistan' => 'TJ',
- 'Tanzania' => 'TZ',
- 'Timor-Leste' => 'TL',
- 'Tonga' => 'TO',
- 'Trinidad and Tobago' => 'TT',
- 'Tunisia' => 'TN',
- 'Uganda' => 'UG',
- 'Uruguay' => 'UY',
- 'Uzbekistan' => 'UZ',
- 'Venezuela' => 'VE',
- 'Yemen' => 'YE',
- 'Zambia' => 'ZM',
- 'Zimbabwe' => 'ZW',
- ];
- return $map[$country] ?? null;
- }
-
- function countryFlagHtml(string $country): string
- {
- $iso = countryToIso2($country);
- if ($iso === null) {
- return '';
- }
- $flagFileOverrides = [
- 'GB' => 'GB-UKM',
- ];
- $flagFile = $flagFileOverrides[$iso] ?? $iso;
- $url = 'https://cdn.privimetrics.com/flag/' . $flagFile . '.svg';
- return '<img class="flag" src="' . sanitize($url) . '" alt="" loading="lazy">';
- }
- ?>
- <!DOCTYPE html>
- <html lang="en" data-theme="<?= sanitize($theme) ?>">
-
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Dashboard - <?= sanitize($siteName) ?></title>
- <?= fontFaceStyleTag() ?>
- <script>
- (function() {
- var t = localStorage.getItem('pm_theme') || '<?= sanitize($theme) ?>';
- document.documentElement.setAttribute('data-theme', t);
-
- if (sessionStorage.getItem('pm_intro_played')) {
- document.documentElement.setAttribute('data-no-intro', '1');
- } else {
- sessionStorage.setItem('pm_intro_played', '1');
- }
- })();
- </script>
- <style>
- :root {
- --font-body: <?= fontFamilyCss('body') ?>;
- --font-heading: <?= fontFamilyCss('heading') ?>;
- --font-value: <?= fontFamilyCss('value') ?>;
- }
-
- :root[data-theme="dark"] {
- --bg-primary: #0a0a0a;
- --bg-secondary: #141414;
- --bg-tertiary: #1c1c1c;
- --bg-hover: #222222;
- --border-color: #262626;
- --text-primary: #eaeaea;
- --text-secondary: #9a9a9a;
- --text-tertiary: #6b6b6b;
- --accent: #f1484e;
- --accent-hover: #d53b40;
- --accent-soft: rgba(241, 72, 78, 0.12);
- --success: #22c55e;
- --success-soft: rgba(34, 197, 94, 0.12);
- --info: #3b82f6;
- --info-soft: rgba(59, 130, 246, 0.12);
- --shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
- }
-
- :root[data-theme="light"] {
- --bg-primary: #f7f7f8;
- --bg-secondary: #ffffff;
- --bg-tertiary: #f2f2f3;
- --bg-hover: #ececed;
- --border-color: #e5e5e7;
- --text-primary: #16171a;
- --text-secondary: #6b6d73;
- --text-tertiary: #9799a0;
- --accent: #f1484e;
- --accent-hover: #d53b40;
- --accent-soft: rgba(241, 72, 78, 0.08);
- --success: #16a34a;
- --success-soft: rgba(22, 163, 74, 0.1);
- --info: #2563eb;
- --info-soft: rgba(37, 99, 235, 0.1);
- --shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
- }
-
- html {
- overflow-y: scroll;
- scrollbar-gutter: stable;
- }
-
- * {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
- }
-
- body {
- font-family: var(--font-body);
- background: var(--bg-primary);
- color: var(--text-primary);
- font-size: 14px;
- line-height: 1.5;
- }
-
- h1 {
- font-family: var(--font-heading);
- }
-
- a {
- color: inherit;
- text-decoration: none;
- }
-
- .app {
- display: flex;
- min-height: 100vh;
- }
-
- .sidebar {
- width: 250px;
- flex-shrink: 0;
- background: linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 94%, #fff) 0%, var(--bg-secondary) 100%);
- border: 1px solid var(--border-color);
- border-radius: 16px;
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 1px 2px rgba(0, 0, 0, 0.2), 0 12px 28px -14px rgba(0, 0, 0, 0.35);
- display: flex;
- flex-direction: column;
- padding: 18px 14px;
- position: sticky;
- top: 14px;
- align-self: flex-start;
- margin: 14px 0 14px 14px;
- height: calc(100vh - 28px);
- overflow-y: auto;
- scrollbar-width: none;
- -ms-overflow-style: none;
- z-index: 10;
- }
-
- .sidebar::-webkit-scrollbar {
- display: none;
- }
-
- .brand {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 6px 8px 18px;
- margin-bottom: 12px;
- border-bottom: 1px solid var(--border-color);
- }
-
- .brand .mark {
- width: 34px;
- height: 34px;
- border-radius: 9px;
- background: var(--accent);
- display: flex;
- align-items: center;
- justify-content: center;
- font-weight: 700;
- color: white;
- font-size: 14px;
- flex-shrink: 0;
- }
-
- .brand .name {
- font-family: var(--font-heading);
- font-weight: 600;
- font-size: 15px;
- }
-
- .brand .sub {
- font-size: 11px;
- color: var(--text-tertiary);
- }
-
- .nav {
- display: flex;
- flex-direction: column;
- gap: 2px;
- }
-
- .nav a {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 9px 12px;
- border-radius: 10px;
- font-size: 13.5px;
- font-weight: 500;
- color: var(--text-secondary);
- border: 1px solid transparent;
- transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
- }
-
- .nav a svg {
- width: 17px;
- height: 17px;
- flex-shrink: 0;
- }
-
- .nav a:hover {
- background: var(--bg-tertiary);
- color: var(--text-primary);
- transform: translateX(2px);
- }
-
- .nav a.active {
- background: var(--accent-soft);
- color: var(--accent);
- font-weight: 600;
- border-color: color-mix(in srgb, var(--accent) 22%, transparent);
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18);
- transform: none;
- }
-
- .sidebar-footer {
- margin-top: auto;
- padding-top: 14px;
- border-top: 1px solid var(--border-color);
- }
-
- .sidebar-footer a {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 9px 12px;
- border-radius: 8px;
- font-size: 13px;
- color: var(--text-secondary);
- }
-
- .sidebar-footer a:hover {
- background: var(--bg-hover);
- color: var(--text-primary);
- }
-
- .sidebar-footer .user {
- font-size: 12px;
- color: var(--text-tertiary);
- padding: 6px 12px 10px;
- }
-
- .main {
- flex: 1;
- min-width: 0;
- }
-
- .topbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 16px 28px;
- margin: 14px 14px 0;
- border: 1px solid var(--border-color);
- border-radius: 16px;
- background: linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 94%, #fff) 0%, var(--bg-secondary) 100%);
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18), 0 12px 28px -14px rgba(0, 0, 0, 0.35);
- flex-wrap: wrap;
- gap: 12px;
- min-height: 68px;
- }
-
- .topbar h1 {
- font-size: 17px;
- font-weight: 600;
- }
-
- .topbar-actions {
- display: flex;
- align-items: center;
- gap: 10px;
- min-height: 36px;
- }
-
- .select {
- height: 36px;
- padding: 0 12px;
- background: var(--bg-tertiary);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- color: var(--text-primary);
- font-size: 13px;
- cursor: pointer;
- box-sizing: border-box;
- line-height: 34px;
- }
-
- .icon-btn {
- width: 36px;
- height: 36px;
- display: flex;
- align-items: center;
- justify-content: center;
- background: var(--bg-tertiary);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- cursor: pointer;
- }
-
- .icon-btn:hover {
- background: var(--bg-hover);
- }
-
- .content {
- padding: 24px 28px 60px;
- max-width: 1200px;
- margin: 0 auto;
- }
-
- .mobile-topbar {
- display: none;
- }
-
- .mobile-menu-btn {
- width: 38px;
- height: 38px;
- display: flex;
- align-items: center;
- justify-content: center;
- background: var(--bg-tertiary);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- }
-
- @media (max-width: 880px) {
- .app {
- flex-direction: column;
- }
-
- .sidebar {
- position: fixed;
- top: auto;
- bottom: calc(8px + env(safe-area-inset-bottom));
- left: calc(8px + env(safe-area-inset-left));
- right: calc(8px + env(safe-area-inset-right));
- width: auto;
- height: auto;
- margin: 0;
- flex-direction: row;
- align-items: center;
- padding: 6px;
- border-radius: 24px;
- border-color: var(--border-color);
- box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.04);
- overflow: visible;
- z-index: 60;
- }
-
- .sidebar .brand,
- .sidebar-footer .user {
- display: none;
- }
-
- .nav {
- flex: 3 1 0;
- min-width: 0;
- flex-direction: row;
- justify-content: space-around;
- gap: 0;
- }
-
- .sidebar-footer {
- margin: 0;
- padding: 0;
- border: none;
- flex: 1 1 0;
- min-width: 0;
- display: flex;
- }
-
- .nav a,
- .sidebar-footer a {
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 3px;
- padding: 8px 4px;
- font-size: 10px;
- font-weight: 600;
- border-radius: 18px;
- text-align: center;
- flex: 1;
- transform: none;
- }
-
- .nav a:hover {
- transform: none;
- }
-
- .nav a svg {
- width: 20px;
- height: 20px;
- }
-
- .sidebar-footer a svg {
- width: 20px;
- height: 20px;
- }
-
- .sidebar-backdrop,
- .sidebar-backdrop.show {
- display: none;
- }
-
- .mobile-menu-btn {
- display: none;
- }
-
- .main {
- background: var(--bg-secondary);
- overflow-x: clip;
- }
-
- .mobile-topbar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 12px 16px;
- background: var(--bg-primary);
- position: sticky;
- top: 0;
- z-index: 30;
- }
-
- .mobile-topbar::after {
- content: "";
- position: absolute;
- left: 0;
- right: 0;
- top: calc(100% - 1px);
- height: 24px;
- pointer-events: none;
- background:
- radial-gradient(circle at 100% 100%, transparent 23.5px, var(--bg-primary) 24.5px) left top / 24px 24px no-repeat,
- radial-gradient(circle at 0 100%, transparent 23.5px, var(--bg-primary) 24.5px) right top / 24px 24px no-repeat;
- }
-
- .content {
- padding: 18px 16px 110px;
- }
-
- .topbar {
- padding: 14px 16px;
- margin: 12px 12px 0;
- }
- }
-
- .flash {
- padding: 12px 16px;
- border-radius: 10px;
- font-size: 13px;
- margin-bottom: 18px;
- display: flex;
- align-items: center;
- gap: 8px;
- }
-
- .flash.success {
- background: var(--success-soft);
- color: var(--success);
- border: 1px solid var(--success);
- }
-
- .flash.error {
- background: var(--accent-soft);
- color: var(--accent);
- border: 1px solid var(--accent);
- }
-
- .stat-grid {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 14px;
- margin-bottom: 20px;
- }
-
- @media (max-width: 880px) {
- .stat-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
- }
-
- .stat-card {
- position: relative;
- background: linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 92%, #fff) 0%, var(--bg-secondary) 100%);
- border: 1px solid var(--border-color);
- border-radius: 16px;
- padding: 20px 22px;
- opacity: 0;
- transform: translateY(8px);
- animation: statCardIn 0.4s ease forwards;
- transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18), 0 12px 28px -14px rgba(0, 0, 0, 0.35);
- }
-
- .stat-card::after {
- content: "";
- position: absolute;
- inset: 0 0 auto 0;
- height: 1px;
- background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent) 55%, transparent), transparent);
- opacity: 0;
- transition: opacity 0.2s ease;
- }
-
- .stat-card:hover {
- border-color: color-mix(in srgb, var(--accent) 45%, var(--border-color));
- transform: translateY(-2px);
- }
-
- .stat-card:hover::after {
- opacity: 1;
- }
-
- .stat-grid .stat-card:nth-child(1) {
- animation-delay: 0s;
- }
-
- .stat-grid .stat-card:nth-child(2) {
- animation-delay: 0.05s;
- }
-
- .stat-grid .stat-card:nth-child(3) {
- animation-delay: 0.1s;
- }
-
- .stat-grid .stat-card:nth-child(4) {
- animation-delay: 0.15s;
- }
-
- @keyframes statCardIn {
- to {
- opacity: 1;
- transform: translateY(0);
- }
- }
-
- @media (prefers-reduced-motion: reduce) {
- .stat-card {
- animation: none;
- opacity: 1;
- transform: none;
- }
- }
-
- :root[data-no-intro] .stat-card {
- animation: none;
- opacity: 1;
- transform: none;
- }
-
- .stat-card .label {
- font-size: 12px;
- color: var(--text-secondary);
- margin-bottom: 8px;
- transition: color 0.2s ease;
- }
-
- .stat-card .value {
- font-family: var(--font-value);
- font-size: 30px;
- line-height: 1.1;
- font-weight: 600;
- letter-spacing: -0.03em;
- font-variant-numeric: tabular-nums;
- transition: color 0.2s ease;
- }
-
- .stat-card .sub {
- font-size: 11.5px;
- color: var(--text-tertiary);
- margin-top: auto;
- padding-top: 4px;
- }
-
- .panel {
- position: relative;
- min-width: 0;
- background: linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 94%, #fff) 0%, var(--bg-secondary) 100%);
- border: 1px solid var(--border-color);
- border-radius: 16px;
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18), 0 12px 28px -14px rgba(0, 0, 0, 0.35);
- padding: 22px;
- margin-bottom: 18px;
- opacity: 0;
- transform: translateY(10px);
- animation: statCardIn 0.45s ease forwards;
- animation-delay: 0.2s;
- transition: border-color 0.2s ease, box-shadow 0.2s ease;
- }
-
- .panel:hover {
- border-color: var(--border-color-hover, var(--accent));
- box-shadow: 0 8px 22px -10px rgba(0, 0, 0, 0.4);
- }
-
- .content .panel:nth-of-type(2) {
- animation-delay: 0.25s;
- }
-
- .content .panel:nth-of-type(3) {
- animation-delay: 0.3s;
- }
-
- .content .panel:nth-of-type(4) {
- animation-delay: 0.35s;
- }
-
- .content .panel:nth-of-type(5) {
- animation-delay: 0.4s;
- }
-
- @media (prefers-reduced-motion: reduce) {
- .panel {
- animation: none;
- opacity: 1;
- transform: none;
- }
- }
-
- :root[data-no-intro] .panel {
- animation: none;
- opacity: 1;
- transform: none;
- }
-
- .panel h3 {
- font-size: 14px;
- font-weight: 600;
- margin-bottom: 16px;
- }
-
- .chart-wrap {
- width: 100%;
- overflow-x: auto;
- position: relative;
- }
-
- .chart-svg {
- width: 100%;
- height: 220px;
- display: block;
- }
-
- .chart-bar {
- cursor: pointer;
- transition: opacity 0.15s ease;
- transform-box: fill-box;
- transform-origin: bottom;
- animation: barGrow 0.5s cubic-bezier(0.22, 1, 0.36, 1) backwards;
- }
-
- .chart-bar:hover {
- opacity: 0.75;
- }
-
- @keyframes barGrow {
- from {
- transform: scaleY(0);
- }
-
- to {
- transform: scaleY(1);
- }
- }
-
- @media (prefers-reduced-motion: reduce) {
- .chart-bar {
- animation: none;
- }
- }
-
- :root[data-no-intro] .chart-bar {
- animation: none;
- }
-
- .chart-tooltip {
- display: none;
- position: fixed;
- pointer-events: none;
- z-index: 1000;
- background: var(--bg-tertiary);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- padding: 7px 10px;
- font-size: 12px;
- white-space: nowrap;
- box-shadow: var(--shadow);
- transform: translateY(-100%) translateY(-6px) scale(0.96);
- opacity: 0;
- transition: opacity 0.12s ease, transform 0.12s ease;
- }
-
- .chart-tooltip.visible {
- opacity: 1;
- transform: translateY(-100%) translateY(-6px) scale(1);
- }
-
- .chart-tooltip .tt-label {
- color: var(--text-secondary);
- font-size: 11px;
- margin-bottom: 2px;
- }
-
- .chart-tooltip .tt-value {
- color: var(--text-primary);
- font-weight: 600;
- }
-
- .chart-legend {
- display: flex;
- gap: 16px;
- margin-bottom: 14px;
- flex-wrap: wrap;
- }
-
- .legend-item {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 12px;
- color: var(--text-secondary);
- }
-
- .legend-item .dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- flex-shrink: 0;
- }
-
- .dot-future {
- background: #4a4a4a;
- }
-
- .dot-empty {
- background: #eab308;
- }
-
- .dot-hit {
- background: #f1484e;
- }
-
- .two-col {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
- gap: 18px;
- }
-
- @media (max-width: 880px) {
- .two-col {
- grid-template-columns: minmax(0, 1fr);
- }
- }
-
- .list-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 9px 0;
- border-bottom: 1px solid var(--border-color);
- font-size: 13px;
- gap: 12px;
- }
-
- .list-row:last-child {
- border-bottom: none;
- }
-
- .list-row .name {
- overflow: hidden;
- min-width: 0;
- text-overflow: ellipsis;
- white-space: nowrap;
- flex: 1;
- color: var(--text-primary);
- }
-
- .list-row .count {
- background: var(--bg-tertiary);
- padding: 2px 9px;
- border-radius: 20px;
- font-size: 12px;
- font-weight: 600;
- color: var(--text-secondary);
- flex-shrink: 0;
- }
-
- .list-row .row-actions {
- display: flex;
- align-items: center;
- gap: 8px;
- flex-shrink: 0;
- }
-
- .row-copy-btn {
- position: static;
- display: flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- padding: 0;
- border-radius: 6px;
- border: 1px solid var(--border-color);
- background: transparent;
- color: var(--text-tertiary);
- cursor: pointer;
- flex-shrink: 0;
- transition: background 0.15s, color 0.15s, border-color 0.15s;
- }
-
- .row-copy-btn:hover {
- background: var(--bg-hover);
- color: var(--text-primary);
- border-color: var(--text-tertiary);
- }
-
- .row-copy-btn svg {
- width: 13px;
- height: 13px;
- display: block;
- }
-
- .row-copy-btn.copied {
- color: #2ecc71;
- border-color: #2ecc71;
- }
-
- .list-row .flag {
- width: 20px;
- height: 14px;
- border-radius: 4px;
- object-fit: cover;
- flex-shrink: 0;
- border: 1px solid var(--border-color);
- background: var(--bg-hover);
- }
-
- .list-row .name-with-flag {
- display: flex;
- align-items: center;
- gap: 8px;
- overflow: hidden;
- flex: 1;
- }
-
- .list-row .name-with-flag .name {
- flex: 1;
- }
-
- .list-row .rank {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 20px;
- height: 20px;
- flex-shrink: 0;
- font-size: 11px;
- font-weight: 700;
- color: var(--text-tertiary);
- }
-
- .list-row .rank.medal {
- border-radius: 50%;
- }
-
- .list-row .rank.medal svg {
- width: 13px;
- height: 13px;
- display: block;
- }
-
- .list-row .rank.medal-1 {
- background: rgba(255, 215, 0, 0.18);
- color: #ffd700;
- }
-
- .list-row .rank.medal-2 {
- background: rgba(192, 192, 192, 0.18);
- color: #c0c0c0;
- }
-
- .list-row .rank.medal-3 {
- background: rgba(205, 127, 50, 0.18);
- color: #cd7f32;
- }
-
- .empty-state {
- text-align: center;
- padding: 40px 20px;
- color: var(--text-secondary);
- }
-
- .empty-state .big {
- font-size: 32px;
- margin-bottom: 10px;
- }
-
- .site-list {
- display: flex;
- flex-direction: column;
- gap: 10px;
- }
-
- .site-item {
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 12px;
- padding: 16px 18px;
- display: flex;
- gap: 10px;
- align-items: flex-start;
- transition: opacity .15s ease, transform .15s ease;
- }
-
- .site-item.dragging {
- opacity: 0.4;
- }
-
- .site-item.drag-over {
- border-color: var(--accent);
- }
-
- .drag-handle {
- flex: 0 0 auto;
- cursor: grab;
- color: var(--text-secondary);
- padding: 4px 2px;
- margin-top: 2px;
- touch-action: none;
- user-select: none;
- }
-
- .drag-handle:active {
- cursor: grabbing;
- }
-
- .site-item-body {
- flex: 1 1 auto;
- min-width: 0;
- }
-
- .site-item-top {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- flex-wrap: wrap;
- }
-
- .site-item .name {
- font-weight: 600;
- font-size: 14.5px;
- }
-
- .site-item .domain {
- font-size: 12.5px;
- color: var(--text-secondary);
- }
-
- .badge {
- font-size: 11px;
- padding: 2px 9px;
- border-radius: 20px;
- font-weight: 600;
- }
-
- .badge.xml {
- background: var(--accent-soft);
- color: var(--accent);
- }
-
- .badge.mysql {
- background: var(--success-soft);
- color: var(--success);
- }
-
- .badge.pmaf {
- background: var(--info-soft);
- color: var(--info);
- }
-
- .badge.inactive {
- background: var(--bg-tertiary);
- color: var(--text-tertiary);
- }
-
- .site-actions {
- display: flex;
- gap: 8px;
- margin-top: 12px;
- }
-
- .btn {
- padding: 8px 14px;
- border-radius: 10px;
- font-size: 12.5px;
- font-weight: 600;
- cursor: pointer;
- border: 1px solid color-mix(in srgb, var(--border-color) 60%, var(--text-tertiary));
- background: var(--bg-tertiary);
- color: var(--text-primary);
- box-sizing: border-box;
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18);
- transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
- }
-
- .btn:active {
- transform: scale(0.97);
- }
-
- .topbar-actions .btn {
- height: 36px;
- display: inline-flex;
- align-items: center;
- }
-
- .btn:hover {
- background: var(--bg-hover);
- }
-
- .btn.primary {
- background: linear-gradient(155deg, var(--accent-hover), var(--accent));
- border-color: var(--accent);
- color: white;
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 1px 2px rgba(0, 0, 0, 0.2);
- }
-
- .btn.primary:hover {
- background: linear-gradient(155deg, var(--accent-hover), var(--accent-hover));
- box-shadow: 0 4px 14px color-mix(in srgb, var(--accent) 40%, transparent), inset 0 1px 0 rgba(255, 255, 255, 0.14);
- transform: translateY(-1px);
- }
-
- .btn.primary:active {
- transform: translateY(0) scale(0.98);
- }
-
- .btn.danger {
- color: var(--accent);
- background: transparent;
- border-color: color-mix(in srgb, var(--accent) 45%, transparent);
- }
-
- .btn.danger:hover {
- background: var(--accent-soft);
- border-color: var(--accent);
- }
-
- .embed-block {
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- padding: 12px 68px 12px 14px;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 12px;
- margin-top: 10px;
- position: relative;
- display: block;
- }
-
- .embed-code {
- display: block;
- width: 100%;
- overflow-x: auto;
- white-space: pre;
- color: var(--text-primary);
- font-family: inherit;
- font-size: inherit;
- }
-
- .copy-btn {
- position: absolute;
- top: 8px;
- right: 8px;
- font-size: 11px;
- padding: 4px 9px;
- background: var(--bg-tertiary);
- border: 1px solid var(--border-color);
- border-radius: 6px;
- cursor: pointer;
- color: var(--text-secondary);
- }
-
- .modal-backdrop {
- display: none;
- position: fixed;
- inset: 0;
- background: rgba(0, 0, 0, 0.55);
- z-index: 100;
- align-items: center;
- justify-content: center;
- padding: 20px;
- }
-
- .modal-backdrop.show {
- display: flex;
- }
-
- .modal {
- background: var(--bg-secondary);
- border: 1px solid var(--border-color);
- border-radius: 14px;
- padding: 26px;
- max-width: 460px;
- width: 100%;
- max-height: 90vh;
- overflow-y: auto;
- }
-
- .modal h2 {
- font-size: 17px;
- margin-bottom: 18px;
- }
-
- .form-group {
- margin-bottom: 16px;
- }
-
- .form-group label {
- display: block;
- font-size: 12.5px;
- font-weight: 500;
- color: var(--text-secondary);
- margin-bottom: 6px;
- }
-
- .form-group input,
- .form-group select {
- width: 100%;
- padding: 10px 12px;
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 10px;
- color: var(--text-primary);
- font-size: 13.5px;
- font-family: inherit;
- box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25);
- transition: border-color 0.2s ease, box-shadow 0.2s ease;
- }
-
- .form-group input:focus,
- .form-group select:focus {
- outline: none;
- border-color: var(--accent);
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
- }
-
- .form-actions {
- display: flex;
- gap: 10px;
- margin-top: 20px;
- }
-
- .form-actions .btn {
- flex: 1;
- text-align: center;
- }
-
- .checkbox-row {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 16px;
- }
-
- .switch-row {
- display: flex;
- align-items: center;
- gap: 10px;
- cursor: pointer;
- user-select: none;
- }
-
- .switch {
- width: 38px;
- height: 22px;
- background: var(--border-color);
- border-radius: 20px;
- box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
- position: relative;
- transition: background 0.25s ease, box-shadow 0.25s ease;
- flex-shrink: 0;
- }
-
- .switch.on {
- background: linear-gradient(155deg, var(--accent-hover), var(--accent));
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 0 12px color-mix(in srgb, var(--accent) 35%, transparent);
- }
-
- .switch::after {
- content: '';
- position: absolute;
- top: 2px;
- left: 2px;
- width: 18px;
- height: 18px;
- background: white;
- border-radius: 50%;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
- transition: transform 0.25s cubic-bezier(.22, 1, .36, 1);
- }
-
- .switch.on::after {
- transform: translateX(16px);
- }
-
- .checkbox-row input {
- width: auto;
- }
-
- .switch-checkbox-row {
- display: flex;
- align-items: center;
- gap: 10px;
- margin-bottom: 16px;
- }
-
- .switch-checkbox-row label {
- margin: 0;
- cursor: pointer;
- }
-
- .switch-checkbox {
- appearance: none;
- -webkit-appearance: none;
- width: 38px;
- height: 22px;
- background: var(--border-color);
- border-radius: 20px;
- box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
- position: relative;
- flex-shrink: 0;
- cursor: pointer;
- transition: background 0.25s ease, box-shadow 0.25s ease;
- margin: 0;
- }
-
- .switch-checkbox::after {
- content: '';
- position: absolute;
- top: 2px;
- left: 2px;
- width: 18px;
- height: 18px;
- background: white;
- border-radius: 50%;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
- transition: transform 0.25s cubic-bezier(.22, 1, .36, 1);
- }
-
- .switch-checkbox:checked {
- background: linear-gradient(155deg, var(--accent-hover), var(--accent));
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 0 12px color-mix(in srgb, var(--accent) 35%, transparent);
- }
-
- .switch-checkbox:checked::after {
- transform: translateX(16px);
- }
-
- .advanced-toggle {
- display: flex;
- align-items: center;
- gap: 6px;
- cursor: pointer;
- user-select: none;
- color: var(--text-secondary, #999);
- font-size: 13px;
- margin: 4px 0 16px;
- width: fit-content;
- }
-
- .advanced-toggle svg {
- transition: transform 0.2s;
- }
-
- .advanced-toggle.open svg {
- transform: rotate(90deg);
- }
-
- .advanced-section {
- display: none;
- }
-
- .advanced-section.open {
- display: block;
- }
- </style>
- </head>
-
- <body>
- <div class="app">
-
- <div class="sidebar-backdrop" id="backdrop" onclick="closeSidebar()"></div>
- <aside class="sidebar" id="sidebar">
- <div class="brand">
- <div class="mark">
- <img src="logo.webp" alt="Logo" width="24" height="24" loading="lazy">
- </div>
- <div>
- <div class="name"><?= sanitize($siteName) ?></div>
- <div class="sub">PriviMetrics 2</div>
- </div>
- </div>
- <nav class="nav">
- <a href="?view=overview<?= $activeSiteId ? '&site=' . urlencode($activeSiteId) : '' ?>&range=<?= urlencode($range) ?>" class="<?= $view === 'overview' ? 'active' : '' ?>">
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M7 12v7M12 4v15M17 8v11"></path>
- </svg>
- Analytics
- </a>
- <a href="?view=sites<?= $activeSiteId ? '&site=' . urlencode($activeSiteId) : '' ?>&range=<?= urlencode($range) ?>" class="<?= $view === 'sites' ? 'active' : '' ?>">
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"></path>
- <path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"></path>
- </svg>
- Sites
- </a>
- <a href="settings.php?return=<?= urlencode('dashboard.php' . ($_SERVER['QUERY_STRING'] !== '' ? '?' . $_SERVER['QUERY_STRING'] : '')) ?>">
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
- <circle cx="12" cy="12" r="3" />
- <path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
- </svg>
- Settings
- </a>
- </nav>
- <div class="sidebar-footer">
- <div class="user">Signed in as <strong><?= sanitize($_SESSION['admin_username'] ?? '') ?></strong></div>
- <a href="logout.php">
- <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
- <path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
- <path d="M16 17l5-5-5-5" />
- <path d="M21 12H9" />
- </svg>
- Sign out
- </a>
- </div>
- </aside>
-
- <div class="main">
- <div class="mobile-topbar">
- <div class="mobile-menu-btn" onclick="openSidebar()">
- <svg width="24" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
- <line x1="3" y1="12" x2="21" y2="12" />
- <line x1="3" y1="6" x2="21" y2="6" />
- <line x1="3" y1="18" x2="21" y2="18" />
- </svg>
- </div>
- <strong style="font-size:14px;"><?= sanitize($siteName) ?></strong>
- <div style="width:38px;"></div>
- </div>
-
- <?php if ($view === 'overview'): ?>
- <div class="topbar">
- <h1><?= $currentSite ? sanitize($currentSite['name']) : 'Analytics' ?></h1>
- <div class="topbar-actions">
- <?php if (count($sites) > 1): ?>
- <select class="select" onchange="location.href='?view=overview&site='+this.value+'&range=<?= sanitize($range) ?>'">
- <?php foreach ($sites as $s): ?>
- <option value="<?= sanitize($s['id']) ?>" <?= $s['id'] === $activeSiteId ? 'selected' : '' ?>><?= sanitize($s['name']) ?></option>
- <?php endforeach; ?>
- </select>
- <?php endif; ?>
- <select class="select" onchange="location.href='?view=overview&site=<?= sanitize($activeSiteId) ?>&range='+this.value">
- <option value="24h" <?= $range === '24h' ? 'selected' : '' ?>>Today</option>
- <option value="7d" <?= $range === '7d' ? 'selected' : '' ?>>Last 7 days</option>
- <option value="30d" <?= $range === '30d' ? 'selected' : '' ?>>Last 30 days</option>
- <option value="90d" <?= $range === '90d' ? 'selected' : '' ?>>Last 90 days</option>
- <option value="1y" <?= $range === '1y' ? 'selected' : '' ?>>Last year</option>
- </select>
- <div class="icon-btn" onclick="toggleTheme()" title="Toggle theme">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
- <circle cx="12" cy="12" r="5" />
- <path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
- </svg>
- </div>
- </div>
- </div>
- <div class="content">
- <?php if ($flash): ?><div class="flash <?= $flash['type'] ?>"><?= sanitize($flash['text']) ?></div><?php endif; ?>
-
- <?php if (!$currentSite): ?>
- <div class="panel">
- <div class="empty-state">
- <div class="big">
- <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
- <rect x="2" y="3" width="20" height="14" rx="2"></rect>
- <line x1="8" y1="21" x2="16" y2="21"></line>
- <line x1="12" y1="17" x2="12" y2="21"></line>
- <path d="M7 12l3-3 2 2 4-4"></path>
- </svg>
- </div>
- <p>You don't have any tracked sites yet.</p>
- <div style="margin-top:16px;"><a href="?view=sites" class="btn primary">Add your first site</a></div>
- </div>
- </div>
- <?php else: ?>
-
- <div class="stat-grid">
- <div class="stat-card">
- <div class="label" style="display: flex; align-items: center; gap: 6px;">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: -2px;">
- <path d="M2 12h3l3-9 4 18 4-9h6"></path>
- </svg>
- Page views
- </div>
- <div class="value"><?= formatNumber($stats['total_views']) ?></div>
- <div class="sub">in the selected period</div>
- </div>
- <div class="stat-card">
- <div class="label" style="display: flex; align-items: center; gap: 6px;">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: -2px;">
- <path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
- <circle cx="8.5" cy="7" r="4"></circle>
- <line x1="20" y1="8" x2="20" y2="14"></line>
- <line x1="23" y1="11" x2="17" y2="11"></line>
- </svg>
- Unique visitors
- </div>
- <div class="value"><?= formatNumber($stats['unique_visitors']) ?></div>
- <div class="sub">no cookie tracking</div>
- </div>
- <div class="stat-card">
- <div class="label" style="display: flex; align-items: center; gap: 6px;">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: -2px;">
- <ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
- <path d="M21 5v6c0 1.66-4.03 3-9 3s-9-1.34-9-3V5"></path>
- <path d="M3 11v6c0 1.66 4.03 3 9 3s9-1.34 9-3v-6"></path>
- </svg>
- Storage
- </div>
- <div class="value" style="font-size:18px; text-transform:uppercase;"><?= sanitize($currentSite['storage']) ?></div>
- <div class="sub">this site's data engine</div>
- </div>
- <div class="stat-card">
- <div class="label" style="display: flex; align-items: center; gap: 6px;">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: -2px;">
- <circle cx="18" cy="5" r="3"></circle>
- <circle cx="6" cy="12" r="3"></circle>
- <circle cx="18" cy="19" r="3"></circle>
- <line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line>
- <line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line>
- </svg>
- All sites combined
- </div>
- <div class="value"><?= formatNumber($totalHitsAllSites) ?></div>
- <div class="sub">total views collected</div>
- </div>
- </div>
-
- <div class="panel">
- <h3>
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: -2px;">
- <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
- </svg>
- Traffic over time
- </h3>
- <div class="chart-legend">
- <span class="legend-item"><span class="dot dot-future"></span>Not yet</span>
- <span class="legend-item"><span class="dot dot-empty"></span>No views</span>
- <span class="legend-item"><span class="dot dot-hit"></span>Views</span>
- </div>
- <div class="chart-wrap" id="trafficChartWrap">
- <?php
- $timelineValues = array_values($stats['timeline']);
- $timelineKeys = array_keys($stats['timeline']);
- $timelineFutureFlags = array_values($stats['timeline_future']);
- $maxVal = max($timelineValues ?: [1]);
- $maxVal = $maxVal === 0 ? 1 : $maxVal;
- $count = count($timelineValues);
- $chartW = 1000;
- $chartH = 200;
- $padL = 6;
- $padR = 6;
- $padTop = 10;
- $padBottom = 24;
- $usableW = $chartW - $padL - $padR;
- $usableH = $chartH - $padTop - $padBottom;
- $gap = 3;
- $barW = $count > 0 ? max(1, ($usableW / $count) - $gap) : 0;
- $colorFor = ['future' => '#4a4a4a', 'empty' => '#eab308', 'hit' => '#f1484e'];
-
- $labelFor = function (string $key) use ($range): string {
- if ($range === '24h') {
- return $key;
- }
- if ($range === '1y') {
- $ts = strtotime($key . '-01 00:00:00');
- return $ts ? date('M Y', $ts) : $key;
- }
- $ts = strtotime($key . ' 00:00:00');
- return $ts ? date('M j, Y', $ts) : $key;
- };
- ?>
- <svg class="chart-svg" id="trafficChartSvg" viewBox="0 0 <?= $chartW ?> <?= $chartH ?>" preserveAspectRatio="none">
- <?php foreach ($timelineValues as $i => $v):
- $state = !empty($timelineFutureFlags[$i]) ? 'future' : ($v > 0 ? 'hit' : 'empty');
- $color = $colorFor[$state];
- $barH = $state === 'hit' ? max(2, ($v / $maxVal) * $usableH) : 2;
- $x = $padL + $i * ($barW + $gap);
- $y = $chartH - $padBottom - $barH;
- $label = $labelFor($timelineKeys[$i]);
- ?>
- <?php $barRx = min(4, $barW / 2, $barH / 2); ?>
- <?php $barDelay = round(min($i, 60) * 0.012, 3); ?>
- <rect class="chart-bar" style="animation-delay: <?= $barDelay ?>s;" x="<?= round($x, 2) ?>" y="<?= round($y, 2) ?>" width="<?= round($barW, 2) ?>" height="<?= round($barH, 2) ?>" rx="<?= round($barRx, 2) ?>" fill="<?= $color ?>" data-label="<?= htmlspecialchars($label, ENT_QUOTES, 'UTF-8') ?>" data-value="<?= (int) $v ?>" data-state="<?= $state ?>" />
- <?php endforeach; ?>
- </svg>
- <div class="chart-tooltip" id="trafficTooltip"></div>
- </div>
- </div>
- <script>
- (function() {
- var wrap = document.getElementById('trafficChartWrap');
- var svg = document.getElementById('trafficChartSvg');
- var tooltip = document.getElementById('trafficTooltip');
- if (!wrap || !svg || !tooltip) return;
-
- document.body.appendChild(tooltip);
-
- var stateLabel = {
- future: 'Not yet',
- empty: 'No views',
- hit: 'view'
- };
-
- function showTooltip(bar, evt) {
- var label = bar.getAttribute('data-label');
- var value = bar.getAttribute('data-value');
- var state = bar.getAttribute('data-state');
-
- var valueText = state === 'future' ? 'Not yet' : (value + ' ' + (value === '1' ? 'view' : 'views'));
- tooltip.innerHTML = '<div class="tt-label">' + label + '</div><div class="tt-value">' + valueText + '</div>';
- tooltip.style.display = 'block';
-
- var left = evt.clientX;
- var top = evt.clientY - 14;
-
- var ttWidth = tooltip.offsetWidth || 110;
- var ttHeight = tooltip.offsetHeight || 40;
-
- if (left + ttWidth + 14 > window.innerWidth) {
- left = left - ttWidth - 14;
- } else {
- left = left + 14;
- }
- left = Math.max(4, Math.min(left, window.innerWidth - ttWidth - 4));
- top = Math.max(ttHeight + 4, top);
-
- tooltip.style.left = left + 'px';
- tooltip.style.top = top + 'px';
-
- requestAnimationFrame(function() {
- tooltip.classList.add('visible');
- });
- }
-
- function hideTooltip() {
- tooltip.classList.remove('visible');
- setTimeout(function() {
- if (!tooltip.classList.contains('visible')) {
- tooltip.style.display = 'none';
- }
- }, 120);
- }
-
- svg.addEventListener('mousemove', function(evt) {
- var target = evt.target;
- if (target && target.classList && target.classList.contains('chart-bar')) {
- showTooltip(target, evt);
- } else {
- hideTooltip();
- }
- });
- svg.addEventListener('mouseleave', hideTooltip);
-
- svg.addEventListener('touchstart', function(evt) {
- var touch = evt.touches[0];
- var el = document.elementFromPoint(touch.clientX, touch.clientY);
- if (el && el.classList && el.classList.contains('chart-bar')) {
- showTooltip(el, touch);
- }
- }, {
- passive: true
- });
- })();
- </script>
-
- <div class="two-col">
- <div class="panel">
- <h3>
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: -2px;">
- <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
- <polyline points="13 2 13 9 20 9"></polyline>
- </svg>
- Top pages
- </h3>
- <?php if ($stats['top_pages']): $rank = 0;
- foreach ($stats['top_pages'] as $page => $count): $page = (string) $page;
- $rank++; ?>
- <div class="list-row">
- <span class="rank<?= $rank <= 3 ? ' medal medal-' . $rank : '' ?>"><?= $rank <= 3 ? rankMedalSvg() : $rank ?></span>
- <span class="name" title="<?= sanitize($page) ?>"><?= sanitize(truncateText($page)) ?></span>
- <div class="row-actions">
- <span class="count"><?= formatNumber($count) ?></span>
- <button type="button" class="row-copy-btn" data-copy="<?= sanitize($page) ?>" title="Copy" onclick="copyListItem(this)">
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
- </svg>
- </button>
- </div>
- </div>
- <?php endforeach;
- else: ?>
- <div class="empty-state">No data for this period.</div>
- <?php endif; ?>
- </div>
- <div class="panel">
- <h3>
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: -2px;">
- <circle cx="11" cy="11" r="8"></circle>
- <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
- </svg>
- Search queries
- </h3>
- <?php if (!$featureSearchQueries): ?>
- <div class="empty-state">Search query tracking is disabled for this site.</div>
- <?php elseif ($stats['top_searches']): $rank = 0;
- foreach ($stats['top_searches'] as $q => $count): $q = (string) $q;
- $rank++; ?>
- <div class="list-row">
- <span class="rank<?= $rank <= 3 ? ' medal medal-' . $rank : '' ?>"><?= $rank <= 3 ? rankMedalSvg() : $rank ?></span>
- <span class="name" title="<?= sanitize($q) ?>"><?= sanitize(truncateText($q)) ?></span>
- <div class="row-actions">
- <span class="count"><?= formatNumber($count) ?></span>
- <button type="button" class="row-copy-btn" data-copy="<?= sanitize($q) ?>" title="Copy" onclick="copyListItem(this)">
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
- </svg>
- </button>
- </div>
- </div>
- <?php endforeach;
- else: ?>
- <div class="empty-state">No searches recorded.</div>
- <?php endif; ?>
- </div>
- </div>
-
- <div class="two-col">
- <div class="panel">
- <h3>
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: -2px;">
- <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
- <circle cx="12" cy="10" r="3"></circle>
- </svg>
- Visitor countries
- </h3>
- <?php if (!$featureVisitorCountries): ?>
- <div class="empty-state">Visitor country tracking is disabled for this site.</div>
- <?php elseif ($stats['top_countries']): $rank = 0;
- foreach ($stats['top_countries'] as $country => $count): $country = (string) $country;
- $rank++; ?>
- <div class="list-row">
- <span class="rank<?= $rank <= 3 ? ' medal medal-' . $rank : '' ?>"><?= $rank <= 3 ? rankMedalSvg() : $rank ?></span>
- <div class="name-with-flag">
- <?= countryFlagHtml($country) ?>
- <span class="name" title="<?= sanitize($country) ?>"><?= sanitize(truncateText($country)) ?></span>
- </div>
- <div class="row-actions">
- <span class="count"><?= formatNumber($count) ?></span>
- <button type="button" class="row-copy-btn" data-copy="<?= sanitize($country) ?>" title="Copy" onclick="copyListItem(this)">
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
- </svg>
- </button>
- </div>
- </div>
- <?php endforeach;
- else: ?>
- <div class="empty-state">No data available.</div>
- <?php endif; ?>
- </div>
- <div class="panel">
- <h3>
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: -2px;">
- <rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect>
- <line x1="12" y1="18" x2="12.01" y2="18"></line>
- </svg>
- Device type
- </h3>
- <?php if (!$featureDeviceType): ?>
- <div class="empty-state">Device type tracking is disabled.</div>
- <?php elseif ($stats['top_devices']): $rank = 0;
- foreach ($stats['top_devices'] as $device => $count): $device = (string) $device;
- $rank++; ?>
- <div class="list-row">
- <span class="rank<?= $rank <= 3 ? ' medal medal-' . $rank : '' ?>"><?= $rank <= 3 ? rankMedalSvg() : $rank ?></span>
- <span class="name" title="<?= sanitize($device) ?>"><?= sanitize($device) ?></span>
- <div class="row-actions">
- <span class="count"><?= formatNumber($count) ?></span>
- <button type="button" class="row-copy-btn" data-copy="<?= sanitize($device) ?>" title="Copy" onclick="copyListItem(this)">
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
- </svg>
- </button>
- </div>
- </div>
- <?php endforeach;
- else: ?>
- <div class="empty-state">No data for this period.</div>
- <?php endif; ?>
- </div>
- </div>
-
- <div class="two-col">
- <div class="panel">
- <h3>
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: -2px;">
- <circle cx="12" cy="12" r="10"></circle>
- <line x1="2" y1="12" x2="22" y2="12"></line>
- <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
- </svg>
- Top Referrers
- </h3>
- <?php if (!$featureTrafficSources): ?>
- <div class="empty-state">Traffic source tracking is disabled for this site.</div>
- <?php elseif ($stats['top_referrers']): $rank = 0;
- foreach ($stats['top_referrers'] as $ref => $count): $ref = (string) $ref;
- $rank++; ?>
- <div class="list-row">
- <span class="rank<?= $rank <= 3 ? ' medal medal-' . $rank : '' ?>"><?= $rank <= 3 ? rankMedalSvg() : $rank ?></span>
- <span class="name" title="<?= sanitize($ref) ?>"><?= sanitize(truncateText($ref)) ?></span>
- <div class="row-actions">
- <span class="count"><?= formatNumber($count) ?></span>
- <button type="button" class="row-copy-btn" data-copy="<?= sanitize($ref) ?>" title="Copy" onclick="copyListItem(this)">
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
- </svg>
- </button>
- </div>
- </div>
- <?php endforeach;
- else: ?>
- <div class="empty-state">No external referrers.</div>
- <?php endif; ?>
- </div>
- <div class="panel">
- <h3>
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: -2px;">
- <path d="M3 11l18-5v12L3 14v-3z"></path>
- <path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"></path>
- </svg>
- Top UTM sources
- </h3>
- <?php if (!$featureTrafficSources): ?>
- <div class="empty-state">Traffic source tracking is disabled for this site.</div>
- <?php elseif ($stats['top_utm_sources']): $rank = 0;
- foreach ($stats['top_utm_sources'] as $utm => $count): $utm = (string) $utm;
- $rank++; ?>
- <div class="list-row">
- <span class="rank<?= $rank <= 3 ? ' medal medal-' . $rank : '' ?>"><?= $rank <= 3 ? rankMedalSvg() : $rank ?></span>
- <span class="name" title="<?= sanitize($utm) ?>"><?= sanitize(truncateText($utm)) ?></span>
- <div class="row-actions">
- <span class="count"><?= formatNumber($count) ?></span>
- <button type="button" class="row-copy-btn" data-copy="<?= sanitize($utm) ?>" title="Copy" onclick="copyListItem(this)">
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
- </svg>
- </button>
- </div>
- </div>
- <?php endforeach;
- else: ?>
- <div class="empty-state">No UTM campaign data.</div>
- <?php endif; ?>
- </div>
- </div>
- <?php endif; ?>
- </div>
-
- <?php else: ?>
- <div class="topbar">
- <h1>Sites</h1>
- <div class="topbar-actions">
- <button class="btn primary" onclick="openModal('create')">+ Add site</button>
- </div>
- </div>
- <div class="content">
- <?php if ($flash): ?><div class="flash <?= $flash['type'] ?>"><?= sanitize($flash['text']) ?></div><?php endif; ?>
-
- <?php if (!$sites): ?>
- <div class="panel">
- <div class="empty-state">
- <div class="big">
- <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
- <circle cx="12" cy="12" r="9" />
- <path d="M3 12h18" />
- <path d="M12 3c2.5 2.6 4 6 4 9s-1.5 6.4-4 9c-2.5-2.6-4-6-4-9s1.5-6.4 4-9z" />
- </svg>
- </div>
- <p>No sites added yet. Add your first one to start collecting data.</p>
- </div>
- </div>
- <?php else: ?>
- <div class="site-list" id="siteList">
- <?php foreach ($sites as $s): ?>
- <div class="site-item" data-id="<?= sanitize($s['id']) ?>">
- <div class="drag-handle" title="Drag to reorder">
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <circle cx="9" cy="6" r="1.2"></circle>
- <circle cx="15" cy="6" r="1.2"></circle>
- <circle cx="9" cy="12" r="1.2"></circle>
- <circle cx="15" cy="12" r="1.2"></circle>
- <circle cx="9" cy="18" r="1.2"></circle>
- <circle cx="15" cy="18" r="1.2"></circle>
- </svg>
- </div>
- <div class="site-item-body">
- <div class="site-item-top">
- <div>
- <div class="name"><?= sanitize($s['name']) ?></div>
- <div class="domain"><?= sanitize($s['domain']) ?></div>
- </div>
- <div style="display:flex; gap:6px; align-items:center;">
- <span class="badge <?= sanitize($s['storage']) ?>"><?= strtoupper(sanitize($s['storage'])) ?></span>
- <?php if (!$s['active']): ?><span class="badge inactive">INACTIVE</span><?php endif; ?>
- </div>
- </div>
-
- <?php
- $embedCode = '<script src="' . $baseUrl . '/privimetrics.js" data-code="' . $s['tracking_code'] . '"></script>';
- $embedCodeNoIp = '<script src="' . $baseUrl . '/privimetrics.js" data-code="' . $s['tracking_code'] . '" data-no-track-ip="true"></script>';
- $embedEscaped = htmlspecialchars($embedCode, ENT_QUOTES, 'UTF-8');
- $embedEscapedNoIp = htmlspecialchars($embedCodeNoIp, ENT_QUOTES, 'UTF-8');
- ?>
- <div class="switch-row" style="margin-top:10px;" onclick="toggleEmbedNoIp('<?= sanitize($s['id']) ?>', this)">
- <div class="switch" id="noip-switch-<?= sanitize($s['id']) ?>"></div>
- <span>No IP</span>
- </div>
- <div class="embed-block" id="embed-<?= sanitize($s['id']) ?>"><code class="embed-code" id="embed-code-<?= sanitize($s['id']) ?>"><?= $embedEscaped ?></code><button class="copy-btn" type="button" id="embed-copy-<?= sanitize($s['id']) ?>" data-embed="<?= $embedEscaped ?>" data-embed-noip="<?= $embedEscapedNoIp ?>" onclick="copyEmbed(this)">Copy</button></div>
-
- <div class="site-actions">
- <a href="?view=overview&site=<?= sanitize($s['id']) ?>" class="btn">
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 6px;">
- <line x1="18" y1="20" x2="18" y2="10"></line>
- <line x1="12" y1="20" x2="12" y2="4"></line>
- <line x1="6" y1="20" x2="6" y2="14"></line>
- </svg>
- View analytics
- </a>
- <button class="btn" onclick='openEditModal(<?= htmlspecialchars(json_encode($s, JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP), ENT_QUOTES, 'UTF-8') ?>)'>
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 6px;">
- <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
- <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
- </svg>
- Edit
- </button>
- <button class="btn danger" onclick="confirmDelete('<?= sanitize($s['id']) ?>', '<?= sanitize(addslashes($s['name'])) ?>')">
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 6px;">
- <polyline points="3 6 5 6 21 6"></polyline>
- <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
- <line x1="10" y1="11" x2="10" y2="17"></line>
- <line x1="14" y1="11" x2="14" y2="17"></line>
- </svg>
- Delete
- </button>
- </div>
- </div>
- </div>
- <?php endforeach; ?>
- </div>
- <?php endif; ?>
- </div>
- <?php endif; ?>
- </div>
- </div>
-
- <div class="modal-backdrop" id="siteModal">
- <div class="modal">
- <h2 id="modalTitle">Add site</h2>
- <form method="post" id="siteForm">
- <input type="hidden" name="csrf_token" value="<?= sanitize($csrfToken) ?>">
- <input type="hidden" name="action" id="formAction" value="create_site">
- <input type="hidden" name="id" id="formId" value="">
-
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px; margin-right:6px;">
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
- <line x1="3" y1="9" x2="21" y2="9"></line>
- <line x1="9" y1="21" x2="9" y2="9"></line>
- </svg>Site name</label>
- <input type="text" name="name" id="formName" required placeholder="e.g. My Store">
- </div>
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px; margin-right:6px;">
- <path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"></path>
- <path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"></path>
- </svg>Domain</label>
- <input type="text" name="domain" id="formDomain" required placeholder="example.com">
- </div>
- <div class="form-group" id="storageGroup">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px; margin-right:6px;">
- <ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
- <path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
- <path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
- </svg>Data engine</label>
- <select name="storage" id="formStorage">
- <option value="xml">XML (file-based, always available)</option>
- <option value="pmaf">PMAF (binary file-based, faster & smaller)</option>
- <option value="mysql" <?= !$storage->isMySQLAvailable() ? 'disabled' : '' ?>>MySQL <?= !$storage->isMySQLAvailable() ? '(not configured)' : '' ?></option>
- </select>
- </div>
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px; margin-right:6px;">
- <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
- </svg>Domain restriction</label>
- <select name="restriction_mode" id="formRestriction">
- <option value="full">Main domain + subdomains</option>
- <option value="main">Main domain only</option>
- <option value="none">No restriction</option>
- </select>
- </div>
- <div class="switch-checkbox-row" id="activeRow" style="display:none;">
- <input type="checkbox" class="switch-checkbox" name="active" id="formActive" value="1" checked>
- <label for="formActive"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px; margin-right:6px;">
- <circle cx="12" cy="12" r="10"></circle>
- <polyline points="12 6 12 12 16 14"></polyline>
- </svg>Site active</label>
- </div>
-
- <div class="advanced-toggle" id="advancedToggle" onclick="toggleAdvanced()">
- <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <polyline points="9 18 15 12 9 6"></polyline>
- </svg>
- <span>Show advanced</span>
- </div>
-
- <div class="advanced-section" id="advancedSection">
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <circle cx="12" cy="12" r="10"></circle>
- <line x1="2" y1="12" x2="22" y2="12"></line>
- <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
- </svg>Traffic sources tracking</label>
- <select name="traffic_sources_mode" id="formTrafficSources">
- <option value="inherit">Use global setting</option>
- <option value="on">Always on for this site</option>
- <option value="off">Always off for this site</option>
- </select>
- </div>
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
- <circle cx="12" cy="10" r="3"></circle>
- </svg>Visitor countries tracking</label>
- <select name="visitor_countries_mode" id="formVisitorCountries">
- <option value="inherit">Use global setting</option>
- <option value="on">Always on for this site</option>
- <option value="off">Always off for this site</option>
- </select>
- </div>
- <div class="form-group">
- <label><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;">
- <circle cx="11" cy="11" r="8"></circle>
- <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
- </svg>Search queries tracking</label>
- <select name="search_queries_mode" id="formSearchQueries">
- <option value="inherit">Use global setting</option>
- <option value="on">Always on for this site</option>
- <option value="off">Always off for this site</option>
- </select>
- </div>
- </div>
-
- <div class="form-actions">
- <button type="button" class="btn" onclick="closeModal()">Cancel</button>
- <button type="submit" class="btn primary">Save</button>
- </div>
- </form>
- </div>
- </div>
-
-
- <div class="modal-backdrop" id="deleteModal">
- <div class="modal">
- <h2>Delete site?</h2>
- <p style="color:var(--text-secondary); font-size:13.5px; margin-bottom:6px;" id="deleteText"></p>
- <p style="color:var(--accent); font-size:12.5px;">This action is irreversible and will delete all data collected for this site.</p>
- <form method="post">
- <input type="hidden" name="csrf_token" value="<?= sanitize($csrfToken) ?>">
- <input type="hidden" name="action" value="delete_site">
- <input type="hidden" name="id" id="deleteId" value="">
- <div class="form-actions">
- <button type="button" class="btn" onclick="closeDeleteModal()">Cancel</button>
- <button type="submit" class="btn danger" style="background:var(--accent); color:white; border-color:var(--accent);">Delete site</button>
- </div>
- </form>
- </div>
- </div>
-
- <script>
- function openSidebar() {
- document.getElementById('sidebar').classList.add('open');
- document.getElementById('backdrop').classList.add('show');
- }
-
- function closeSidebar() {
- document.getElementById('sidebar').classList.remove('open');
- document.getElementById('backdrop').classList.remove('show');
- }
-
- function toggleTheme() {
- const html = document.documentElement;
- const next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
- html.setAttribute('data-theme', next);
- localStorage.setItem('pm_theme', next);
- }
-
- function openModal(mode) {
- document.getElementById('modalTitle').textContent = 'Add site';
- document.getElementById('formAction').value = 'create_site';
- document.getElementById('formId').value = '';
- document.getElementById('formName').value = '';
- document.getElementById('formDomain').value = '';
- document.getElementById('formRestriction').value = 'full';
- document.getElementById('formTrafficSources').value = 'inherit';
- document.getElementById('formVisitorCountries').value = 'inherit';
- document.getElementById('formSearchQueries').value = 'inherit';
- document.getElementById('storageGroup').style.display = 'block';
- document.getElementById('activeRow').style.display = 'none';
- collapseAdvanced();
- document.getElementById('siteModal').classList.add('show');
- }
-
- function openEditModal(site) {
- document.getElementById('modalTitle').textContent = 'Edit site';
- document.getElementById('formAction').value = 'update_site';
- document.getElementById('formId').value = site.id;
- document.getElementById('formName').value = site.name;
- document.getElementById('formDomain').value = site.domain;
- document.getElementById('formRestriction').value = site.restriction_mode;
- document.getElementById('formTrafficSources').value = site.traffic_sources_mode || 'inherit';
- document.getElementById('formVisitorCountries').value = site.visitor_countries_mode || 'inherit';
- document.getElementById('formSearchQueries').value = site.search_queries_mode || 'inherit';
- document.getElementById('formActive').checked = !!(site.active == 1 || site.active === true);
- document.getElementById('storageGroup').style.display = 'none';
- document.getElementById('activeRow').style.display = 'flex';
- collapseAdvanced();
- document.getElementById('siteModal').classList.add('show');
- }
-
- function closeModal() {
- document.getElementById('siteModal').classList.remove('show');
- }
-
- function toggleAdvanced() {
- document.getElementById('advancedToggle').classList.toggle('open');
- const section = document.getElementById('advancedSection');
- const isOpen = section.classList.toggle('open');
- document.getElementById('advancedToggle').querySelector('span').textContent = isOpen ? 'Hide advanced' : 'Show advanced';
- }
-
- function collapseAdvanced() {
- document.getElementById('advancedToggle').classList.remove('open');
- document.getElementById('advancedSection').classList.remove('open');
- document.getElementById('advancedToggle').querySelector('span').textContent = 'Show advanced';
- }
-
- function confirmDelete(id, name) {
- document.getElementById('deleteId').value = id;
- document.getElementById('deleteText').textContent = 'Site "' + name + '" will be permanently deleted.';
- document.getElementById('deleteModal').classList.add('show');
- }
-
- function closeDeleteModal() {
- document.getElementById('deleteModal').classList.remove('show');
- }
-
- function copyEmbed(btn) {
- const useNoIp = btn.getAttribute('data-active') === 'noip';
- const text = useNoIp ? btn.getAttribute('data-embed-noip') : btn.getAttribute('data-embed');
- navigator.clipboard.writeText(text).then(() => {
- const original = btn.textContent;
- btn.textContent = 'Copied!';
- setTimeout(() => btn.textContent = original, 1500);
- });
- }
-
- function toggleEmbedNoIp(siteId, sw) {
- const noIp = sw.querySelector('.switch').classList.toggle('on');
- const codeEl = document.getElementById('embed-code-' + siteId);
- const copyBtn = document.getElementById('embed-copy-' + siteId);
- codeEl.textContent = noIp ? copyBtn.getAttribute('data-embed-noip') : copyBtn.getAttribute('data-embed');
- copyBtn.setAttribute('data-active', noIp ? 'noip' : 'default');
- }
-
- function copyListItem(btn) {
- const text = btn.getAttribute('data-copy');
- navigator.clipboard.writeText(text).then(() => {
- const original = btn.innerHTML;
- btn.classList.add('copied');
- btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
- setTimeout(() => {
- btn.classList.remove('copied');
- btn.innerHTML = original;
- }, 1200);
- });
- }
-
- (function initSiteReorder() {
- const list = document.getElementById('siteList');
- if (!list) return;
-
- let dragEl = null;
-
- list.addEventListener('mousedown', (e) => {
- const handle = e.target.closest('.drag-handle');
- if (!handle) return;
- const item = handle.closest('.site-item');
- if (item) item.setAttribute('draggable', 'true');
- });
-
- list.addEventListener('mouseup', () => {
- list.querySelectorAll('.site-item[draggable="true"]').forEach(el => el.removeAttribute('draggable'));
- });
-
- list.addEventListener('dragstart', (e) => {
- const item = e.target.closest('.site-item');
- if (!item || item.getAttribute('draggable') !== 'true') return;
- dragEl = item;
- item.classList.add('dragging');
- e.dataTransfer.effectAllowed = 'move';
- e.dataTransfer.setData('text/plain', item.dataset.id);
- });
-
- list.addEventListener('dragend', () => {
- if (dragEl) dragEl.classList.remove('dragging');
- list.querySelectorAll('.site-item.drag-over').forEach(el => el.classList.remove('drag-over'));
- list.querySelectorAll('.site-item[draggable="true"]').forEach(el => el.removeAttribute('draggable'));
- dragEl = null;
- });
-
- list.addEventListener('dragover', (e) => {
- e.preventDefault();
- const target = e.target.closest('.site-item');
- if (!target || target === dragEl) return;
-
- list.querySelectorAll('.site-item.drag-over').forEach(el => el.classList.remove('drag-over'));
- target.classList.add('drag-over');
-
- const rect = target.getBoundingClientRect();
- const after = (e.clientY - rect.top) > rect.height / 2;
- list.insertBefore(dragEl, after ? target.nextSibling : target);
- });
-
- list.addEventListener('drop', (e) => {
- e.preventDefault();
- list.querySelectorAll('.site-item.drag-over').forEach(el => el.classList.remove('drag-over'));
- saveSiteOrder();
- });
-
- function saveSiteOrder() {
- const order = Array.from(list.querySelectorAll('.site-item')).map(el => el.dataset.id);
- const body = new URLSearchParams();
- body.set('action', 'reorder_sites');
- body.set('csrf_token', document.querySelector('input[name="csrf_token"]').value);
- order.forEach(id => body.append('order[]', id));
-
- fetch(location.href, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- body: body.toString(),
- }).catch(() => {});
- }
- })();
- </script>
- </body>
-
- </html>