tools: private tools tracker (/trackers/tools): inventory of tools, batteries and consumables by category, +/- quantities, add/edit dialog, scripts/tools-run.sh for bots
Deploy production / deploy (push) Successful in 1m39s

This commit is contained in:
stan committed 2026-10-27 22:58:44 +00:00
1 parent 42711253b7
commit 7f71e88953
18 files changed
+47392 -3

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
# Tools tracker (/trackers/tools)
Private (owner-only, 404 for everyone else) inventory of Stan's physical tools, batteries and
consumables. One row per kind of thing with a quantity (32 AAA batteries is one row).
- **Table** `tools.items`: name (unique, case-insensitive), brand, model, category, quantity,
location, purchased_on, price_eur, specs (jsonb, verified specs only), notes, timestamps.
- **Categories** live in `src/lib/tools/index.ts` (label, colours) and are validated in code, so
adding one needs no migration: batteries, power_tools, hand_tools, measuring, electrical,
fasteners, consumables, safety, other.
- **Page**: grouped by category with item/piece totals, search, category filter, +/- quantity
buttons and an add/edit dialog (specs as `key: value` lines).
## Adding items as a bot
On stan-ovh, from an up-to-date checkout of stan/website (e.g. `~/work/tools-tracker`, `git pull`
first; never ~/dev/personal-website):
```sh
scripts/tools-run.sh list # id, category, quantity, name
scripts/tools-run.sh add '{"name":"AA batteries","category":"batteries","quantity":24,"specs":{"size":"AA"}}'
scripts/tools-run.sh add '{"name":"AAA batteries","add":-4}' # used 4 (quantity delta)
scripts/tools-run.sh import ~/imports/tools.json --dry-run # array of entries
scripts/tools-run.sh delete 12
```
Entries are upserted by name (or `id`): only the given fields change, `specs` are merged (a
`null` value removes a key), `quantity` sets and `add` changes the count. DB_URL is read from
the app container and never printed.
+2 -1
View File
@@ -39,7 +39,8 @@ export default defineConfig({
'math', 'math',
'plant', 'plant',
'radio', 'radio',
'media' 'media',
'tools'
], ],
migrations: { schema: 'drizzle', table: '__drizzle_migrations' }, migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
verbose: true, verbose: true,
@@ -0,0 +1,57 @@
CREATE SCHEMA "tools";
--> statement-breakpoint
CREATE TABLE "tools"."items" (
"id" serial PRIMARY KEY,
"name" text NOT NULL,
"brand" text,
"model" text,
"category" text DEFAULT 'other' NOT NULL,
"quantity" integer DEFAULT 1 NOT NULL,
"location" text,
"purchased_on" date,
"price_eur" numeric(10,2),
"specs" jsonb DEFAULT '{}' NOT NULL,
"notes" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "items_name_check" CHECK (length(btrim("name")) > 0),
CONSTRAINT "items_quantity_check" CHECK ("quantity" >= 0),
CONSTRAINT "items_price_check" CHECK ("price_eur" >= 0)
);
--> statement-breakpoint
CREATE UNIQUE INDEX "items_name_key" ON "tools"."items" (lower("name"));--> statement-breakpoint
CREATE INDEX "items_category_idx" ON "tools"."items" ("category");
--> statement-breakpoint
-- Stan's first items (27 Oct 2026). Specs only where verified: CR2032/CR2016 are standard sizes;
-- the LV1R specs are from Kiprim's product page; every Bosch IXO generation is 3.6 V with a
-- built-in Li-ion battery (which generation Stan has isn't known yet).
INSERT INTO "tools"."items" (name, brand, model, category, quantity, specs, notes) VALUES
('AAA batteries', NULL, NULL, 'batteries', 32, '{"size": "AAA"}', NULL),
('CR2032 coin cells', NULL, 'CR2032', 'batteries', 14,
'{"chemistry": "lithium", "voltage": "3 V", "size": "20 × 3.2 mm"}', NULL),
('CR2016 coin cell', NULL, 'CR2016', 'batteries', 1,
'{"chemistry": "lithium", "voltage": "3 V", "size": "20 × 1.6 mm"}', NULL),
('Kiprim LV1R laser level', 'Kiprim', 'LV1R', 'measuring', 1,
'{"type": "self-levelling cross-line laser", "laser": "red, 635 nm, class 2", "accuracy": "±2.5 mm at 5 m", "self_levelling": "±4°", "power": "2× AA", "ip_rating": "IP54", "mount": "1/4\" tripod thread"}',
NULL),
('Bosch IXO cordless screwdriver', 'Bosch', 'IXO', 'power_tools', 1,
'{"type": "cordless screwdriver", "voltage": "3.6 V", "battery": "built-in Li-ion"}',
'Small green drill-shaped electric screwdriver. Which IXO generation is not recorded yet.')
ON CONFLICT ((lower(name))) DO NOTHING;
--> statement-breakpoint
-- Privacy: owner-only on the site (ownerOnlyPrefixes). Like radio, plant and notes, only the app
-- role can read or write (no personal_ro). Grants only run on prod (migrations as `personal`).
REVOKE ALL ON SCHEMA tools FROM PUBLIC;
--> statement-breakpoint
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA tools TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tools TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA tools TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA tools
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA tools
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
END $$;
File diff suppressed because it is too large. Load diff
+23 -1
View File
@@ -368,6 +368,28 @@ try {
throw new Error(`radio schema tables/grants are wrong: ${JSON.stringify(radio)}`); throw new Error(`radio schema tables/grants are wrong: ${JSON.stringify(radio)}`);
const [{ n: radioFreqs }] = await client`select count(*)::int as n from radio.frequencies`; const [{ n: radioFreqs }] = await client`select count(*)::int as n from radio.frequencies`;
if (!radioFreqs) throw new Error('radio.frequencies is empty, expected the seeded frequencies'); if (!radioFreqs) throw new Error('radio.frequencies is empty, expected the seeded frequencies');
// Tools tracker (owner-only): 1 = items; the migration seeds Stan's first 5 items.
const [tools] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'tools', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'tools' and c.relkind = 'r'
`;
if (
Number(tools.tables) !== 1 ||
Number(tools.app_rw) !== 1 ||
Number(tools.ro_read) !== 0 ||
tools.public_usage
)
throw new Error(`tools schema tables/grants are wrong: ${JSON.stringify(tools)}`);
// Energy tracker reads the ned-collector tables (SELECT only). // Energy tracker reads the ned-collector tables (SELECT only).
const [energy] = await client` const [energy] = await client`
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
@@ -377,7 +399,7 @@ try {
if (!Number(energy.hours)) if (!Number(energy.hours))
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days'); console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
console.log( console.log(
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, worldtodo: ${worldtodo.tables} tables (${worldtodoTasks} tasks), notes: ${notes.tables} tables, math: ${math.tables} tables (${mathChapters} chapters), plant: ${plant.tables} tables (${plantEntries} entries), radio: ${radio.tables} tables (${radioFreqs} frequencies), app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}` `health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, movies: ${movies.tables} tables, worldtodo: ${worldtodo.tables} tables (${worldtodoTasks} tasks), notes: ${notes.tables} tables, math: ${math.tables} tables (${mathChapters} chapters), plant: ${plant.tables} tables (${plantEntries} entries), radio: ${radio.tables} tables (${radioFreqs} frequencies), tools: ${tools.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
); );
const [catalog] = await client` const [catalog] = await client`
select select
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Run scripts/tools.ts on stan-ovh against prod, without printing secrets.
# scripts/tools-run.sh list | import <file.json> | add '<json>' | delete <id> (see scripts/tools.ts)
# DB_URL (app role) comes from the running app container (db:5432 → 127.0.0.1:5440).
set -euo pipefail
cd "$(dirname "$0")/.."
DB_URL=$(sudo -n docker inspect personal-website-app-1 --format '{{range .Config.Env}}{{println .}}{{end}}' \
| sed -n 's/^DB_URL=//p' | sed 's/@db:5432/@127.0.0.1:5440/')
[ -n "$DB_URL" ] || { echo "no DB_URL from the app container" >&2; exit 1; }
export DB_URL
exec bun scripts/tools.ts "$@"
+75
View File
@@ -0,0 +1,75 @@
/**
* Tools tracker (/trackers/tools, tools.items) from the command line, for bots and Stan.
* Items are keyed by name (case-insensitive) or id; importing again only updates the fields
* given, merges specs (a null spec value removes that key) and never duplicates an item.
*
* On stan-ovh (DB_URL is loaded from the app container, nothing printed):
* scripts/tools-run.sh list [--json]
* scripts/tools-run.sh import ~/imports/tools.json [--dry-run]
* scripts/tools-run.sh add '{"name":"AA batteries","category":"batteries","quantity":24}'
* scripts/tools-run.sh add '{"name":"AAA batteries","add":-4}' # used 4
* scripts/tools-run.sh delete 12 # by id
*
* Entry fields (only name required): name, brand, model, category, quantity | add (delta),
* location, purchased_on (YYYY-MM-DD), price_eur, specs (object of string|number), notes, id.
* Categories: batteries, power_tools, hand_tools, measuring, electrical, fasteners, consumables,
* safety, other (see src/lib/tools/index.ts; "power tools" etc. are understood too).
*/
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
import { importItems, listItems, parseImport } from '../src/lib/server/tools/store';
const [cmd, ...rest] = process.argv.slice(2);
const dry = rest.includes('--dry-run');
const args = rest.filter((a) => !a.startsWith('--'));
const usage =
'usage: tools.ts list [--json] | import <file.json> [--dry-run] | add <json> [--dry-run] | delete <id>';
if (!cmd) throw new Error(usage);
let input: unknown;
if (cmd === 'import') {
if (!args[0]) throw new Error(usage);
input = JSON.parse(await readFile(args[0], 'utf8'));
} else if (cmd === 'add') {
if (!args[0]) throw new Error(usage);
input = JSON.parse(args.join(' '));
} else if (!['list', 'delete'].includes(cmd)) throw new Error(usage);
const parsed = input === undefined ? null : parseImport(input);
if (parsed) {
console.log(`parsed ${parsed.rows.length} entries`);
if (parsed.errors.length) {
for (const e of parsed.errors.slice(0, 50)) console.error(` ${e}`);
console.error(`${parsed.errors.length} invalid entries; nothing imported`);
process.exit(1);
}
if (dry) process.exit(0);
}
if (!process.env.DB_URL)
throw new Error('DB_URL is required (use scripts/tools-run.sh on stan-ovh)');
const sql = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
try {
if (cmd === 'list') {
const items = await listItems(sql);
if (rest.includes('--json')) console.log(JSON.stringify(items, null, 2));
else
for (const it of items)
console.log(
`${String(it.id).padStart(4)} ${it.category.padEnd(12)} x${String(it.quantity).padEnd(4)} ${it.name}${
it.brand || it.model ? ` (${[it.brand, it.model].filter(Boolean).join(' ')})` : ''
}`
);
} else if (cmd === 'delete') {
const id = Number(args[0]);
if (!Number.isInteger(id) || id <= 0) throw new Error(usage);
const r = await sql`DELETE FROM tools.items WHERE id = ${id} RETURNING name`;
console.log(r.length ? `deleted ${id} ${r[0].name}` : `no item ${id}`);
} else {
const r = await importItems(sql, parsed!.rows);
console.log(`imported ${r.total}: ${r.created} new, ${r.updated} updated`);
if (r.missing.length) console.error(`not found: ${r.missing.join(', ')}`);
}
} finally {
await sql.end();
}
+7
View File
@@ -405,6 +405,13 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
external: false, external: false,
private: false private: false
}, },
{
name: 'tools',
subtitle: 'my tools, batteries and consumables: what I have and how many',
href: '/trackers/tools',
external: false,
private: true
},
{ {
name: 'ai', name: 'ai',
subtitle: 'AI and software news, my bots and how much I use Claude, ChatGPT and Gemini', subtitle: 'AI and software news, my bots and how much I use Claude, ChatGPT and Gemini',
+1
View File
@@ -37,3 +37,4 @@ export * from './math';
export * from './plant'; export * from './plant';
export * from './radio'; export * from './radio';
export * from './media'; export * from './media';
export * from './tools';
+1
View File
@@ -32,4 +32,5 @@ export const notesSchema = pgSchema('notes');
export const mathSchema = pgSchema('math'); export const mathSchema = pgSchema('math');
export const plantSchema = pgSchema('plant'); export const plantSchema = pgSchema('plant');
export const radioSchema = pgSchema('radio'); export const radioSchema = pgSchema('radio');
export const toolsSchema = pgSchema('tools');
export const mediaSchema = pgSchema('media'); export const mediaSchema = pgSchema('media');
+48
View File
@@ -0,0 +1,48 @@
import { sql } from 'drizzle-orm';
import {
check,
date,
index,
integer,
jsonb,
numeric,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { toolsSchema } from './schemas';
import { id, timestamps } from './helpers';
// Tools tracker (/trackers/tools, owner-only): an inventory of Stan's physical tools, batteries
// and consumables. One row per kind of thing with a quantity (32 AAA batteries = one row).
// Names are unique (case-insensitive) so scripts/tools.ts can upsert by name. Categories live
// in #lib/tools (validated in code, so adding one needs no migration). Unknown stays null.
export const toolsItems = toolsSchema.table(
'items',
{
id: id(),
name: text('name').notNull(),
brand: text('brand'),
model: text('model'),
/** batteries | power_tools | hand_tools | measuring | electrical | ... (see #lib/tools). */
category: text('category').notNull().default('other'),
quantity: integer('quantity').notNull().default(1),
location: text('location'),
purchasedOn: date('purchased_on'),
priceEur: numeric('price_eur', { precision: 10, scale: 2 }),
/** Verified specs only, e.g. {"voltage": "3 V", "chemistry": "lithium"}. */
specs: jsonb('specs')
.$type<Record<string, string | number>>()
.notNull()
.default(sql`'{}'::jsonb`),
notes: text('notes'),
...timestamps
},
(t) => [
uniqueIndex('items_name_key').on(sql`lower(${t.name})`),
index('items_category_idx').on(t.category),
check('items_name_check', sql`length(btrim(${t.name})) > 0`),
check('items_quantity_check', sql`${t.quantity} >= 0`),
check('items_price_check', sql`${t.priceEur} >= 0`)
]
);
+6
View File
@@ -63,6 +63,12 @@ describe('owner gate', () => {
expect(isOwnerWritePath('/trackers/radio/__data.json')).toBe(true); expect(isOwnerWritePath('/trackers/radio/__data.json')).toBe(true);
expect(isOwnerWritePath('/trackers/radiox')).toBe(false); expect(isOwnerWritePath('/trackers/radiox')).toBe(false);
}); });
it('gates the whole tools tracker, reads and writes', () => {
expect(isOwnerOnlyPath('/trackers/tools')).toBe(true);
expect(isOwnerOnlyPath('/trackers/tools/__data.json')).toBe(true);
expect(isOwnerOnlyPath('/trackers/toolsx')).toBe(false);
expect(isOwnerWritePath('/trackers/tools')).toBe(true);
});
it('only lets the owner write in owner areas', () => { it('only lets the owner write in owner areas', () => {
expect(isOwnerWritePath('/trackers/books/12')).toBe(true); expect(isOwnerWritePath('/trackers/books/12')).toBe(true);
expect(isOwnerWritePath('/trackers/books')).toBe(true); expect(isOwnerWritePath('/trackers/books')).toBe(true);
+4 -1
View File
@@ -44,6 +44,8 @@ export const ownerOnlyPrefixes = [
'/projects/worldwide-todo/api/queue', '/projects/worldwide-todo/api/queue',
// Cargill plant tracker: what the factory I work at does. Mine only. // Cargill plant tracker: what the factory I work at does. Mine only.
'/trackers/cargill', '/trackers/cargill',
// Tools tracker: my tools, batteries and consumables inventory. Mine only.
'/trackers/tools',
// Math tracker (the Napkin): owner-only while MATH_PRIVATE (#lib/math) is on. // Math tracker (the Napkin): owner-only while MATH_PRIVATE (#lib/math) is on.
...(MATH_PRIVATE ? (['/trackers/math'] as const) : []) ...(MATH_PRIVATE ? (['/trackers/math'] as const) : [])
] as const; ] as const;
@@ -82,7 +84,8 @@ export const ownerWritePrefixes = [
'/trackers/videos', '/trackers/videos',
'/trackers/math', '/trackers/math',
'/trackers/cargill', '/trackers/cargill',
'/trackers/radio' '/trackers/radio',
'/trackers/tools'
] as const; ] as const;
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, ''); const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');
+179
View File
@@ -0,0 +1,179 @@
// Tools tracker store: listing, the owner's form edits and the idempotent bot import, all on a
// postgres.js client passed in (no SvelteKit imports), so the page and scripts/tools.ts share it.
// Items are keyed by id or by name (case-insensitive): re-importing an item updates only the
// fields given, specs are merged, and `add` changes the quantity by a delta.
import type { Sql } from 'postgres';
import * as z from 'zod';
import { CATEGORIES, toCategory, type Specs, type ToolItem } from '../../tools/index';
export type { ToolItem };
export async function listItems(sql: Sql): Promise<ToolItem[]> {
const rows = await sql<ToolItem[]>`
SELECT id, name, brand, model, category, quantity, location,
to_char(purchased_on, 'YYYY-MM-DD') AS "purchasedOn", price_eur::float8 AS "priceEur",
specs, notes, created_at AS "createdAt", updated_at AS "updatedAt"
FROM tools.items ORDER BY category, lower(name)`;
return rows.map((r) => ({
...r,
createdAt: new Date(r.createdAt).toISOString(),
updatedAt: new Date(r.updatedAt).toISOString()
}));
}
const text = z
.string()
.trim()
.transform((s) => (s ? s : null))
.nullable();
const specValue = z.union([z.string().trim().min(1), z.number().finite()]);
/** One import entry. Only `name` (or `id`) is needed; everything else is optional. */
export const ItemInput = z
.object({
id: z.number().int().positive().optional(),
name: z.string().trim().min(1).max(200).optional(),
brand: text.optional(),
model: text.optional(),
category: z
.string()
.transform((s, ctx) => {
const c = toCategory(s);
if (!c) {
ctx.addIssue({
code: 'custom',
message: `category must be one of ${CATEGORIES.join(', ')}`
});
return z.NEVER;
}
return c;
})
.optional(),
quantity: z.number().int().min(0).optional(),
/** Change the quantity by this much (e.g. -2 when two batteries are used up). */
add: z.number().int().optional(),
location: text.optional(),
purchased_on: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'purchased_on must be YYYY-MM-DD')
.nullable()
.optional(),
price_eur: z.number().min(0).nullable().optional(),
/** Merged into the stored specs; a null value removes that key. */
specs: z.record(z.string(), specValue.nullable()).optional(),
notes: text.optional()
})
.strict()
.refine((v) => v.id || v.name, 'name (or id) is required')
.refine((v) => v.quantity === undefined || v.add === undefined, 'use quantity or add, not both');
export type ItemInput = z.infer<typeof ItemInput>;
export function parseImport(data: unknown): { rows: ItemInput[]; errors: string[] } {
const list = Array.isArray(data) ? data : [data];
const rows: ItemInput[] = [];
const errors: string[] = [];
list.forEach((entry, i) => {
const r = ItemInput.safeParse(entry);
if (r.success) rows.push(r.data);
else
errors.push(
`#${i + 1} ${(entry as { name?: string })?.name ?? ''}: ${r.error.issues.map((x) => `${x.path.join('.') || 'entry'} ${x.message}`).join('; ')}`
);
});
return { rows, errors };
}
const COLS = {
brand: 'brand',
model: 'model',
category: 'category',
quantity: 'quantity',
location: 'location',
purchased_on: 'purchased_on',
price_eur: 'price_eur',
notes: 'notes'
} as const;
/** Upserts entries in one transaction. Returns how many were created and updated. */
export async function importItems(sql: Sql, rows: ItemInput[]) {
let created = 0;
let updated = 0;
const missing: string[] = [];
await sql.begin(async (tx) => {
for (const r of rows) {
const [found] = r.id
? await tx<{ id: number }[]>`SELECT id FROM tools.items WHERE id = ${r.id}`
: await tx<
{ id: number }[]
>`SELECT id FROM tools.items WHERE lower(name) = lower(${r.name!})`;
const set: Record<string, unknown> = {};
for (const [k, col] of Object.entries(COLS))
if (r[k as keyof typeof COLS] !== undefined) set[col] = r[k as keyof typeof COLS];
const addSpecs = Object.fromEntries(
Object.entries(r.specs ?? {}).filter(([, v]) => v !== null)
);
const dropSpecs = Object.entries(r.specs ?? {})
.filter(([, v]) => v === null)
.map(([k]) => k);
if (found) {
if (r.id && r.name) set.name = r.name;
const [{ id }] = await tx<{ id: number }[]>`
UPDATE tools.items SET
${Object.keys(set).length ? tx`${tx(set)},` : tx``}
quantity = greatest(0, quantity + ${r.add ?? 0}),
specs = (specs || ${tx.json(addSpecs)}) - ${dropSpecs}::text[],
updated_at = now()
WHERE id = ${found.id} RETURNING id`;
if (id) updated++;
} else if (r.id && !r.name) {
missing.push(`id ${r.id}`);
} else {
await tx`INSERT INTO tools.items ${tx({
name: r.name!,
category: 'other',
quantity: Math.max(0, r.add ?? 1),
...set,
specs: tx.json(addSpecs)
})}`;
created++;
}
}
});
return { total: rows.length, created, updated, missing };
}
/** The owner's edit form: replaces every field (specs too). id = null creates. */
export type SaveInput = {
name: string;
brand: string | null;
model: string | null;
category: string;
quantity: number;
location: string | null;
purchased_on: string | null;
price_eur: number | null;
specs: Specs;
notes: string | null;
};
export async function saveItem(sql: Sql, id: number | null, v: SaveInput) {
const row = { ...v, specs: sql.json(v.specs) };
try {
if (id) await sql`UPDATE tools.items SET ${sql(row)}, updated_at = now() WHERE id = ${id}`;
else await sql`INSERT INTO tools.items ${sql(row)}`;
} catch (e) {
if ((e as { code?: string }).code === '23505')
return { ok: false as const, message: 'An item with that name already exists.' };
throw e;
}
return { ok: true as const };
}
export async function adjustQuantity(sql: Sql, id: number, delta: number) {
await sql`UPDATE tools.items SET quantity = greatest(0, quantity + ${delta}), updated_at = now()
WHERE id = ${id}`;
}
export async function deleteItem(sql: Sql, id: number) {
await sql`DELETE FROM tools.items WHERE id = ${id}`;
}
+98
View File
@@ -0,0 +1,98 @@
// Tools tracker (/trackers/tools): categories and small pure helpers shared by the page,
// the server store and scripts/tools.ts. Add a category here; the DB doesn't constrain it.
export const CATEGORIES = [
'batteries',
'power_tools',
'hand_tools',
'measuring',
'electrical',
'fasteners',
'consumables',
'safety',
'other'
] as const;
export type Category = (typeof CATEGORIES)[number];
/** Label and gradient (icon tile colours) per category; icons are picked in the page. */
export const CATEGORY_META: Record<Category, { label: string; from: string; to: string }> = {
batteries: { label: 'Batteries', from: '#fbbf24', to: '#b45309' },
power_tools: { label: 'Power tools', from: '#4ade80', to: '#15803d' },
hand_tools: { label: 'Hand tools', from: '#60a5fa', to: '#1d4ed8' },
measuring: { label: 'Measuring', from: '#a78bfa', to: '#5b21b6' },
electrical: { label: 'Electrical', from: '#22d3ee', to: '#0e7490' },
fasteners: { label: 'Fasteners', from: '#94a3b8', to: '#334155' },
consumables: { label: 'Consumables', from: '#f472b6', to: '#9d174d' },
safety: { label: 'Safety', from: '#fb923c', to: '#c2410c' },
other: { label: 'Other', from: '#a1a1aa', to: '#3f3f46' }
};
const ALIASES: Record<string, Category> = {
battery: 'batteries',
power: 'power_tools',
power_tool: 'power_tools',
hand: 'hand_tools',
hand_tool: 'hand_tools',
tools: 'hand_tools',
measure: 'measuring',
measurement: 'measuring',
electric: 'electrical',
electronics: 'electrical',
fastener: 'fasteners',
screws: 'fasteners',
consumable: 'consumables',
ppe: 'safety'
};
/** "Power tools", "power-tools", "power_tool" -> power_tools; null when unknown. */
export function toCategory(s: string | null | undefined): Category | null {
if (!s) return null;
const k = s
.trim()
.toLowerCase()
.replace(/[\s-]+/g, '_');
if ((CATEGORIES as readonly string[]).includes(k)) return k as Category;
return ALIASES[k] ?? null;
}
export type Specs = Record<string, string | number>;
/** One row of tools.items as the page gets it. */
export type ToolItem = {
id: number;
name: string;
brand: string | null;
model: string | null;
category: string;
quantity: number;
location: string | null;
purchasedOn: string | null;
priceEur: number | null;
specs: Specs;
notes: string | null;
createdAt: string;
updatedAt: string;
};
/** "voltage: 3.6 V" lines (or "key = value") -> object; numbers stay numbers. */
export function parseSpecs(text: string | null | undefined): Specs {
const out: Specs = {};
for (const line of (text ?? '').split(/\r?\n/)) {
const m = line.match(/^\s*([^:=]+?)\s*[:=]\s*(.+?)\s*$/);
if (!m) continue;
const key = m[1].toLowerCase().replace(/\s+/g, '_');
out[key] = /^-?\d+(\.\d+)?$/.test(m[2]) ? Number(m[2]) : m[2];
}
return out;
}
export const specsText = (specs: Specs) =>
Object.entries(specs)
.map(([k, v]) => `${k}: ${v}`)
.join('\n');
/** snake_case spec key -> "Snake case" for display. */
export const specLabel = (k: string) => {
const s = k.replace(/_/g, ' ');
return s.charAt(0).toUpperCase() + s.slice(1);
};
+9
View File
@@ -103,6 +103,15 @@ export const trackerIcons: Record<string, TrackerIcon> = {
// factory: saw-tooth roof, chimney and windows // factory: saw-tooth roof, chimney and windows
paths: ['M3 20V10l5 3V10l5 3V10l5 3V4h3v16z', 'M7 17h1M11 17h1M15 17h1'] paths: ['M3 20V10l5 3V10l5 3V10l5 3V4h3v16z', 'M7 17h1M11 17h1M15 17h1']
}, },
tools: {
from: '#f59e0b',
to: '#9a3412',
ink: WHITE,
// wrench
paths: [
'M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z'
]
},
radio: { radio: {
from: '#f43f5e', from: '#f43f5e',
to: '#881337', to: '#881337',
+77
View File
@@ -0,0 +1,77 @@
import { requireOwner } from '#lib/server/owner.js';
import { client } from '#lib/server/db/index.js';
import { adjustQuantity, deleteItem, listItems, saveItem } from '#lib/server/tools/store.js';
import { parseSpecs, toCategory } from '#lib/tools/index.js';
import { fail, type RequestEvent } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
// /trackers/tools: owner-only (ownerOnlyPrefixes 404s it for everyone else; the load and every
// action check again). Bots add items with scripts/tools-run.sh (see docs/tools-tracker.md).
export const load: PageServerLoad = async (event) => {
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
return {
items: await listItems(client),
seo: { title: 'tools', description: 'My tools, batteries and consumables.' }
};
};
const str = (form: FormData, k: string) => {
const v = form.get(k);
return typeof v === 'string' && v.trim() ? v.trim() : null;
};
const int = (form: FormData, k: string) => {
const v = Number(str(form, k));
return Number.isInteger(v) && v > 0 ? v : null;
};
const action =
(name: string, fn: (form: FormData) => Promise<{ ok: true } | { ok: false; message: string }>) =>
async (event: RequestEvent) => {
requireOwner(event);
const r = await fn(await event.request.formData());
if (!r.ok) return fail(400, { action: name, message: r.message });
return { action: name, ok: true as const };
};
export const actions: Actions = {
save: action('save', async (form) => {
const name = str(form, 'name');
if (!name) return { ok: false, message: 'A name is needed.' };
const category = toCategory(str(form, 'category')) ?? 'other';
const qty = Number(str(form, 'quantity') ?? '1');
if (!Number.isInteger(qty) || qty < 0)
return { ok: false, message: 'Quantity must be a whole number, 0 or more.' };
const date = str(form, 'purchased_on');
if (date && !/^\d{4}-\d{2}-\d{2}$/.test(date))
return { ok: false, message: 'Purchase date must be YYYY-MM-DD.' };
const priceRaw = str(form, 'price_eur');
const price = priceRaw === null ? null : Number(priceRaw.replace(',', '.'));
if (price !== null && !(price >= 0)) return { ok: false, message: 'Price not understood.' };
return saveItem(client, int(form, 'id'), {
name,
brand: str(form, 'brand'),
model: str(form, 'model'),
category,
quantity: qty,
location: str(form, 'location'),
purchased_on: date,
price_eur: price,
specs: parseSpecs(str(form, 'specs')),
notes: str(form, 'notes')
});
}),
qty: action('qty', async (form) => {
const id = int(form, 'id');
const delta = Number(str(form, 'delta'));
if (!id || !Number.isInteger(delta)) return { ok: false, message: 'Bad quantity change.' };
await adjustQuantity(client, id, delta);
return { ok: true };
}),
delete: action('delete', async (form) => {
const id = int(form, 'id');
if (!id) return { ok: false, message: 'Missing id.' };
await deleteItem(client, id);
return { ok: true };
})
};
+690
View File
@@ -0,0 +1,690 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
import Tile from '#lib/radio/Tile.svelte';
import {
CATEGORIES,
CATEGORY_META,
specLabel,
specsText,
toCategory,
type Category,
type ToolItem
} from '#lib/tools/index.js';
import Battery from 'lucide-svelte/icons/battery-full';
import Drill from 'lucide-svelte/icons/drill';
import Wrench from 'lucide-svelte/icons/wrench';
import Ruler from 'lucide-svelte/icons/ruler';
import PlugZap from 'lucide-svelte/icons/plug-zap';
import Bolt from 'lucide-svelte/icons/bolt';
import SprayCan from 'lucide-svelte/icons/spray-can';
import HardHat from 'lucide-svelte/icons/hard-hat';
import Box from 'lucide-svelte/icons/box';
import Package from 'lucide-svelte/icons/package';
import Layers from 'lucide-svelte/icons/layers';
import Search from 'lucide-svelte/icons/search';
import Plus from 'lucide-svelte/icons/plus';
import Minus from 'lucide-svelte/icons/minus';
import Pencil from 'lucide-svelte/icons/pencil';
import Trash from 'lucide-svelte/icons/trash-2';
import MapPin from 'lucide-svelte/icons/map-pin';
import X from 'lucide-svelte/icons/x';
import type { Component } from 'svelte';
let { data, form } = $props();
type Icon = Component<{ size?: number; strokeWidth?: number }>;
const ICONS: Record<Category, Icon> = {
batteries: Battery as unknown as Icon,
power_tools: Drill as unknown as Icon,
hand_tools: Wrench as unknown as Icon,
measuring: Ruler as unknown as Icon,
electrical: PlugZap as unknown as Icon,
fasteners: Bolt as unknown as Icon,
consumables: SprayCan as unknown as Icon,
safety: HardHat as unknown as Icon,
other: Box as unknown as Icon
};
const catOf = (c: string): Category => toCategory(c) ?? 'other';
let query = $state('');
let filter = $state<Category | 'all'>('all');
const matches = (it: ToolItem, q: string) =>
!q ||
[it.name, it.brand, it.model, it.location, it.notes, ...Object.values(it.specs)]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(q);
const visible = $derived.by(() => {
const q = query.trim().toLowerCase();
return data.items.filter(
(it) => (filter === 'all' || catOf(it.category) === filter) && matches(it, q)
);
});
const groups = $derived(
CATEGORIES.map((c) => {
const items = visible.filter((it) => catOf(it.category) === c);
return { c, items, pieces: items.reduce((n, it) => n + it.quantity, 0) };
}).filter((g) => g.items.length)
);
const counts = $derived(
Object.fromEntries(
CATEGORIES.map((c) => [c, data.items.filter((it) => catOf(it.category) === c).length])
) as Record<Category, number>
);
const totalPieces = $derived(data.items.reduce((n, it) => n + it.quantity, 0));
const usedCats = $derived(CATEGORIES.filter((c) => counts[c]));
// Add / edit dialog.
let dialog = $state<HTMLDialogElement>();
let editing = $state<ToolItem | null>(null);
let saving = $state(false);
const open = (it: ToolItem | null) => {
editing = it;
dialog?.showModal();
};
const close = () => dialog?.close();
const error = $derived(form && !form.ok ? form.message : null);
const euro = (n: number) =>
new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR' }).format(n);
</script>
<div class="tools">
<header class="head">
<a href={resolve('/trackers/tools')} class="title-link" aria-label="tools"
><TrackerIcon name="tools" class="size-12" /></a
>
<div class="title">
<h1>tools<PrivateLock size={18} /></h1>
<p>Everything in the toolbox: tools, batteries and consumables.</p>
</div>
<button class="primary" type="button" onclick={() => open(null)}
><Plus size={18} strokeWidth={2.5} />Add item</button
>
</header>
<section class="stats" aria-label="Totals">
<div class="stat">
<Tile icon={Package as unknown as Icon} from="#f59e0b" to="#9a3412" size={36} />
<div><b>{data.items.length}</b><span>items</span></div>
</div>
<div class="stat">
<Tile icon={Layers as unknown as Icon} from="#38bdf8" to="#0369a1" size={36} />
<div><b>{totalPieces}</b><span>pieces</span></div>
</div>
<div class="stat">
<Tile icon={Box as unknown as Icon} from="#a78bfa" to="#5b21b6" size={36} />
<div><b>{usedCats.length}</b><span>categories</span></div>
</div>
</section>
<div class="controls">
<label class="search">
<Search size={18} />
<input
type="search"
placeholder="Search name, brand, model, specs…"
bind:value={query}
aria-label="Search tools"
/>
</label>
<div class="chips" role="group" aria-label="Category">
<button class={['chip', { active: filter === 'all' }]} onclick={() => (filter = 'all')}
>All <span class="n">{data.items.length}</span></button
>
{#each usedCats as c (c)}
<button class={['chip', { active: filter === c }]} onclick={() => (filter = c)}
><Tile icon={ICONS[c]} from={CATEGORY_META[c].from} to={CATEGORY_META[c].to} size={22} />
{CATEGORY_META[c].label} <span class="n">{counts[c]}</span></button
>
{/each}
</div>
</div>
{#if error && !dialog?.open}<p class="err" role="alert">{error}</p>{/if}
{#if !groups.length}
<p class="empty">
{data.items.length ? 'Nothing matches.' : 'No items yet. Add the first one.'}
</p>
{/if}
{#each groups as g (g.c)}
<section class="card">
<h2 class="card-title">
<Tile
icon={ICONS[g.c]}
from={CATEGORY_META[g.c].from}
to={CATEGORY_META[g.c].to}
size={28}
/>
{CATEGORY_META[g.c].label}
<span class="sub"
>{g.items.length} {g.items.length === 1 ? 'item' : 'items'} · {g.pieces} pcs</span
>
</h2>
<ul class="items">
{#each g.items as it (it.id)}
{@const specs = Object.entries(it.specs)}
<li class="item">
<div class="main">
<div class="name">{it.name}</div>
{#if it.brand || it.model}
<div class="bm">{[it.brand, it.model].filter(Boolean).join(' · ')}</div>
{/if}
{#if specs.length}
<ul class="specs">
{#each specs as [k, v] (k)}<li><span>{specLabel(k)}</span> {v}</li>{/each}
</ul>
{/if}
{#if it.location || it.priceEur !== null || it.purchasedOn}
<div class="meta">
{#if it.location}<span class="loc"><MapPin size={14} />{it.location}</span>{/if}
{#if it.purchasedOn}<span>bought {it.purchasedOn}</span>{/if}
{#if it.priceEur !== null}<span>{euro(it.priceEur)}</span>{/if}
</div>
{/if}
{#if it.notes}<p class="notes">{it.notes}</p>{/if}
</div>
<div class="side">
<form method="POST" action="?/qty" use:enhance class="qty">
<input type="hidden" name="id" value={it.id} />
<button
name="delta"
value="-1"
aria-label="One less {it.name}"
disabled={it.quantity === 0}><Minus size={16} /></button
>
<span class={['q', { zero: it.quantity === 0 }]}>×{it.quantity}</span>
<button name="delta" value="1" aria-label="One more {it.name}"
><Plus size={16} /></button
>
</form>
<button
class="icon"
type="button"
aria-label="Edit {it.name}"
onclick={() => open(it)}><Pencil size={16} /></button
>
</div>
</li>
{/each}
</ul>
</section>
{/each}
</div>
<dialog bind:this={dialog} class="dlg" onclose={() => (editing = null)}>
{#key editing}
<form
method="POST"
action="?/save"
use:enhance={() => {
saving = true;
return async ({ result, update }) => {
saving = false;
await update({ reset: false });
if (result.type === 'success') close();
};
}}
>
<div class="dlg-head">
<h2>{editing ? 'Edit item' : 'Add item'}</h2>
<button class="icon" type="button" aria-label="Close" onclick={close}
><X size={18} /></button
>
</div>
{#if editing}<input type="hidden" name="id" value={editing.id} />{/if}
<div class="grid">
<label class="wide"
>Name <input name="name" required value={editing?.name ?? ''} autocomplete="off" /></label
>
<label>Brand <input name="brand" value={editing?.brand ?? ''} autocomplete="off" /></label>
<label>Model <input name="model" value={editing?.model ?? ''} autocomplete="off" /></label>
<label
>Category
<select
name="category"
value={editing ? catOf(editing.category) : filter === 'all' ? 'other' : filter}
>
{#each CATEGORIES as c (c)}<option value={c}>{CATEGORY_META[c].label}</option>{/each}
</select></label
>
<label
>Quantity <input
name="quantity"
type="number"
min="0"
step="1"
value={editing?.quantity ?? 1}
/></label
>
<label
>Location <input
name="location"
value={editing?.location ?? ''}
placeholder="e.g. drawer 2"
autocomplete="off"
/></label
>
<label
>Bought on <input
name="purchased_on"
type="date"
value={editing?.purchasedOn ?? ''}
/></label
>
<label
>Price (€) <input
name="price_eur"
inputmode="decimal"
value={editing?.priceEur ?? ''}
autocomplete="off"
/></label
>
<label class="wide"
>Specs <span class="hint">one per line, "voltage: 3.6 V"</span>
<textarea name="specs" rows="4">{editing ? specsText(editing.specs) : ''}</textarea
></label
>
<label class="wide"
>Notes <textarea name="notes" rows="2">{editing?.notes ?? ''}</textarea></label
>
</div>
{#if error}<p class="err" role="alert">{error}</p>{/if}
<div class="dlg-foot">
{#if editing}
<button
class="ghost danger"
type="submit"
formaction="?/delete"
formnovalidate
onclick={(e) => {
if (!confirm(`Delete ${editing?.name}?`)) e.preventDefault();
}}><Trash size={16} />Delete</button
>
{/if}
<span class="spacer"></span>
<button class="ghost" type="button" onclick={close}>Cancel</button>
<button class="primary" type="submit" disabled={saving}
>{saving ? 'Saving…' : 'Save'}</button
>
</div>
</form>
{/key}
</dialog>
<style>
.tools {
max-width: 72rem;
margin: 0 auto;
padding: 2rem 1rem 3rem;
display: flex;
flex-direction: column;
gap: 1.1rem;
line-height: 1.5;
}
.head {
display: flex;
align-items: center;
gap: 0.9rem;
flex-wrap: wrap;
}
.title-link {
display: inline-flex;
}
.title {
flex: 1 1 16rem;
min-width: 0;
}
h1 {
font-size: 2.25rem;
font-weight: 650;
letter-spacing: -0.02em;
line-height: 1.1;
}
.title p {
color: var(--site-muted);
margin-top: 0.2rem;
}
.stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.stat {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 1rem;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
background: var(--site-surface);
}
.stat b {
display: block;
font-size: 1.5rem;
font-weight: 650;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.stat span {
color: var(--site-muted);
font-size: 0.9rem;
}
.controls {
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.search {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.85rem;
min-height: 2.75rem;
border: 1px solid var(--site-border);
border-radius: 0.7rem;
background: var(--site-bg);
color: var(--site-muted);
}
.search:focus-within {
border-color: var(--site-text);
}
.search input {
flex: 1;
min-width: 0;
background: transparent;
border: 0;
outline: none;
color: var(--site-text);
font: inherit;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 2.4rem;
padding: 0.2rem 0.8rem 0.2rem 0.4rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.95rem;
color: var(--site-text);
}
.chip:first-child {
padding-left: 0.8rem;
}
.chip:hover {
border-color: var(--site-muted);
}
.chip.active {
background: var(--site-text);
border-color: var(--site-text);
color: var(--site-bg);
}
.n {
font-size: 0.8rem;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
.card {
padding: 1.1rem;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
background: var(--site-surface);
}
.card-title {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 1.15rem;
font-weight: 650;
margin-bottom: 0.5rem;
}
.sub {
margin-left: auto;
font-size: 0.9rem;
font-weight: 400;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
.items {
display: flex;
flex-direction: column;
}
.item {
display: flex;
gap: 1rem;
align-items: flex-start;
padding: 0.8rem 0;
border-top: 1px solid var(--site-border);
}
.item:first-child {
border-top: 0;
}
.main {
flex: 1;
min-width: 0;
}
.name {
font-weight: 600;
}
.bm {
color: var(--site-muted);
font-size: 0.92rem;
}
.specs {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.4rem;
}
.specs li {
font-size: 0.8rem;
padding: 0.1rem 0.5rem;
border: 1px solid var(--site-border);
border-radius: 999px;
background: var(--site-bg);
}
.specs li span {
color: var(--site-muted);
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
margin-top: 0.35rem;
font-size: 0.85rem;
color: var(--site-muted);
}
.loc {
display: inline-flex;
align-items: center;
gap: 0.2rem;
}
.notes {
margin-top: 0.35rem;
font-size: 0.9rem;
color: var(--site-muted);
}
.side {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.qty {
display: inline-flex;
align-items: center;
border: 1px solid var(--site-border);
border-radius: 999px;
background: var(--site-bg);
}
.qty button,
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
color: var(--site-text);
cursor: pointer;
}
.qty button:hover:not(:disabled),
.icon:hover {
background: var(--site-border);
}
.qty button:disabled {
opacity: 0.35;
cursor: default;
}
.q {
min-width: 2.6rem;
text-align: center;
font-weight: 650;
font-variant-numeric: tabular-nums;
}
.q.zero {
color: #ef4444;
}
.icon {
border: 1px solid var(--site-border);
}
.primary {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-height: 2.6rem;
padding: 0.35rem 1rem;
border-radius: 0.6rem;
background: var(--site-text);
color: var(--site-bg);
font-weight: 600;
cursor: pointer;
}
.primary:hover {
opacity: 0.85;
}
.primary:disabled {
opacity: 0.5;
}
.ghost {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-height: 2.6rem;
padding: 0.35rem 0.9rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
color: var(--site-text);
cursor: pointer;
}
.ghost:hover {
border-color: var(--site-muted);
}
.danger {
color: #ef4444;
}
.empty {
color: var(--site-muted);
padding: 1rem 0;
}
.err {
color: #ef4444;
font-size: 0.95rem;
}
.dlg {
width: min(36rem, calc(100vw - 2rem));
max-height: calc(100dvh - 2rem);
margin: auto;
padding: 1.2rem;
border: 1px solid var(--site-border);
border-radius: 1rem;
background: var(--site-surface);
color: var(--site-text);
}
.dlg::backdrop {
background: rgb(0 0 0 / 0.55);
}
.dlg-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.9rem;
}
.dlg-head h2 {
font-size: 1.25rem;
font-weight: 650;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
}
.grid label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: var(--site-muted);
}
.grid .wide {
grid-column: 1 / -1;
}
.hint {
font-size: 0.78rem;
opacity: 0.8;
}
.grid input,
.grid select,
.grid textarea {
width: 100%;
padding: 0.5rem 0.65rem;
border: 1px solid var(--site-border);
border-radius: 0.55rem;
background: var(--site-bg);
color: var(--site-text);
font: inherit;
font-size: 0.95rem;
}
.grid input:focus,
.grid select:focus,
.grid textarea:focus {
outline: none;
border-color: var(--site-text);
}
.dlg-foot {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
}
.spacer {
flex: 1;
}
@media (max-width: 640px) {
.stats {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.stat {
flex-direction: column;
align-items: flex-start;
gap: 0.4rem;
padding: 0.7rem;
}
.grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>