Last active
April 16, 2026 08:50
-
-
Save drego85/243c32d7e939e6bc2e401c65d02306af to your computer and use it in GitHub Desktop.
CKBoard is a lightweight PHP dashboard for monitoring CKPool solo Bitcoin mining statistics by address and region. It fetches pool data server-side and displays hashrate, workers, shares, best share, and per- worker mining performance in a clean Bootstrap interface.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| declare(strict_types=1); | |
| /* | |
| * CKBoard is a small PHP dashboard for CKPool solo Bitcoin mining stats. | |
| * | |
| * The page accepts a Bitcoin address and a CKPool region through GET parameters, | |
| * fetches the JSON data server-side to avoid browser CORS restrictions, validates | |
| * user input against a strict allowlist, and renders aggregate plus per-worker | |
| * mining metrics with Bootstrap. | |
| * | |
| * Made with ♥ by Andrea Draghetti | |
| * | |
| * This file may be licensed under the terms of of the | |
| * GNU General Public License Version 3 (the ``GPL''). | |
| * | |
| */ | |
| const DEFAULT_POOL = 'eu'; | |
| const DEFAULT_USER = ''; | |
| const CKPOOL_POOLS = [ | |
| 'eu' => [ | |
| 'label' => 'Europe and Africa', | |
| 'host' => 'eusolo.ckpool.org', | |
| ], | |
| 'us' => [ | |
| 'label' => 'America', | |
| 'host' => 'solo.ckpool.org', | |
| ], | |
| 'au' => [ | |
| 'label' => 'Oceania', | |
| 'host' => 'ausolo.ckpool.org', | |
| ], | |
| ]; | |
| $rawUser = isset($_GET['user']) ? trim((string) $_GET['user']) : DEFAULT_USER; | |
| $rawPool = isset($_GET['pool']) ? trim((string) $_GET['pool']) : DEFAULT_POOL; | |
| $validationError = null; | |
| $user = ''; | |
| $poolKey = array_key_exists($rawPool, CKPOOL_POOLS) ? $rawPool : DEFAULT_POOL; | |
| if ($rawPool !== '' && !array_key_exists($rawPool, CKPOOL_POOLS)) { | |
| $validationError = 'Invalid pool parameter: using Europe and Africa.'; | |
| } | |
| if ($rawUser !== '' && preg_match('/^[A-Za-z0-9]{26,120}$/', $rawUser) === 1) { | |
| $user = $rawUser; | |
| } elseif ($rawUser !== '') { | |
| $validationError = 'Invalid user parameter: enter a valid Bitcoin address.'; | |
| } | |
| $pool = CKPOOL_POOLS[$poolKey]; | |
| $ckpoolBaseUrl = 'https://' . $pool['host'] . '/users/'; | |
| $ckpoolUrl = $user !== '' ? $ckpoolBaseUrl . rawurlencode($user) : ''; | |
| $data = null; | |
| $loadError = null; | |
| if ($user !== '') { | |
| try { | |
| $json = fetchUrl($ckpoolUrl); | |
| $data = decodeJson($json); | |
| } catch (Throwable $error) { | |
| $loadError = 'Unable to load data from CKPool.'; | |
| } | |
| } | |
| $workers = is_array($data['worker'] ?? null) ? $data['worker'] : []; | |
| usort($workers, static fn (array $a, array $b): int => parseHashrate((string) ($b['hashrate1m'] ?? '0')) <=> parseHashrate((string) ($a['hashrate1m'] ?? '0'))); | |
| $hashratePeriods = [ | |
| '1 minute' => $data['hashrate1m'] ?? null, | |
| '5 minutes' => $data['hashrate5m'] ?? null, | |
| '1 hour' => $data['hashrate1hr'] ?? null, | |
| '1 day' => $data['hashrate1d'] ?? null, | |
| '7 days' => $data['hashrate7d'] ?? null, | |
| ]; | |
| $hashrateValues = array_map(static fn ($value): float => parseHashrate((string) $value), $hashratePeriods); | |
| $maxHashrate = max(array_merge($hashrateValues, [1])); | |
| function fetchUrl(string $url): string | |
| { | |
| if (function_exists('curl_init')) { | |
| $curl = curl_init($url); | |
| curl_setopt_array($curl, [ | |
| CURLOPT_RETURNTRANSFER => true, | |
| CURLOPT_FOLLOWLOCATION => false, | |
| CURLOPT_CONNECTTIMEOUT => 5, | |
| CURLOPT_TIMEOUT => 10, | |
| CURLOPT_HTTPHEADER => ['Accept: application/json'], | |
| CURLOPT_USERAGENT => 'CKBoard/1.0', | |
| ]); | |
| $body = curl_exec($curl); | |
| $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); | |
| $error = curl_error($curl); | |
| curl_close($curl); | |
| if ($body === false || $status < 200 || $status >= 300) { | |
| throw new RuntimeException($error !== '' ? $error : 'HTTP ' . $status); | |
| } | |
| return (string) $body; | |
| } | |
| $context = stream_context_create([ | |
| 'http' => [ | |
| 'method' => 'GET', | |
| 'header' => "Accept: application/json\r\nUser-Agent: CKBoard/1.0\r\n", | |
| 'timeout' => 10, | |
| ], | |
| ]); | |
| $body = @file_get_contents($url, false, $context); | |
| if ($body === false) { | |
| throw new RuntimeException('Richiesta HTTP non riuscita'); | |
| } | |
| return (string) $body; | |
| } | |
| function decodeJson(string $json): array | |
| { | |
| $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); | |
| if (!is_array($decoded)) { | |
| throw new RuntimeException('Risposta JSON non valida'); | |
| } | |
| return $decoded; | |
| } | |
| function e(?string $value): string | |
| { | |
| return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); | |
| } | |
| function valueOrDash($value): string | |
| { | |
| if ($value === null || $value === '') { | |
| return '-'; | |
| } | |
| return e((string) $value); | |
| } | |
| function formatNumber($value): string | |
| { | |
| if (!is_numeric($value)) { | |
| return '-'; | |
| } | |
| $numericValue = (float) $value; | |
| $decimals = floor($numericValue) === $numericValue ? 0 : 2; | |
| return number_format($numericValue, $decimals, ',', '.'); | |
| } | |
| function formatLargeShare($value): string | |
| { | |
| if (!is_numeric($value)) { | |
| return '-'; | |
| } | |
| $numericValue = (float) $value; | |
| $absoluteValue = abs($numericValue); | |
| $units = [ | |
| ['threshold' => 1000000000000000, 'divisor' => 1000000000000000, 'suffix' => 'Qn'], | |
| ['threshold' => 1000000000000, 'divisor' => 1000000000000, 'suffix' => 'Tn'], | |
| ['threshold' => 1000000000, 'divisor' => 1000000000, 'suffix' => 'Bn'], | |
| ['threshold' => 1000000, 'divisor' => 1000000, 'suffix' => 'Mn'], | |
| ]; | |
| foreach ($units as $unit) { | |
| if ($absoluteValue >= $unit['threshold']) { | |
| $shortValue = floor(($numericValue / $unit['divisor']) * 1000) / 1000; | |
| $formatted = number_format($shortValue, 3, '.', ''); | |
| $formatted = rtrim(rtrim($formatted, '0'), '.'); | |
| return $formatted . ' ' . $unit['suffix']; | |
| } | |
| } | |
| return formatNumber($value); | |
| } | |
| function formatUnixTime($value): string | |
| { | |
| if (!is_numeric($value) || (int) $value <= 0) { | |
| return '-'; | |
| } | |
| return date('d/m/Y H:i:s', (int) $value); | |
| } | |
| function parseHashrate(string $value): float | |
| { | |
| if ($value === '' || $value === '0') { | |
| return 0.0; | |
| } | |
| if (preg_match('/^([\d.]+)\s*([kKmMgGtTpPeE]?)/', trim($value), $matches) !== 1) { | |
| return 0.0; | |
| } | |
| $number = (float) $matches[1]; | |
| $unit = strtoupper($matches[2] ?? ''); | |
| $multipliers = [ | |
| 'K' => 1e3, | |
| 'M' => 1e6, | |
| 'G' => 1e9, | |
| 'T' => 1e12, | |
| 'P' => 1e15, | |
| 'E' => 1e18, | |
| ]; | |
| return $number * ($multipliers[$unit] ?? 1); | |
| } | |
| function shortenWorkerName(?string $workerName, string $user): string | |
| { | |
| if ($workerName === null || $workerName === '') { | |
| return '-'; | |
| } | |
| $prefix = $user . '.'; | |
| if (substr($workerName, 0, strlen($prefix)) === $prefix) { | |
| $shortName = substr($workerName, strlen($prefix)); | |
| return $shortName !== '' ? $shortName : $workerName; | |
| } | |
| $dotPosition = strpos($workerName, '.'); | |
| return $dotPosition === false ? $workerName : substr($workerName, $dotPosition + 1); | |
| } | |
| function isShareStale($lastShare): bool | |
| { | |
| if (!is_numeric($lastShare)) { | |
| return true; | |
| } | |
| return time() - (int) $lastShare > 1800; | |
| } | |
| ?> | |
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>CKPool Solo Mining Dashboard</title> | |
| <!-- SEO Meta Tags --> | |
| <meta name="description" content="CKBoard is a PHP dashboard for viewing CKPool solo Bitcoin mining statistics by address, region, hashrate, workers, shares, and best share data."> | |
| <meta name="keywords" content="CKBoard, CKPool, solo mining, Bitcoin mining dashboard, Bitcoin hashrate, mining workers, best share, solo.ckpool.org, eusolo.ckpool.org, ausolo.ckpool.org"> | |
| <meta name="author" content="Andrea Draghetti"> | |
| <meta name="robots" content="index, follow"> | |
| <!-- Open Graph Meta Tags --> | |
| <meta property="og:title" content="CKBoard - CKPool Solo Mining Dashboard"> | |
| <meta property="og:description" content="View CKPool solo Bitcoin mining statistics by address and region, including hashrate, active workers, shares, and best share data."> | |
| <meta property="og:type" content="website"> | |
| <meta property="og:site_name" content="CKBoard"> | |
| <link rel="icon" type="image/svg+xml" href="https://eusolo.ckpool.org/ckbtc.svg"> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.8/css/bootstrap.min.css" integrity="sha512-2bBQCjcnw658Lho4nlXJcc6WkV/UxpE/sAokbXPxQNGqmNdQrWqtw26Ns9kFF/yG792pKR1Sx8/Y1Lf1XN4GKA==" crossorigin="anonymous" referrerpolicy="no-referrer" /> | |
| <style> | |
| :root { | |
| --ck-bg: #f6f8fb; | |
| --ck-ink: #17202a; | |
| --ck-muted: #657485; | |
| --ck-line: #dbe3ec; | |
| --ck-primary: #116466; | |
| --ck-accent: #d79922; | |
| --ck-good: #1f8a5b; | |
| } | |
| body { | |
| background: var(--ck-bg); | |
| color: var(--ck-ink); | |
| } | |
| .top-band { | |
| background: #ffffff; | |
| border-bottom: 1px solid var(--ck-line); | |
| } | |
| .brand-mark { | |
| width: 42px; | |
| height: 42px; | |
| object-fit: contain; | |
| } | |
| .dashboard-form { | |
| width: 100%; | |
| } | |
| .dashboard-form .form-select, | |
| .dashboard-form .form-control, | |
| .dashboard-form .btn { | |
| min-height: 44px; | |
| } | |
| .dashboard-form .form-control { | |
| min-width: 0; | |
| } | |
| .metric-card { | |
| border: 1px solid var(--ck-line); | |
| border-radius: 8px; | |
| box-shadow: 0 8px 24px rgba(23, 32, 42, 0.06); | |
| } | |
| .metric-label { | |
| color: var(--ck-muted); | |
| font-size: 0.85rem; | |
| font-weight: 700; | |
| letter-spacing: 0; | |
| text-transform: uppercase; | |
| } | |
| .metric-value { | |
| font-size: clamp(1.85rem, 4vw, 3rem); | |
| font-weight: 800; | |
| line-height: 1; | |
| overflow-wrap: anywhere; | |
| } | |
| .primary-metric { | |
| border-top: 5px solid var(--ck-primary); | |
| } | |
| .accent-metric { | |
| border-top: 5px solid var(--ck-accent); | |
| } | |
| .good-metric { | |
| border-top: 5px solid var(--ck-good); | |
| } | |
| .hash-bar { | |
| height: 12px; | |
| border-radius: 8px; | |
| background: #e8eef5; | |
| overflow: hidden; | |
| } | |
| .hash-bar > span { | |
| display: block; | |
| height: 100%; | |
| min-width: 2px; | |
| background: linear-gradient(90deg, var(--ck-primary), #2a9d8f); | |
| } | |
| .table { | |
| --bs-table-bg: #ffffff; | |
| --bs-table-striped-bg: #f7fafc; | |
| border: 1px solid var(--ck-line); | |
| } | |
| .table th, | |
| .table td { | |
| white-space: nowrap; | |
| } | |
| .worker-name { | |
| font-weight: 800; | |
| overflow-wrap: anywhere; | |
| white-space: normal; | |
| } | |
| .small-muted { | |
| color: var(--ck-muted); | |
| font-size: 0.92rem; | |
| } | |
| .status-dot { | |
| display: inline-block; | |
| width: 0.7rem; | |
| height: 0.7rem; | |
| border-radius: 999px; | |
| background: var(--ck-good); | |
| margin-right: 0.4rem; | |
| } | |
| .status-dot.stale { | |
| background: #b45309; | |
| } | |
| .section-title { | |
| font-weight: 800; | |
| letter-spacing: 0; | |
| } | |
| .alert code { | |
| overflow-wrap: anywhere; | |
| white-space: normal; | |
| } | |
| @media (min-width: 992px) { | |
| .dashboard-form { | |
| max-width: 760px; | |
| } | |
| .dashboard-form .pool-select { | |
| max-width: 280px; | |
| } | |
| } | |
| @media (max-width: 767.98px) { | |
| .top-band .container { | |
| padding-bottom: 1rem; | |
| padding-top: 1rem; | |
| } | |
| .brand-mark { | |
| width: 36px; | |
| height: 36px; | |
| } | |
| .dashboard-form .btn, | |
| .dashboard-action { | |
| width: 100%; | |
| } | |
| .metric-card .card-body { | |
| padding: 1rem; | |
| } | |
| .metric-value { | |
| font-size: 2rem; | |
| } | |
| } | |
| @media (max-width: 575.98px) { | |
| .mobile-secondary { | |
| display: none; | |
| } | |
| .table th, | |
| .table td { | |
| padding-left: 0.65rem; | |
| padding-right: 0.65rem; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header class="top-band"> | |
| <div class="container py-4"> | |
| <div class="d-flex flex-column flex-lg-row gap-3 align-items-lg-center justify-content-between"> | |
| <div class="d-flex gap-3 align-items-center"> | |
| <img class="brand-mark" src="https://upload.wikimedia.org/wikipedia/commons/thumb/4/46/Bitcoin.svg/120px-Bitcoin.svg.png" alt="Bitcoin"> | |
| <div> | |
| <h1 class="h3 mb-1 fw-bold">CKPool Solo Mining Dashboard</h1> | |
| <div class="small-muted"> | |
| <?php if ($user !== ''): ?> | |
| Bitcoin mining statistics for <span class="fw-semibold"><?= e($user) ?></span> | |
| <?php else: ?> | |
| Select a CKPool portal and enter a Bitcoin address to load mining statistics. | |
| <?php endif; ?> | |
| </div> | |
| </div> | |
| </div> | |
| <form class="dashboard-form d-flex flex-column flex-md-row gap-2" method="get" autocomplete="off"> | |
| <select class="form-select pool-select" name="pool" aria-label="CKPool portal"> | |
| <?php foreach (CKPOOL_POOLS as $key => $poolOption): ?> | |
| <option value="<?= e($key) ?>" <?= $key === $poolKey ? 'selected' : '' ?>> | |
| <?= e($poolOption['label'] . ' - ' . $poolOption['host']) ?> | |
| </option> | |
| <?php endforeach; ?> | |
| </select> | |
| <input class="form-control" name="user" type="text" aria-label="Bitcoin address" placeholder="Bitcoin address" value="<?= e($user) ?>" inputmode="text" autocapitalize="none" autocomplete="off" autocorrect="off" spellcheck="false"> | |
| <button class="btn btn-dark" type="submit">Load</button> | |
| </form> | |
| </div> | |
| </div> | |
| </header> | |
| <main class="container py-4 py-lg-3"> | |
| <?php if ($validationError !== null): ?> | |
| <div class="alert alert-warning" role="alert"><?= e($validationError) ?></div> | |
| <?php endif; ?> | |
| <?php if ($loadError !== null): ?> | |
| <div class="alert alert-danger" role="alert"><?= e($loadError) ?></div> | |
| <?php endif; ?> | |
| <?php if ($user === ''): ?> | |
| <div class="alert alert-info" role="alert"> | |
| <div class="fw-semibold mb-1">Enter a Bitcoin address and choose a CKPool portal to load the dashboard.</div> | |
| <div class="small"> | |
| You can also use GET parameters: | |
| <code>?pool=eu&user=YOUR_BITCOIN_ADDRESS</code>, | |
| <code>?pool=us&user=YOUR_BITCOIN_ADDRESS</code>, or | |
| <code>?pool=au&user=YOUR_BITCOIN_ADDRESS</code>. | |
| </div> | |
| </div> | |
| <?php endif; ?> | |
| <div class="d-flex flex-column flex-md-row gap-2 align-items-md-center justify-content-between mb-4"> | |
| <div class="small-muted"> | |
| <?php if ($data !== null): ?> | |
| Updated <?= e(date('H:i:s')) ?> | |
| <?php endif; ?> | |
| </div> | |
| <?php if ($ckpoolUrl !== ''): ?> | |
| <a class="btn btn-outline-dark btn-sm dashboard-action" href="<?= e($ckpoolUrl) ?>" target="_blank" rel="noopener noreferrer">Open original CKPool data</a> | |
| <?php endif; ?> | |
| </div> | |
| <section class="row g-3 mb-4" aria-label="Main metrics"> | |
| <div class="col-12 col-md-4"> | |
| <div class="card metric-card primary-metric h-100"> | |
| <div class="card-body"> | |
| <div class="metric-label">1-minute hashrate</div> | |
| <div class="metric-value mt-2"><?= valueOrDash($data['hashrate1m'] ?? null) ?></div> | |
| <div class="small-muted mt-2">Recent total mining power</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="col-12 col-md-4"> | |
| <div class="card metric-card accent-metric h-100"> | |
| <div class="card-body"> | |
| <div class="metric-label">Active workers</div> | |
| <div class="metric-value mt-2"><?= formatNumber($data['workers'] ?? null) ?></div> | |
| <div class="small-muted mt-2">Devices reported by the pool</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="col-12 col-md-4"> | |
| <div class="card metric-card good-metric h-100"> | |
| <div class="card-body"> | |
| <div class="metric-label">Best ever</div> | |
| <div class="metric-value mt-2" title="<?= e(formatNumber($data['bestever'] ?? null)) ?>"><?= formatLargeShare($data['bestever'] ?? null) ?></div> | |
| <div class="small-muted mt-2">Highest share recorded</div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <section class="row g-4 mb-4"> | |
| <div class="col-12 col-lg-7"> | |
| <div class="card metric-card h-100"> | |
| <div class="card-body"> | |
| <h2 class="h5 section-title mb-3">Hashrate trend</h2> | |
| <div class="vstack gap-3"> | |
| <?php foreach ($hashratePeriods as $label => $value): ?> | |
| <?php | |
| $parsed = parseHashrate((string) $value); | |
| $width = $parsed > 0 ? max(($parsed / $maxHashrate) * 100, 3) : 0; | |
| ?> | |
| <div> | |
| <div class="d-flex justify-content-between gap-3 mb-1"> | |
| <span class="fw-semibold"><?= e($label) ?></span> | |
| <span class="small-muted"><?= valueOrDash($value) ?></span> | |
| </div> | |
| <div class="hash-bar" aria-label="Hashrate <?= e($label) ?>"> | |
| <span style="width: <?= e((string) $width) ?>%"></span> | |
| </div> | |
| </div> | |
| <?php endforeach; ?> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="col-12 col-lg-5"> | |
| <div class="card metric-card h-100"> | |
| <div class="card-body"> | |
| <h2 class="h5 section-title mb-3">Pool summary</h2> | |
| <dl class="row mb-0"> | |
| <dt class="col-5 small-muted">Last share</dt> | |
| <dd class="col-7 fw-semibold text-end"><?= formatUnixTime($data['lastshare'] ?? null) ?></dd> | |
| <dt class="col-5 small-muted">Total shares</dt> | |
| <dd class="col-7 fw-semibold text-end"><?= formatNumber($data['shares'] ?? null) ?></dd> | |
| <dt class="col-5 small-muted">Best share</dt> | |
| <dd class="col-7 fw-semibold text-end"><?= formatNumber($data['bestshare'] ?? null) ?></dd> | |
| <dt class="col-5 small-muted">Authorised since</dt> | |
| <dd class="col-7 fw-semibold text-end"><?= formatUnixTime($data['authorised'] ?? null) ?></dd> | |
| </dl> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <section> | |
| <div class="d-flex flex-column flex-md-row align-items-md-end justify-content-between gap-2 mb-3"> | |
| <div> | |
| <h2 class="h4 section-title mb-1">Worker</h2> | |
| <div class="small-muted">Short name, recent hashrate, and highest share by device.</div> | |
| </div> | |
| </div> | |
| <div class="table-responsive"> | |
| <table class="table table-striped table-hover align-middle mb-0"> | |
| <thead> | |
| <tr> | |
| <th scope="col">Worker</th> | |
| <th scope="col">Hashrate 1m</th> | |
| <th class="mobile-secondary" scope="col">Hashrate 1h</th> | |
| <th class="mobile-secondary" scope="col">Hashrate 1d</th> | |
| <th scope="col">Best ever</th> | |
| <th class="mobile-secondary" scope="col">Last share</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <?php if (count($workers) === 0): ?> | |
| <tr> | |
| <td colspan="6" class="text-center py-4 small-muted"> | |
| <?= $user === '' ? 'Enter a Bitcoin address to load workers.' : 'No workers available.' ?> | |
| </td> | |
| </tr> | |
| <?php else: ?> | |
| <?php foreach ($workers as $worker): ?> | |
| <?php | |
| $shortName = shortenWorkerName(isset($worker['workername']) ? (string) $worker['workername'] : null, $user); | |
| $stale = isShareStale($worker['lastshare'] ?? null); | |
| ?> | |
| <tr> | |
| <td> | |
| <span class="status-dot <?= $stale ? 'stale' : '' ?>" title="<?= $stale ? 'Stale share' : 'Recent share' ?>"></span> | |
| <span class="worker-name"><?= e($shortName) ?></span> | |
| </td> | |
| <td class="fw-bold"><?= valueOrDash($worker['hashrate1m'] ?? null) ?></td> | |
| <td class="mobile-secondary"><?= valueOrDash($worker['hashrate1hr'] ?? null) ?></td> | |
| <td class="mobile-secondary"><?= valueOrDash($worker['hashrate1d'] ?? null) ?></td> | |
| <td class="fw-semibold" title="<?= e(formatNumber($worker['bestever'] ?? null)) ?>"><?= formatLargeShare($worker['bestever'] ?? null) ?></td> | |
| <td class="mobile-secondary"><?= formatUnixTime($worker['lastshare'] ?? null) ?></td> | |
| </tr> | |
| <?php endforeach; ?> | |
| <?php endif; ?> | |
| </tbody> | |
| </table> | |
| </div> | |
| </section> | |
| </main> | |
| <footer class="container pb-4"> | |
| <div class="small-muted text-center"> | |
| CKBoard made with ♥ by <a href="https://www.andreadraghetti.it/" target="_blank" rel="noopener noreferrer">Andrea Draghetti</a>. | |
| </div> | |
| </footer> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment