237 lines · 8.2 KB
Raw Download
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