WebOrbiton
v1.0.3

PriviMetrics 2

2,617 lines · 109.5 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. require_once __DIR__ . '/functions.php';
  5. require_once __DIR__ . '/font-options.php';
  6. ​
  7. if (!envIsInstalled()) {
  8. header('Location: install.php');
  9. exit;
  10. }
  11. require_once __DIR__ . '/storage.php';
  12. ​
  13. requireLogin();
  14. pmSecurityHeaders();
  15. ​
  16. $storage = new StorageManager();
  17. $csrfToken = generateCSRFToken();
  18. $flash = null;
  19. ​
  20. if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'reorder_sites') {
  21. header('Content-Type: application/json');
  22. if (!verifyCSRFToken($_POST['csrf_token'] ?? null)) {
  23. http_response_code(403);
  24. echo json_encode(['ok' => false, 'error' => 'Your session has expired. Refresh the page and try again.']);
  25. exit;
  26. }
  27. $orderedIds = array_map('sanitize', (array) ($_POST['order'] ?? []));
  28. $ok = $storage->saveSiteOrder($orderedIds);
  29. echo json_encode(['ok' => $ok]);
  30. exit;
  31. }
  32. ​
  33. if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  34. if (!verifyCSRFToken($_POST['csrf_token'] ?? null)) {
  35. $flash = ['type' => 'error', 'text' => 'Your session has expired. Refresh the page and try again.'];
  36. } else {
  37. $action = $_POST['action'] ?? '';
  38. ​
  39. if ($action === 'create_site') {
  40. $name = sanitize($_POST['name'] ?? '');
  41. $domain = trim((string) ($_POST['domain'] ?? ''));
  42. $storageType = in_array($_POST['storage'] ?? '', ['xml', 'mysql', 'pmaf'], true) ? $_POST['storage'] : 'xml';
  43. $restrictionMode = in_array($_POST['restriction_mode'] ?? '', ['full', 'main', 'none'], true) ? $_POST['restriction_mode'] : 'full';
  44. $trafficSourcesMode = pmValidFeatureMode($_POST['traffic_sources_mode'] ?? 'inherit');
  45. $visitorCountriesMode = pmValidFeatureMode($_POST['visitor_countries_mode'] ?? 'inherit');
  46. $searchQueriesMode = pmValidFeatureMode($_POST['search_queries_mode'] ?? 'inherit');
  47. ​
  48. if ($storageType === 'mysql' && !$storage->isMySQLAvailable()) {
  49. $flash = ['type' => 'error', 'text' => 'MySQL isn\'t set up yet. Turn it on in Settings, or pick another storage option.'];
  50. } elseif ($name === '' || !validateDomain($domain)) {
  51. $flash = ['type' => 'error', 'text' => 'Enter a name and a domain, like example.com.'];
  52. } else {
  53. $domain = preg_replace('#^https?://#', '', $domain);
  54. $domain = rtrim($domain, '/');
  55. $storage->createSite($name, $domain, $storageType, $restrictionMode, $trafficSourcesMode, $visitorCountriesMode, $searchQueriesMode);
  56. $flash = ['type' => 'success', 'text' => 'Site added. Paste the tracking code on your site to start counting visits.'];
  57. }
  58. } elseif ($action === 'update_site') {
  59. $id = sanitize($_POST['id'] ?? '');
  60. $name = sanitize($_POST['name'] ?? '');
  61. $domain = preg_replace('#^https?://#', '', trim((string) ($_POST['domain'] ?? '')));
  62. $domain = rtrim($domain, '/');
  63. $restrictionMode = in_array($_POST['restriction_mode'] ?? '', ['full', 'main', 'none'], true) ? $_POST['restriction_mode'] : 'full';
  64. $trafficSourcesMode = pmValidFeatureMode($_POST['traffic_sources_mode'] ?? 'inherit');
  65. $visitorCountriesMode = pmValidFeatureMode($_POST['visitor_countries_mode'] ?? 'inherit');
  66. $searchQueriesMode = pmValidFeatureMode($_POST['search_queries_mode'] ?? 'inherit');
  67. $active = isset($_POST['active']) ? 1 : 0;
  68. ​
  69. if ($id === '' || $name === '' || !validateDomain($domain)) {
  70. $flash = ['type' => 'error', 'text' => 'Enter a name and a domain, like example.com.'];
  71. } else {
  72. $storage->updateSite($id, [
  73. 'name' => $name,
  74. 'domain' => $domain,
  75. 'restriction_mode' => $restrictionMode,
  76. 'traffic_sources_mode' => $trafficSourcesMode,
  77. 'visitor_countries_mode' => $visitorCountriesMode,
  78. 'search_queries_mode' => $searchQueriesMode,
  79. 'active' => $active,
  80. ]);
  81. $flash = ['type' => 'success', 'text' => 'Changes saved.'];
  82. }
  83. } elseif ($action === 'delete_site') {
  84. $id = sanitize($_POST['id'] ?? '');
  85. if ($id !== '' && $storage->deleteSite($id)) {
  86. $flash = ['type' => 'success', 'text' => 'Site deleted, along with all of its stats.'];
  87. }
  88. }
  89. }
  90. }
  91. ​
  92. $sites = $storage->listSites();
  93. $activeSiteId = sanitize($_GET['site'] ?? ($sites[0]['id'] ?? ''));
  94. $rangeParam = $_GET['range'] ?? '7d';
  95. $range = in_array($rangeParam, ['24h', '7d', '30d', '90d', '1y'], true) ? $rangeParam : '7d';
  96. $viewParam = $_GET['view'] ?? 'overview';
  97. $view = in_array($viewParam, ['overview', 'sites'], true) ? $viewParam : 'overview';
  98. ​
  99. $dateRange = getDateRange($range);
  100. $currentSite = null;
  101. foreach ($sites as $s) {
  102. if ($s['id'] === $activeSiteId) {
  103. $currentSite = $s;
  104. break;
  105. }
  106. }
  107. ​
  108. $hits = [];
  109. $stats = [
  110. 'total_views' => 0,
  111. 'unique_visitors' => 0,
  112. 'top_pages' => [],
  113. 'top_referrers' => [],
  114. 'top_utm_sources' => [],
  115. 'top_countries' => [],
  116. 'top_searches' => [],
  117. 'top_devices' => [],
  118. 'timeline' => [],
  119. 'timeline_future' => [],
  120. ];
  121. ​
  122. $featureTrafficSources = pmFeatureEnabledForSite($currentSite, 'traffic_sources');
  123. $featureVisitorCountries = pmFeatureEnabledForSite($currentSite, 'visitor_countries');
  124. $featureSearchQueries = pmFeatureEnabledForSite($currentSite, 'search_queries');
  125. $featureDeviceType = pmFeatureGlobalEnabled('device_type');
  126. ​
  127. if ($currentSite) {
  128. $hits = $storage->getHits($currentSite, $dateRange['start'], $dateRange['end']);
  129. ​
  130. $uniqueHashes = [];
  131. $pages = [];
  132. $referrers = [];
  133. $utmSources = [];
  134. $countries = [];
  135. $searches = [];
  136. $devices = [];
  137. $timeline = [];
  138. ​
  139. $bucketFormat = $range === '24h' ? 'H:00' : ($range === '1y' ? 'Y-m' : 'Y-m-d');
  140. ​
  141. foreach ($hits as $hit) {
  142. $uniqueHashes[$hit['user_hash']] = true;
  143. ​
  144. $page = $hit['page_url'] ?: '(unknown)';
  145. $pages[$page] = ($pages[$page] ?? 0) + 1;
  146. ​
  147. if ($featureTrafficSources) {
  148. if (!empty($hit['utm_source'])) {
  149. $utmSources[$hit['utm_source']] = ($utmSources[$hit['utm_source']] ?? 0) + 1;
  150. }
  151. if (!empty($hit['referrer'])) {
  152. $referrers[$hit['referrer']] = ($referrers[$hit['referrer']] ?? 0) + 1;
  153. }
  154. }
  155. ​
  156. if ($featureVisitorCountries) {
  157. $country = $hit['country'] ?: 'Unknown';
  158. $countries[$country] = ($countries[$country] ?? 0) + 1;
  159. }
  160. ​
  161. if ($featureSearchQueries && !empty($hit['search_query'])) {
  162. $searches[$hit['search_query']] = ($searches[$hit['search_query']] ?? 0) + 1;
  163. }
  164. ​
  165. if ($featureDeviceType) {
  166. $device = deviceTypeFromUserAgent($hit['user_agent'] ?? '');
  167. $devices[$device] = ($devices[$device] ?? 0) + 1;
  168. }
  169. ​
  170. $bucket = date($bucketFormat, (int) $hit['ts']);
  171. $timeline[$bucket] = ($timeline[$bucket] ?? 0) + 1;
  172. }
  173. ​
  174. arsort($pages);
  175. arsort($referrers);
  176. arsort($utmSources);
  177. arsort($countries);
  178. arsort($searches);
  179. arsort($devices);
  180. ​
  181. $stats['total_views'] = count($hits);
  182. $stats['unique_visitors'] = count($uniqueHashes);
  183. $topListsLimit = max(1, min(50, (int) env('TOP_LISTS_LIMIT', '8')));
  184. $stats['top_pages'] = array_slice($pages, 0, $topListsLimit, true);
  185. $stats['top_referrers'] = array_slice($referrers, 0, $topListsLimit, true);
  186. $stats['top_utm_sources'] = array_slice($utmSources, 0, $topListsLimit, true);
  187. $stats['top_countries'] = array_slice($countries, 0, $topListsLimit, true);
  188. $stats['top_searches'] = array_slice($searches, 0, $topListsLimit, true);
  189. $stats['top_devices'] = $devices;
  190. ​
  191. $filledTimeline = [];
  192. $timelineFuture = [];
  193. $nowTs = time();
  194. if ($range === '24h') {
  195. $todayStart = strtotime('today 00:00:00');
  196. for ($h = 0; $h < 24; $h++) {
  197. $key = sprintf('%02d:00', $h);
  198. $filledTimeline[$key] = $timeline[$key] ?? 0;
  199. $timelineFuture[$key] = ($todayStart + $h * 3600) > $nowTs;
  200. }
  201. } elseif ($range === '1y') {
  202. for ($i = 11; $i >= 0; $i--) {
  203. $key = date('Y-m', strtotime("-{$i} months"));
  204. $filledTimeline[$key] = $timeline[$key] ?? 0;
  205. $timelineFuture[$key] = strtotime($key . '-01 00:00:00') > $nowTs;
  206. }
  207. } else {
  208. $days = $range === '7d' ? 7 : ($range === '30d' ? 30 : 90);
  209. for ($i = $days - 1; $i >= 0; $i--) {
  210. $key = date('Y-m-d', strtotime("-{$i} days"));
  211. $filledTimeline[$key] = $timeline[$key] ?? 0;
  212. $timelineFuture[$key] = strtotime($key . ' 00:00:00') > $nowTs;
  213. }
  214. }
  215. $stats['timeline'] = $filledTimeline;
  216. $stats['timeline_future'] = $timelineFuture;
  217. }
  218. ​
  219. $totalHitsAllSites = $storage->countAllHits($sites);
  220. $siteName = env('SITE_NAME', 'PriviMetrics');
  221. $theme = env('DEFAULT_THEME', 'dark');
  222. $baseUrl = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? 'yourdomain.com');
  223. ​
  224. function truncateText(string $text, int $limit = 120): string
  225. {
  226. if (mb_strlen($text) <= $limit) {
  227. return $text;
  228. }
  229. return rtrim(mb_substr($text, 0, $limit)) . '...';
  230. }
  231. ​
  232. function rankMedalSvg(): string
  233. {
  234. 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>';
  235. }
  236. ​
  237. function deviceTypeFromUserAgent(string $userAgent): string
  238. {
  239. $ua = strtolower($userAgent);
  240. if ($ua === '' || $ua === 'unknown') {
  241. return 'Unknown';
  242. }
  243. ​
  244. if (
  245. str_contains($ua, 'ipad')
  246. || (str_contains($ua, 'android') && !str_contains($ua, 'mobile'))
  247. || str_contains($ua, 'tablet')
  248. || str_contains($ua, 'kindle')
  249. || str_contains($ua, 'playbook')
  250. ) {
  251. return 'Tablet';
  252. }
  253. ​
  254. if (
  255. str_contains($ua, 'mobi')
  256. || str_contains($ua, 'iphone')
  257. || str_contains($ua, 'ipod')
  258. || str_contains($ua, 'android')
  259. || str_contains($ua, 'blackberry')
  260. || str_contains($ua, 'windows phone')
  261. ) {
  262. return 'Mobile';
  263. }
  264. ​
  265. return 'Desktop';
  266. }
  267. ​
  268. function countryToIso2(string $country): ?string
  269. {
  270. static $map = [
  271. 'Poland' => 'PL',
  272. 'Germany' => 'DE',
  273. 'United States' => 'US',
  274. 'United Kingdom' => 'GB',
  275. 'France' => 'FR',
  276. 'Spain' => 'ES',
  277. 'Italy' => 'IT',
  278. 'Netherlands' => 'NL',
  279. 'Belgium' => 'BE',
  280. 'Portugal' => 'PT',
  281. 'Sweden' => 'SE',
  282. 'Norway' => 'NO',
  283. 'Denmark' => 'DK',
  284. 'Finland' => 'FI',
  285. 'Ireland' => 'IE',
  286. 'Switzerland' => 'CH',
  287. 'Austria' => 'AT',
  288. 'Czech Republic' => 'CZ',
  289. 'Czechia' => 'CZ',
  290. 'Slovakia' => 'SK',
  291. 'Hungary' => 'HU',
  292. 'Romania' => 'RO',
  293. 'Bulgaria' => 'BG',
  294. 'Greece' => 'GR',
  295. 'Ukraine' => 'UA',
  296. 'Russia' => 'RU',
  297. 'Belarus' => 'BY',
  298. 'Lithuania' => 'LT',
  299. 'Latvia' => 'LV',
  300. 'Estonia' => 'EE',
  301. 'Turkey' => 'TR',
  302. 'China' => 'CN',
  303. 'Japan' => 'JP',
  304. 'South Korea' => 'KR',
  305. 'India' => 'IN',
  306. 'Brazil' => 'BR',
  307. 'Canada' => 'CA',
  308. 'Mexico' => 'MX',
  309. 'Australia' => 'AU',
  310. 'New Zealand' => 'NZ',
  311. 'South Africa' => 'ZA',
  312. 'Egypt' => 'EG',
  313. 'Israel' => 'IL',
  314. 'United Arab Emirates' => 'AE',
  315. 'Saudi Arabia' => 'SA',
  316. 'Indonesia' => 'ID',
  317. 'Vietnam' => 'VN',
  318. 'Thailand' => 'TH',
  319. 'Philippines' => 'PH',
  320. 'Malaysia' => 'MY',
  321. 'Singapore' => 'SG',
  322. 'Pakistan' => 'PK',
  323. 'Bangladesh' => 'BD',
  324. 'Argentina' => 'AR',
  325. 'Chile' => 'CL',
  326. 'Colombia' => 'CO',
  327. 'Peru' => 'PE',
  328. 'Croatia' => 'HR',
  329. 'Slovenia' => 'SI',
  330. 'Serbia' => 'RS',
  331. 'Iceland' => 'IS',
  332. 'Luxembourg' => 'LU',
  333. 'Afghanistan' => 'AF',
  334. 'Albania' => 'AL',
  335. 'Algeria' => 'DZ',
  336. 'American Samoa' => 'AS',
  337. 'Andorra' => 'AD',
  338. 'Angola' => 'AO',
  339. 'Armenia' => 'AM',
  340. 'Azerbaijan' => 'AZ',
  341. 'Bahamas' => 'BS',
  342. 'Bahrain' => 'BH',
  343. 'Belize' => 'BZ',
  344. 'Bermuda' => 'BM',
  345. 'Bhutan' => 'BT',
  346. 'Bolivia' => 'BO',
  347. 'Bosnia and Herzegovina' => 'BA',
  348. 'Botswana' => 'BW',
  349. 'Brunei' => 'BN',
  350. 'Cambodia' => 'KH',
  351. 'Cameroon' => 'CM',
  352. 'Costa Rica' => 'CR',
  353. 'Cuba' => 'CU',
  354. 'Cyprus' => 'CY',
  355. 'Dominican Republic' => 'DO',
  356. 'Ecuador' => 'EC',
  357. 'El Salvador' => 'SV',
  358. 'Eswatini' => 'SZ',
  359. 'Ethiopia' => 'ET',
  360. 'Faroe Islands' => 'FO',
  361. 'French Guiana' => 'GF',
  362. 'Georgia' => 'GE',
  363. 'Ghana' => 'GH',
  364. 'Greenland' => 'GL',
  365. 'Guatemala' => 'GT',
  366. 'Haiti' => 'HT',
  367. 'Honduras' => 'HN',
  368. 'Hong Kong' => 'HK',
  369. 'Iran' => 'IR',
  370. 'Iraq' => 'IQ',
  371. 'Jamaica' => 'JM',
  372. 'Jordan' => 'JO',
  373. 'Kazakhstan' => 'KZ',
  374. 'Kenya' => 'KE',
  375. 'Kuwait' => 'KW',
  376. 'Kyrgyzstan' => 'KG',
  377. 'Laos' => 'LA',
  378. 'Lebanon' => 'LB',
  379. 'Libya' => 'LY',
  380. 'Macao' => 'MO',
  381. 'Madagascar' => 'MG',
  382. 'Malawi' => 'MW',
  383. 'Maldives' => 'MV',
  384. 'Malta' => 'MT',
  385. 'Moldova' => 'MD',
  386. 'Monaco' => 'MC',
  387. 'Mongolia' => 'MN',
  388. 'Morocco' => 'MA',
  389. 'Mozambique' => 'MZ',
  390. 'Myanmar' => 'MM',
  391. 'Namibia' => 'NA',
  392. 'Nepal' => 'NP',
  393. 'New Caledonia' => 'NC',
  394. 'Nicaragua' => 'NI',
  395. 'Nigeria' => 'NG',
  396. 'North Korea' => 'KP',
  397. 'North Macedonia' => 'MK',
  398. 'Oman' => 'OM',
  399. 'Panama' => 'PA',
  400. 'Papua New Guinea' => 'PG',
  401. 'Paraguay' => 'PY',
  402. 'Qatar' => 'QA',
  403. 'Rwanda' => 'RW',
  404. 'Samoa' => 'WS',
  405. 'Senegal' => 'SN',
  406. 'Solomon Islands' => 'SB',
  407. 'Somalia' => 'SO',
  408. 'Sri Lanka' => 'LK',
  409. 'Sudan' => 'SD',
  410. 'Suriname' => 'SR',
  411. 'Syria' => 'SY',
  412. 'Taiwan' => 'TW',
  413. 'Tajikistan' => 'TJ',
  414. 'Tanzania' => 'TZ',
  415. 'Timor-Leste' => 'TL',
  416. 'Tonga' => 'TO',
  417. 'Trinidad and Tobago' => 'TT',
  418. 'Tunisia' => 'TN',
  419. 'Uganda' => 'UG',
  420. 'Uruguay' => 'UY',
  421. 'Uzbekistan' => 'UZ',
  422. 'Venezuela' => 'VE',
  423. 'Yemen' => 'YE',
  424. 'Zambia' => 'ZM',
  425. 'Zimbabwe' => 'ZW',
  426. ];
  427. return $map[$country] ?? null;
  428. }
  429. ​
  430. function countryFlagHtml(string $country): string
  431. {
  432. $iso = countryToIso2($country);
  433. if ($iso === null) {
  434. return '';
  435. }
  436. $flagFileOverrides = [
  437. 'GB' => 'GB-UKM',
  438. ];
  439. $flagFile = $flagFileOverrides[$iso] ?? $iso;
  440. $url = 'https://cdn.privimetrics.com/flag/' . $flagFile . '.svg';
  441. return '<img class="flag" src="' . sanitize($url) . '" alt="" loading="lazy">';
  442. }
  443. ?>
  444. <!DOCTYPE html>
  445. <html lang="en" data-theme="<?= sanitize($theme) ?>">
  446. ​
  447. <head>
  448. <meta charset="UTF-8">
  449. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  450. <title>Dashboard - <?= sanitize($siteName) ?></title>
  451. <?= fontFaceStyleTag() ?>
  452. <script>
  453. (function() {
  454. var t = localStorage.getItem('pm_theme') || '<?= sanitize($theme) ?>';
  455. document.documentElement.setAttribute('data-theme', t);
  456. ​
  457. if (sessionStorage.getItem('pm_intro_played')) {
  458. document.documentElement.setAttribute('data-no-intro', '1');
  459. } else {
  460. sessionStorage.setItem('pm_intro_played', '1');
  461. }
  462. })();
  463. </script>
  464. <style>
  465. :root {
  466. --font-body: <?= fontFamilyCss('body') ?>;
  467. --font-heading: <?= fontFamilyCss('heading') ?>;
  468. --font-value: <?= fontFamilyCss('value') ?>;
  469. }
  470. ​
  471. :root[data-theme="dark"] {
  472. --bg-primary: #0a0a0a;
  473. --bg-secondary: #141414;
  474. --bg-tertiary: #1c1c1c;
  475. --bg-hover: #222222;
  476. --border-color: #262626;
  477. --text-primary: #eaeaea;
  478. --text-secondary: #9a9a9a;
  479. --text-tertiary: #6b6b6b;
  480. --accent: #f1484e;
  481. --accent-hover: #d53b40;
  482. --accent-soft: rgba(241, 72, 78, 0.12);
  483. --success: #22c55e;
  484. --success-soft: rgba(34, 197, 94, 0.12);
  485. --info: #3b82f6;
  486. --info-soft: rgba(59, 130, 246, 0.12);
  487. --shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
  488. }
  489. ​
  490. :root[data-theme="light"] {
  491. --bg-primary: #f7f7f8;
  492. --bg-secondary: #ffffff;
  493. --bg-tertiary: #f2f2f3;
  494. --bg-hover: #ececed;
  495. --border-color: #e5e5e7;
  496. --text-primary: #16171a;
  497. --text-secondary: #6b6d73;
  498. --text-tertiary: #9799a0;
  499. --accent: #f1484e;
  500. --accent-hover: #d53b40;
  501. --accent-soft: rgba(241, 72, 78, 0.08);
  502. --success: #16a34a;
  503. --success-soft: rgba(22, 163, 74, 0.1);
  504. --info: #2563eb;
  505. --info-soft: rgba(37, 99, 235, 0.1);
  506. --shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
  507. }
  508. ​
  509. html {
  510. overflow-y: scroll;
  511. scrollbar-gutter: stable;
  512. }
  513. ​
  514. * {
  515. margin: 0;
  516. padding: 0;
  517. box-sizing: border-box;
  518. }
  519. ​
  520. body {
  521. font-family: var(--font-body);
  522. background: var(--bg-primary);
  523. color: var(--text-primary);
  524. font-size: 14px;
  525. line-height: 1.5;
  526. }
  527. ​
  528. h1 {
  529. font-family: var(--font-heading);
  530. }
  531. ​
  532. a {
  533. color: inherit;
  534. text-decoration: none;
  535. }
  536. ​
  537. .app {
  538. display: flex;
  539. min-height: 100vh;
  540. }
  541. ​
  542. .sidebar {
  543. width: 250px;
  544. flex-shrink: 0;
  545. background: linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 94%, #fff) 0%, var(--bg-secondary) 100%);
  546. border: 1px solid var(--border-color);
  547. border-radius: 16px;
  548. 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);
  549. display: flex;
  550. flex-direction: column;
  551. padding: 18px 14px;
  552. position: sticky;
  553. top: 14px;
  554. align-self: flex-start;
  555. margin: 14px 0 14px 14px;
  556. height: calc(100vh - 28px);
  557. overflow-y: auto;
  558. scrollbar-width: none;
  559. -ms-overflow-style: none;
  560. z-index: 10;
  561. }
  562. ​
  563. .sidebar::-webkit-scrollbar {
  564. display: none;
  565. }
  566. ​
  567. .brand {
  568. display: flex;
  569. align-items: center;
  570. gap: 10px;
  571. padding: 6px 8px 18px;
  572. margin-bottom: 12px;
  573. border-bottom: 1px solid var(--border-color);
  574. }
  575. ​
  576. .brand .mark {
  577. width: 34px;
  578. height: 34px;
  579. border-radius: 9px;
  580. background: var(--accent);
  581. display: flex;
  582. align-items: center;
  583. justify-content: center;
  584. font-weight: 700;
  585. color: white;
  586. font-size: 14px;
  587. flex-shrink: 0;
  588. }
  589. ​
  590. .brand .name {
  591. font-family: var(--font-heading);
  592. font-weight: 600;
  593. font-size: 15px;
  594. }
  595. ​
  596. .brand .sub {
  597. font-size: 11px;
  598. color: var(--text-tertiary);
  599. }
  600. ​
  601. .nav {
  602. display: flex;
  603. flex-direction: column;
  604. gap: 2px;
  605. }
  606. ​
  607. .nav a {
  608. display: flex;
  609. align-items: center;
  610. gap: 10px;
  611. padding: 9px 12px;
  612. border-radius: 10px;
  613. font-size: 13.5px;
  614. font-weight: 500;
  615. color: var(--text-secondary);
  616. border: 1px solid transparent;
  617. transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
  618. }
  619. ​
  620. .nav a svg {
  621. width: 17px;
  622. height: 17px;
  623. flex-shrink: 0;
  624. }
  625. ​
  626. .nav a:hover {
  627. background: var(--bg-tertiary);
  628. color: var(--text-primary);
  629. transform: translateX(2px);
  630. }
  631. ​
  632. .nav a.active {
  633. background: var(--accent-soft);
  634. color: var(--accent);
  635. font-weight: 600;
  636. border-color: color-mix(in srgb, var(--accent) 22%, transparent);
  637. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18);
  638. transform: none;
  639. }
  640. ​
  641. .sidebar-footer {
  642. margin-top: auto;
  643. padding-top: 14px;
  644. border-top: 1px solid var(--border-color);
  645. }
  646. ​
  647. .sidebar-footer a {
  648. display: flex;
  649. align-items: center;
  650. gap: 10px;
  651. padding: 9px 12px;
  652. border-radius: 8px;
  653. font-size: 13px;
  654. color: var(--text-secondary);
  655. }
  656. ​
  657. .sidebar-footer a:hover {
  658. background: var(--bg-hover);
  659. color: var(--text-primary);
  660. }
  661. ​
  662. .sidebar-footer .user {
  663. font-size: 12px;
  664. color: var(--text-tertiary);
  665. padding: 6px 12px 10px;
  666. }
  667. ​
  668. .main {
  669. flex: 1;
  670. min-width: 0;
  671. }
  672. ​
  673. .topbar {
  674. display: flex;
  675. align-items: center;
  676. justify-content: space-between;
  677. padding: 16px 28px;
  678. margin: 14px 14px 0;
  679. border: 1px solid var(--border-color);
  680. border-radius: 16px;
  681. background: linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 94%, #fff) 0%, var(--bg-secondary) 100%);
  682. 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);
  683. flex-wrap: wrap;
  684. gap: 12px;
  685. min-height: 68px;
  686. }
  687. ​
  688. .topbar h1 {
  689. font-size: 17px;
  690. font-weight: 600;
  691. }
  692. ​
  693. .topbar-actions {
  694. display: flex;
  695. align-items: center;
  696. gap: 10px;
  697. min-height: 36px;
  698. }
  699. ​
  700. .select {
  701. height: 36px;
  702. padding: 0 12px;
  703. background: var(--bg-tertiary);
  704. border: 1px solid var(--border-color);
  705. border-radius: 8px;
  706. color: var(--text-primary);
  707. font-size: 13px;
  708. cursor: pointer;
  709. box-sizing: border-box;
  710. line-height: 34px;
  711. }
  712. ​
  713. .icon-btn {
  714. width: 36px;
  715. height: 36px;
  716. display: flex;
  717. align-items: center;
  718. justify-content: center;
  719. background: var(--bg-tertiary);
  720. border: 1px solid var(--border-color);
  721. border-radius: 8px;
  722. cursor: pointer;
  723. }
  724. ​
  725. .icon-btn:hover {
  726. background: var(--bg-hover);
  727. }
  728. ​
  729. .content {
  730. padding: 24px 28px 60px;
  731. max-width: 1200px;
  732. margin: 0 auto;
  733. }
  734. ​
  735. .mobile-topbar {
  736. display: none;
  737. }
  738. ​
  739. .mobile-menu-btn {
  740. width: 38px;
  741. height: 38px;
  742. display: flex;
  743. align-items: center;
  744. justify-content: center;
  745. background: var(--bg-tertiary);
  746. border: 1px solid var(--border-color);
  747. border-radius: 8px;
  748. }
  749. ​
  750. @media (max-width: 880px) {
  751. .app {
  752. flex-direction: column;
  753. }
  754. ​
  755. .sidebar {
  756. position: fixed;
  757. top: auto;
  758. bottom: calc(8px + env(safe-area-inset-bottom));
  759. left: calc(8px + env(safe-area-inset-left));
  760. right: calc(8px + env(safe-area-inset-right));
  761. width: auto;
  762. height: auto;
  763. margin: 0;
  764. flex-direction: row;
  765. align-items: center;
  766. padding: 6px;
  767. border-radius: 24px;
  768. border-color: var(--border-color);
  769. box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.04);
  770. overflow: visible;
  771. z-index: 60;
  772. }
  773. ​
  774. .sidebar .brand,
  775. .sidebar-footer .user {
  776. display: none;
  777. }
  778. ​
  779. .nav {
  780. flex: 3 1 0;
  781. min-width: 0;
  782. flex-direction: row;
  783. justify-content: space-around;
  784. gap: 0;
  785. }
  786. ​
  787. .sidebar-footer {
  788. margin: 0;
  789. padding: 0;
  790. border: none;
  791. flex: 1 1 0;
  792. min-width: 0;
  793. display: flex;
  794. }
  795. ​
  796. .nav a,
  797. .sidebar-footer a {
  798. flex-direction: column;
  799. align-items: center;
  800. justify-content: center;
  801. gap: 3px;
  802. padding: 8px 4px;
  803. font-size: 10px;
  804. font-weight: 600;
  805. border-radius: 18px;
  806. text-align: center;
  807. flex: 1;
  808. transform: none;
  809. }
  810. ​
  811. .nav a:hover {
  812. transform: none;
  813. }
  814. ​
  815. .nav a svg {
  816. width: 20px;
  817. height: 20px;
  818. }
  819. ​
  820. .sidebar-footer a svg {
  821. width: 20px;
  822. height: 20px;
  823. }
  824. ​
  825. .sidebar-backdrop,
  826. .sidebar-backdrop.show {
  827. display: none;
  828. }
  829. ​
  830. .mobile-menu-btn {
  831. display: none;
  832. }
  833. ​
  834. .main {
  835. background: var(--bg-secondary);
  836. overflow-x: clip;
  837. }
  838. ​
  839. .mobile-topbar {
  840. display: flex;
  841. align-items: center;
  842. justify-content: space-between;
  843. padding: 12px 16px;
  844. background: var(--bg-primary);
  845. position: sticky;
  846. top: 0;
  847. z-index: 30;
  848. }
  849. ​
  850. .mobile-topbar::after {
  851. content: "";
  852. position: absolute;
  853. left: 0;
  854. right: 0;
  855. top: calc(100% - 1px);
  856. height: 24px;
  857. pointer-events: none;
  858. background:
  859. radial-gradient(circle at 100% 100%, transparent 23.5px, var(--bg-primary) 24.5px) left top / 24px 24px no-repeat,
  860. radial-gradient(circle at 0 100%, transparent 23.5px, var(--bg-primary) 24.5px) right top / 24px 24px no-repeat;
  861. }
  862. ​
  863. .content {
  864. padding: 18px 16px 110px;
  865. }
  866. ​
  867. .topbar {
  868. padding: 14px 16px;
  869. margin: 12px 12px 0;
  870. }
  871. }
  872. ​
  873. .flash {
  874. padding: 12px 16px;
  875. border-radius: 10px;
  876. font-size: 13px;
  877. margin-bottom: 18px;
  878. display: flex;
  879. align-items: center;
  880. gap: 8px;
  881. }
  882. ​
  883. .flash.success {
  884. background: var(--success-soft);
  885. color: var(--success);
  886. border: 1px solid var(--success);
  887. }
  888. ​
  889. .flash.error {
  890. background: var(--accent-soft);
  891. color: var(--accent);
  892. border: 1px solid var(--accent);
  893. }
  894. ​
  895. .stat-grid {
  896. display: grid;
  897. grid-template-columns: repeat(4, minmax(0, 1fr));
  898. gap: 14px;
  899. margin-bottom: 20px;
  900. }
  901. ​
  902. @media (max-width: 880px) {
  903. .stat-grid {
  904. grid-template-columns: repeat(2, minmax(0, 1fr));
  905. }
  906. }
  907. ​
  908. .stat-card {
  909. position: relative;
  910. background: linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 92%, #fff) 0%, var(--bg-secondary) 100%);
  911. border: 1px solid var(--border-color);
  912. border-radius: 16px;
  913. padding: 20px 22px;
  914. opacity: 0;
  915. transform: translateY(8px);
  916. animation: statCardIn 0.4s ease forwards;
  917. transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
  918. display: flex;
  919. flex-direction: column;
  920. overflow: hidden;
  921. 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);
  922. }
  923. ​
  924. .stat-card::after {
  925. content: "";
  926. position: absolute;
  927. inset: 0 0 auto 0;
  928. height: 1px;
  929. background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent) 55%, transparent), transparent);
  930. opacity: 0;
  931. transition: opacity 0.2s ease;
  932. }
  933. ​
  934. .stat-card:hover {
  935. border-color: color-mix(in srgb, var(--accent) 45%, var(--border-color));
  936. transform: translateY(-2px);
  937. }
  938. ​
  939. .stat-card:hover::after {
  940. opacity: 1;
  941. }
  942. ​
  943. .stat-grid .stat-card:nth-child(1) {
  944. animation-delay: 0s;
  945. }
  946. ​
  947. .stat-grid .stat-card:nth-child(2) {
  948. animation-delay: 0.05s;
  949. }
  950. ​
  951. .stat-grid .stat-card:nth-child(3) {
  952. animation-delay: 0.1s;
  953. }
  954. ​
  955. .stat-grid .stat-card:nth-child(4) {
  956. animation-delay: 0.15s;
  957. }
  958. ​
  959. @keyframes statCardIn {
  960. to {
  961. opacity: 1;
  962. transform: translateY(0);
  963. }
  964. }
  965. ​
  966. @media (prefers-reduced-motion: reduce) {
  967. .stat-card {
  968. animation: none;
  969. opacity: 1;
  970. transform: none;
  971. }
  972. }
  973. ​
  974. :root[data-no-intro] .stat-card {
  975. animation: none;
  976. opacity: 1;
  977. transform: none;
  978. }
  979. ​
  980. .stat-card .label {
  981. font-size: 12px;
  982. color: var(--text-secondary);
  983. margin-bottom: 8px;
  984. transition: color 0.2s ease;
  985. }
  986. ​
  987. .stat-card .value {
  988. font-family: var(--font-value);
  989. font-size: 30px;
  990. line-height: 1.1;
  991. font-weight: 600;
  992. letter-spacing: -0.03em;
  993. font-variant-numeric: tabular-nums;
  994. transition: color 0.2s ease;
  995. }
  996. ​
  997. .stat-card .sub {
  998. font-size: 11.5px;
  999. color: var(--text-tertiary);
  1000. margin-top: auto;
  1001. padding-top: 4px;
  1002. }
  1003. ​
  1004. .panel {
  1005. position: relative;
  1006. min-width: 0;
  1007. background: linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 94%, #fff) 0%, var(--bg-secondary) 100%);
  1008. border: 1px solid var(--border-color);
  1009. border-radius: 16px;
  1010. 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);
  1011. padding: 22px;
  1012. margin-bottom: 18px;
  1013. opacity: 0;
  1014. transform: translateY(10px);
  1015. animation: statCardIn 0.45s ease forwards;
  1016. animation-delay: 0.2s;
  1017. transition: border-color 0.2s ease, box-shadow 0.2s ease;
  1018. }
  1019. ​
  1020. .panel:hover {
  1021. border-color: var(--border-color-hover, var(--accent));
  1022. box-shadow: 0 8px 22px -10px rgba(0, 0, 0, 0.4);
  1023. }
  1024. ​
  1025. .content .panel:nth-of-type(2) {
  1026. animation-delay: 0.25s;
  1027. }
  1028. ​
  1029. .content .panel:nth-of-type(3) {
  1030. animation-delay: 0.3s;
  1031. }
  1032. ​
  1033. .content .panel:nth-of-type(4) {
  1034. animation-delay: 0.35s;
  1035. }
  1036. ​
  1037. .content .panel:nth-of-type(5) {
  1038. animation-delay: 0.4s;
  1039. }
  1040. ​
  1041. @media (prefers-reduced-motion: reduce) {
  1042. .panel {
  1043. animation: none;
  1044. opacity: 1;
  1045. transform: none;
  1046. }
  1047. }
  1048. ​
  1049. :root[data-no-intro] .panel {
  1050. animation: none;
  1051. opacity: 1;
  1052. transform: none;
  1053. }
  1054. ​
  1055. .panel h3 {
  1056. font-size: 14px;
  1057. font-weight: 600;
  1058. margin-bottom: 16px;
  1059. }
  1060. ​
  1061. .chart-wrap {
  1062. width: 100%;
  1063. overflow-x: auto;
  1064. position: relative;
  1065. }
  1066. ​
  1067. .chart-svg {
  1068. width: 100%;
  1069. height: 220px;
  1070. display: block;
  1071. }
  1072. ​
  1073. .chart-bar {
  1074. cursor: pointer;
  1075. transition: opacity 0.15s ease;
  1076. transform-box: fill-box;
  1077. transform-origin: bottom;
  1078. animation: barGrow 0.5s cubic-bezier(0.22, 1, 0.36, 1) backwards;
  1079. }
  1080. ​
  1081. .chart-bar:hover {
  1082. opacity: 0.75;
  1083. }
  1084. ​
  1085. @keyframes barGrow {
  1086. from {
  1087. transform: scaleY(0);
  1088. }
  1089. ​
  1090. to {
  1091. transform: scaleY(1);
  1092. }
  1093. }
  1094. ​
  1095. @media (prefers-reduced-motion: reduce) {
  1096. .chart-bar {
  1097. animation: none;
  1098. }
  1099. }
  1100. ​
  1101. :root[data-no-intro] .chart-bar {
  1102. animation: none;
  1103. }
  1104. ​
  1105. .chart-tooltip {
  1106. display: none;
  1107. position: fixed;
  1108. pointer-events: none;
  1109. z-index: 1000;
  1110. background: var(--bg-tertiary);
  1111. border: 1px solid var(--border-color);
  1112. border-radius: 8px;
  1113. padding: 7px 10px;
  1114. font-size: 12px;
  1115. white-space: nowrap;
  1116. box-shadow: var(--shadow);
  1117. transform: translateY(-100%) translateY(-6px) scale(0.96);
  1118. opacity: 0;
  1119. transition: opacity 0.12s ease, transform 0.12s ease;
  1120. }
  1121. ​
  1122. .chart-tooltip.visible {
  1123. opacity: 1;
  1124. transform: translateY(-100%) translateY(-6px) scale(1);
  1125. }
  1126. ​
  1127. .chart-tooltip .tt-label {
  1128. color: var(--text-secondary);
  1129. font-size: 11px;
  1130. margin-bottom: 2px;
  1131. }
  1132. ​
  1133. .chart-tooltip .tt-value {
  1134. color: var(--text-primary);
  1135. font-weight: 600;
  1136. }
  1137. ​
  1138. .chart-legend {
  1139. display: flex;
  1140. gap: 16px;
  1141. margin-bottom: 14px;
  1142. flex-wrap: wrap;
  1143. }
  1144. ​
  1145. .legend-item {
  1146. display: flex;
  1147. align-items: center;
  1148. gap: 6px;
  1149. font-size: 12px;
  1150. color: var(--text-secondary);
  1151. }
  1152. ​
  1153. .legend-item .dot {
  1154. width: 8px;
  1155. height: 8px;
  1156. border-radius: 50%;
  1157. flex-shrink: 0;
  1158. }
  1159. ​
  1160. .dot-future {
  1161. background: #4a4a4a;
  1162. }
  1163. ​
  1164. .dot-empty {
  1165. background: #eab308;
  1166. }
  1167. ​
  1168. .dot-hit {
  1169. background: #f1484e;
  1170. }
  1171. ​
  1172. .two-col {
  1173. display: grid;
  1174. grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  1175. gap: 18px;
  1176. }
  1177. ​
  1178. @media (max-width: 880px) {
  1179. .two-col {
  1180. grid-template-columns: minmax(0, 1fr);
  1181. }
  1182. }
  1183. ​
  1184. .list-row {
  1185. display: flex;
  1186. align-items: center;
  1187. justify-content: space-between;
  1188. padding: 9px 0;
  1189. border-bottom: 1px solid var(--border-color);
  1190. font-size: 13px;
  1191. gap: 12px;
  1192. }
  1193. ​
  1194. .list-row:last-child {
  1195. border-bottom: none;
  1196. }
  1197. ​
  1198. .list-row .name {
  1199. overflow: hidden;
  1200. min-width: 0;
  1201. text-overflow: ellipsis;
  1202. white-space: nowrap;
  1203. flex: 1;
  1204. color: var(--text-primary);
  1205. }
  1206. ​
  1207. .list-row .count {
  1208. background: var(--bg-tertiary);
  1209. padding: 2px 9px;
  1210. border-radius: 20px;
  1211. font-size: 12px;
  1212. font-weight: 600;
  1213. color: var(--text-secondary);
  1214. flex-shrink: 0;
  1215. }
  1216. ​
  1217. .list-row .row-actions {
  1218. display: flex;
  1219. align-items: center;
  1220. gap: 8px;
  1221. flex-shrink: 0;
  1222. }
  1223. ​
  1224. .row-copy-btn {
  1225. position: static;
  1226. display: flex;
  1227. align-items: center;
  1228. justify-content: center;
  1229. width: 24px;
  1230. height: 24px;
  1231. padding: 0;
  1232. border-radius: 6px;
  1233. border: 1px solid var(--border-color);
  1234. background: transparent;
  1235. color: var(--text-tertiary);
  1236. cursor: pointer;
  1237. flex-shrink: 0;
  1238. transition: background 0.15s, color 0.15s, border-color 0.15s;
  1239. }
  1240. ​
  1241. .row-copy-btn:hover {
  1242. background: var(--bg-hover);
  1243. color: var(--text-primary);
  1244. border-color: var(--text-tertiary);
  1245. }
  1246. ​
  1247. .row-copy-btn svg {
  1248. width: 13px;
  1249. height: 13px;
  1250. display: block;
  1251. }
  1252. ​
  1253. .row-copy-btn.copied {
  1254. color: #2ecc71;
  1255. border-color: #2ecc71;
  1256. }
  1257. ​
  1258. .list-row .flag {
  1259. width: 20px;
  1260. height: 14px;
  1261. border-radius: 4px;
  1262. object-fit: cover;
  1263. flex-shrink: 0;
  1264. border: 1px solid var(--border-color);
  1265. background: var(--bg-hover);
  1266. }
  1267. ​
  1268. .list-row .name-with-flag {
  1269. display: flex;
  1270. align-items: center;
  1271. gap: 8px;
  1272. overflow: hidden;
  1273. flex: 1;
  1274. }
  1275. ​
  1276. .list-row .name-with-flag .name {
  1277. flex: 1;
  1278. }
  1279. ​
  1280. .list-row .rank {
  1281. display: flex;
  1282. align-items: center;
  1283. justify-content: center;
  1284. width: 20px;
  1285. height: 20px;
  1286. flex-shrink: 0;
  1287. font-size: 11px;
  1288. font-weight: 700;
  1289. color: var(--text-tertiary);
  1290. }
  1291. ​
  1292. .list-row .rank.medal {
  1293. border-radius: 50%;
  1294. }
  1295. ​
  1296. .list-row .rank.medal svg {
  1297. width: 13px;
  1298. height: 13px;
  1299. display: block;
  1300. }
  1301. ​
  1302. .list-row .rank.medal-1 {
  1303. background: rgba(255, 215, 0, 0.18);
  1304. color: #ffd700;
  1305. }
  1306. ​
  1307. .list-row .rank.medal-2 {
  1308. background: rgba(192, 192, 192, 0.18);
  1309. color: #c0c0c0;
  1310. }
  1311. ​
  1312. .list-row .rank.medal-3 {
  1313. background: rgba(205, 127, 50, 0.18);
  1314. color: #cd7f32;
  1315. }
  1316. ​
  1317. .empty-state {
  1318. text-align: center;
  1319. padding: 40px 20px;
  1320. color: var(--text-secondary);
  1321. }
  1322. ​
  1323. .empty-state .big {
  1324. font-size: 32px;
  1325. margin-bottom: 10px;
  1326. }
  1327. ​
  1328. .site-list {
  1329. display: flex;
  1330. flex-direction: column;
  1331. gap: 10px;
  1332. }
  1333. ​
  1334. .site-item {
  1335. background: var(--bg-secondary);
  1336. border: 1px solid var(--border-color);
  1337. border-radius: 12px;
  1338. padding: 16px 18px;
  1339. display: flex;
  1340. gap: 10px;
  1341. align-items: flex-start;
  1342. transition: opacity .15s ease, transform .15s ease;
  1343. }
  1344. ​
  1345. .site-item.dragging {
  1346. opacity: 0.4;
  1347. }
  1348. ​
  1349. .site-item.drag-over {
  1350. border-color: var(--accent);
  1351. }
  1352. ​
  1353. .drag-handle {
  1354. flex: 0 0 auto;
  1355. cursor: grab;
  1356. color: var(--text-secondary);
  1357. padding: 4px 2px;
  1358. margin-top: 2px;
  1359. touch-action: none;
  1360. user-select: none;
  1361. }
  1362. ​
  1363. .drag-handle:active {
  1364. cursor: grabbing;
  1365. }
  1366. ​
  1367. .site-item-body {
  1368. flex: 1 1 auto;
  1369. min-width: 0;
  1370. }
  1371. ​
  1372. .site-item-top {
  1373. display: flex;
  1374. align-items: center;
  1375. justify-content: space-between;
  1376. gap: 10px;
  1377. flex-wrap: wrap;
  1378. }
  1379. ​
  1380. .site-item .name {
  1381. font-weight: 600;
  1382. font-size: 14.5px;
  1383. }
  1384. ​
  1385. .site-item .domain {
  1386. font-size: 12.5px;
  1387. color: var(--text-secondary);
  1388. }
  1389. ​
  1390. .badge {
  1391. font-size: 11px;
  1392. padding: 2px 9px;
  1393. border-radius: 20px;
  1394. font-weight: 600;
  1395. }
  1396. ​
  1397. .badge.xml {
  1398. background: var(--accent-soft);
  1399. color: var(--accent);
  1400. }
  1401. ​
  1402. .badge.mysql {
  1403. background: var(--success-soft);
  1404. color: var(--success);
  1405. }
  1406. ​
  1407. .badge.pmaf {
  1408. background: var(--info-soft);
  1409. color: var(--info);
  1410. }
  1411. ​
  1412. .badge.inactive {
  1413. background: var(--bg-tertiary);
  1414. color: var(--text-tertiary);
  1415. }
  1416. ​
  1417. .site-actions {
  1418. display: flex;
  1419. gap: 8px;
  1420. margin-top: 12px;
  1421. }
  1422. ​
  1423. .btn {
  1424. padding: 8px 14px;
  1425. border-radius: 10px;
  1426. font-size: 12.5px;
  1427. font-weight: 600;
  1428. cursor: pointer;
  1429. border: 1px solid color-mix(in srgb, var(--border-color) 60%, var(--text-tertiary));
  1430. background: var(--bg-tertiary);
  1431. color: var(--text-primary);
  1432. box-sizing: border-box;
  1433. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 1px 2px rgba(0, 0, 0, 0.18);
  1434. transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
  1435. }
  1436. ​
  1437. .btn:active {
  1438. transform: scale(0.97);
  1439. }
  1440. ​
  1441. .topbar-actions .btn {
  1442. height: 36px;
  1443. display: inline-flex;
  1444. align-items: center;
  1445. }
  1446. ​
  1447. .btn:hover {
  1448. background: var(--bg-hover);
  1449. }
  1450. ​
  1451. .btn.primary {
  1452. background: linear-gradient(155deg, var(--accent-hover), var(--accent));
  1453. border-color: var(--accent);
  1454. color: white;
  1455. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 1px 2px rgba(0, 0, 0, 0.2);
  1456. }
  1457. ​
  1458. .btn.primary:hover {
  1459. background: linear-gradient(155deg, var(--accent-hover), var(--accent-hover));
  1460. box-shadow: 0 4px 14px color-mix(in srgb, var(--accent) 40%, transparent), inset 0 1px 0 rgba(255, 255, 255, 0.14);
  1461. transform: translateY(-1px);
  1462. }
  1463. ​
  1464. .btn.primary:active {
  1465. transform: translateY(0) scale(0.98);
  1466. }
  1467. ​
  1468. .btn.danger {
  1469. color: var(--accent);
  1470. background: transparent;
  1471. border-color: color-mix(in srgb, var(--accent) 45%, transparent);
  1472. }
  1473. ​
  1474. .btn.danger:hover {
  1475. background: var(--accent-soft);
  1476. border-color: var(--accent);
  1477. }
  1478. ​
  1479. .embed-block {
  1480. background: var(--bg-primary);
  1481. border: 1px solid var(--border-color);
  1482. border-radius: 8px;
  1483. padding: 12px 68px 12px 14px;
  1484. font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  1485. font-size: 12px;
  1486. margin-top: 10px;
  1487. position: relative;
  1488. display: block;
  1489. }
  1490. ​
  1491. .embed-code {
  1492. display: block;
  1493. width: 100%;
  1494. overflow-x: auto;
  1495. white-space: pre;
  1496. color: var(--text-primary);
  1497. font-family: inherit;
  1498. font-size: inherit;
  1499. }
  1500. ​
  1501. .copy-btn {
  1502. position: absolute;
  1503. top: 8px;
  1504. right: 8px;
  1505. font-size: 11px;
  1506. padding: 4px 9px;
  1507. background: var(--bg-tertiary);
  1508. border: 1px solid var(--border-color);
  1509. border-radius: 6px;
  1510. cursor: pointer;
  1511. color: var(--text-secondary);
  1512. }
  1513. ​
  1514. .modal-backdrop {
  1515. display: none;
  1516. position: fixed;
  1517. inset: 0;
  1518. background: rgba(0, 0, 0, 0.55);
  1519. z-index: 100;
  1520. align-items: center;
  1521. justify-content: center;
  1522. padding: 20px;
  1523. }
  1524. ​
  1525. .modal-backdrop.show {
  1526. display: flex;
  1527. }
  1528. ​
  1529. .modal {
  1530. background: var(--bg-secondary);
  1531. border: 1px solid var(--border-color);
  1532. border-radius: 14px;
  1533. padding: 26px;
  1534. max-width: 460px;
  1535. width: 100%;
  1536. max-height: 90vh;
  1537. overflow-y: auto;
  1538. }
  1539. ​
  1540. .modal h2 {
  1541. font-size: 17px;
  1542. margin-bottom: 18px;
  1543. }
  1544. ​
  1545. .form-group {
  1546. margin-bottom: 16px;
  1547. }
  1548. ​
  1549. .form-group label {
  1550. display: block;
  1551. font-size: 12.5px;
  1552. font-weight: 500;
  1553. color: var(--text-secondary);
  1554. margin-bottom: 6px;
  1555. }
  1556. ​
  1557. .form-group input,
  1558. .form-group select {
  1559. width: 100%;
  1560. padding: 10px 12px;
  1561. background: var(--bg-primary);
  1562. border: 1px solid var(--border-color);
  1563. border-radius: 10px;
  1564. color: var(--text-primary);
  1565. font-size: 13.5px;
  1566. font-family: inherit;
  1567. box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25);
  1568. transition: border-color 0.2s ease, box-shadow 0.2s ease;
  1569. }
  1570. ​
  1571. .form-group input:focus,
  1572. .form-group select:focus {
  1573. outline: none;
  1574. border-color: var(--accent);
  1575. box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
  1576. }
  1577. ​
  1578. .form-actions {
  1579. display: flex;
  1580. gap: 10px;
  1581. margin-top: 20px;
  1582. }
  1583. ​
  1584. .form-actions .btn {
  1585. flex: 1;
  1586. text-align: center;
  1587. }
  1588. ​
  1589. .checkbox-row {
  1590. display: flex;
  1591. align-items: center;
  1592. gap: 8px;
  1593. margin-bottom: 16px;
  1594. }
  1595. ​
  1596. .switch-row {
  1597. display: flex;
  1598. align-items: center;
  1599. gap: 10px;
  1600. cursor: pointer;
  1601. user-select: none;
  1602. }
  1603. ​
  1604. .switch {
  1605. width: 38px;
  1606. height: 22px;
  1607. background: var(--border-color);
  1608. border-radius: 20px;
  1609. box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
  1610. position: relative;
  1611. transition: background 0.25s ease, box-shadow 0.25s ease;
  1612. flex-shrink: 0;
  1613. }
  1614. ​
  1615. .switch.on {
  1616. background: linear-gradient(155deg, var(--accent-hover), var(--accent));
  1617. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 0 12px color-mix(in srgb, var(--accent) 35%, transparent);
  1618. }
  1619. ​
  1620. .switch::after {
  1621. content: '';
  1622. position: absolute;
  1623. top: 2px;
  1624. left: 2px;
  1625. width: 18px;
  1626. height: 18px;
  1627. background: white;
  1628. border-radius: 50%;
  1629. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
  1630. transition: transform 0.25s cubic-bezier(.22, 1, .36, 1);
  1631. }
  1632. ​
  1633. .switch.on::after {
  1634. transform: translateX(16px);
  1635. }
  1636. ​
  1637. .checkbox-row input {
  1638. width: auto;
  1639. }
  1640. ​
  1641. .switch-checkbox-row {
  1642. display: flex;
  1643. align-items: center;
  1644. gap: 10px;
  1645. margin-bottom: 16px;
  1646. }
  1647. ​
  1648. .switch-checkbox-row label {
  1649. margin: 0;
  1650. cursor: pointer;
  1651. }
  1652. ​
  1653. .switch-checkbox {
  1654. appearance: none;
  1655. -webkit-appearance: none;
  1656. width: 38px;
  1657. height: 22px;
  1658. background: var(--border-color);
  1659. border-radius: 20px;
  1660. box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
  1661. position: relative;
  1662. flex-shrink: 0;
  1663. cursor: pointer;
  1664. transition: background 0.25s ease, box-shadow 0.25s ease;
  1665. margin: 0;
  1666. }
  1667. ​
  1668. .switch-checkbox::after {
  1669. content: '';
  1670. position: absolute;
  1671. top: 2px;
  1672. left: 2px;
  1673. width: 18px;
  1674. height: 18px;
  1675. background: white;
  1676. border-radius: 50%;
  1677. box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
  1678. transition: transform 0.25s cubic-bezier(.22, 1, .36, 1);
  1679. }
  1680. ​
  1681. .switch-checkbox:checked {
  1682. background: linear-gradient(155deg, var(--accent-hover), var(--accent));
  1683. box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 0 12px color-mix(in srgb, var(--accent) 35%, transparent);
  1684. }
  1685. ​
  1686. .switch-checkbox:checked::after {
  1687. transform: translateX(16px);
  1688. }
  1689. ​
  1690. .advanced-toggle {
  1691. display: flex;
  1692. align-items: center;
  1693. gap: 6px;
  1694. cursor: pointer;
  1695. user-select: none;
  1696. color: var(--text-secondary, #999);
  1697. font-size: 13px;
  1698. margin: 4px 0 16px;
  1699. width: fit-content;
  1700. }
  1701. ​
  1702. .advanced-toggle svg {
  1703. transition: transform 0.2s;
  1704. }
  1705. ​
  1706. .advanced-toggle.open svg {
  1707. transform: rotate(90deg);
  1708. }
  1709. ​
  1710. .advanced-section {
  1711. display: none;
  1712. }
  1713. ​
  1714. .advanced-section.open {
  1715. display: block;
  1716. }
  1717. </style>
  1718. </head>
  1719. ​
  1720. <body>
  1721. <div class="app">
  1722. ​
  1723. <div class="sidebar-backdrop" id="backdrop" onclick="closeSidebar()"></div>
  1724. <aside class="sidebar" id="sidebar">
  1725. <div class="brand">
  1726. <div class="mark">
  1727. <img src="logo.webp" alt="Logo" width="24" height="24" loading="lazy">
  1728. </div>
  1729. <div>
  1730. <div class="name"><?= sanitize($siteName) ?></div>
  1731. <div class="sub">PriviMetrics 2</div>
  1732. </div>
  1733. </div>
  1734. <nav class="nav">
  1735. <a href="?view=overview<?= $activeSiteId ? '&site=' . urlencode($activeSiteId) : '' ?>&range=<?= urlencode($range) ?>" class="<?= $view === 'overview' ? 'active' : '' ?>">
  1736. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  1737. <path d="M7 12v7M12 4v15M17 8v11"></path>
  1738. </svg>
  1739. Analytics
  1740. </a>
  1741. <a href="?view=sites<?= $activeSiteId ? '&site=' . urlencode($activeSiteId) : '' ?>&range=<?= urlencode($range) ?>" class="<?= $view === 'sites' ? 'active' : '' ?>">
  1742. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  1743. <path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"></path>
  1744. <path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"></path>
  1745. </svg>
  1746. Sites
  1747. </a>
  1748. <a href="settings.php?return=<?= urlencode('dashboard.php' . ($_SERVER['QUERY_STRING'] !== '' ? '?' . $_SERVER['QUERY_STRING'] : '')) ?>">
  1749. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  1750. <circle cx="12" cy="12" r="3" />
  1751. <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" />
  1752. </svg>
  1753. Settings
  1754. </a>
  1755. </nav>
  1756. <div class="sidebar-footer">
  1757. <div class="user">Signed in as <strong><?= sanitize($_SESSION['admin_username'] ?? '') ?></strong></div>
  1758. <a href="logout.php">
  1759. <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
  1760. <path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
  1761. <path d="M16 17l5-5-5-5" />
  1762. <path d="M21 12H9" />
  1763. </svg>
  1764. Sign out
  1765. </a>
  1766. </div>
  1767. </aside>
  1768. ​
  1769. <div class="main">
  1770. <div class="mobile-topbar">
  1771. <div class="mobile-menu-btn" onclick="openSidebar()">
  1772. <svg width="24" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  1773. <line x1="3" y1="12" x2="21" y2="12" />
  1774. <line x1="3" y1="6" x2="21" y2="6" />
  1775. <line x1="3" y1="18" x2="21" y2="18" />
  1776. </svg>
  1777. </div>
  1778. <strong style="font-size:14px;"><?= sanitize($siteName) ?></strong>
  1779. <div style="width:38px;"></div>
  1780. </div>
  1781. ​
  1782. <?php if ($view === 'overview'): ?>
  1783. <div class="topbar">
  1784. <h1><?= $currentSite ? sanitize($currentSite['name']) : 'Analytics' ?></h1>
  1785. <div class="topbar-actions">
  1786. <?php if (count($sites) > 1): ?>
  1787. <select class="select" onchange="location.href='?view=overview&site='+this.value+'&range=<?= sanitize($range) ?>'">
  1788. <?php foreach ($sites as $s): ?>
  1789. <option value="<?= sanitize($s['id']) ?>" <?= $s['id'] === $activeSiteId ? 'selected' : '' ?>><?= sanitize($s['name']) ?></option>
  1790. <?php endforeach; ?>
  1791. </select>
  1792. <?php endif; ?>
  1793. <select class="select" onchange="location.href='?view=overview&site=<?= sanitize($activeSiteId) ?>&range='+this.value">
  1794. <option value="24h" <?= $range === '24h' ? 'selected' : '' ?>>Today</option>
  1795. <option value="7d" <?= $range === '7d' ? 'selected' : '' ?>>Last 7 days</option>
  1796. <option value="30d" <?= $range === '30d' ? 'selected' : '' ?>>Last 30 days</option>
  1797. <option value="90d" <?= $range === '90d' ? 'selected' : '' ?>>Last 90 days</option>
  1798. <option value="1y" <?= $range === '1y' ? 'selected' : '' ?>>Last year</option>
  1799. </select>
  1800. <div class="icon-btn" onclick="toggleTheme()" title="Switch between light and dark mode">
  1801. <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  1802. <circle cx="12" cy="12" r="5" />
  1803. <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" />
  1804. </svg>
  1805. </div>
  1806. </div>
  1807. </div>
  1808. <div class="content">
  1809. <?php if ($flash): ?><div class="flash <?= $flash['type'] ?>"><?= sanitize($flash['text']) ?></div><?php endif; ?>
  1810. ​
  1811. <?php if (!$currentSite): ?>
  1812. <div class="panel">
  1813. <div class="empty-state">
  1814. <div class="big">
  1815. <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
  1816. <rect x="2" y="3" width="20" height="14" rx="2"></rect>
  1817. <line x1="8" y1="21" x2="16" y2="21"></line>
  1818. <line x1="12" y1="17" x2="12" y2="21"></line>
  1819. <path d="M7 12l3-3 2 2 4-4"></path>
  1820. </svg>
  1821. </div>
  1822. <p>You haven't added any sites yet.</p>
  1823. <div style="margin-top:16px;"><a href="?view=sites" class="btn primary">Add your first site</a></div>
  1824. </div>
  1825. </div>
  1826. <?php else: ?>
  1827. ​
  1828. <div class="stat-grid">
  1829. <div class="stat-card">
  1830. <div class="label" style="display: flex; align-items: center; gap: 6px;">
  1831. <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;">
  1832. <path d="M2 12h3l3-9 4 18 4-9h6"></path>
  1833. </svg>
  1834. Page views
  1835. </div>
  1836. <div class="value"><?= formatNumber($stats['total_views']) ?></div>
  1837. <div class="sub">in this period</div>
  1838. </div>
  1839. <div class="stat-card">
  1840. <div class="label" style="display: flex; align-items: center; gap: 6px;">
  1841. <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;">
  1842. <path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
  1843. <circle cx="8.5" cy="7" r="4"></circle>
  1844. <line x1="20" y1="8" x2="20" y2="14"></line>
  1845. <line x1="23" y1="11" x2="17" y2="11"></line>
  1846. </svg>
  1847. Unique visitors
  1848. </div>
  1849. <div class="value"><?= formatNumber($stats['unique_visitors']) ?></div>
  1850. <div class="sub">counted per day, without cookies</div>
  1851. </div>
  1852. <div class="stat-card">
  1853. <div class="label" style="display: flex; align-items: center; gap: 6px;">
  1854. <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;">
  1855. <ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
  1856. <path d="M21 5v6c0 1.66-4.03 3-9 3s-9-1.34-9-3V5"></path>
  1857. <path d="M3 11v6c0 1.66 4.03 3 9 3s9-1.34 9-3v-6"></path>
  1858. </svg>
  1859. Stored in
  1860. </div>
  1861. <div class="value" style="font-size:18px; text-transform:uppercase;"><?= sanitize($currentSite['storage']) ?></div>
  1862. <div class="sub">where this site's stats are kept</div>
  1863. </div>
  1864. <div class="stat-card">
  1865. <div class="label" style="display: flex; align-items: center; gap: 6px;">
  1866. <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;">
  1867. <circle cx="18" cy="5" r="3"></circle>
  1868. <circle cx="6" cy="12" r="3"></circle>
  1869. <circle cx="18" cy="19" r="3"></circle>
  1870. <line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line>
  1871. <line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line>
  1872. </svg>
  1873. All sites combined
  1874. </div>
  1875. <div class="value"><?= formatNumber($totalHitsAllSites) ?></div>
  1876. <div class="sub">views across all your sites</div>
  1877. </div>
  1878. </div>
  1879. ​
  1880. <div class="panel">
  1881. <h3>
  1882. <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;">
  1883. <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
  1884. </svg>
  1885. Visits over time
  1886. </h3>
  1887. <div class="chart-legend">
  1888. <span class="legend-item"><span class="dot dot-future"></span>Not yet</span>
  1889. <span class="legend-item"><span class="dot dot-empty"></span>No views</span>
  1890. <span class="legend-item"><span class="dot dot-hit"></span>Views</span>
  1891. </div>
  1892. <div class="chart-wrap" id="trafficChartWrap">
  1893. <?php
  1894. $timelineValues = array_values($stats['timeline']);
  1895. $timelineKeys = array_keys($stats['timeline']);
  1896. $timelineFutureFlags = array_values($stats['timeline_future']);
  1897. $maxVal = max($timelineValues ?: [1]);
  1898. $maxVal = $maxVal === 0 ? 1 : $maxVal;
  1899. $count = count($timelineValues);
  1900. $chartW = 1000;
  1901. $chartH = 200;
  1902. $padL = 6;
  1903. $padR = 6;
  1904. $padTop = 10;
  1905. $padBottom = 24;
  1906. $usableW = $chartW - $padL - $padR;
  1907. $usableH = $chartH - $padTop - $padBottom;
  1908. $gap = 3;
  1909. $barW = $count > 0 ? max(1, ($usableW / $count) - $gap) : 0;
  1910. $colorFor = ['future' => '#4a4a4a', 'empty' => '#eab308', 'hit' => '#f1484e'];
  1911. ​
  1912. $labelFor = function (string $key) use ($range): string {
  1913. if ($range === '24h') {
  1914. return $key;
  1915. }
  1916. if ($range === '1y') {
  1917. $ts = strtotime($key . '-01 00:00:00');
  1918. return $ts ? date('M Y', $ts) : $key;
  1919. }
  1920. $ts = strtotime($key . ' 00:00:00');
  1921. return $ts ? date('M j, Y', $ts) : $key;
  1922. };
  1923. ?>
  1924. <svg class="chart-svg" id="trafficChartSvg" viewBox="0 0 <?= $chartW ?> <?= $chartH ?>" preserveAspectRatio="none">
  1925. <?php foreach ($timelineValues as $i => $v):
  1926. $state = !empty($timelineFutureFlags[$i]) ? 'future' : ($v > 0 ? 'hit' : 'empty');
  1927. $color = $colorFor[$state];
  1928. $barH = $state === 'hit' ? max(2, ($v / $maxVal) * $usableH) : 2;
  1929. $x = $padL + $i * ($barW + $gap);
  1930. $y = $chartH - $padBottom - $barH;
  1931. $label = $labelFor($timelineKeys[$i]);
  1932. ?>
  1933. <?php $barRx = min(4, $barW / 2, $barH / 2); ?>
  1934. <?php $barDelay = round(min($i, 60) * 0.012, 3); ?>
  1935. <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 ?>" />
  1936. <?php endforeach; ?>
  1937. </svg>
  1938. <div class="chart-tooltip" id="trafficTooltip"></div>
  1939. </div>
  1940. </div>
  1941. <script>
  1942. (function() {
  1943. var wrap = document.getElementById('trafficChartWrap');
  1944. var svg = document.getElementById('trafficChartSvg');
  1945. var tooltip = document.getElementById('trafficTooltip');
  1946. if (!wrap || !svg || !tooltip) return;
  1947. ​
  1948. document.body.appendChild(tooltip);
  1949. ​
  1950. var stateLabel = {
  1951. future: 'Not yet',
  1952. empty: 'No views',
  1953. hit: 'view'
  1954. };
  1955. ​
  1956. function showTooltip(bar, evt) {
  1957. var label = bar.getAttribute('data-label');
  1958. var value = bar.getAttribute('data-value');
  1959. var state = bar.getAttribute('data-state');
  1960. ​
  1961. var valueText = state === 'future' ? 'Not yet' : (value + ' ' + (value === '1' ? 'view' : 'views'));
  1962. tooltip.innerHTML = '<div class="tt-label">' + label + '</div><div class="tt-value">' + valueText + '</div>';
  1963. tooltip.style.display = 'block';
  1964. ​
  1965. var left = evt.clientX;
  1966. var top = evt.clientY - 14;
  1967. ​
  1968. var ttWidth = tooltip.offsetWidth || 110;
  1969. var ttHeight = tooltip.offsetHeight || 40;
  1970. ​
  1971. if (left + ttWidth + 14 > window.innerWidth) {
  1972. left = left - ttWidth - 14;
  1973. } else {
  1974. left = left + 14;
  1975. }
  1976. left = Math.max(4, Math.min(left, window.innerWidth - ttWidth - 4));
  1977. top = Math.max(ttHeight + 4, top);
  1978. ​
  1979. tooltip.style.left = left + 'px';
  1980. tooltip.style.top = top + 'px';
  1981. ​
  1982. requestAnimationFrame(function() {
  1983. tooltip.classList.add('visible');
  1984. });
  1985. }
  1986. ​
  1987. function hideTooltip() {
  1988. tooltip.classList.remove('visible');
  1989. setTimeout(function() {
  1990. if (!tooltip.classList.contains('visible')) {
  1991. tooltip.style.display = 'none';
  1992. }
  1993. }, 120);
  1994. }
  1995. ​
  1996. svg.addEventListener('mousemove', function(evt) {
  1997. var target = evt.target;
  1998. if (target && target.classList && target.classList.contains('chart-bar')) {
  1999. showTooltip(target, evt);
  2000. } else {
  2001. hideTooltip();
  2002. }
  2003. });
  2004. svg.addEventListener('mouseleave', hideTooltip);
  2005. ​
  2006. svg.addEventListener('touchstart', function(evt) {
  2007. var touch = evt.touches[0];
  2008. var el = document.elementFromPoint(touch.clientX, touch.clientY);
  2009. if (el && el.classList && el.classList.contains('chart-bar')) {
  2010. showTooltip(el, touch);
  2011. }
  2012. }, {
  2013. passive: true
  2014. });
  2015. })();
  2016. </script>
  2017. ​
  2018. <div class="two-col">
  2019. <div class="panel">
  2020. <h3>
  2021. <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;">
  2022. <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
  2023. <polyline points="13 2 13 9 20 9"></polyline>
  2024. </svg>
  2025. Most visited pages
  2026. </h3>
  2027. <?php if ($stats['top_pages']): $rank = 0;
  2028. foreach ($stats['top_pages'] as $page => $count): $page = (string) $page;
  2029. $rank++; ?>
  2030. <div class="list-row">
  2031. <span class="rank<?= $rank <= 3 ? ' medal medal-' . $rank : '' ?>"><?= $rank <= 3 ? rankMedalSvg() : $rank ?></span>
  2032. <span class="name" title="<?= sanitize($page) ?>"><?= sanitize(truncateText($page)) ?></span>
  2033. <div class="row-actions">
  2034. <span class="count"><?= formatNumber($count) ?></span>
  2035. <button type="button" class="row-copy-btn" data-copy="<?= sanitize($page) ?>" title="Copy" onclick="copyListItem(this)">
  2036. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  2037. <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
  2038. <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
  2039. </svg>
  2040. </button>
  2041. </div>
  2042. </div>
  2043. <?php endforeach;
  2044. else: ?>
  2045. <div class="empty-state">Nothing here for this period yet.</div>
  2046. <?php endif; ?>
  2047. </div>
  2048. <div class="panel">
  2049. <h3>
  2050. <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;">
  2051. <circle cx="11" cy="11" r="8"></circle>
  2052. <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
  2053. </svg>
  2054. What people searched for
  2055. </h3>
  2056. <?php if (!$featureSearchQueries): ?>
  2057. <div class="empty-state">Search tracking is turned off for this site.</div>
  2058. <?php elseif ($stats['top_searches']): $rank = 0;
  2059. foreach ($stats['top_searches'] as $q => $count): $q = (string) $q;
  2060. $rank++; ?>
  2061. <div class="list-row">
  2062. <span class="rank<?= $rank <= 3 ? ' medal medal-' . $rank : '' ?>"><?= $rank <= 3 ? rankMedalSvg() : $rank ?></span>
  2063. <span class="name" title="<?= sanitize($q) ?>"><?= sanitize(truncateText($q)) ?></span>
  2064. <div class="row-actions">
  2065. <span class="count"><?= formatNumber($count) ?></span>
  2066. <button type="button" class="row-copy-btn" data-copy="<?= sanitize($q) ?>" title="Copy" onclick="copyListItem(this)">
  2067. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  2068. <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
  2069. <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
  2070. </svg>
  2071. </button>
  2072. </div>
  2073. </div>
  2074. <?php endforeach;
  2075. else: ?>
  2076. <div class="empty-state">Nobody has searched your site in this period.</div>
  2077. <?php endif; ?>
  2078. </div>
  2079. </div>
  2080. ​
  2081. <div class="two-col">
  2082. <div class="panel">
  2083. <h3>
  2084. <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;">
  2085. <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
  2086. <circle cx="12" cy="10" r="3"></circle>
  2087. </svg>
  2088. Countries
  2089. </h3>
  2090. <?php if (!$featureVisitorCountries): ?>
  2091. <div class="empty-state">Country tracking is turned off for this site.</div>
  2092. <?php elseif ($stats['top_countries']): $rank = 0;
  2093. foreach ($stats['top_countries'] as $country => $count): $country = (string) $country;
  2094. $rank++; ?>
  2095. <div class="list-row">
  2096. <span class="rank<?= $rank <= 3 ? ' medal medal-' . $rank : '' ?>"><?= $rank <= 3 ? rankMedalSvg() : $rank ?></span>
  2097. <div class="name-with-flag">
  2098. <?= countryFlagHtml($country) ?>
  2099. <span class="name" title="<?= sanitize($country) ?>"><?= sanitize(truncateText($country)) ?></span>
  2100. </div>
  2101. <div class="row-actions">
  2102. <span class="count"><?= formatNumber($count) ?></span>
  2103. <button type="button" class="row-copy-btn" data-copy="<?= sanitize($country) ?>" title="Copy" onclick="copyListItem(this)">
  2104. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  2105. <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
  2106. <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
  2107. </svg>
  2108. </button>
  2109. </div>
  2110. </div>
  2111. <?php endforeach;
  2112. else: ?>
  2113. <div class="empty-state">Nothing here for this period yet.</div>
  2114. <?php endif; ?>
  2115. </div>
  2116. <div class="panel">
  2117. <h3>
  2118. <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;">
  2119. <rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect>
  2120. <line x1="12" y1="18" x2="12.01" y2="18"></line>
  2121. </svg>
  2122. Devices
  2123. </h3>
  2124. <?php if (!$featureDeviceType): ?>
  2125. <div class="empty-state">Device tracking is turned off.</div>
  2126. <?php elseif ($stats['top_devices']): $rank = 0;
  2127. foreach ($stats['top_devices'] as $device => $count): $device = (string) $device;
  2128. $rank++; ?>
  2129. <div class="list-row">
  2130. <span class="rank<?= $rank <= 3 ? ' medal medal-' . $rank : '' ?>"><?= $rank <= 3 ? rankMedalSvg() : $rank ?></span>
  2131. <span class="name" title="<?= sanitize($device) ?>"><?= sanitize($device) ?></span>
  2132. <div class="row-actions">
  2133. <span class="count"><?= formatNumber($count) ?></span>
  2134. <button type="button" class="row-copy-btn" data-copy="<?= sanitize($device) ?>" title="Copy" onclick="copyListItem(this)">
  2135. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  2136. <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
  2137. <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
  2138. </svg>
  2139. </button>
  2140. </div>
  2141. </div>
  2142. <?php endforeach;
  2143. else: ?>
  2144. <div class="empty-state">Nothing here for this period yet.</div>
  2145. <?php endif; ?>
  2146. </div>
  2147. </div>
  2148. ​
  2149. <div class="two-col">
  2150. <div class="panel">
  2151. <h3>
  2152. <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;">
  2153. <circle cx="12" cy="12" r="10"></circle>
  2154. <line x1="2" y1="12" x2="22" y2="12"></line>
  2155. <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>
  2156. </svg>
  2157. Websites sending visitors
  2158. </h3>
  2159. <?php if (!$featureTrafficSources): ?>
  2160. <div class="empty-state">Traffic source tracking is turned off for this site.</div>
  2161. <?php elseif ($stats['top_referrers']): $rank = 0;
  2162. foreach ($stats['top_referrers'] as $ref => $count): $ref = (string) $ref;
  2163. $rank++; ?>
  2164. <div class="list-row">
  2165. <span class="rank<?= $rank <= 3 ? ' medal medal-' . $rank : '' ?>"><?= $rank <= 3 ? rankMedalSvg() : $rank ?></span>
  2166. <span class="name" title="<?= sanitize($ref) ?>"><?= sanitize(truncateText($ref)) ?></span>
  2167. <div class="row-actions">
  2168. <span class="count"><?= formatNumber($count) ?></span>
  2169. <button type="button" class="row-copy-btn" data-copy="<?= sanitize($ref) ?>" title="Copy" onclick="copyListItem(this)">
  2170. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  2171. <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
  2172. <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
  2173. </svg>
  2174. </button>
  2175. </div>
  2176. </div>
  2177. <?php endforeach;
  2178. else: ?>
  2179. <div class="empty-state">No visits from other websites yet.</div>
  2180. <?php endif; ?>
  2181. </div>
  2182. <div class="panel">
  2183. <h3>
  2184. <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;">
  2185. <path d="M3 11l18-5v12L3 14v-3z"></path>
  2186. <path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"></path>
  2187. </svg>
  2188. Campaigns (UTM)
  2189. </h3>
  2190. <?php if (!$featureTrafficSources): ?>
  2191. <div class="empty-state">Traffic source tracking is turned off for this site.</div>
  2192. <?php elseif ($stats['top_utm_sources']): $rank = 0;
  2193. foreach ($stats['top_utm_sources'] as $utm => $count): $utm = (string) $utm;
  2194. $rank++; ?>
  2195. <div class="list-row">
  2196. <span class="rank<?= $rank <= 3 ? ' medal medal-' . $rank : '' ?>"><?= $rank <= 3 ? rankMedalSvg() : $rank ?></span>
  2197. <span class="name" title="<?= sanitize($utm) ?>"><?= sanitize(truncateText($utm)) ?></span>
  2198. <div class="row-actions">
  2199. <span class="count"><?= formatNumber($count) ?></span>
  2200. <button type="button" class="row-copy-btn" data-copy="<?= sanitize($utm) ?>" title="Copy" onclick="copyListItem(this)">
  2201. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  2202. <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
  2203. <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
  2204. </svg>
  2205. </button>
  2206. </div>
  2207. </div>
  2208. <?php endforeach;
  2209. else: ?>
  2210. <div class="empty-state">No visits from campaign links yet.</div>
  2211. <?php endif; ?>
  2212. </div>
  2213. </div>
  2214. <?php endif; ?>
  2215. </div>
  2216. ​
  2217. <?php else: ?>
  2218. <div class="topbar">
  2219. <h1>Sites</h1>
  2220. <div class="topbar-actions">
  2221. <button class="btn primary" onclick="openModal('create')">+ Add site</button>
  2222. </div>
  2223. </div>
  2224. <div class="content">
  2225. <?php if ($flash): ?><div class="flash <?= $flash['type'] ?>"><?= sanitize($flash['text']) ?></div><?php endif; ?>
  2226. ​
  2227. <?php if (!$sites): ?>
  2228. <div class="panel">
  2229. <div class="empty-state">
  2230. <div class="big">
  2231. <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
  2232. <circle cx="12" cy="12" r="9" />
  2233. <path d="M3 12h18" />
  2234. <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" />
  2235. </svg>
  2236. </div>
  2237. <p>No sites yet. Add one and you'll start seeing visits here.</p>
  2238. </div>
  2239. </div>
  2240. <?php else: ?>
  2241. <div class="site-list" id="siteList">
  2242. <?php foreach ($sites as $s): ?>
  2243. <div class="site-item" data-id="<?= sanitize($s['id']) ?>">
  2244. <div class="drag-handle" title="Drag to reorder">
  2245. <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  2246. <circle cx="9" cy="6" r="1.2"></circle>
  2247. <circle cx="15" cy="6" r="1.2"></circle>
  2248. <circle cx="9" cy="12" r="1.2"></circle>
  2249. <circle cx="15" cy="12" r="1.2"></circle>
  2250. <circle cx="9" cy="18" r="1.2"></circle>
  2251. <circle cx="15" cy="18" r="1.2"></circle>
  2252. </svg>
  2253. </div>
  2254. <div class="site-item-body">
  2255. <div class="site-item-top">
  2256. <div>
  2257. <div class="name"><?= sanitize($s['name']) ?></div>
  2258. <div class="domain"><?= sanitize($s['domain']) ?></div>
  2259. </div>
  2260. <div style="display:flex; gap:6px; align-items:center;">
  2261. <span class="badge <?= sanitize($s['storage']) ?>"><?= strtoupper(sanitize($s['storage'])) ?></span>
  2262. <?php if (!$s['active']): ?><span class="badge inactive">PAUSED</span><?php endif; ?>
  2263. </div>
  2264. </div>
  2265. ​
  2266. <?php
  2267. $embedCode = '<script src="' . $baseUrl . '/privimetrics.js" data-code="' . $s['tracking_code'] . '"></script>';
  2268. $embedCodeNoIp = '<script src="' . $baseUrl . '/privimetrics.js" data-code="' . $s['tracking_code'] . '" data-no-track-ip="true"></script>';
  2269. $embedEscaped = htmlspecialchars($embedCode, ENT_QUOTES, 'UTF-8');
  2270. $embedEscapedNoIp = htmlspecialchars($embedCodeNoIp, ENT_QUOTES, 'UTF-8');
  2271. ?>
  2272. <div class="switch-row" style="margin-top:10px;" onclick="toggleEmbedNoIp('<?= sanitize($s['id']) ?>', this)">
  2273. <div class="switch" id="noip-switch-<?= sanitize($s['id']) ?>"></div>
  2274. <span>Don't collect IP addresses</span>
  2275. </div>
  2276. <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>
  2277. ​
  2278. <div class="site-actions">
  2279. <a href="?view=overview&site=<?= sanitize($s['id']) ?>" class="btn">
  2280. <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;">
  2281. <line x1="18" y1="20" x2="18" y2="10"></line>
  2282. <line x1="12" y1="20" x2="12" y2="4"></line>
  2283. <line x1="6" y1="20" x2="6" y2="14"></line>
  2284. </svg>
  2285. View stats
  2286. </a>
  2287. <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') ?>)'>
  2288. <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;">
  2289. <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
  2290. <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
  2291. </svg>
  2292. Edit
  2293. </button>
  2294. <button class="btn danger" onclick="confirmDelete('<?= sanitize($s['id']) ?>', '<?= sanitize(addslashes($s['name'])) ?>')">
  2295. <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;">
  2296. <polyline points="3 6 5 6 21 6"></polyline>
  2297. <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>
  2298. <line x1="10" y1="11" x2="10" y2="17"></line>
  2299. <line x1="14" y1="11" x2="14" y2="17"></line>
  2300. </svg>
  2301. Delete
  2302. </button>
  2303. </div>
  2304. </div>
  2305. </div>
  2306. <?php endforeach; ?>
  2307. </div>
  2308. <?php endif; ?>
  2309. </div>
  2310. <?php endif; ?>
  2311. </div>
  2312. </div>
  2313. ​
  2314. <div class="modal-backdrop" id="siteModal">
  2315. <div class="modal">
  2316. <h2 id="modalTitle">Add site</h2>
  2317. <form method="post" id="siteForm">
  2318. <input type="hidden" name="csrf_token" value="<?= sanitize($csrfToken) ?>">
  2319. <input type="hidden" name="action" id="formAction" value="create_site">
  2320. <input type="hidden" name="id" id="formId" value="">
  2321. ​
  2322. <div class="form-group">
  2323. <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;">
  2324. <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
  2325. <line x1="3" y1="9" x2="21" y2="9"></line>
  2326. <line x1="9" y1="21" x2="9" y2="9"></line>
  2327. </svg>Site name</label>
  2328. <input type="text" name="name" id="formName" required placeholder="e.g. My Store">
  2329. </div>
  2330. <div class="form-group">
  2331. <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;">
  2332. <path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"></path>
  2333. <path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"></path>
  2334. </svg>Domain</label>
  2335. <input type="text" name="domain" id="formDomain" required placeholder="example.com">
  2336. </div>
  2337. <div class="form-group" id="storageGroup">
  2338. <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;">
  2339. <ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
  2340. <path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
  2341. <path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
  2342. </svg>Where to store stats</label>
  2343. <select name="storage" id="formStorage">
  2344. <option value="xml">Files (XML), works on any server</option>
  2345. <option value="pmaf">Compact files (PMAF), faster and smaller</option>
  2346. <option value="mysql" <?= !$storage->isMySQLAvailable() ? 'disabled' : '' ?>>MySQL <?= !$storage->isMySQLAvailable() ? '(set it up in Settings first)' : '' ?></option>
  2347. </select>
  2348. </div>
  2349. <div class="form-group">
  2350. <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;">
  2351. <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
  2352. </svg>Count visits from</label>
  2353. <select name="restriction_mode" id="formRestriction">
  2354. <option value="full">This domain and its subdomains</option>
  2355. <option value="main">This domain only</option>
  2356. <option value="none">Any domain</option>
  2357. </select>
  2358. </div>
  2359. <div class="switch-checkbox-row" id="activeRow" style="display:none;">
  2360. <input type="checkbox" class="switch-checkbox" name="active" id="formActive" value="1" checked>
  2361. <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;">
  2362. <circle cx="12" cy="12" r="10"></circle>
  2363. <polyline points="12 6 12 12 16 14"></polyline>
  2364. </svg>Count visits for this site</label>
  2365. </div>
  2366. ​
  2367. <div class="advanced-toggle" id="advancedToggle" onclick="toggleAdvanced()">
  2368. <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  2369. <polyline points="9 18 15 12 9 6"></polyline>
  2370. </svg>
  2371. <span>More options</span>
  2372. </div>
  2373. ​
  2374. <div class="advanced-section" id="advancedSection">
  2375. <div class="form-group">
  2376. <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;">
  2377. <circle cx="12" cy="12" r="10"></circle>
  2378. <line x1="2" y1="12" x2="22" y2="12"></line>
  2379. <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>
  2380. </svg>Track where visitors come from</label>
  2381. <select name="traffic_sources_mode" id="formTrafficSources">
  2382. <option value="inherit">Same as in Settings</option>
  2383. <option value="on">On for this site</option>
  2384. <option value="off">Off for this site</option>
  2385. </select>
  2386. </div>
  2387. <div class="form-group">
  2388. <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;">
  2389. <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
  2390. <circle cx="12" cy="10" r="3"></circle>
  2391. </svg>Track visitor countries</label>
  2392. <select name="visitor_countries_mode" id="formVisitorCountries">
  2393. <option value="inherit">Same as in Settings</option>
  2394. <option value="on">On for this site</option>
  2395. <option value="off">Off for this site</option>
  2396. </select>
  2397. </div>
  2398. <div class="form-group">
  2399. <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;">
  2400. <circle cx="11" cy="11" r="8"></circle>
  2401. <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
  2402. </svg>Track searches on your site</label>
  2403. <select name="search_queries_mode" id="formSearchQueries">
  2404. <option value="inherit">Same as in Settings</option>
  2405. <option value="on">On for this site</option>
  2406. <option value="off">Off for this site</option>
  2407. </select>
  2408. </div>
  2409. </div>
  2410. ​
  2411. <div class="form-actions">
  2412. <button type="button" class="btn" onclick="closeModal()">Cancel</button>
  2413. <button type="submit" class="btn primary">Save</button>
  2414. </div>
  2415. </form>
  2416. </div>
  2417. </div>
  2418. ​
  2419. ​
  2420. <div class="modal-backdrop" id="deleteModal">
  2421. <div class="modal">
  2422. <h2>Delete site?</h2>
  2423. <p style="color:var(--text-secondary); font-size:13.5px; margin-bottom:6px;" id="deleteText"></p>
  2424. <p style="color:var(--accent); font-size:12.5px;">This can't be undone. All stats collected for this site will be deleted too.</p>
  2425. <form method="post">
  2426. <input type="hidden" name="csrf_token" value="<?= sanitize($csrfToken) ?>">
  2427. <input type="hidden" name="action" value="delete_site">
  2428. <input type="hidden" name="id" id="deleteId" value="">
  2429. <div class="form-actions">
  2430. <button type="button" class="btn" onclick="closeDeleteModal()">Cancel</button>
  2431. <button type="submit" class="btn danger" style="background:var(--accent); color:white; border-color:var(--accent);">Delete site</button>
  2432. </div>
  2433. </form>
  2434. </div>
  2435. </div>
  2436. ​
  2437. <script>
  2438. function openSidebar() {
  2439. document.getElementById('sidebar').classList.add('open');
  2440. document.getElementById('backdrop').classList.add('show');
  2441. }
  2442. ​
  2443. function closeSidebar() {
  2444. document.getElementById('sidebar').classList.remove('open');
  2445. document.getElementById('backdrop').classList.remove('show');
  2446. }
  2447. ​
  2448. function toggleTheme() {
  2449. const html = document.documentElement;
  2450. const next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
  2451. html.setAttribute('data-theme', next);
  2452. localStorage.setItem('pm_theme', next);
  2453. }
  2454. ​
  2455. function openModal(mode) {
  2456. document.getElementById('modalTitle').textContent = 'Add site';
  2457. document.getElementById('formAction').value = 'create_site';
  2458. document.getElementById('formId').value = '';
  2459. document.getElementById('formName').value = '';
  2460. document.getElementById('formDomain').value = '';
  2461. document.getElementById('formRestriction').value = 'full';
  2462. document.getElementById('formTrafficSources').value = 'inherit';
  2463. document.getElementById('formVisitorCountries').value = 'inherit';
  2464. document.getElementById('formSearchQueries').value = 'inherit';
  2465. document.getElementById('storageGroup').style.display = 'block';
  2466. document.getElementById('activeRow').style.display = 'none';
  2467. collapseAdvanced();
  2468. document.getElementById('siteModal').classList.add('show');
  2469. }
  2470. ​
  2471. function openEditModal(site) {
  2472. document.getElementById('modalTitle').textContent = 'Edit site';
  2473. document.getElementById('formAction').value = 'update_site';
  2474. document.getElementById('formId').value = site.id;
  2475. document.getElementById('formName').value = site.name;
  2476. document.getElementById('formDomain').value = site.domain;
  2477. document.getElementById('formRestriction').value = site.restriction_mode;
  2478. document.getElementById('formTrafficSources').value = site.traffic_sources_mode || 'inherit';
  2479. document.getElementById('formVisitorCountries').value = site.visitor_countries_mode || 'inherit';
  2480. document.getElementById('formSearchQueries').value = site.search_queries_mode || 'inherit';
  2481. document.getElementById('formActive').checked = !!(site.active == 1 || site.active === true);
  2482. document.getElementById('storageGroup').style.display = 'none';
  2483. document.getElementById('activeRow').style.display = 'flex';
  2484. collapseAdvanced();
  2485. document.getElementById('siteModal').classList.add('show');
  2486. }
  2487. ​
  2488. function closeModal() {
  2489. document.getElementById('siteModal').classList.remove('show');
  2490. }
  2491. ​
  2492. function toggleAdvanced() {
  2493. document.getElementById('advancedToggle').classList.toggle('open');
  2494. const section = document.getElementById('advancedSection');
  2495. const isOpen = section.classList.toggle('open');
  2496. document.getElementById('advancedToggle').querySelector('span').textContent = isOpen ? 'Fewer options' : 'More options';
  2497. }
  2498. ​
  2499. function collapseAdvanced() {
  2500. document.getElementById('advancedToggle').classList.remove('open');
  2501. document.getElementById('advancedSection').classList.remove('open');
  2502. document.getElementById('advancedToggle').querySelector('span').textContent = 'More options';
  2503. }
  2504. ​
  2505. function confirmDelete(id, name) {
  2506. document.getElementById('deleteId').value = id;
  2507. document.getElementById('deleteText').textContent = '"' + name + '" will be deleted for good.';
  2508. document.getElementById('deleteModal').classList.add('show');
  2509. }
  2510. ​
  2511. function closeDeleteModal() {
  2512. document.getElementById('deleteModal').classList.remove('show');
  2513. }
  2514. ​
  2515. function copyEmbed(btn) {
  2516. const useNoIp = btn.getAttribute('data-active') === 'noip';
  2517. const text = useNoIp ? btn.getAttribute('data-embed-noip') : btn.getAttribute('data-embed');
  2518. navigator.clipboard.writeText(text).then(() => {
  2519. const original = btn.textContent;
  2520. btn.textContent = 'Copied';
  2521. setTimeout(() => btn.textContent = original, 1500);
  2522. });
  2523. }
  2524. ​
  2525. function toggleEmbedNoIp(siteId, sw) {
  2526. const noIp = sw.querySelector('.switch').classList.toggle('on');
  2527. const codeEl = document.getElementById('embed-code-' + siteId);
  2528. const copyBtn = document.getElementById('embed-copy-' + siteId);
  2529. codeEl.textContent = noIp ? copyBtn.getAttribute('data-embed-noip') : copyBtn.getAttribute('data-embed');
  2530. copyBtn.setAttribute('data-active', noIp ? 'noip' : 'default');
  2531. }
  2532. ​
  2533. function copyListItem(btn) {
  2534. const text = btn.getAttribute('data-copy');
  2535. navigator.clipboard.writeText(text).then(() => {
  2536. const original = btn.innerHTML;
  2537. btn.classList.add('copied');
  2538. 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>';
  2539. setTimeout(() => {
  2540. btn.classList.remove('copied');
  2541. btn.innerHTML = original;
  2542. }, 1200);
  2543. });
  2544. }
  2545. ​
  2546. (function initSiteReorder() {
  2547. const list = document.getElementById('siteList');
  2548. if (!list) return;
  2549. ​
  2550. let dragEl = null;
  2551. ​
  2552. list.addEventListener('mousedown', (e) => {
  2553. const handle = e.target.closest('.drag-handle');
  2554. if (!handle) return;
  2555. const item = handle.closest('.site-item');
  2556. if (item) item.setAttribute('draggable', 'true');
  2557. });
  2558. ​
  2559. list.addEventListener('mouseup', () => {
  2560. list.querySelectorAll('.site-item[draggable="true"]').forEach(el => el.removeAttribute('draggable'));
  2561. });
  2562. ​
  2563. list.addEventListener('dragstart', (e) => {
  2564. const item = e.target.closest('.site-item');
  2565. if (!item || item.getAttribute('draggable') !== 'true') return;
  2566. dragEl = item;
  2567. item.classList.add('dragging');
  2568. e.dataTransfer.effectAllowed = 'move';
  2569. e.dataTransfer.setData('text/plain', item.dataset.id);
  2570. });
  2571. ​
  2572. list.addEventListener('dragend', () => {
  2573. if (dragEl) dragEl.classList.remove('dragging');
  2574. list.querySelectorAll('.site-item.drag-over').forEach(el => el.classList.remove('drag-over'));
  2575. list.querySelectorAll('.site-item[draggable="true"]').forEach(el => el.removeAttribute('draggable'));
  2576. dragEl = null;
  2577. });
  2578. ​
  2579. list.addEventListener('dragover', (e) => {
  2580. e.preventDefault();
  2581. const target = e.target.closest('.site-item');
  2582. if (!target || target === dragEl) return;
  2583. ​
  2584. list.querySelectorAll('.site-item.drag-over').forEach(el => el.classList.remove('drag-over'));
  2585. target.classList.add('drag-over');
  2586. ​
  2587. const rect = target.getBoundingClientRect();
  2588. const after = (e.clientY - rect.top) > rect.height / 2;
  2589. list.insertBefore(dragEl, after ? target.nextSibling : target);
  2590. });
  2591. ​
  2592. list.addEventListener('drop', (e) => {
  2593. e.preventDefault();
  2594. list.querySelectorAll('.site-item.drag-over').forEach(el => el.classList.remove('drag-over'));
  2595. saveSiteOrder();
  2596. });
  2597. ​
  2598. function saveSiteOrder() {
  2599. const order = Array.from(list.querySelectorAll('.site-item')).map(el => el.dataset.id);
  2600. const body = new URLSearchParams();
  2601. body.set('action', 'reorder_sites');
  2602. body.set('csrf_token', document.querySelector('input[name="csrf_token"]').value);
  2603. order.forEach(id => body.append('order[]', id));
  2604. ​
  2605. fetch(location.href, {
  2606. method: 'POST',
  2607. headers: {
  2608. 'Content-Type': 'application/x-www-form-urlencoded'
  2609. },
  2610. body: body.toString(),
  2611. }).catch(() => {});
  2612. }
  2613. })();
  2614. </script>
  2615. </body>
  2616. ​
  2617. </html>