prepare( 'UPDATE filaments SET ' . implode(', ', $set) . ", updated_at=datetime('now') WHERE id IN ($ph)" )->execute(array_merge($vals, $ids)); flash('Updated ' . count($ids) . ' spool' . (count($ids) === 1 ? '' : 's') . '.'); header('Location: index.php'); exit; } if (!is_logged_in() && !config()['public_view']) { header('Location: login.php'); exit; } // hex_hsl(), hue_key(), pie_color() and spool_pct() live in inc/layout.php — // use.php renders the same swatch. $sorts = [ 'used' => '(last_used_at IS NULL), last_used_at DESC, brand COLLATE NOCASE, type COLLATE NOCASE', 'type' => 'type COLLATE NOCASE, color_name COLLATE NOCASE, brand COLLATE NOCASE', 'color' => 'id', // real ordering happens in PHP below (hue needs math SQLite can't do) 'name' => "(color_name IS NULL OR color_name = ''), color_name COLLATE NOCASE, brand COLLATE NOCASE", 'brand' => "(brand IS NULL OR brand = ''), brand COLLATE NOCASE, type COLLATE NOCASE", 'left' => 'remaining_g DESC, type COLLATE NOCASE', 'added' => 'created_at DESC, id DESC', ]; $sortLabels = [ 'used' => 'Recently used', 'type' => 'Type', 'color' => 'Color', 'name' => 'Color name', 'brand' => 'Manufacturer', 'left' => 'Amount left', 'added' => 'Recently added', ]; $sort = $_GET['sort'] ?? $_SESSION['sort'] ?? 'used'; if (!is_string($sort) || !isset($sorts[$sort])) { $sort = 'used'; } $_SESSION['sort'] = $sort; // List or color-grid view, remembered in the session like the sort. $view = $_GET['view'] ?? $_SESSION['view'] ?? 'list'; if (!is_string($view) || !in_array($view, ['list', 'grid'], true)) { $view = 'list'; } $_SESSION['view'] = $view; $rows = db()->query('SELECT * FROM filaments ORDER BY ' . $sorts[$sort])->fetchAll(); if ($sort === 'color') { // Rainbow spools have no single hue — group them ahead of the hue wheel. $colorKey = fn(array $r) => !empty($r['rainbow']) ? [-1, 0.0, 0.0] : hue_key($r['color_hex']); usort($rows, fn($a, $b) => $colorKey($a) <=> $colorKey($b)); } $cur = config()['currency']; // ---- filters: AND across the groups, OR within a group ---- $flagDefs = ['abrasive' => 'Abrasive', 'highflow' => 'High-flow', 'silk' => 'Silk', 'matte' => 'Matte', 'rainbow' => 'Rainbow', 'twotone' => 'Two-tone', 'transparent' => 'Clear', 'sparkle' => 'Sparkle']; $remDefs = ['full' => 'Nearly full (85%+)', 'partial' => 'Partly used', 'low' => 'Low (under 15%)']; $clean = fn($v) => array_values(array_unique(array_filter((array)$v, fn($s) => is_string($s) && $s !== ''))); $lc = fn(?string $s) => mb_strtolower(trim((string)$s)); $fTypes = $clean($_GET['ft'] ?? []); $fBrands = $clean($_GET['fb'] ?? []); $fBrandKeys = array_map($lc, $fBrands); $fRem = array_values(array_intersect(array_keys($remDefs), $clean($_GET['fr'] ?? []))); $fProps = array_values(array_intersect(array_keys($flagDefs), $clean($_GET['fp'] ?? []))); $q = trim((string)($_GET['q'] ?? '')); $nFilters = count($fTypes) + count($fBrands) + count($fRem) + count($fProps) + ($q !== '' ? 1 : 0); $filterOpen = isset($_GET['filter']) || $nFilters > 0; $allCount = count($rows); if ($nFilters) { $rows = array_values(array_filter($rows, function (array $r) use ($fTypes, $fBrandKeys, $fRem, $fProps, $q, $lc) { if ($fTypes && !in_array($r['type'], $fTypes, true)) { return false; } if ($fBrandKeys && !in_array($lc($r['brand']), $fBrandKeys, true)) { return false; } if ($fRem) { $pct = spool_pct($r); $bucket = $pct >= 85 ? 'full' : ($pct < 15 ? 'low' : 'partial'); if (!in_array($bucket, $fRem, true)) { return false; } } if ($fProps) { $any = false; foreach ($fProps as $f) { if (!empty($r[$f])) { $any = true; break; } } if (!$any) { return false; } } if ($q !== '' && !str_contains($lc($r['type'] . ' ' . $r['brand'] . ' ' . $r['color_name'] . ' ' . $r['notes']), $lc($q))) { return false; } return true; })); } // index.php URL keeping the current bulk/filter query; null drops a key. // sort is left out — the session remembers it. function index_url(array $overrides = []): string { $params = $_GET; unset($params['cb'], $params['sort']); foreach ($overrides as $k => $v) { if ($v === null) { unset($params[$k]); } else { $params[$k] = $v; } } $qs = http_build_query($params); return 'index.php' . ($qs === '' ? '' : '?' . $qs); } // One spool's full card: list view renders it directly (optionally with the // bulk-edit checkbox), the color grid renders it hidden with a DOM id so // tapping the spool's mini swatch can reveal it. function spool_card(array $r, bool $bulk = false, ?string $domId = null): void { $cur = config()['currency']; $pct = spool_pct($r); $details = [round((float)$r['remaining_g']) . ' g left (' . $pct . '%)']; if ($r['cost'] !== null && $r['cost'] !== '') { $details[] = $cur . number_format((float)$r['cost'], 2); } // A part-used spool with no logged usage has an unknown history — // "never used" would be wrong, so the label is only shown when there is // a usage date or the spool is still full. if ($r['last_used_at'] || $pct >= 100) { $details[] = rel_time($r['last_used_at']); } ?>
No filaments yet= is_logged_in() ? ' — add your first spool above.' : '.' ?>
No spools match the filters.
= number_format($allCount) ?> spool= $allCount === 1 ? '' : 's' ?> · View stats