| 1 |
<?php |
| 2 |
require_once __DIR__ . '/auth.php'; |
| 3 |
|
| 4 |
function e(?string $s): string |
| 5 |
{ |
| 6 |
return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); |
| 7 |
} |
| 8 |
|
| 9 |
function rel_time(?string $utc): string |
| 10 |
{ |
| 11 |
if (!$utc) { |
| 12 |
return 'never used'; |
| 13 |
} |
| 14 |
$diff = time() - (new DateTime($utc, new DateTimeZone('UTC')))->getTimestamp(); |
| 15 |
if ($diff < 90) { |
| 16 |
return 'used just now'; |
| 17 |
} |
| 18 |
foreach ([31536000 => 'y', 2592000 => 'mo', 604800 => 'w', 86400 => 'd', 3600 => 'h', 60 => 'min'] as $secs => $unit) { |
| 19 |
if ($diff >= $secs) { |
| 20 |
return 'used ' . floor($diff / $secs) . " $unit ago"; |
| 21 |
} |
| 22 |
} |
| 23 |
return 'used just now'; |
| 24 |
} |
| 25 |
|
| 26 |
// Hex color to [hue 0-360 (0 when achromatic), saturation 0-1, lightness 0-1]. |
| 27 |
function hex_hsl(?string $hex): array |
| 28 |
{ |
| 29 |
if (!preg_match('/^#[0-9a-fA-F]{6}$/', (string)$hex)) { |
| 30 |
$hex = '#888888'; |
| 31 |
} |
| 32 |
$r = hexdec(substr($hex, 1, 2)) / 255; |
| 33 |
$g = hexdec(substr($hex, 3, 2)) / 255; |
| 34 |
$b = hexdec(substr($hex, 5, 2)) / 255; |
| 35 |
$max = max($r, $g, $b); |
| 36 |
$min = min($r, $g, $b); |
| 37 |
$d = $max - $min; |
| 38 |
$l = ($max + $min) / 2; |
| 39 |
$s = ($l == 0 || $l == 1) ? 0 : $d / (1 - abs(2 * $l - 1)); |
| 40 |
if ($d == 0) { |
| 41 |
$h = 0; |
| 42 |
} elseif ($max == $r) { |
| 43 |
$h = fmod(($g - $b) / $d, 6); |
| 44 |
} elseif ($max == $g) { |
| 45 |
$h = ($b - $r) / $d + 2; |
| 46 |
} else { |
| 47 |
$h = ($r - $g) / $d + 4; |
| 48 |
} |
| 49 |
return [fmod($h * 60 + 360, 360), $s, $l]; |
| 50 |
} |
| 51 |
|
| 52 |
// Sort key for ordering by color: colorful spools by hue then lightness, |
| 53 |
// grays (low saturation) grouped after them, dark to light. |
| 54 |
function hue_key(?string $hex): array |
| 55 |
{ |
| 56 |
[$h, $s, $l] = hex_hsl($hex); |
| 57 |
return $s < 0.18 ? [1, $l, 0.0] : [0, $h, $l]; |
| 58 |
} |
| 59 |
|
| 60 |
// The remaining-amount pie inside the spool hub is always drawn white, so it |
| 61 |
// stays consistent and readable on the black hub regardless of filament color. |
| 62 |
function pie_color(?string $hex): string |
| 63 |
{ |
| 64 |
return '#ffffff'; |
| 65 |
} |
| 66 |
|
| 67 |
// Percent of a spool's original (nominal) filament amount still on it. Rows |
| 68 |
// with original_g unset are assumed to be 1 kg spools. |
| 69 |
function spool_pct(array $s): int |
| 70 |
{ |
| 71 |
$orig = (float)($s['original_g'] ?? 0); |
| 72 |
if ($orig <= 0) { |
| 73 |
$orig = 1000.0; |
| 74 |
} |
| 75 |
return max(0, min(100, (int)round(100 * (float)$s['remaining_g'] / $orig))); |
| 76 |
} |
| 77 |
|
| 78 |
// Whether a spool has been used up. The cutoff is half a gram rather than |
| 79 |
// zero so that the spools this reports as empty are exactly the ones the UI |
| 80 |
// already displays as "0 g left" — round() shows anything under 0.5 as 0 — |
| 81 |
// and so a deduction that takes the last of the filament still counts as |
| 82 |
// emptying the spool when floating-point leaves a sliver behind. |
| 83 |
function spool_is_empty(array $s): bool |
| 84 |
{ |
| 85 |
return (float)$s['remaining_g'] < 0.5; |
| 86 |
} |
| 87 |
|
| 88 |
// Whether a spool should render as a "small" (physically smaller) swatch: |
| 89 |
// sub-250 g spools (the common 250 g / 100 g sizes) are auto-small, and the |
| 90 |
// small_spool flag lets any spool be marked small by hand on the edit form. |
| 91 |
function is_small_spool(array $s): bool |
| 92 |
{ |
| 93 |
if (!empty($s['small_spool'])) { |
| 94 |
return true; |
| 95 |
} |
| 96 |
$orig = (float)($s['original_g'] ?? 0); |
| 97 |
return $orig > 0 && $orig <= 250; |
| 98 |
} |
| 99 |
|
| 100 |
// The filament types offered in the add/edit and bulk-edit type dropdowns. |
| 101 |
function filament_types(): array |
| 102 |
{ |
| 103 |
return ['PLA', 'PETG', 'ASA', 'TPU', 'PLA+', 'PLA-CF', 'PETG-CF', 'PCTG', 'ABS', 'PC', 'PA (Nylon)', 'PA-CF', 'PVA', 'HIPS', 'PVB']; |
| 104 |
} |
| 105 |
|
| 106 |
// CSS classes for a spool's swatch: rainbow spools get the animated full-hue |
| 107 |
// ring, silk spools the recurring shine sweep, transparent spools a |
| 108 |
// checkerboard showing through the color, sparkle spools twinkling flecks. |
| 109 |
function swatch_class(array $s): string |
| 110 |
{ |
| 111 |
$cls = 'swatch'; |
| 112 |
if (!empty($s['rainbow'])) { |
| 113 |
$cls .= ' rainbow'; |
| 114 |
} |
| 115 |
if (!empty($s['silk'])) { |
| 116 |
$cls .= ' silk'; |
| 117 |
} |
| 118 |
if (!empty($s['transparent'])) { |
| 119 |
$cls .= ' transparent'; |
| 120 |
} |
| 121 |
if (!empty($s['twotone'])) { |
| 122 |
$cls .= ' twotone'; |
| 123 |
} |
| 124 |
if (!empty($s['sparkle'])) { |
| 125 |
$cls .= ' sparkle'; |
| 126 |
} |
| 127 |
return $cls; |
| 128 |
} |
| 129 |
|
| 130 |
// A spool's swatch as HTML. Every page renders it through this so the markup |
| 131 |
// (the CSS custom properties and the sparkle <i> layer) stays in one place. |
| 132 |
// Mini swatches (the color grid) are decorative — the button around them |
| 133 |
// carries the label — so they get aria-hidden instead of the role/title. |
| 134 |
function swatch_html(array $s, bool $mini = false, string $id = ''): string |
| 135 |
{ |
| 136 |
$pct = spool_pct($s); |
| 137 |
$fc = $s['color_hex'] ?: '#888888'; |
| 138 |
$fc2 = !empty($s['color_hex2']) ? $s['color_hex2'] : $fc; |
| 139 |
$attrs = $mini ? ' aria-hidden="true"' |
| 140 |
: ' role="img" aria-label="' . $pct . '% of spool left" title="' . $pct . '% of spool left"'; |
| 141 |
// Small spools draw a smaller circle at both sizes; the CSS keeps the grid |
| 142 |
// cell around a mini one uniform so rows and columns still line up. |
| 143 |
$size = ($mini ? ' mini' : '') . (is_small_spool($s) ? ' small' : ''); |
| 144 |
return '<span class="' . swatch_class($s) . $size . '"' |
| 145 |
. ($id !== '' ? ' id="' . e($id) . '"' : '') . $attrs |
| 146 |
. ' style="--fc:' . e($fc) . ';--fc2:' . e($fc2) . ';--pc:' . e(pie_color($fc)) . ';--pct:' . $pct . '%">' |
| 147 |
. '<i class="spark"></i></span>'; |
| 148 |
} |
| 149 |
|
| 150 |
// "Prusament PLA · Galaxy Black" — whichever of those the spool actually has. |
| 151 |
function spool_label(array $s): string |
| 152 |
{ |
| 153 |
$label = trim($s['brand'] . ' ' . $s['type']); |
| 154 |
if (!empty($s['color_name'])) { |
| 155 |
$label .= ' · ' . $s['color_name']; |
| 156 |
} |
| 157 |
return $label; |
| 158 |
} |
| 159 |
|
| 160 |
// The HTML is sent no-store, but browsers and LiteSpeed happily hold on to a |
| 161 |
// stale assets/*.js — which serves new markup against old script. Key each |
| 162 |
// asset URL to its mtime so an edited file is always fetched fresh. |
| 163 |
function asset(string $path): string |
| 164 |
{ |
| 165 |
$full = dirname(__DIR__) . '/' . $path; |
| 166 |
return e($path . '?v=' . (is_file($full) ? filemtime($full) : 0)); |
| 167 |
} |
| 168 |
|
| 169 |
// Some proxies (and LiteSpeed's page cache) ignore no-store and serve stale |
| 170 |
// HTML. Redirecting every plain GET to a cb=<random>-stamped URL makes each |
| 171 |
// visit a unique URL that can never come from a cache. POSTs and requests |
| 172 |
// already carrying cb pass straight through; existing GET vars are kept. |
| 173 |
function cache_buster_redirect(): void |
| 174 |
{ |
| 175 |
if (isset($_GET['cb']) || $_SERVER['REQUEST_METHOD'] === 'POST') { |
| 176 |
return; |
| 177 |
} |
| 178 |
$params = $_GET; |
| 179 |
$params['cb'] = bin2hex(random_bytes(6)); |
| 180 |
header('Location: ' . strtok($_SERVER['REQUEST_URI'], '?') . '?' . http_build_query($params), true, 302); |
| 181 |
exit; |
| 182 |
} |
| 183 |
|
| 184 |
function page_header(string $title): void |
| 185 |
{ |
| 186 |
$cfg = config(); |
| 187 |
// Every page is dynamic (inventory changes between visits), so forbid |
| 188 |
// caching by browsers, proxies, and LiteSpeed's server-side page cache. |
| 189 |
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); |
| 190 |
header('Pragma: no-cache'); |
| 191 |
header('Expires: 0'); |
| 192 |
header('X-LiteSpeed-Cache-Control: no-cache'); |
| 193 |
?> |
| 194 |
<!doctype html> |
| 195 |
<html lang="en"> |
| 196 |
<head> |
| 197 |
<meta charset="utf-8"> |
| 198 |
<meta name="viewport" content="width=device-width, initial-scale=1"> |
| 199 |
<title><?= e($title) ?> · <?= e($cfg['site_name']) ?></title> |
| 200 |
<link rel="stylesheet" href="<?= asset('assets/style.css') ?>"> |
| 201 |
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"> |
| 202 |
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"> |
| 203 |
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"> |
| 204 |
<link rel="manifest" href="/site.webmanifest"> |
| 205 |
</head> |
| 206 |
<body> |
| 207 |
<nav> |
| 208 |
<a class="brand" href="index.php"><?= e($cfg['site_name']) ?></a> |
| 209 |
<?php if (is_logged_in()): ?> |
| 210 |
<a href="spool.php">Add</a> |
| 211 |
<a href="use.php">Use</a> |
| 212 |
<a href="report.php">Report</a> |
| 213 |
<a href="passkeys.php">Keys</a> |
| 214 |
<a href="login.php?logout=1">Logout</a> |
| 215 |
<?php else: ?> |
| 216 |
<a href="login.php">Login</a> |
| 217 |
<?php endif; ?> |
| 218 |
</nav> |
| 219 |
<main> |
| 220 |
<?php |
| 221 |
$msg = flash(); |
| 222 |
if ($msg !== null) { |
| 223 |
echo '<div class="flash">' . e($msg) . '</div>'; |
| 224 |
} |
| 225 |
} |
| 226 |
|
| 227 |
// $extra names further scripts to load after app.js (e.g. 'assets/passkey.js'), |
| 228 |
// so pages that need one are not paid for by every other page. |
| 229 |
function page_footer(array $extra = []): void |
| 230 |
{ |
| 231 |
echo '</main>' . "\n" . '<script src="' . asset('assets/app.js') . '"></script>' . "\n"; |
| 232 |
foreach ($extra as $src) { |
| 233 |
echo '<script src="' . asset($src) . '"></script>' . "\n"; |
| 234 |
} |
| 235 |
echo "</body>\n</html>\n"; |
| 236 |
} |
| 237 |
|