| 1 |
<?php |
| 2 |
require_once __DIR__ . '/inc/layout.php'; |
| 3 |
|
| 4 |
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); |
| 5 |
header('Pragma: no-cache'); |
| 6 |
header('Expires: 0'); |
| 7 |
|
| 8 |
// Same stale-cache defense as index.php; save/delete POSTs pass through. |
| 9 |
cache_buster_redirect(); |
| 10 |
|
| 11 |
require_login(); |
| 12 |
|
| 13 |
$types = filament_types(); |
| 14 |
|
| 15 |
// Brands already in the inventory, deduplicated case-insensitively, |
| 16 |
// offered as autocomplete suggestions on the brand field. |
| 17 |
$brands = db()->query( |
| 18 |
"SELECT brand FROM filaments WHERE brand IS NOT NULL AND brand <> '' |
| 19 |
GROUP BY brand COLLATE NOCASE ORDER BY brand COLLATE NOCASE" |
| 20 |
)->fetchAll(PDO::FETCH_COLUMN); |
| 21 |
|
| 22 |
$id = (int)($_POST['id'] ?? $_GET['id'] ?? 0); |
| 23 |
|
| 24 |
// Manufacturers tend to reuse one spool design, so when adding a spool the |
| 25 |
// empty-spool weight is pre-filled from the brand's most common value. |
| 26 |
// Keyed by lowercased brand; ties go to the most recently added spool. |
| 27 |
$spoolWeights = []; |
| 28 |
if (!$id) { |
| 29 |
$wrows = db()->query( |
| 30 |
"SELECT brand, spool_weight_g AS w, COUNT(*) AS n FROM filaments |
| 31 |
WHERE brand IS NOT NULL AND brand <> '' AND spool_weight_g > 0 |
| 32 |
GROUP BY brand COLLATE NOCASE, spool_weight_g |
| 33 |
ORDER BY n, MAX(id)" |
| 34 |
)->fetchAll(); |
| 35 |
foreach ($wrows as $r) { |
| 36 |
$spoolWeights[mb_strtolower(trim($r['brand']))] = ['w' => (float)$r['w'], 'n' => (int)$r['n'], 'brand' => $r['brand']]; |
| 37 |
} |
| 38 |
} |
| 39 |
$spool = null; |
| 40 |
if ($id) { |
| 41 |
$st = db()->prepare('SELECT * FROM filaments WHERE id = ?'); |
| 42 |
$st->execute([$id]); |
| 43 |
$spool = $st->fetch(); |
| 44 |
if (!$spool) { |
| 45 |
flash('Spool not found.'); |
| 46 |
header('Location: index.php'); |
| 47 |
exit; |
| 48 |
} |
| 49 |
} |
| 50 |
|
| 51 |
$error = ''; |
| 52 |
|
| 53 |
if ($_SERVER['REQUEST_METHOD'] === 'POST') { |
| 54 |
check_csrf(); |
| 55 |
|
| 56 |
if (($_POST['action'] ?? '') === 'delete' && $spool) { |
| 57 |
backup_db(); |
| 58 |
db()->prepare('DELETE FROM filaments WHERE id = ?')->execute([$id]); |
| 59 |
flash('Deleted ' . spool_label($spool) . '.'); |
| 60 |
header('Location: index.php'); |
| 61 |
exit; |
| 62 |
} |
| 63 |
|
| 64 |
$type = trim((string)($_POST['type'] ?? '')); |
| 65 |
$brand = trim((string)($_POST['brand'] ?? '')); |
| 66 |
$colorName = trim((string)($_POST['color_name'] ?? '')); |
| 67 |
$color = (string)($_POST['color_hex'] ?? ''); |
| 68 |
$color2 = (string)($_POST['color_hex2'] ?? ''); |
| 69 |
$total = (float)($_POST['total_weight_g'] ?? 0); |
| 70 |
$sw = (float)($_POST['spool_weight_g'] ?? 0); |
| 71 |
$origRaw = trim((string)($_POST['original_g'] ?? '')); |
| 72 |
$orig = $origRaw === '' ? 1000.0 : (float)$origRaw; |
| 73 |
$cost = trim((string)($_POST['cost'] ?? '')); |
| 74 |
$cost = $cost === '' ? null : (float)$cost; |
| 75 |
$notes = trim((string)($_POST['notes'] ?? '')); |
| 76 |
$abrasive = isset($_POST['abrasive']) ? 1 : 0; |
| 77 |
$highflow = isset($_POST['highflow']) ? 1 : 0; |
| 78 |
$silk = isset($_POST['silk']) ? 1 : 0; |
| 79 |
$matte = isset($_POST['matte']) ? 1 : 0; |
| 80 |
$rainbow = isset($_POST['rainbow']) ? 1 : 0; |
| 81 |
$transparent = isset($_POST['transparent']) ? 1 : 0; |
| 82 |
$twotone = isset($_POST['twotone']) ? 1 : 0; |
| 83 |
$sparkle = isset($_POST['sparkle']) ? 1 : 0; |
| 84 |
$small = isset($_POST['small_spool']) ? 1 : 0; |
| 85 |
|
| 86 |
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) { |
| 87 |
$color = '#888888'; |
| 88 |
} |
| 89 |
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color2)) { |
| 90 |
$color2 = $twotone ? '#888888' : null; |
| 91 |
} |
| 92 |
if ($type === '') { |
| 93 |
$error = 'Filament type is required.'; |
| 94 |
} elseif ($total <= 0 || $sw < 0 || $total <= $sw) { |
| 95 |
$error = 'Total weight must be greater than the spool weight.'; |
| 96 |
} elseif ($orig <= 0) { |
| 97 |
$error = 'Original spool amount must be greater than zero.'; |
| 98 |
} else { |
| 99 |
$net = $total - $sw; |
| 100 |
backup_db(); |
| 101 |
if ($spool) { |
| 102 |
// A changed weight means the spool was re-weighed: reset remaining |
| 103 |
// to total - spool. Otherwise keep remaining (usage already deducted). |
| 104 |
$weightsChanged = abs($total - (float)$spool['total_weight_g']) > 0.001 |
| 105 |
|| abs($sw - (float)$spool['spool_weight_g']) > 0.001; |
| 106 |
$remaining = $weightsChanged ? $net : (float)$spool['remaining_g']; |
| 107 |
db()->prepare( |
| 108 |
'UPDATE filaments SET type=?, brand=?, color_name=?, color_hex=?, color_hex2=?, total_weight_g=?, spool_weight_g=?, |
| 109 |
original_g=?, remaining_g=?, abrasive=?, highflow=?, silk=?, matte=?, rainbow=?, transparent=?, twotone=?, sparkle=?, small_spool=?, cost=?, notes=?, updated_at=datetime(\'now\') WHERE id=?' |
| 110 |
)->execute([$type, $brand, $colorName, $color, $color2, $total, $sw, $orig, $remaining, $abrasive, $highflow, $silk, $matte, $rainbow, $transparent, $twotone, $sparkle, $small, $cost, $notes, $id]); |
| 111 |
flash('Saved ' . spool_label(['brand' => $brand, 'type' => $type, 'color_name' => $colorName]) . '.'); |
| 112 |
$dest = 'index.php'; |
| 113 |
} else { |
| 114 |
db()->prepare( |
| 115 |
'INSERT INTO filaments (type, brand, color_name, color_hex, color_hex2, total_weight_g, spool_weight_g, |
| 116 |
original_g, remaining_g, abrasive, highflow, silk, matte, rainbow, transparent, twotone, sparkle, small_spool, cost, notes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)' |
| 117 |
)->execute([$type, $brand, $colorName, $color, $color2, $total, $sw, $orig, $net, $abrasive, $highflow, $silk, $matte, $rainbow, $transparent, $twotone, $sparkle, $small, $cost, $notes]); |
| 118 |
flash('Added ' . spool_label(['brand' => $brand, 'type' => $type, 'color_name' => $colorName]) . '.'); |
| 119 |
// Land on the "recently added" sort so the new spool is at the top. |
| 120 |
$dest = 'index.php?sort=added'; |
| 121 |
} |
| 122 |
header('Location: index.php'); |
| 123 |
exit; |
| 124 |
} |
| 125 |
// Validation failed: re-show submitted values. |
| 126 |
$spool = array_merge($spool ?? [], [ |
| 127 |
'type' => $type, 'brand' => $brand, 'color_name' => $colorName, 'color_hex' => $color, |
| 128 |
'color_hex2' => $color2, 'total_weight_g' => $total, 'spool_weight_g' => $sw, |
| 129 |
'original_g' => $orig, 'cost' => $cost, |
| 130 |
'abrasive' => $abrasive, 'highflow' => $highflow, 'silk' => $silk, 'matte' => $matte, |
| 131 |
'rainbow' => $rainbow, 'transparent' => $transparent, 'twotone' => $twotone, 'sparkle' => $sparkle, |
| 132 |
'small_spool' => $small, 'notes' => $notes, |
| 133 |
]); |
| 134 |
} |
| 135 |
|
| 136 |
page_header($id ? 'Edit spool' : 'Add spool'); |
| 137 |
?> |
| 138 |
<h1><?= $id ? 'Edit spool' : 'Add spool' ?></h1> |
| 139 |
<?php if ($error): ?> |
| 140 |
<p class="error"><?= e($error) ?></p> |
| 141 |
<?php endif; ?> |
| 142 |
<form method="post" id="spoolForm"> |
| 143 |
<?= csrf_field() ?> |
| 144 |
<?php if ($id): ?><input type="hidden" name="id" value="<?= $id ?>"><?php endif; ?> |
| 145 |
|
| 146 |
<label for="type">Type</label> |
| 147 |
<?php |
| 148 |
$curType = (string)($spool['type'] ?? ''); |
| 149 |
// A spool saved with a type outside the list must stay selectable. |
| 150 |
$typeOptions = in_array($curType, $types, true) || $curType === '' ? $types : array_merge($types, [$curType]); |
| 151 |
?> |
| 152 |
<select id="type" name="type" required> |
| 153 |
<option value="" disabled <?= $curType === '' ? 'selected' : '' ?>>Select type…</option> |
| 154 |
<?php foreach ($typeOptions as $t): ?> |
| 155 |
<option value="<?= e($t) ?>" <?= $t === $curType ? 'selected' : '' ?>><?= e($t) ?></option> |
| 156 |
<?php endforeach; ?> |
| 157 |
</select> |
| 158 |
|
| 159 |
<label for="color_name">Color name (optional)</label> |
| 160 |
<input id="color_name" name="color_name" value="<?= e($spool['color_name'] ?? '') ?>" placeholder="Galaxy Black, Signal White…"> |
| 161 |
|
| 162 |
<label for="brand">Manufacturer / brand</label> |
| 163 |
<input id="brand" name="brand" list="brandList" value="<?= e($spool['brand'] ?? '') ?>" placeholder="Prusament, Polymaker…"> |
| 164 |
<datalist id="brandList"> |
| 165 |
<?php foreach ($brands as $b): ?><option value="<?= e($b) ?>"></option><?php endforeach; ?> |
| 166 |
</datalist> |
| 167 |
|
| 168 |
<label for="color">Color</label> |
| 169 |
<div class="row"> |
| 170 |
<input type="color" id="color" name="color_hex" value="<?= e($spool['color_hex'] ?? '#da6019') ?>"> |
| 171 |
<input type="color" id="color2" name="color_hex2" aria-label="Second color" value="<?= e(!empty($spool['color_hex2']) ? $spool['color_hex2'] : '#3355ff') ?>" <?= empty($spool['twotone']) ? 'hidden' : '' ?>> |
| 172 |
<button type="button" class="secondary" id="camBtn" hidden>📷 Camera</button> |
| 173 |
<label class="btn secondary" style="margin:0">🖼 Photo<input type="file" id="photoIn" accept="image/*" capture="environment" hidden></label> |
| 174 |
</div> |
| 175 |
<div id="camPanel" hidden> |
| 176 |
<video id="camVideo" autoplay playsinline muted></video> |
| 177 |
<div class="actions"> |
| 178 |
<button type="button" id="sampleBtn">Sample color</button> |
| 179 |
<button type="button" class="secondary" id="camClose">Close</button> |
| 180 |
</div> |
| 181 |
</div> |
| 182 |
<p class="muted" id="colorMsg">Point the camera at the filament — the average color of the center of the frame is sampled.</p> |
| 183 |
<label class="check"><input type="checkbox" name="rainbow" <?= !empty($spool['rainbow']) ? 'checked' : '' ?>> Rainbow / multicolor (shows an animated all-hue swatch)</label> |
| 184 |
<label class="check"><input type="checkbox" name="twotone" id="twotoneChk" <?= !empty($spool['twotone']) ? 'checked' : '' ?>> Color-shifting / two-tone (pick a second color; a moving gradient blends them)</label> |
| 185 |
<label class="check"><input type="checkbox" name="transparent" <?= !empty($spool['transparent']) ? 'checked' : '' ?>> Transparent / clear (a checkerboard shows through the swatch)</label> |
| 186 |
<label class="check"><input type="checkbox" name="sparkle" <?= !empty($spool['sparkle']) ? 'checked' : '' ?>> Sparkle / glitter (galaxy-style flecks twinkle on the swatch)</label> |
| 187 |
|
| 188 |
<label for="total_weight_g">Total weight (g) — spool + filament</label> |
| 189 |
<input type="number" id="total_weight_g" name="total_weight_g" step="0.1" min="0" inputmode="decimal" required value="<?= e(isset($spool['total_weight_g']) ? (string)(float)$spool['total_weight_g'] : '') ?>"> |
| 190 |
|
| 191 |
<label for="spool_weight_g">Empty spool weight (g)</label> |
| 192 |
<input type="number" id="spool_weight_g" name="spool_weight_g" step="0.1" min="0" inputmode="decimal" required value="<?= e(isset($spool['spool_weight_g']) ? (string)(float)$spool['spool_weight_g'] : '') ?>"> |
| 193 |
<p class="muted" id="swMsg"></p> |
| 194 |
<p class="muted" id="netMsg"></p> |
| 195 |
|
| 196 |
<label for="original_g">Original spool amount (g) — filament when new</label> |
| 197 |
<input type="number" id="original_g" name="original_g" step="0.1" min="0" inputmode="decimal" required value="<?= e(isset($spool['original_g']) && $spool['original_g'] !== null ? (string)(float)$spool['original_g'] : '1000') ?>"> |
| 198 |
<div class="chips" id="origPresets" role="group" aria-label="Common spool amounts"> |
| 199 |
<button type="button" class="chip" data-g="1000">1 kg</button> |
| 200 |
<button type="button" class="chip" data-g="500">500 g</button> |
| 201 |
<button type="button" class="chip" data-g="250">250 g</button> |
| 202 |
<button type="button" class="chip" data-g="100">100 g</button> |
| 203 |
</div> |
| 204 |
<label class="check"><input type="checkbox" name="small_spool" <?= !empty($spool['small_spool']) ? 'checked' : '' ?>> Small spool (draws a smaller swatch — 250 g and under are small automatically)</label> |
| 205 |
|
| 206 |
<label for="cost">Cost (<?= e(config()['currency']) ?>)</label> |
| 207 |
<input type="number" id="cost" name="cost" step="0.01" min="0" inputmode="decimal" value="<?= e(isset($spool['cost']) && $spool['cost'] !== null ? (string)(float)$spool['cost'] : '') ?>"> |
| 208 |
|
| 209 |
<div class="check-row"> |
| 210 |
<label class="check"><input type="checkbox" name="abrasive" <?= !empty($spool['abrasive']) ? 'checked' : '' ?>> Abrasive (CF, GF, glow…)</label> |
| 211 |
<label class="check"><input type="checkbox" name="highflow" <?= !empty($spool['highflow']) ? 'checked' : '' ?>> High-flow</label> |
| 212 |
<label class="check"><input type="checkbox" name="silk" <?= !empty($spool['silk']) ? 'checked' : '' ?>> Silk</label> |
| 213 |
<label class="check"><input type="checkbox" name="matte" <?= !empty($spool['matte']) ? 'checked' : '' ?>> Matte</label> |
| 214 |
</div> |
| 215 |
|
| 216 |
<label for="notes">Notes</label> |
| 217 |
<textarea id="notes" name="notes" rows="1"><?= e($spool['notes'] ?? '') ?></textarea> |
| 218 |
|
| 219 |
<div class="actions"> |
| 220 |
<button>Save</button> |
| 221 |
<a class="btn secondary" href="index.php">Cancel</a> |
| 222 |
</div> |
| 223 |
</form> |
| 224 |
<?php if ($spoolWeights): ?> |
| 225 |
<script type="application/json" id="spoolWeights"><?= json_encode($spoolWeights, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE) ?></script> |
| 226 |
<?php endif; ?> |
| 227 |
<?php if ($id): ?> |
| 228 |
<form method="post" onsubmit="return confirm('Delete this spool and its usage history?')"> |
| 229 |
<?= csrf_field() ?> |
| 230 |
<input type="hidden" name="id" value="<?= $id ?>"> |
| 231 |
<input type="hidden" name="action" value="delete"> |
| 232 |
<div class="actions"><button class="danger">Delete spool</button></div> |
| 233 |
</form> |
| 234 |
<?php endif; ?> |
| 235 |
<?php |
| 236 |
page_footer(); |
| 237 |
|