breadcrumb.php 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. <?php
  2. error_reporting(E_ALL);
  3. ini_set("display_errors", 0);
  4. ini_set("log_errors", 1);
  5. date_default_timezone_set("Australia/Hobart");
  6. // Adjust path if your contracts live elsewhere.
  7. if (!defined('CONTRACTS_DIR')) {
  8. define('CONTRACTS_DIR', realpath(__DIR__ . '/contracts'));
  9. }
  10. function contract_path_for_client(string $clientid): string {
  11. $id = preg_replace('/[^A-Za-z0-9_-]/', '', $clientid);
  12. return rtrim(CONTRACTS_DIR, '/\\') . DIRECTORY_SEPARATOR . $id . '.md';
  13. }
  14. /** Very small front-matter puller; same idea as contracts admin */
  15. function extract_front_matter_fields_progress(string $file): array {
  16. $out = [];
  17. $txt = @file_get_contents($file);
  18. if (!$txt) return $out;
  19. if (!preg_match('/^---\s*\R(.*?)\R---/s', $txt, $m)) return $out;
  20. $fm = $m[1];
  21. // admin.secret or admin_secret
  22. if (preg_match('/^\s*admin\s*:\s*$(.*?)^(?=\S)/ms', $fm."\nX", $block)) {
  23. $adminBlock = $block[1];
  24. if (preg_match('/^\s*secret\s*:\s*["\']?([^"\']+)["\']?/mi', $adminBlock, $mm)) {
  25. $out['admin_secret'] = trim($mm[1]);
  26. }
  27. }
  28. if (empty($out['admin_secret']) && preg_match('/^\s*admin_secret\s*:\s*["\']?([^"\']+)["\']?/mi', $fm, $mm)) {
  29. $out['admin_secret'] = trim($mm[1]);
  30. }
  31. return $out;
  32. }
  33. /** Build the exact token we expect for the public progress page */
  34. function progress_expected_token(string $clientid, $appId): ?string {
  35. $path = contract_path_for_client($clientid);
  36. $fm = extract_front_matter_fields_progress($path);
  37. $secret = $fm['admin_secret'] ?? '';
  38. if ($secret === '') return null;
  39. return hash_hmac('sha256', 'progress|' . (string)$appId, $secret);
  40. }
  41. $cfg = require __DIR__ . '/config.php';
  42. $dsn = 'mysql:host=' . $cfg['db_host'] . ';dbname=' . $cfg['db_name'] . ';charset=utf8mb4';
  43. $options = [
  44. PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  45. PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  46. ];
  47. try {
  48. $pdo = new PDO($dsn, $cfg['db_username'], $cfg['db_password'], $options);
  49. } catch (PDOException $e) {
  50. error_log('Database connection failed: ' . $e->getMessage());
  51. http_response_code(500);
  52. exit('Service unavailable');
  53. }
  54. $app_id_raw = $_GET['id'] ?? '';
  55. $token = $_GET['token'] ?? '';
  56. $app_id = preg_match('/^\d+$/', $app_id_raw) ? $app_id_raw : '0';
  57. // Verify token (optional: match your token logic)
  58. $stmt = $pdo->prepare("SELECT client_email, reference, created_at, submission_date, required_by FROM applications WHERE id = ?");
  59. $stmt->execute([$app_id]);
  60. $app = $stmt->fetch(PDO::FETCH_ASSOC);
  61. if (!$app) {
  62. http_response_code(404);
  63. exit("Application not found.");
  64. }
  65. // Fetch stages
  66. $stmt = $pdo->prepare("SELECT * FROM application_stages WHERE application_id = ? ORDER BY position ASC");
  67. $stmt->execute([$app_id]);
  68. $stages = $stmt->fetchAll(PDO::FETCH_ASSOC);
  69. $totalStages = 7;
  70. $currentStage = count(array_filter($stages, function ($s) {
  71. return strtolower(trim($s['status'] ?? '')) === 'complete';
  72. }));
  73. $progress = round(($currentStage / $totalStages) * 100);
  74. $decisionDate = null;
  75. // 1) Look for an explicit 'Council Decision Due' stage date
  76. $decisionStage = null;
  77. foreach ($stages as $s) {
  78. if (stripos($s['title'] ?? '', 'decision') !== false && !empty($s['stage_date'])) {
  79. $decisionStage = $s;
  80. break;
  81. }
  82. }
  83. if ($decisionStage) {
  84. $decisionDate = new DateTime($decisionStage['stage_date'], new DateTimeZone('Australia/Hobart'));
  85. } elseif (!empty($app['required_by'])) {
  86. $decisionDate = new DateTime($app['required_by'], new DateTimeZone('Australia/Hobart'));
  87. } elseif (!empty($app['submission_date'])) {
  88. $decisionDate = (new DateTime($app['submission_date'], new DateTimeZone('Australia/Hobart')))->modify('+42 days');
  89. }
  90. // set a friendly “end of business day” time so the countdown isn’t midnight-awkward
  91. if ($decisionDate) { $decisionDate->setTime(17, 0, 0); }
  92. $decisionIso = $decisionDate ? $decisionDate->format('c') : '';
  93. // --- Create correspondence entry ---
  94. if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'add_correspondence') {
  95. $tz = new DateTimeZone('Australia/Hobart');
  96. $typeAllow = ['incoming','outgoing','note'];
  97. $channelAllow = ['email','phone','portal','letter','meeting','other'];
  98. $visibilityAllow= ['client','internal'];
  99. $type = in_array($_POST['type'] ?? 'note', $typeAllow, true) ? $_POST['type'] : 'note';
  100. $channel = in_array($_POST['channel'] ?? 'other', $channelAllow, true) ? $_POST['channel'] : 'other';
  101. $visibility = in_array($_POST['visibility'] ?? 'client', $visibilityAllow, true) ? $_POST['visibility'] : 'client';
  102. $subject = trim($_POST['subject'] ?? '') ?: null;
  103. $author = trim($_POST['author'] ?? '') ?: null;
  104. $pin = isset($_POST['pin']) ? 1 : 0;
  105. $bodyRaw = trim($_POST['body'] ?? '');
  106. if ($bodyRaw === '') { $bodyRaw = '(no content)'; }
  107. // event_at: prefer user input, else "now"
  108. $eventAtRaw = trim($_POST['event_at'] ?? '');
  109. try {
  110. $eventAt = $eventAtRaw ? new DateTime($eventAtRaw, $tz) : new DateTime('now', $tz);
  111. } catch (Exception $e) {
  112. $eventAt = new DateTime('now', $tz);
  113. }
  114. $stmt = $pdo->prepare("
  115. INSERT INTO application_correspondence
  116. (application_id, event_at, type, channel, subject, body, author, visibility, pin)
  117. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
  118. ");
  119. $stmt->execute([
  120. $app_id,
  121. $eventAt->format('Y-m-d H:i:s'),
  122. $type,
  123. $channel,
  124. $subject,
  125. $bodyRaw,
  126. $author,
  127. $visibility,
  128. $pin
  129. ]);
  130. // Redirect to avoid resubmission and jump to the timeline section
  131. header("Location: ".$_SERVER['REQUEST_URI']."#correspondence");
  132. exit;
  133. }
  134. // Fetch timeline (newest first; pinned first)
  135. $stmt = $pdo->prepare("
  136. SELECT id, event_at, type, channel, subject, body, author, visibility, pin, created_at
  137. FROM application_correspondence
  138. WHERE application_id = ?
  139. ORDER BY pin DESC, event_at DESC, id DESC
  140. LIMIT 200
  141. ");
  142. $stmt->execute([$app_id]);
  143. $correspondence = $stmt->fetchAll(PDO::FETCH_ASSOC);
  144. /* NEW: attachment counts (and optional details) */
  145. $fileCounts = [];
  146. $filesByCorr = []; // if you also want to list the files
  147. if (!empty($correspondence)) {
  148. $ids = array_column($correspondence, 'id');
  149. $ph = implode(',', array_fill(0, count($ids), '?'));
  150. // Count files per correspondence
  151. $qc = $pdo->prepare("
  152. SELECT correspondence_id, COUNT(*) AS n
  153. FROM application_correspondence_files
  154. WHERE correspondence_id IN ($ph)
  155. GROUP BY correspondence_id
  156. ");
  157. $qc->execute($ids);
  158. foreach ($qc->fetchAll(PDO::FETCH_ASSOC) as $r) {
  159. $fileCounts[(int)$r['correspondence_id']] = (int)$r['n'];
  160. }
  161. // OPTIONAL: load file details if you want links
  162. $qd = $pdo->prepare("
  163. SELECT correspondence_id, original_name, file_url
  164. FROM application_correspondence_files
  165. WHERE correspondence_id IN ($ph)
  166. ORDER BY id ASC
  167. ");
  168. $qd->execute($ids);
  169. foreach ($qd->fetchAll(PDO::FETCH_ASSOC) as $f) {
  170. $cid = (int)$f['correspondence_id'];
  171. if (!isset($filesByCorr[$cid])) $filesByCorr[$cid] = [];
  172. $filesByCorr[$cid][] = $f;
  173. }
  174. }
  175. // ------------------ HELPERS ------------------
  176. function render_body_html(string $text): string {
  177. // escape first
  178. $s = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
  179. // linkify http(s)
  180. $s = preg_replace('~(https?://[^\s<]+)~i', '<a href="$1" target="_blank" rel="noopener">$1</a>', $s);
  181. // newlines to <br>
  182. return nl2br($s);
  183. }
  184. // --- Require signed token from Contracts Admin link ---
  185. $clientid = $_GET['clientid'] ?? '';
  186. $token = $_GET['token'] ?? '';
  187. if (!preg_match('/^[A-Za-z0-9_-]+$/', $clientid)) {
  188. http_response_code(400);
  189. exit('Bad link (clientid).');
  190. }
  191. if ($token === '') {
  192. http_response_code(403);
  193. exit('Missing token.');
  194. }
  195. // Build expected token from the .md front matter secret
  196. $expected = progress_expected_token($clientid, $app_id);
  197. if (!$expected || !hash_equals($expected, $token)) {
  198. http_response_code(403);
  199. exit('Invalid or expired link.');
  200. }
  201. ?>
  202. <!doctype html>
  203. <html lang="en">
  204. <head>
  205. <meta charset="utf-8">
  206. <meta name="viewport" content="width=device-width, initial-scale=1">
  207. <title><?= htmlspecialchars($app['reference']) ?> – Application Progress</title>
  208. <link rel="shortcut icon" href="../../internal/images/blueprint.ico" type="image/x-icon">
  209. <meta name="robots" content="noindex">
  210. <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
  211. <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js" integrity="sha384-ndDqU0Gzau9qJ1lfW4pNLlhNTkCfHzAVBReH9diLvGRem5+R9g2FzA8ZGN954O5Q" crossorigin="anonymous"></script>
  212. <link href="../../internal/css/blueprint.css" rel="stylesheet">
  213. <link href="../../internal/css/print.css" rel="stylesheet" media="print">
  214. <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
  215. <link href="progress.css" rel="stylesheet">
  216. <style>
  217. .popover-attachments { max-width: 360px; }
  218. .popover-attachments .popover-body { padding: .5rem .75rem; }
  219. </style>
  220. </head>
  221. <body>
  222. <!--
  223. <nav class="navbar bg-dark border-bottom border-body d-print-none" data-bs-theme="dark">
  224. <div class="container">
  225. <a class="navbar-brand text-white" href="#">
  226. <img src="../internal/images/blueprint-logo-light.png" width="30" height="24" class="d-inline-block align-text-top" alt="Modulos">
  227. Modulos Design
  228. </a>
  229. </div>
  230. </nav>
  231. -->
  232. <main class="container my-4">
  233. <div class="bg-white p-4 p-md-5 rounded-0 shadow-sm">
  234. <div class="row align-items-center page-header">
  235. <div class="col-12 col-md-6 text-start">
  236. <!-- CUSTOMER DETAILS HERE -->
  237. </div>
  238. <div class="col-12 col-md-6 text-end pt-2">
  239. <h3 class="fw-bold mb-1 text-dark">Development Application</h3>
  240. <h3 class="fw-bold mb-1 text-dark">Application No: <?= htmlspecialchars($app['reference']) ?></h3>
  241. <h5 class="mb-0 text-muted">Started: <?= date("d M Y", strtotime($app['created_at'])) ?></h5>
  242. </div>
  243. </div>
  244. <hr class="my-4">
  245. <div class="row my-4">
  246. <div class="col-12 align-self-center">
  247. <div class="steps">
  248. <?php
  249. $labels = ['Submit','Acknowledge','Paid','Confirmed','Advertise','Complete','Decision'];
  250. $N = count($labels);
  251. // Build status + date arrays by position
  252. $statusByIndex = array_fill(0, $N, 'pending');
  253. $dateByIndex = array_fill(0, $N, null);
  254. foreach ($stages as $row) {
  255. $idx = (int)($row['position'] ?? -1);
  256. if ($idx < 0 || $idx >= $N) continue;
  257. $st = strtolower(trim($row['status'] ?? 'pending'));
  258. if (!in_array($st, ['complete','current','pending'], true)) $st = 'pending';
  259. $statusByIndex[$idx] = $st;
  260. $dateByIndex[$idx] = $row['stage_date'] ?: ($row['updated_at'] ?: ($row['created_at'] ?? null));
  261. }
  262. // If no explicit "current", highlight the *last complete* stage
  263. $hasCurrent = false;
  264. foreach ($statusByIndex as $st) { if (strpos($st, 'current') !== false) { $hasCurrent = true; break; } }
  265. if (!$hasCurrent) {
  266. $lastComplete = -1;
  267. for ($i = 0; $i < $N; $i++) if ($statusByIndex[$i] === 'complete') $lastComplete = $i;
  268. if ($lastComplete >= 0) {
  269. $statusByIndex[$lastComplete] = 'current'; // keep green, add highlight
  270. } else {
  271. $statusByIndex[0] = trim($statusByIndex[0] . ' current'); // nothing complete yet
  272. }
  273. }
  274. $fmt = function (?string $s): string {
  275. if (!$s) return '';
  276. $t = strtotime($s);
  277. return $t ? date('d M Y', $t) : '';
  278. };
  279. ?>
  280. <!-- Top row: arrows + inline (mobile-only) dates -->
  281. <ul class="step-menu">
  282. <?php for ($i = 0; $i < $N; $i++):
  283. $class = htmlspecialchars($statusByIndex[$i]);
  284. $dateText = $fmt($dateByIndex[$i]);
  285. $isComplete = (strpos($class, 'complete') !== false);
  286. $prefix = (!$isComplete && $dateText) ? 'Due ' : '';
  287. ?>
  288. <li class="<?= $class ?>">
  289. <?= htmlspecialchars($labels[$i]) ?>
  290. <?php if ($dateText): ?>
  291. <div class="date-inline small mt-1 d-xxl-none"><?= htmlspecialchars($prefix . $dateText) ?></div>
  292. <?php endif; ?>
  293. </li>
  294. <?php endfor; ?>
  295. </ul>
  296. <!-- Bottom row: desktop-only date line, aligned with arrows -->
  297. <ul class="step-dates d-none d-xxl-flex">
  298. <?php for ($i = 0; $i < $N; $i++):
  299. $rawClass = trim((string)($statusByIndex[$i] ?? ''));
  300. $tokens = preg_split('/\s+/', $rawClass, -1, PREG_SPLIT_NO_EMPTY);
  301. $isComplete = in_array('complete', $tokens, true);
  302. $isCurrent = in_array('current', $tokens, true);
  303. $dateText = $fmt($dateByIndex[$i]);
  304. $prefix = (!$isComplete && !$isCurrent && $dateText) ? 'Due ' : '';
  305. ?>
  306. <li class="<?= htmlspecialchars($rawClass, ENT_QUOTES, 'UTF-8') ?>">
  307. <?= $dateText ? htmlspecialchars($prefix . $dateText, ENT_QUOTES, 'UTF-8') : '&nbsp;' ?>
  308. </li>
  309. <?php endfor; ?>
  310. </ul>
  311. </div>
  312. </div>
  313. </div>
  314. <div class="row py-3">
  315. <div class="col-12">
  316. <?php if (!empty($decisionIso)): ?>
  317. <div class="countdown" data-target-date="<?php echo $decisionIso; ?>"></div>
  318. <?php endif; ?>
  319. </div>
  320. </div>
  321. <div class="row">
  322. <?php if (empty($stages)): ?>
  323. <div class="col-12">
  324. <div class="alert alert-warning">This application has not started yet.</div>
  325. </div>
  326. <?php endif; ?>
  327. </div>
  328. <hr class="my-4">
  329. <div class="row">
  330. <div class="col-12 text-center">
  331. <h4>Timeline of Correspondence</h4>
  332. </div>
  333. </div>
  334. <div class="row py-3">
  335. <div class="col">
  336. <div class="timeline">
  337. <?php
  338. $badgeMap = [
  339. 'email_incoming' => 'bi-envelope-arrow-up',
  340. 'email_outgoing' => 'bi-send-check',
  341. 'phone_incoming' => 'bi-telephone-inbound',
  342. 'phone_outgoing' => 'bi-telephone-outbound',
  343. 'note' => 'bi-journal-text'
  344. ];
  345. $fallbackByChannel = [
  346. 'email' => 'bi-envelope',
  347. 'phone' => 'bi-telephone',
  348. 'meeting' => 'bi-people',
  349. 'other' => 'bi-chat-dots'
  350. ];
  351. $typeLabel = ['incoming'=>'Incoming','outgoing'=>'Outgoing','note'=>'Note'];
  352. $chLabel = ['email'=>'Email','phone'=>'Phone','meeting'=>'Meeting','other'=>'Other'];
  353. foreach ($correspondence as $row):
  354. $id = (int)$row['id'];
  355. $typeVal = strtolower(trim($row['type'] ?? 'note'));
  356. $channelVal = strtolower(trim($row['channel'] ?? 'other'));
  357. $key = ($typeVal === 'note') ? 'note' : "{$channelVal}_{$typeVal}";
  358. $icon = $badgeMap[$key] ?? ($fallbackByChannel[$channelVal] ?? 'bi-journal-text');
  359. $when = (new DateTime($row['event_at'], new DateTimeZone('Australia/Hobart')))->format('d M Y, h:ia');
  360. $visBadge = $row['visibility']==='internal' ? '<span class="badge rounded-pill text-bg-secondary ms-2">Internal</span>' : '';
  361. $typeClass = 'type-'.preg_replace('/[^a-z]/','', $typeVal);
  362. $numFiles = $fileCounts[$id] ?? 0; // <- NEW
  363. $hasFiles = $numFiles > 0; // <- NEW
  364. ?>
  365. <div class="timeline-item <?= $row['pin'] ? 'pinned' : '' ?>">
  366. <div class="timeline-badge"><i class="bi <?= $icon ?>"></i></div>
  367. <div class="timeline-panel <?= $typeClass ?>">
  368. <div class="timeline-heading d-flex justify-content-between align-items-start">
  369. <div>
  370. <h6 class="mb-1">
  371. <?= $when ?> • <?= $typeLabel[$typeVal] ?? ucfirst($typeVal) ?>
  372. via <?= $chLabel[$channelVal] ?? ucfirst($channelVal) ?>
  373. <?= $visBadge ?>
  374. <?php if ($row['pin']): ?>
  375. <i class="bi bi-pin-angle-fill text-warning ms-1" title="Pinned"></i>
  376. <?php endif; ?>
  377. <?php if (!empty($filesByCorr[$id])): ?>
  378. <span class="ms-2 att-pop"
  379. role="button"
  380. tabindex="0"
  381. data-bs-toggle="popover"
  382. data-bs-trigger="hover focus"
  383. data-bs-placement="top"
  384. data-bs-custom-class="popover-attachments"
  385. data-content-id="att-popover-<?= $id ?>"
  386. title="Attachments (<?= (int)$numFiles ?>)">
  387. <i class="bi bi-paperclip"></i>
  388. </span>
  389. <?php endif; ?>
  390. </h6>
  391. <small class="text-muted">
  392. <?= htmlspecialchars($row['subject'] ?: ucfirst($typeVal)) ?>
  393. <?= $row['author'] ? ' • by: '.htmlspecialchars($row['author']) : '' ?>
  394. </small>
  395. </div>
  396. </div>
  397. <div class="timeline-body mt-2 small">
  398. <?= render_body_html($row['body']) ?>
  399. </div>
  400. </div>
  401. </div>
  402. <?php endforeach; ?>
  403. <?php if (empty($correspondence)): ?>
  404. <div class="text-muted">No correspondence recorded yet.</div>
  405. <?php endif; ?>
  406. </div>
  407. </div>
  408. </div>
  409. </div>
  410. </main>
  411. <script>
  412. const countdownEls = document.querySelectorAll(".countdown")
  413. countdownEls.forEach(countdownEl => createCountdown(countdownEl))
  414. function createCountdown(countdownEl){
  415. const target = new Date(new Date(countdownEl.dataset.targetDate).toLocaleString('en', ))
  416. const parts = {
  417. days: {text: ["days","day"], dots: 30},
  418. hours: {text: ["hours","hour"], dots: 24},
  419. minutes: {text: ["minutes","minute"], dots: 60},
  420. seconds: {text: ["seconds","second"], dots: 60},
  421. }
  422. Object.entries(parts).forEach(([key, value])=>{
  423. const partEl = document.createElement("div");
  424. partEl.classList.add("part", key);
  425. partEl.style.setProperty("--dots", value.dots);
  426. value.element = partEl;
  427. const remainingEl = document.createElement("div");
  428. remainingEl.classList.add("remaining");
  429. remainingEl.innerHTML = `<span class="number"></span><span class="text"></span>`
  430. partEl.append(remainingEl);
  431. for(let i = 0; i < value.dots; i++){
  432. const dotContainerEl = document.createElement("div");
  433. dotContainerEl.style.setProperty("--dot-idx", i);
  434. dotContainerEl.classList.add("dot-container")
  435. const dotEl = document.createElement("div");
  436. dotEl.classList.add("dot")
  437. dotContainerEl.append(dotEl);
  438. partEl.append(dotContainerEl);
  439. }
  440. countdownEl.append(partEl);
  441. })
  442. getRemainingTime(target, parts)
  443. }
  444. function getRemainingTime(target, parts, first=true){
  445. const now = new Date()
  446. const remaining = {}
  447. let seconds = Math.floor((target - (now))/1000);
  448. let minutes = Math.floor(seconds/60);
  449. let hours = Math.floor(minutes/60);
  450. let days = Math.floor(hours/24);
  451. hours = hours-(days*24);
  452. minutes = minutes-(days*24*60)-(hours*60);
  453. seconds = seconds-(days*24*60*60)-(hours*60*60)-(minutes*60);
  454. Object.entries({days, hours, minutes, seconds}).forEach(([key, value])=>{
  455. const remaining = parts[key].element.querySelector(".number");
  456. const text = parts[key].element.querySelector(".text");
  457. remaining.innerText = value;
  458. text.innerText = parts[key].text[Number(value==1)]
  459. const dots = parts[key].element.querySelectorAll(".dot")
  460. dots.forEach((dot, idx)=>{
  461. dot.dataset.active = idx <= value;
  462. dot.dataset.lastactive = idx == value;
  463. })
  464. })
  465. if(now <= target){
  466. window.requestAnimationFrame(()=>{
  467. getRemainingTime(target, parts, false)
  468. });
  469. }
  470. }
  471. document.getElementById('tryParse')?.addEventListener('click', function(e){
  472. e.preventDefault();
  473. const body = document.getElementById('corrBody').value || '';
  474. const subj = /(?:^|\n)Subject:\s*(.+)/i.exec(body);
  475. const from = /(?:^|\n)From:\s*(.+)/i.exec(body);
  476. const date = /(?:^|\n)Date:\s*(.+)/i.exec(body);
  477. if (subj) document.getElementById('corrSubject').value = subj[1].trim();
  478. if (from) document.getElementById('corrAuthor').value = from[1].trim();
  479. if (date) {
  480. const guess = new Date(date[1]);
  481. if (!isNaN(guess.getTime())) {
  482. // to local datetime-local string
  483. const pad = n => String(n).padStart(2,'0');
  484. const v = guess.getFullYear() + '-' + pad(guess.getMonth()+1) + '-' + pad(guess.getDate())
  485. + 'T' + pad(guess.getHours()) + ':' + pad(guess.getMinutes());
  486. const el = document.querySelector('input[name="event_at"]');
  487. if (el) el.value = v;
  488. }
  489. }
  490. });
  491. document.addEventListener('DOMContentLoaded', () => {
  492. document.querySelectorAll('[data-bs-toggle="popover"][data-content-id]').forEach(el => {
  493. const id = el.getAttribute('data-content-id');
  494. const tpl = document.getElementById(id);
  495. const content = tpl ? tpl.innerHTML : '';
  496. new bootstrap.Popover(el, {
  497. html: true,
  498. content,
  499. container: 'body',
  500. sanitize: true, // keep Bootstrap’s sanitizer on
  501. trigger: 'hover focus' // hover on desktop, tap/focus on mobile
  502. });
  503. });
  504. });
  505. </script>
  506. </body>
  507. </html>