pc: PC setup tracker at /trackers/pc on the hardware schema
All checks were successful
Deploy production / deploy (push) Successful in 1m4s
All checks were successful
Deploy production / deploy (push) Successful in 1m4s
- model the existing hardware schema in Drizzle (devices, components, new sensitivities) - idempotent reconciling migration: new columns, checks, setup_overview view, grants, meta.tables - seed: main PC labels/hostnames, planned AMD X3D + RTX 5090 build, stan-ovh (OVH RISE-S), other devices, sensitivity placeholders (no invented cm/360) - owner-only add/edit via modals; public view hides notes/order numbers - trackers dropdown entry + icon, seo/title/sitemap, owner write prefix
This commit is contained in:
@@ -4,7 +4,6 @@ import { defineConfig } from 'drizzle-kit';
|
|||||||
// - lists: runtime tables created through meta.create_list() (owned by personal_ddl)
|
// - lists: runtime tables created through meta.create_list() (owned by personal_ddl)
|
||||||
// - legacy: pre-reorg tables kept read-only for rollback
|
// - legacy: pre-reorg tables kept read-only for rollback
|
||||||
// - energy: owned by ned-collector
|
// - energy: owned by ned-collector
|
||||||
// - hardware: not modelled yet
|
|
||||||
// Migrations run as the schema owner (`personal`), never as the app role.
|
// Migrations run as the schema owner (`personal`), never as the app role.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
dialect: 'postgresql',
|
dialect: 'postgresql',
|
||||||
@@ -26,7 +25,8 @@ export default defineConfig({
|
|||||||
'groceries',
|
'groceries',
|
||||||
'home',
|
'home',
|
||||||
'cars',
|
'cars',
|
||||||
'music'
|
'music',
|
||||||
|
'hardware'
|
||||||
],
|
],
|
||||||
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
||||||
verbose: true,
|
verbose: true,
|
||||||
|
|||||||
290
drizzle/20261024174805_hardware/migration.sql
Normal file
290
drizzle/20261024174805_hardware/migration.sql
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
-- Hand-reconciled: the hardware schema already existed (hand-made 2026-10-22 from Stan's list +
|
||||||
|
-- order mails, see ~/dev/hardware/schema.sql; prices later moved to cents). This brings an existing
|
||||||
|
-- schema to the Drizzle model, and creates it from scratch on an empty database.
|
||||||
|
CREATE SCHEMA IF NOT EXISTS "hardware";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "hardware"."devices" (
|
||||||
|
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hardware"."devices_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||||
|
"name" text NOT NULL UNIQUE,
|
||||||
|
"label" text,
|
||||||
|
"hostnames" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"status" text DEFAULT 'active' NOT NULL,
|
||||||
|
"brand" text,
|
||||||
|
"model" text,
|
||||||
|
"specs" jsonb DEFAULT '{}' NOT NULL,
|
||||||
|
"price_cents" integer,
|
||||||
|
"monthly_cents" integer,
|
||||||
|
"budget_cents" integer,
|
||||||
|
"shop" text,
|
||||||
|
"purchase_date" date,
|
||||||
|
"url" text,
|
||||||
|
"image_url" text,
|
||||||
|
"sort_order" integer DEFAULT 100 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 "devices_kind_check" CHECK (kind in ('desktop', 'laptop', 'server', 'vm', 'phone', 'tablet', 'monitor', 'other')),
|
||||||
|
CONSTRAINT "devices_status_check" CHECK (status in ('active', 'spare', 'planned', 'retired', 'sold')),
|
||||||
|
CONSTRAINT "devices_cents_check" CHECK ((price_cents is null or price_cents >= 0) and (monthly_cents is null or monthly_cents >= 0) and (budget_cents is null or budget_cents >= 0))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "hardware"."components" (
|
||||||
|
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hardware"."components_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||||
|
"device_id" bigint NOT NULL,
|
||||||
|
"category" text NOT NULL,
|
||||||
|
"brand" text,
|
||||||
|
"model" text NOT NULL,
|
||||||
|
"quantity" integer DEFAULT 1 NOT NULL,
|
||||||
|
"specs" jsonb DEFAULT '{}' NOT NULL,
|
||||||
|
"unit_price_cents" integer,
|
||||||
|
"total_price_cents" integer,
|
||||||
|
"shop" text,
|
||||||
|
"order_number" text,
|
||||||
|
"order_date" date,
|
||||||
|
"warranty_until" date,
|
||||||
|
"invoice_number" text,
|
||||||
|
"status" text DEFAULT 'in_use' NOT NULL,
|
||||||
|
"verified" boolean DEFAULT true NOT NULL,
|
||||||
|
"dpi" integer,
|
||||||
|
"measured_dpi" integer,
|
||||||
|
"url" text,
|
||||||
|
"image_url" text,
|
||||||
|
"notes" text,
|
||||||
|
"source" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "components_dedupe_uq" UNIQUE NULLS NOT DISTINCT("device_id","model","order_number"),
|
||||||
|
CONSTRAINT "components_category_check" CHECK (category in ('case', 'cpu', 'motherboard', 'ram', 'cpu_cooler', 'gpu', 'psu', 'storage', 'fan', 'lighting', 'monitor', 'mouse', 'keyboard', 'headset', 'audio', 'controller', 'accessory', 'other')),
|
||||||
|
CONSTRAINT "components_status_check" CHECK (status in ('in_use', 'planned', 'spare', 'retired', 'sold')),
|
||||||
|
CONSTRAINT "components_quantity_check" CHECK (quantity > 0),
|
||||||
|
CONSTRAINT "components_unit_price_cents_check" CHECK (unit_price_cents >= 0),
|
||||||
|
CONSTRAINT "components_total_price_cents_check" CHECK (total_price_cents >= 0),
|
||||||
|
CONSTRAINT "components_dpi_check" CHECK ((dpi is null or dpi > 0) and (measured_dpi is null or measured_dpi > 0))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "hardware"."sensitivities" (
|
||||||
|
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hardware"."sensitivities_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||||
|
"game" text NOT NULL UNIQUE,
|
||||||
|
"cm_per_360" numeric(7,2),
|
||||||
|
"yaw" numeric(10,6),
|
||||||
|
"in_game_sens" numeric(10,4),
|
||||||
|
"mouse_id" bigint,
|
||||||
|
"fov" text,
|
||||||
|
"notes" text,
|
||||||
|
"sort_order" integer DEFAULT 100 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "sensitivities_cm_check" CHECK (cm_per_360 is null or cm_per_360 > 0),
|
||||||
|
CONSTRAINT "sensitivities_yaw_check" CHECK (yaw is null or yaw > 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Existing tables: new columns.
|
||||||
|
ALTER TABLE "hardware"."devices"
|
||||||
|
ADD COLUMN IF NOT EXISTS "label" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "hostnames" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS "status" text DEFAULT 'active' NOT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS "brand" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "model" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "specs" jsonb DEFAULT '{}' NOT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS "price_cents" integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS "monthly_cents" integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS "budget_cents" integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS "shop" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "purchase_date" date,
|
||||||
|
ADD COLUMN IF NOT EXISTS "url" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "image_url" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "sort_order" integer DEFAULT 100 NOT NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS "updated_at" timestamp with time zone DEFAULT now() NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "hardware"."components"
|
||||||
|
ADD COLUMN IF NOT EXISTS "dpi" integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS "measured_dpi" integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS "url" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "image_url" text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Checks: widen kinds/categories/statuses (drop + re-add by name).
|
||||||
|
ALTER TABLE "hardware"."devices" DROP CONSTRAINT IF EXISTS "devices_kind_check";
|
||||||
|
ALTER TABLE "hardware"."devices" ADD CONSTRAINT "devices_kind_check" CHECK (kind in ('desktop', 'laptop', 'server', 'vm', 'phone', 'tablet', 'monitor', 'other'));
|
||||||
|
ALTER TABLE "hardware"."devices" DROP CONSTRAINT IF EXISTS "devices_status_check";
|
||||||
|
ALTER TABLE "hardware"."devices" ADD CONSTRAINT "devices_status_check" CHECK (status in ('active', 'spare', 'planned', 'retired', 'sold'));
|
||||||
|
ALTER TABLE "hardware"."devices" DROP CONSTRAINT IF EXISTS "devices_cents_check";
|
||||||
|
ALTER TABLE "hardware"."devices" ADD CONSTRAINT "devices_cents_check" CHECK ((price_cents is null or price_cents >= 0) and (monthly_cents is null or monthly_cents >= 0) and (budget_cents is null or budget_cents >= 0));
|
||||||
|
ALTER TABLE "hardware"."components" DROP CONSTRAINT IF EXISTS "components_category_check";
|
||||||
|
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_category_check" CHECK (category in ('case', 'cpu', 'motherboard', 'ram', 'cpu_cooler', 'gpu', 'psu', 'storage', 'fan', 'lighting', 'monitor', 'mouse', 'keyboard', 'headset', 'audio', 'controller', 'accessory', 'other'));
|
||||||
|
ALTER TABLE "hardware"."components" DROP CONSTRAINT IF EXISTS "components_status_check";
|
||||||
|
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_status_check" CHECK (status in ('in_use', 'planned', 'spare', 'retired', 'sold'));
|
||||||
|
ALTER TABLE "hardware"."components" DROP CONSTRAINT IF EXISTS "components_dpi_check";
|
||||||
|
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_dpi_check" CHECK ((dpi is null or dpi > 0) and (measured_dpi is null or measured_dpi > 0));
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Dedupe key: unique index -> constraint (same name, same NULLS NOT DISTINCT semantics);
|
||||||
|
-- device FK -> Drizzle's name.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'components_dedupe_uq' AND connamespace = 'hardware'::regnamespace) THEN
|
||||||
|
DROP INDEX IF EXISTS "hardware"."components_dedupe_uq";
|
||||||
|
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_dedupe_uq" UNIQUE NULLS NOT DISTINCT ("device_id","model","order_number");
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'components_device_id_fkey' AND connamespace = 'hardware'::regnamespace) THEN
|
||||||
|
ALTER TABLE "hardware"."components" RENAME CONSTRAINT "components_device_id_fkey" TO "components_device_id_devices_id_fkey";
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'components_device_id_devices_id_fkey' AND connamespace = 'hardware'::regnamespace) THEN
|
||||||
|
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_device_id_devices_id_fkey" FOREIGN KEY ("device_id") REFERENCES "hardware"."devices"("id") ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'sensitivities_mouse_id_components_id_fkey' AND connamespace = 'hardware'::regnamespace) THEN
|
||||||
|
ALTER TABLE "hardware"."sensitivities" ADD CONSTRAINT "sensitivities_mouse_id_components_id_fkey" FOREIGN KEY ("mouse_id") REFERENCES "hardware"."components"("id") ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "components_device_idx" ON "hardware"."components" ("device_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "components_category_idx" ON "hardware"."components" ("category");
|
||||||
|
CREATE INDEX IF NOT EXISTS "components_specs_gin" ON "hardware"."components" USING gin ("specs");
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Hand-written: updated_at triggers, overview view, grants, GUI registration, seed.
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH t IN ARRAY ARRAY['devices','components','sensitivities'] LOOP
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'touch_updated_at' AND tgrelid = format('hardware.%I', t)::regclass) THEN
|
||||||
|
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON hardware.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP VIEW IF EXISTS "hardware"."setup_overview";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE VIEW "hardware"."setup_overview" AS
|
||||||
|
SELECT d.name AS device,
|
||||||
|
d.label AS device_label,
|
||||||
|
d.kind AS device_kind,
|
||||||
|
d.status AS device_status,
|
||||||
|
c.category,
|
||||||
|
c.brand,
|
||||||
|
c.model,
|
||||||
|
c.quantity,
|
||||||
|
c.total_price_cents,
|
||||||
|
round(c.total_price_cents::numeric / 100.0, 2) AS total_price_eur,
|
||||||
|
c.shop,
|
||||||
|
c.order_date,
|
||||||
|
c.warranty_until,
|
||||||
|
(c.warranty_until >= current_date) AS under_warranty,
|
||||||
|
c.status,
|
||||||
|
c.verified,
|
||||||
|
c.specs,
|
||||||
|
c.dpi,
|
||||||
|
c.measured_dpi,
|
||||||
|
c.notes,
|
||||||
|
c.order_number,
|
||||||
|
c.invoice_number,
|
||||||
|
c.source,
|
||||||
|
c.id AS component_id
|
||||||
|
FROM hardware.components c
|
||||||
|
JOIN hardware.devices d ON d.id = c.device_id
|
||||||
|
ORDER BY d.sort_order, d.name,
|
||||||
|
array_position(ARRAY['case','cpu','motherboard','ram','cpu_cooler','gpu','psu','storage','fan','lighting','monitor','mouse','keyboard','headset','audio','controller','accessory','other'], c.category),
|
||||||
|
c.order_date NULLS LAST, c.model;
|
||||||
|
--> statement-breakpoint
|
||||||
|
COMMENT ON VIEW "hardware"."setup_overview" IS 'Flat view of every component per device (build order), with a live under_warranty flag. status = ''in_use'' is the current setup, ''planned'' the upgrade plan.';
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT USAGE ON SCHEMA hardware TO personal_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA hardware TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA hardware TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA hardware GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA hardware GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||||
|
GRANT USAGE ON SCHEMA hardware TO personal_ro;
|
||||||
|
GRANT SELECT ON ALL TABLES IN SCHEMA hardware TO personal_ro;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA hardware GRANT SELECT ON TABLES TO personal_ro;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||||
|
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||||
|
VALUES ('hardware','devices','hardware-devices','Hardware devices','core',true,false,1000),
|
||||||
|
('hardware','components','hardware-components','Hardware components','core',true,false,1000),
|
||||||
|
('hardware','sensitivities','hardware-sensitivities','Game sensitivities (cm/360)','core',true,false,1000)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||||
|
PERFORM meta.sync();
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- ===== Seed (idempotent). Facts from Stan's own list, order mails and what he told Daisy;
|
||||||
|
-- unknowns stay null. No home address in public-facing notes.
|
||||||
|
INSERT INTO hardware.devices (name, kind) VALUES ('stan-pc','desktop'), ('old-pc-2021','desktop')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE hardware.devices SET
|
||||||
|
label = 'Main PC', hostnames = '{stan-arch,stan-win11}', status = 'active', model = 'Custom build',
|
||||||
|
shop = 'Megekko', sort_order = 10,
|
||||||
|
notes = 'Stan''s main PC: Lian Li O11 Dynamic XL ROG White, i9-12900KF, RX 7900 XT, 10 Lian Li fans around the case + 3 NZXT Aer on the radiator. Dual-boots Arch (stan-arch) and Windows 11 + WSL2 (stan-win11). Desk peripherals and the Odyssey G9 are tracked here. Mostly bought at Megekko 2021-2022.'
|
||||||
|
WHERE name = 'stan-pc' AND label IS NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE hardware.devices SET
|
||||||
|
label = 'Previous PC (2021)', status = 'retired', model = 'Budget build', shop = 'Megekko',
|
||||||
|
purchase_date = '2021-01-31', sort_order = 90
|
||||||
|
WHERE name = 'old-pc-2021' AND label IS NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO hardware.devices (name, label, hostnames, kind, status, brand, model, specs, price_cents, monthly_cents, budget_cents, shop, purchase_date, url, sort_order, notes) VALUES
|
||||||
|
('stan-pc-next','Next PC (planned)','{}','desktop','planned',NULL,'High-end AMD + RTX 5090 build','{}',NULL,NULL,1000000,NULL,NULL,NULL,15,
|
||||||
|
'Planned upgrade, budget up to about EUR 10k: top AMD X3D CPU, RTX 5090, fast DDR5, PCIe Gen5 SSD, high-end motherboard and a cooler that fits. Parts not picked yet.'),
|
||||||
|
('stan-ovh','Server','{}','server','active','OVHcloud','RISE-S',
|
||||||
|
'{"cpu":"AMD Ryzen 7 9700X","ram":"64 GB DDR5 ECC","storage":"2x 512 GB NVMe (software RAID1)","os":"Ubuntu 26.04"}',
|
||||||
|
NULL,6174,NULL,'OVHcloud','2026-10-24','https://www.ovhcloud.com/en/bare-metal/rise/',20,
|
||||||
|
'Dedicated server, prod since 2026-10-24 (replaced stan-vm). EUR 61.74/month excl. VAT. Runs Postgres, Gitea, the website and the bots.'),
|
||||||
|
('stan-vm','Old VM','{}','vm','retired','Hetzner Cloud',NULL,'{"vcpu":4,"ram":"8 GB"}',NULL,2000,NULL,'Hetzner',NULL,NULL,80,
|
||||||
|
'Old always-on Hetzner VM (about EUR 20/month, bought before the price rises). Replaced by stan-ovh on 2026-10-24; stopped, kept as a fallback until Stan cancels it.'),
|
||||||
|
('stan-macbookpro','MacBook Pro','{}','laptop','active','Apple','MacBook Pro','{}',NULL,NULL,NULL,NULL,NULL,NULL,30,
|
||||||
|
'Exact model/chip not recorded yet. An M5 Max MacBook Pro is planned.'),
|
||||||
|
('stan-pixel','Phone','{}','phone','active','Google','Pixel 11 Pro XL','{"storage":"256 GB"}',NULL,NULL,NULL,'Belsimpel','2026-10-18',NULL,40,
|
||||||
|
'Main phone since 2026-10-19, stock Android. About EUR 1,299 SIM-free (exact amount not recorded).'),
|
||||||
|
('stan-iphone','Old phone','{}','phone','spare','Apple','iPhone 14 Pro Max','{}',NULL,NULL,NULL,NULL,NULL,NULL,45,
|
||||||
|
'Previous phone, kept (weak battery and speed).'),
|
||||||
|
('stan-ipad','iPad','{}','tablet','active','Apple','iPad Air 13-inch (M2)','{"screen_in":13,"chip":"Apple M2"}',NULL,NULL,NULL,NULL,NULL,NULL,50,
|
||||||
|
'With a keyboard case; used for typing on the go and Termius SSH.'),
|
||||||
|
('cargill-laptop','Work laptop','{}','laptop','active',NULL,NULL,'{"ram":"16 GB"}',NULL,NULL,NULL,NULL,NULL,NULL,60,
|
||||||
|
'Cargill work laptop (not personal; Windows + WSL2). Model not recorded.')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Peripherals get real categories instead of "accessory".
|
||||||
|
UPDATE hardware.components SET category = 'mouse' WHERE category = 'accessory' AND model ILIKE 'MX Master%';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE hardware.components SET category = 'keyboard' WHERE category = 'accessory' AND (model ILIKE 'Alloy Origins%' OR model ILIKE 'Pudding Keycaps%' OR model ILIKE 'UwU%');
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE hardware.components SET category = 'controller' WHERE category = 'accessory' AND brand = 'Thrustmaster';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE hardware.components SET category = 'audio' WHERE category = 'accessory' AND model ILIKE '%mic boom arm%';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE hardware.components SET notes = replace(notes, ', delivered 2026-03-03 to Houtenstraat 15 Gouda.', ', delivered 2026-03-03.') WHERE notes LIKE '%Houtenstraat%';
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO hardware.components (device_id, category, brand, model, quantity, specs, status, verified, notes, source)
|
||||||
|
SELECT d.id, v.category, v.brand, v.model, v.quantity, v.specs::jsonb, v.status, true, v.notes, v.source
|
||||||
|
FROM (VALUES
|
||||||
|
('stan-ovh','cpu','AMD','Ryzen 7 9700X',1,'{"cores":8,"threads":16,"socket":"AM5"}','in_use',NULL,'manual'),
|
||||||
|
('stan-ovh','ram',NULL,'64 GB DDR5 ECC',1,'{"type":"DDR5 ECC","capacity_gb":64}','in_use',NULL,'manual'),
|
||||||
|
('stan-ovh','storage',NULL,'512 GB NVMe SSD',2,'{"interface":"NVMe","capacity_gb":512,"raid":"software RAID1"}','in_use','Mirrored (RAID1), about 440 GB usable for data.','manual'),
|
||||||
|
('stan-pc-next','cpu','AMD','Top Ryzen X3D (model TBD)',1,'{"socket":"AM5"}','planned','Pick the fastest gaming X3D chip available when buying.','plan'),
|
||||||
|
('stan-pc-next','gpu','NVIDIA','GeForce RTX 5090',1,'{"vram_gb":32}','planned','Board partner TBD.','plan'),
|
||||||
|
('stan-pc-next','motherboard',NULL,'High-end AM5 motherboard (TBD)',1,'{"socket":"AM5"}','planned',NULL,'plan'),
|
||||||
|
('stan-pc-next','ram',NULL,'Fast DDR5 kit (TBD)',1,'{"type":"DDR5"}','planned',NULL,'plan'),
|
||||||
|
('stan-pc-next','storage',NULL,'PCIe 5.0 NVMe SSD (TBD)',1,'{"interface":"PCIe 5.0 x4 NVMe"}','planned',NULL,'plan'),
|
||||||
|
('stan-pc-next','cpu_cooler',NULL,'Cooler for the new CPU (TBD)',1,'{}','planned','Must fit the new socket and the O11 Dynamic XL (or the new case).','plan')
|
||||||
|
) AS v(device, category, brand, model, quantity, specs, status, notes, source)
|
||||||
|
JOIN hardware.devices d ON d.name = v.device
|
||||||
|
ON CONFLICT (device_id, model, order_number) DO NOTHING;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Games Stan plays; cm/360 left empty for him to fill. CS2's yaw (0.022 deg/count) is the
|
||||||
|
-- Source engine constant, so its in-game value is computed from a mouse's measured DPI.
|
||||||
|
INSERT INTO hardware.sensitivities (game, yaw, sort_order) VALUES
|
||||||
|
('Counter-Strike 2', 0.022, 10),
|
||||||
|
('Call of Duty', NULL, 20),
|
||||||
|
('osu!', NULL, 30),
|
||||||
|
('Sea of Thieves', NULL, 40)
|
||||||
|
ON CONFLICT (game) DO NOTHING;
|
||||||
21290
drizzle/20261024174805_hardware/snapshot.json
Normal file
21290
drizzle/20261024174805_hardware/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
25
src/lib/pc/Modal.svelte
Normal file
25
src/lib/pc/Modal.svelte
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, type Snippet } from 'svelte';
|
||||||
|
|
||||||
|
// Native <dialog> (focus trap, Esc, backdrop from the browser), opened on mount.
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
onclose,
|
||||||
|
children
|
||||||
|
}: { title: string; onclose: () => void; children: Snippet<[() => void]> } = $props();
|
||||||
|
let dialog = $state<HTMLDialogElement>();
|
||||||
|
onMount(() => dialog?.showModal());
|
||||||
|
const close = () => dialog?.close();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
bind:this={dialog}
|
||||||
|
{onclose}
|
||||||
|
class="m-auto max-h-[calc(100dvh-2rem)] w-[min(40rem,calc(100vw-2rem))] overflow-y-auto rounded-2xl border border-border bg-popover p-0 text-popover-foreground shadow-2xl backdrop:bg-black/60 backdrop:backdrop-blur-sm"
|
||||||
|
aria-label={title}
|
||||||
|
>
|
||||||
|
<div class="p-5">
|
||||||
|
<h2 class="mb-4 text-lg font-semibold">{title}</h2>
|
||||||
|
{@render children(close)}
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
160
src/lib/pc/PartCard.svelte
Normal file
160
src/lib/pc/PartCard.svelte
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Pencil from 'lucide-svelte/icons/pencil';
|
||||||
|
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||||
|
import PcIcon from './PcIcon.svelte';
|
||||||
|
import { categoryLabel, money, shortDate, specKey, statusLabel, type Part } from './shared';
|
||||||
|
|
||||||
|
let {
|
||||||
|
part,
|
||||||
|
owner = false,
|
||||||
|
today,
|
||||||
|
onedit
|
||||||
|
}: { part: Part; owner?: boolean; today: string; onedit?: (p: Part) => void } = $props();
|
||||||
|
|
||||||
|
// Internal shop article numbers are noise on a card.
|
||||||
|
const chips = $derived(
|
||||||
|
Object.entries(part.specs)
|
||||||
|
.filter(([k, v]) => v !== null && v !== '' && !k.endsWith('_art') && k !== 'sku')
|
||||||
|
.slice(0, 5)
|
||||||
|
);
|
||||||
|
const underWarranty = $derived(!!part.warrantyUntil && part.warrantyUntil >= today);
|
||||||
|
const price = $derived(money(part.totalPriceCents));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article class="part group" class:planned={part.status === 'planned'}>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<PcIcon category={part.category} />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-[11px] font-medium tracking-wider text-[var(--site-muted)] uppercase">
|
||||||
|
{categoryLabel(part.category)}{#if part.quantity > 1} · ×{part.quantity}{/if}
|
||||||
|
</p>
|
||||||
|
<h3 class="mt-0.5 leading-snug font-semibold">
|
||||||
|
{#if part.brand}<span class="text-[var(--site-muted)]">{part.brand}</span>{/if}
|
||||||
|
{part.model}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{#if owner && onedit}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="edit"
|
||||||
|
onclick={() => onedit(part)}
|
||||||
|
aria-label="Edit {part.brand ?? ''} {part.model}"><Pencil size={15} /></button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if chips.length}
|
||||||
|
<ul class="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
{#each chips as [k, v] (k)}
|
||||||
|
<li class="chip">
|
||||||
|
<span class="text-[var(--site-muted)]">{specKey(k)}</span>
|
||||||
|
{v === true ? 'yes' : v === false ? 'no' : v}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if part.category === 'mouse' && (part.dpi || part.measuredDpi)}
|
||||||
|
<p class="mt-2 text-sm">
|
||||||
|
{#if part.dpi}<span class="chip">set {part.dpi} DPI</span>{/if}
|
||||||
|
{#if part.measuredDpi}<span class="chip">measured {part.measuredDpi} DPI</span>{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-auto flex flex-wrap items-center gap-x-3 gap-y-1 pt-3 text-sm">
|
||||||
|
{#if price}<span class="font-semibold tabular-nums">{price}</span>{/if}
|
||||||
|
{#if part.shop || part.orderDate}
|
||||||
|
<span class="text-[var(--site-muted)]"
|
||||||
|
>{[part.shop, shortDate(part.orderDate)].filter(Boolean).join(' · ')}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if part.status !== 'in_use'}<span class="badge">{statusLabel(part.status)}</span>{/if}
|
||||||
|
{#if underWarranty}<span class="badge ok">warranty to {shortDate(part.warrantyUntil)}</span
|
||||||
|
>{/if}
|
||||||
|
{#if !part.verified}<span class="badge warn" title="Bought, but not confirmed still in use"
|
||||||
|
>unverified</span
|
||||||
|
>{/if}
|
||||||
|
{#if part.url}<a href={part.url} class="badge link" target="_blank" rel="noreferrer"
|
||||||
|
>link <ExternalLink size={11} /></a
|
||||||
|
>{/if}
|
||||||
|
</div>
|
||||||
|
{#if owner && (part.notes || part.orderNumber)}
|
||||||
|
<p class="mt-2 border-t border-[var(--site-border)] pt-2 text-xs text-[var(--site-muted)]">
|
||||||
|
{#if part.orderNumber}order {part.orderNumber}{#if part.invoiceNumber}, invoice {part.invoiceNumber}{/if}.
|
||||||
|
{/if}{part.notes ?? ''}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.part {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--site-surface);
|
||||||
|
padding: 1rem;
|
||||||
|
transition:
|
||||||
|
transform 0.15s ease,
|
||||||
|
border-color 0.15s ease,
|
||||||
|
box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.part:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: color-mix(in srgb, var(--site-text) 35%, var(--site-border));
|
||||||
|
box-shadow: 0 10px 30px -15px rgb(0 0 0 / 0.5);
|
||||||
|
}
|
||||||
|
.part.planned {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--site-hover);
|
||||||
|
padding: 0.1rem 0.55rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.badge.ok {
|
||||||
|
border-color: color-mix(in srgb, #10b981 50%, transparent);
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
.badge.warn {
|
||||||
|
border-color: color-mix(in srgb, #f59e0b 50%, transparent);
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
.badge.link:hover {
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
.edit {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.35rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.part:hover .edit,
|
||||||
|
.edit:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.edit:hover {
|
||||||
|
background: var(--site-hover);
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
@media (hover: none) {
|
||||||
|
.edit {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
76
src/lib/pc/PcIcon.svelte
Normal file
76
src/lib/pc/PcIcon.svelte
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Box from 'lucide-svelte/icons/box';
|
||||||
|
import CircuitBoard from 'lucide-svelte/icons/circuit-board';
|
||||||
|
import Cloud from 'lucide-svelte/icons/cloud';
|
||||||
|
import Cpu from 'lucide-svelte/icons/cpu';
|
||||||
|
import Fan from 'lucide-svelte/icons/fan';
|
||||||
|
import Gamepad2 from 'lucide-svelte/icons/gamepad-2';
|
||||||
|
import Gpu from 'lucide-svelte/icons/gpu';
|
||||||
|
import HardDrive from 'lucide-svelte/icons/hard-drive';
|
||||||
|
import Headphones from 'lucide-svelte/icons/headphones';
|
||||||
|
import Keyboard from 'lucide-svelte/icons/keyboard';
|
||||||
|
import Laptop from 'lucide-svelte/icons/laptop';
|
||||||
|
import Lightbulb from 'lucide-svelte/icons/lightbulb';
|
||||||
|
import MemoryStick from 'lucide-svelte/icons/memory-stick';
|
||||||
|
import Monitor from 'lucide-svelte/icons/monitor';
|
||||||
|
import Mouse from 'lucide-svelte/icons/mouse';
|
||||||
|
import Package from 'lucide-svelte/icons/package';
|
||||||
|
import PcCase from 'lucide-svelte/icons/pc-case';
|
||||||
|
import Server from 'lucide-svelte/icons/server';
|
||||||
|
import Smartphone from 'lucide-svelte/icons/smartphone';
|
||||||
|
import Snowflake from 'lucide-svelte/icons/snowflake';
|
||||||
|
import Speaker from 'lucide-svelte/icons/speaker';
|
||||||
|
import Tablet from 'lucide-svelte/icons/tablet';
|
||||||
|
import Zap from 'lucide-svelte/icons/zap';
|
||||||
|
import { CATEGORY_META, DEVICE_KIND_META } from './shared';
|
||||||
|
|
||||||
|
// A gradient tile with a white icon, for a part category or a device kind.
|
||||||
|
let {
|
||||||
|
category = undefined,
|
||||||
|
kind = undefined,
|
||||||
|
class: className = 'size-10'
|
||||||
|
}: { category?: string; kind?: string; class?: string } = $props();
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
case: PcCase,
|
||||||
|
cpu: Cpu,
|
||||||
|
motherboard: CircuitBoard,
|
||||||
|
ram: MemoryStick,
|
||||||
|
cpu_cooler: Snowflake,
|
||||||
|
gpu: Gpu,
|
||||||
|
psu: Zap,
|
||||||
|
storage: HardDrive,
|
||||||
|
fan: Fan,
|
||||||
|
lighting: Lightbulb,
|
||||||
|
monitor: Monitor,
|
||||||
|
mouse: Mouse,
|
||||||
|
keyboard: Keyboard,
|
||||||
|
headset: Headphones,
|
||||||
|
audio: Speaker,
|
||||||
|
controller: Gamepad2,
|
||||||
|
accessory: Package,
|
||||||
|
other: Box,
|
||||||
|
desktop: PcCase,
|
||||||
|
laptop: Laptop,
|
||||||
|
server: Server,
|
||||||
|
vm: Cloud,
|
||||||
|
phone: Smartphone,
|
||||||
|
tablet: Tablet
|
||||||
|
} as const;
|
||||||
|
const key = $derived(category ?? kind ?? 'other');
|
||||||
|
const meta = $derived(
|
||||||
|
(category ? CATEGORY_META[category] : kind ? DEVICE_KIND_META[kind] : undefined) ??
|
||||||
|
CATEGORY_META.other
|
||||||
|
);
|
||||||
|
const Icon = $derived(icons[key as keyof typeof icons] ?? Box);
|
||||||
|
const light = $derived(key === 'case' || key === 'psu' || key === 'controller');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="flex shrink-0 items-center justify-center rounded-xl shadow-sm ring-1 ring-black/10 ring-inset {className}"
|
||||||
|
style:background-image="linear-gradient(135deg, {meta.from}, {meta.to})"
|
||||||
|
style:color={light ? '#111' : '#fff'}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Icon class="size-[52%]" strokeWidth={2} />
|
||||||
|
</span>
|
||||||
17
src/lib/pc/shared.test.ts
Normal file
17
src/lib/pc/shared.test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { cmFor, sensFor, specsToText, textToSpecs } from './shared';
|
||||||
|
|
||||||
|
describe('pc tracker helpers', () => {
|
||||||
|
it('computes CS2 sensitivity from cm/360 and real DPI', () => {
|
||||||
|
// 800 DPI, sens 1.0 in CS2 (yaw 0.022) is about 51.95 cm/360.
|
||||||
|
expect(cmFor(1, 0.022, 800)).toBeCloseTo(51.95, 1);
|
||||||
|
expect(sensFor(51.95, 0.022, 800)).toBeCloseTo(1, 2);
|
||||||
|
expect(sensFor(null, 0.022, 800)).toBeNull();
|
||||||
|
expect(sensFor(40, null, 800)).toBeNull();
|
||||||
|
});
|
||||||
|
it('round-trips specs through the edit textarea', () => {
|
||||||
|
const specs = { socket: 'AM5', cores: 8, rgb: true, note: null };
|
||||||
|
expect(textToSpecs(specsToText(specs))).toEqual(specs);
|
||||||
|
expect(textToSpecs('no colon here\n: empty key\nspeed_mts: 3600')).toEqual({ speed_mts: 3600 });
|
||||||
|
});
|
||||||
|
});
|
||||||
177
src/lib/pc/shared.ts
Normal file
177
src/lib/pc/shared.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
// Client-safe helpers for the PC setup tracker (/trackers/pc): labels, colours, formatting
|
||||||
|
// and the cm/360 maths. No server imports here.
|
||||||
|
|
||||||
|
export type Specs = Record<string, string | number | boolean | null>;
|
||||||
|
|
||||||
|
export type Part = {
|
||||||
|
id: number;
|
||||||
|
deviceId: number;
|
||||||
|
category: string;
|
||||||
|
brand: string | null;
|
||||||
|
model: string;
|
||||||
|
quantity: number;
|
||||||
|
specs: Specs;
|
||||||
|
unitPriceCents: number | null;
|
||||||
|
totalPriceCents: number | null;
|
||||||
|
shop: string | null;
|
||||||
|
orderDate: string | null;
|
||||||
|
warrantyUntil: string | null;
|
||||||
|
status: string;
|
||||||
|
verified: boolean;
|
||||||
|
dpi: number | null;
|
||||||
|
measuredDpi: number | null;
|
||||||
|
url: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
/** Owner only (null for everyone else). */
|
||||||
|
notes: string | null;
|
||||||
|
orderNumber: string | null;
|
||||||
|
invoiceNumber: string | null;
|
||||||
|
source: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Device = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
label: string | null;
|
||||||
|
hostnames: string[];
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
brand: string | null;
|
||||||
|
model: string | null;
|
||||||
|
specs: Specs;
|
||||||
|
priceCents: number | null;
|
||||||
|
monthlyCents: number | null;
|
||||||
|
budgetCents: number | null;
|
||||||
|
shop: string | null;
|
||||||
|
purchaseDate: string | null;
|
||||||
|
url: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
notes: string | null;
|
||||||
|
parts: Part[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Game = {
|
||||||
|
id: number;
|
||||||
|
game: string;
|
||||||
|
cmPer360: number | null;
|
||||||
|
yaw: number | null;
|
||||||
|
inGameSens: number | null;
|
||||||
|
mouseId: number | null;
|
||||||
|
fov: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Build order, then peripherals. Gradient tiles: black/white page, colourful content. */
|
||||||
|
export const CATEGORY_META: Record<string, { label: string; from: string; to: string }> = {
|
||||||
|
case: { label: 'Case', from: '#e5e7eb', to: '#9ca3af' },
|
||||||
|
cpu: { label: 'CPU', from: '#60a5fa', to: '#2563eb' },
|
||||||
|
motherboard: { label: 'Motherboard', from: '#34d399', to: '#059669' },
|
||||||
|
ram: { label: 'Memory', from: '#a78bfa', to: '#7c3aed' },
|
||||||
|
cpu_cooler: { label: 'Cooler', from: '#67e8f9', to: '#0891b2' },
|
||||||
|
gpu: { label: 'Graphics', from: '#f87171', to: '#dc2626' },
|
||||||
|
psu: { label: 'Power', from: '#fde047', to: '#ca8a04' },
|
||||||
|
storage: { label: 'Storage', from: '#fb923c', to: '#ea580c' },
|
||||||
|
fan: { label: 'Fans', from: '#7dd3fc', to: '#0284c7' },
|
||||||
|
lighting: { label: 'Lighting', from: '#f0abfc', to: '#c026d3' },
|
||||||
|
monitor: { label: 'Monitor', from: '#818cf8', to: '#4338ca' },
|
||||||
|
mouse: { label: 'Mouse', from: '#f472b6', to: '#db2777' },
|
||||||
|
keyboard: { label: 'Keyboard', from: '#2dd4bf', to: '#0d9488' },
|
||||||
|
headset: { label: 'Headset', from: '#c4b5fd', to: '#6d28d9' },
|
||||||
|
audio: { label: 'Audio', from: '#fda4af', to: '#e11d48' },
|
||||||
|
controller: { label: 'Controller', from: '#bef264', to: '#65a30d' },
|
||||||
|
accessory: { label: 'Accessory', from: '#cbd5e1', to: '#64748b' },
|
||||||
|
other: { label: 'Other', from: '#d4d4d8', to: '#71717a' }
|
||||||
|
};
|
||||||
|
export const CATEGORIES = Object.keys(CATEGORY_META);
|
||||||
|
export const categoryLabel = (c: string) => CATEGORY_META[c]?.label ?? c;
|
||||||
|
|
||||||
|
export const DEVICE_KIND_META: Record<string, { label: string; from: string; to: string }> = {
|
||||||
|
desktop: { label: 'Desktop', from: '#22d3ee', to: '#0891b2' },
|
||||||
|
laptop: { label: 'Laptop', from: '#a78bfa', to: '#7c3aed' },
|
||||||
|
server: { label: 'Server', from: '#34d399', to: '#059669' },
|
||||||
|
vm: { label: 'VM', from: '#94a3b8', to: '#475569' },
|
||||||
|
phone: { label: 'Phone', from: '#f472b6', to: '#db2777' },
|
||||||
|
tablet: { label: 'Tablet', from: '#fb923c', to: '#ea580c' },
|
||||||
|
monitor: { label: 'Monitor', from: '#818cf8', to: '#4338ca' },
|
||||||
|
other: { label: 'Other', from: '#d4d4d8', to: '#71717a' }
|
||||||
|
};
|
||||||
|
export const DEVICE_KINDS = Object.keys(DEVICE_KIND_META);
|
||||||
|
export const DEVICE_STATUSES = ['active', 'spare', 'planned', 'retired', 'sold'] as const;
|
||||||
|
export const PART_STATUSES = ['in_use', 'planned', 'spare', 'retired', 'sold'] as const;
|
||||||
|
|
||||||
|
export const statusLabel = (s: string) => s.replace('_', ' ');
|
||||||
|
|
||||||
|
const eur2 = new Intl.NumberFormat('en-GB', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
minimumFractionDigits: 2
|
||||||
|
});
|
||||||
|
const eur0 = new Intl.NumberFormat('en-GB', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
});
|
||||||
|
export const money = (cents: number | null | undefined) =>
|
||||||
|
cents === null || cents === undefined
|
||||||
|
? null
|
||||||
|
: (cents % 100 === 0 ? eur0 : eur2).format(cents / 100);
|
||||||
|
|
||||||
|
export const shortDate = (d: string | null) =>
|
||||||
|
d
|
||||||
|
? new Date(`${d}T12:00:00Z`).toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/** "key: value" lines (the edit form) from specs and back. */
|
||||||
|
export const specsToText = (specs: Specs) =>
|
||||||
|
Object.entries(specs)
|
||||||
|
.map(([k, v]) => `${k}: ${v ?? ''}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
export function textToSpecs(text: string): Specs {
|
||||||
|
const out: Specs = {};
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
const at = line.indexOf(':');
|
||||||
|
if (at < 1) continue;
|
||||||
|
const key = line.slice(0, at).trim().slice(0, 60);
|
||||||
|
const raw = line
|
||||||
|
.slice(at + 1)
|
||||||
|
.trim()
|
||||||
|
.slice(0, 300);
|
||||||
|
if (!key) continue;
|
||||||
|
out[key] =
|
||||||
|
raw === ''
|
||||||
|
? null
|
||||||
|
: raw === 'true'
|
||||||
|
? true
|
||||||
|
: raw === 'false'
|
||||||
|
? false
|
||||||
|
: /^-?\d+(\.\d+)?$/.test(raw)
|
||||||
|
? Number(raw)
|
||||||
|
: raw;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "speed_mts" -> "speed mts", for spec chips. */
|
||||||
|
export const specKey = (k: string) => k.replace(/_/g, ' ');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-game sensitivity for a physical cm/360 with a mouse of `dpi` real counts per inch, in a
|
||||||
|
* game that turns `yaw` degrees per count at sensitivity 1: sens = 360 × 2.54 / (yaw × dpi × cm).
|
||||||
|
*/
|
||||||
|
export function sensFor(cmPer360: number | null, yaw: number | null, dpi: number | null) {
|
||||||
|
if (!cmPer360 || !yaw || !dpi) return null;
|
||||||
|
return (360 * 2.54) / (yaw * dpi * cmPer360);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** cm/360 that a given in-game sens gives with a mouse (inverse of sensFor). */
|
||||||
|
export function cmFor(sens: number | null, yaw: number | null, dpi: number | null) {
|
||||||
|
if (!sens || !yaw || !dpi) return null;
|
||||||
|
return (360 * 2.54) / (yaw * dpi * sens);
|
||||||
|
}
|
||||||
@@ -72,6 +72,13 @@ const allTrackers = [
|
|||||||
external: false,
|
external: false,
|
||||||
private: false
|
private: false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'pc',
|
||||||
|
subtitle: 'my PC setup: every part, my devices, the next build and mouse sensitivities',
|
||||||
|
href: '/trackers/pc',
|
||||||
|
external: false,
|
||||||
|
private: false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'health',
|
name: 'health',
|
||||||
subtitle: 'private: body, workouts, sleep, meds and vaccinations',
|
subtitle: 'private: body, workouts, sleep, meds and vaccinations',
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
|
|||||||
description:
|
description:
|
||||||
'Search every car brand and model: specs, fuel types, Dutch registrations and US fuel economy.'
|
'Search every car brand and model: specs, fuel types, Dutch registrations and US fuel economy.'
|
||||||
},
|
},
|
||||||
|
'/trackers/pc': {
|
||||||
|
title: 'PC setup',
|
||||||
|
description:
|
||||||
|
'Stan Runge’s PC setup: every part with specs and prices, his devices and servers, the planned next build and mouse sensitivities.'
|
||||||
|
},
|
||||||
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
|
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
|
||||||
'/projects/game-tracker': {
|
'/projects/game-tracker': {
|
||||||
title: 'Game Tracker',
|
title: 'Game Tracker',
|
||||||
|
|||||||
191
src/lib/server/db/schema/hardware.ts
Normal file
191
src/lib/server/db/schema/hardware.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
bigint,
|
||||||
|
boolean,
|
||||||
|
check,
|
||||||
|
date,
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
numeric,
|
||||||
|
text,
|
||||||
|
unique
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { hardwareSchema } from './schemas';
|
||||||
|
import { timestamptz } from './helpers';
|
||||||
|
|
||||||
|
// PC setup tracker (/trackers/pc): every device Stan owns or plans (PCs, laptops, servers,
|
||||||
|
// phones, tablets) and the parts/peripherals on them, plus game sensitivities (cm/360) so
|
||||||
|
// every mouse feels the same everywhere. The tables predate Drizzle (hand-made in Oct 2026
|
||||||
|
// from Stan's own list + Megekko/Alternate order mails), so ids are bigint identities.
|
||||||
|
// Unknown stays null; nothing is estimated. Order/invoice numbers and notes are owner-only.
|
||||||
|
|
||||||
|
export const HARDWARE_DEVICE_KINDS = [
|
||||||
|
'desktop',
|
||||||
|
'laptop',
|
||||||
|
'server',
|
||||||
|
'vm',
|
||||||
|
'phone',
|
||||||
|
'tablet',
|
||||||
|
'monitor',
|
||||||
|
'other'
|
||||||
|
] as const;
|
||||||
|
export const HARDWARE_DEVICE_STATUSES = ['active', 'spare', 'planned', 'retired', 'sold'] as const;
|
||||||
|
export const HARDWARE_CATEGORIES = [
|
||||||
|
'case',
|
||||||
|
'cpu',
|
||||||
|
'motherboard',
|
||||||
|
'ram',
|
||||||
|
'cpu_cooler',
|
||||||
|
'gpu',
|
||||||
|
'psu',
|
||||||
|
'storage',
|
||||||
|
'fan',
|
||||||
|
'lighting',
|
||||||
|
'monitor',
|
||||||
|
'mouse',
|
||||||
|
'keyboard',
|
||||||
|
'headset',
|
||||||
|
'audio',
|
||||||
|
'controller',
|
||||||
|
'accessory',
|
||||||
|
'other'
|
||||||
|
] as const;
|
||||||
|
export const HARDWARE_STATUSES = ['in_use', 'planned', 'spare', 'retired', 'sold'] as const;
|
||||||
|
|
||||||
|
const list = (values: readonly string[]) => sql.raw(values.map((v) => `'${v}'`).join(', '));
|
||||||
|
|
||||||
|
/** Structured specs; free-form per category, e.g. {"socket":"AM5","cores":8}. */
|
||||||
|
export type HardwareSpecs = Record<string, string | number | boolean | null>;
|
||||||
|
|
||||||
|
export const hardwareDevices = hardwareSchema.table(
|
||||||
|
'devices',
|
||||||
|
{
|
||||||
|
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
/** Unique hostname-style slug, e.g. stan-pc, stan-ovh. */
|
||||||
|
name: text('name').notNull().unique(),
|
||||||
|
/** Display name, e.g. "Main PC". */
|
||||||
|
label: text('label'),
|
||||||
|
/** Other hostnames/OSes this device runs as, e.g. {stan-arch, stan-win11}. */
|
||||||
|
hostnames: text('hostnames').array().notNull().default([]),
|
||||||
|
kind: text('kind').notNull(),
|
||||||
|
status: text('status').notNull().default('active'),
|
||||||
|
brand: text('brand'),
|
||||||
|
model: text('model'),
|
||||||
|
specs: jsonb('specs').$type<HardwareSpecs>().notNull().default({}),
|
||||||
|
/** One-off purchase price, euro cents incl. VAT. */
|
||||||
|
priceCents: integer('price_cents'),
|
||||||
|
/** Recurring cost per month, euro cents (servers/VMs); see notes for VAT. */
|
||||||
|
monthlyCents: integer('monthly_cents'),
|
||||||
|
/** Budget for planned builds, euro cents. */
|
||||||
|
budgetCents: integer('budget_cents'),
|
||||||
|
shop: text('shop'),
|
||||||
|
purchaseDate: date('purchase_date'),
|
||||||
|
url: text('url'),
|
||||||
|
imageUrl: text('image_url'),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(100),
|
||||||
|
notes: text('notes'),
|
||||||
|
createdAt: timestamptz('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamptz('updated_at')
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
},
|
||||||
|
() => [
|
||||||
|
check('devices_kind_check', sql`kind in (${list(HARDWARE_DEVICE_KINDS)})`),
|
||||||
|
check('devices_status_check', sql`status in (${list(HARDWARE_DEVICE_STATUSES)})`),
|
||||||
|
check(
|
||||||
|
'devices_cents_check',
|
||||||
|
sql`(price_cents is null or price_cents >= 0) and (monthly_cents is null or monthly_cents >= 0) and (budget_cents is null or budget_cents >= 0)`
|
||||||
|
)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const hardwareComponents = hardwareSchema.table(
|
||||||
|
'components',
|
||||||
|
{
|
||||||
|
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
deviceId: bigint('device_id', { mode: 'number' })
|
||||||
|
.notNull()
|
||||||
|
.references(() => hardwareDevices.id, { onDelete: 'cascade' }),
|
||||||
|
category: text('category').notNull(),
|
||||||
|
brand: text('brand'),
|
||||||
|
model: text('model').notNull(),
|
||||||
|
quantity: integer('quantity').notNull().default(1),
|
||||||
|
specs: jsonb('specs').$type<HardwareSpecs>().notNull().default({}),
|
||||||
|
/** Per unit, euro cents incl. VAT. */
|
||||||
|
unitPriceCents: integer('unit_price_cents'),
|
||||||
|
/** unit price × quantity, euro cents incl. VAT, excl. shipping. */
|
||||||
|
totalPriceCents: integer('total_price_cents'),
|
||||||
|
shop: text('shop'),
|
||||||
|
orderNumber: text('order_number'),
|
||||||
|
orderDate: date('order_date'),
|
||||||
|
warrantyUntil: date('warranty_until'),
|
||||||
|
invoiceNumber: text('invoice_number'),
|
||||||
|
status: text('status').notNull().default('in_use'),
|
||||||
|
/** false: the purchase is real but whether it is (still) in this setup is unconfirmed. */
|
||||||
|
verified: boolean('verified').notNull().default(true),
|
||||||
|
/** Mice: DPI set in the mouse software, and the real DPI measured with a ruler. */
|
||||||
|
dpi: integer('dpi'),
|
||||||
|
measuredDpi: integer('measured_dpi'),
|
||||||
|
url: text('url'),
|
||||||
|
imageUrl: text('image_url'),
|
||||||
|
notes: text('notes'),
|
||||||
|
/** user_list | gmail | user_statement | manual | plan. */
|
||||||
|
source: text('source'),
|
||||||
|
createdAt: timestamptz('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamptz('updated_at')
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
check('components_category_check', sql`category in (${list(HARDWARE_CATEGORIES)})`),
|
||||||
|
check('components_status_check', sql`status in (${list(HARDWARE_STATUSES)})`),
|
||||||
|
check('components_quantity_check', sql`quantity > 0`),
|
||||||
|
check('components_unit_price_cents_check', sql`unit_price_cents >= 0`),
|
||||||
|
check('components_total_price_cents_check', sql`total_price_cents >= 0`),
|
||||||
|
check(
|
||||||
|
'components_dpi_check',
|
||||||
|
sql`(dpi is null or dpi > 0) and (measured_dpi is null or measured_dpi > 0)`
|
||||||
|
),
|
||||||
|
unique('components_dedupe_uq').on(t.deviceId, t.model, t.orderNumber).nullsNotDistinct(),
|
||||||
|
index('components_device_idx').on(t.deviceId),
|
||||||
|
index('components_category_idx').on(t.category),
|
||||||
|
index('components_specs_gin').using('gin', t.specs)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per game: the physical sensitivity Stan wants (cm per 360° turn) and, where the
|
||||||
|
* game's yaw is known (degrees per mouse count at sens 1, e.g. CS2 0.022), the in-game value
|
||||||
|
* is computed per mouse from its measured DPI. Stored values are only what Stan entered.
|
||||||
|
*/
|
||||||
|
export const hardwareSensitivities = hardwareSchema.table(
|
||||||
|
'sensitivities',
|
||||||
|
{
|
||||||
|
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
game: text('game').notNull().unique(),
|
||||||
|
cmPer360: numeric('cm_per_360', { precision: 7, scale: 2, mode: 'number' }),
|
||||||
|
/** Degrees per count at sensitivity 1 (CS2/Source: 0.022). */
|
||||||
|
yaw: numeric('yaw', { precision: 10, scale: 6, mode: 'number' }),
|
||||||
|
/** What is set in-game right now (as entered by Stan). */
|
||||||
|
inGameSens: numeric('in_game_sens', { precision: 10, scale: 4, mode: 'number' }),
|
||||||
|
/** Mouse this value was set with (a hardware.components row, category mouse). */
|
||||||
|
mouseId: bigint('mouse_id', { mode: 'number' }).references(() => hardwareComponents.id, {
|
||||||
|
onDelete: 'set null'
|
||||||
|
}),
|
||||||
|
fov: text('fov'),
|
||||||
|
notes: text('notes'),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(100),
|
||||||
|
createdAt: timestamptz('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamptz('updated_at')
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
},
|
||||||
|
() => [
|
||||||
|
check('sensitivities_cm_check', sql`cm_per_360 is null or cm_per_360 > 0`),
|
||||||
|
check('sensitivities_yaw_check', sql`yaw is null or yaw > 0`)
|
||||||
|
]
|
||||||
|
);
|
||||||
@@ -19,3 +19,4 @@ export * from './groceries';
|
|||||||
export * from './home';
|
export * from './home';
|
||||||
export * from './cars';
|
export * from './cars';
|
||||||
export * from './music';
|
export * from './music';
|
||||||
|
export * from './hardware';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { pgSchema } from 'drizzle-orm/pg-core';
|
import { pgSchema } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
// One Postgres schema per domain (see ~/dev/setup/db/PLAN.md). `lists`, `legacy`,
|
// One Postgres schema per domain (see ~/dev/setup/db/PLAN.md). `lists`, `legacy` and
|
||||||
// `energy` and `hardware` are deliberately not modelled here: lists are created at
|
// `energy` are deliberately not modelled here: lists are created at runtime through
|
||||||
// runtime through meta.create_list(), and energy/hardware belong to other tools.
|
// meta.create_list(), and energy belongs to ned-collector.
|
||||||
export const authSchema = pgSchema('auth');
|
export const authSchema = pgSchema('auth');
|
||||||
export const moneySchema = pgSchema('money');
|
export const moneySchema = pgSchema('money');
|
||||||
export const houseSchema = pgSchema('house');
|
export const houseSchema = pgSchema('house');
|
||||||
@@ -19,3 +19,4 @@ export const groceriesSchema = pgSchema('groceries');
|
|||||||
export const homeSchema = pgSchema('home');
|
export const homeSchema = pgSchema('home');
|
||||||
export const carsSchema = pgSchema('cars');
|
export const carsSchema = pgSchema('cars');
|
||||||
export const musicSchema = pgSchema('music');
|
export const musicSchema = pgSchema('music');
|
||||||
|
export const hardwareSchema = pgSchema('hardware');
|
||||||
|
|||||||
360
src/lib/server/hardware.ts
Normal file
360
src/lib/server/hardware.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
// PC setup tracker (/trackers/pc): reads and owner-only writes on the hardware schema.
|
||||||
|
// Public visitors get everything except component notes, order and invoice numbers.
|
||||||
|
import { db } from '#lib/server/db/index.js';
|
||||||
|
import {
|
||||||
|
HARDWARE_CATEGORIES,
|
||||||
|
HARDWARE_DEVICE_KINDS,
|
||||||
|
HARDWARE_DEVICE_STATUSES,
|
||||||
|
HARDWARE_STATUSES,
|
||||||
|
hardwareComponents,
|
||||||
|
hardwareDevices,
|
||||||
|
hardwareSensitivities
|
||||||
|
} from '#lib/server/db/schema/index.js';
|
||||||
|
import { textToSpecs, type Device, type Game, type Part } from '#lib/pc/shared.js';
|
||||||
|
import { asc, eq, sql } from 'drizzle-orm';
|
||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
const ORDER = [...HARDWARE_CATEGORIES];
|
||||||
|
|
||||||
|
export async function loadSetup(owner: boolean) {
|
||||||
|
const [devices, parts, games] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(hardwareDevices)
|
||||||
|
.orderBy(asc(hardwareDevices.sortOrder), asc(hardwareDevices.name)),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(hardwareComponents)
|
||||||
|
.orderBy(
|
||||||
|
sql`array_position(${sql.raw(`ARRAY[${ORDER.map((c) => `'${c}'`).join(',')}]`)}::text[], ${hardwareComponents.category})`,
|
||||||
|
sql`${hardwareComponents.orderDate} nulls last`,
|
||||||
|
asc(hardwareComponents.model)
|
||||||
|
),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(hardwareSensitivities)
|
||||||
|
.orderBy(asc(hardwareSensitivities.sortOrder), asc(hardwareSensitivities.game))
|
||||||
|
]);
|
||||||
|
const byDevice = new Map<number, Part[]>();
|
||||||
|
for (const p of parts) {
|
||||||
|
const part: Part = {
|
||||||
|
id: p.id,
|
||||||
|
deviceId: p.deviceId,
|
||||||
|
category: p.category,
|
||||||
|
brand: p.brand,
|
||||||
|
model: p.model,
|
||||||
|
quantity: p.quantity,
|
||||||
|
specs: p.specs ?? {},
|
||||||
|
unitPriceCents: p.unitPriceCents,
|
||||||
|
totalPriceCents: p.totalPriceCents,
|
||||||
|
shop: p.shop,
|
||||||
|
orderDate: p.orderDate,
|
||||||
|
warrantyUntil: p.warrantyUntil,
|
||||||
|
status: p.status,
|
||||||
|
verified: p.verified,
|
||||||
|
dpi: p.dpi,
|
||||||
|
measuredDpi: p.measuredDpi,
|
||||||
|
url: p.url,
|
||||||
|
imageUrl: p.imageUrl,
|
||||||
|
notes: owner ? p.notes : null,
|
||||||
|
orderNumber: owner ? p.orderNumber : null,
|
||||||
|
invoiceNumber: owner ? p.invoiceNumber : null,
|
||||||
|
source: owner ? p.source : null
|
||||||
|
};
|
||||||
|
const list = byDevice.get(p.deviceId) ?? [];
|
||||||
|
list.push(part);
|
||||||
|
byDevice.set(p.deviceId, list);
|
||||||
|
}
|
||||||
|
const out: Device[] = devices.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
name: d.name,
|
||||||
|
label: d.label,
|
||||||
|
hostnames: d.hostnames,
|
||||||
|
kind: d.kind,
|
||||||
|
status: d.status,
|
||||||
|
brand: d.brand,
|
||||||
|
model: d.model,
|
||||||
|
specs: d.specs ?? {},
|
||||||
|
priceCents: d.priceCents,
|
||||||
|
monthlyCents: d.monthlyCents,
|
||||||
|
budgetCents: d.budgetCents,
|
||||||
|
shop: d.shop,
|
||||||
|
purchaseDate: d.purchaseDate,
|
||||||
|
url: d.url,
|
||||||
|
imageUrl: d.imageUrl,
|
||||||
|
sortOrder: d.sortOrder,
|
||||||
|
notes: d.notes,
|
||||||
|
parts: byDevice.get(d.id) ?? []
|
||||||
|
}));
|
||||||
|
const sens: Game[] = games.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
game: g.game,
|
||||||
|
cmPer360: g.cmPer360,
|
||||||
|
yaw: g.yaw,
|
||||||
|
inGameSens: g.inGameSens,
|
||||||
|
mouseId: g.mouseId,
|
||||||
|
fov: g.fov,
|
||||||
|
notes: owner ? g.notes : null,
|
||||||
|
sortOrder: g.sortOrder
|
||||||
|
}));
|
||||||
|
return { devices: out, games: sens };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- form parsing (owner writes) ----
|
||||||
|
|
||||||
|
const str = (max: number) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(max)
|
||||||
|
.transform((v) => (v === '' ? null : v))
|
||||||
|
.nullable()
|
||||||
|
.default(null);
|
||||||
|
const req = (max: number) => z.string().trim().min(1).max(max);
|
||||||
|
const int = (min: number, max: number) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((v, ctx) => {
|
||||||
|
if (v === '') return null;
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isInteger(n) || n < min || n > max) {
|
||||||
|
ctx.addIssue({ code: 'custom', message: `must be a whole number ${min}-${max}` });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.default(null);
|
||||||
|
const num = (max: number) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((v, ctx) => {
|
||||||
|
if (v === '') return null;
|
||||||
|
const n = Number(v.replace(',', '.'));
|
||||||
|
if (!Number.isFinite(n) || n <= 0 || n > max) {
|
||||||
|
ctx.addIssue({ code: 'custom', message: 'must be a positive number' });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.default(null);
|
||||||
|
/** Euros as typed ("1299", "1.299,00", "61.74") to cents. */
|
||||||
|
const cents = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((v, ctx) => {
|
||||||
|
if (v === '') return null;
|
||||||
|
let s = v.replace(/[€\s]/g, '');
|
||||||
|
if (/,\d{1,2}$/.test(s)) s = s.replace(/\./g, '').replace(',', '.');
|
||||||
|
else s = s.replace(/,/g, '');
|
||||||
|
const n = Number(s);
|
||||||
|
if (!Number.isFinite(n) || n < 0 || n > 10_000_000) {
|
||||||
|
ctx.addIssue({ code: 'custom', message: 'not a price' });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return Math.round(n * 100);
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.default(null);
|
||||||
|
const day = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((v, ctx) => {
|
||||||
|
if (v === '') return null;
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) {
|
||||||
|
ctx.addIssue({ code: 'custom', message: 'use YYYY-MM-DD' });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.default(null);
|
||||||
|
const link = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(1000)
|
||||||
|
.transform((v, ctx) => {
|
||||||
|
if (v === '') return null;
|
||||||
|
if (!/^https?:\/\//i.test(v)) {
|
||||||
|
ctx.addIssue({ code: 'custom', message: 'links start with http(s)://' });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.default(null);
|
||||||
|
const specs = z
|
||||||
|
.string()
|
||||||
|
.max(5000)
|
||||||
|
.default('')
|
||||||
|
.transform((v) => textToSpecs(v));
|
||||||
|
const pick = <T extends readonly [string, ...string[]]>(values: T) => z.enum(values);
|
||||||
|
|
||||||
|
const partSchema = z.object({
|
||||||
|
id: int(1, 2 ** 31),
|
||||||
|
deviceId: z.coerce.number().int().positive(),
|
||||||
|
category: pick(HARDWARE_CATEGORIES),
|
||||||
|
brand: str(120),
|
||||||
|
model: req(300),
|
||||||
|
quantity: z.coerce.number().int().min(1).max(999).default(1),
|
||||||
|
specs,
|
||||||
|
unitPrice: cents,
|
||||||
|
shop: str(120),
|
||||||
|
orderDate: day,
|
||||||
|
warrantyUntil: day,
|
||||||
|
orderNumber: str(120),
|
||||||
|
invoiceNumber: str(120),
|
||||||
|
status: pick(HARDWARE_STATUSES),
|
||||||
|
verified: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v === 'on' || v === 'true'),
|
||||||
|
dpi: int(1, 100_000),
|
||||||
|
measuredDpi: int(1, 100_000),
|
||||||
|
url: link,
|
||||||
|
imageUrl: link,
|
||||||
|
notes: str(4000)
|
||||||
|
});
|
||||||
|
|
||||||
|
const deviceSchema = z.object({
|
||||||
|
id: int(1, 2 ** 31),
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.regex(/^[a-z0-9][a-z0-9-]{0,62}$/, 'lowercase letters, digits and dashes'),
|
||||||
|
label: str(120),
|
||||||
|
hostnames: z
|
||||||
|
.string()
|
||||||
|
.default('')
|
||||||
|
.transform((v) =>
|
||||||
|
v
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map((h) => h.trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 20)
|
||||||
|
),
|
||||||
|
kind: pick(HARDWARE_DEVICE_KINDS),
|
||||||
|
status: pick(HARDWARE_DEVICE_STATUSES),
|
||||||
|
brand: str(120),
|
||||||
|
model: str(200),
|
||||||
|
specs,
|
||||||
|
price: cents,
|
||||||
|
monthly: cents,
|
||||||
|
budget: cents,
|
||||||
|
shop: str(120),
|
||||||
|
purchaseDate: day,
|
||||||
|
url: link,
|
||||||
|
sortOrder: int(0, 10_000),
|
||||||
|
notes: str(4000)
|
||||||
|
});
|
||||||
|
|
||||||
|
const gameSchema = z.object({
|
||||||
|
id: int(1, 2 ** 31),
|
||||||
|
game: req(120),
|
||||||
|
cmPer360: num(1000),
|
||||||
|
yaw: num(100),
|
||||||
|
inGameSens: num(100_000),
|
||||||
|
mouseId: int(1, 2 ** 31),
|
||||||
|
fov: str(60),
|
||||||
|
notes: str(2000),
|
||||||
|
sortOrder: int(0, 10_000)
|
||||||
|
});
|
||||||
|
|
||||||
|
type Result = { ok: true } | { ok: false; message: string };
|
||||||
|
const problem = (e: z.ZodError): Result => ({
|
||||||
|
ok: false,
|
||||||
|
message: e.issues.map((i) => `${i.path.join('.') || 'form'}: ${i.message}`).join('; ')
|
||||||
|
});
|
||||||
|
const fields = (form: FormData) =>
|
||||||
|
Object.fromEntries([...form.entries()].map(([k, v]) => [k, typeof v === 'string' ? v : '']));
|
||||||
|
|
||||||
|
export async function savePart(form: FormData): Promise<Result> {
|
||||||
|
const parsed = partSchema.safeParse(fields(form));
|
||||||
|
if (!parsed.success) return problem(parsed.error);
|
||||||
|
const { id, unitPrice, ...p } = parsed.data;
|
||||||
|
const values = {
|
||||||
|
...p,
|
||||||
|
unitPriceCents: unitPrice,
|
||||||
|
totalPriceCents: unitPrice === null ? null : unitPrice * p.quantity,
|
||||||
|
source: undefined as string | undefined
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (id) {
|
||||||
|
delete values.source;
|
||||||
|
await db.update(hardwareComponents).set(values).where(eq(hardwareComponents.id, id));
|
||||||
|
} else {
|
||||||
|
await db.insert(hardwareComponents).values({ ...values, source: 'manual' });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, message: dbMessage(e) };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDevice(form: FormData): Promise<Result> {
|
||||||
|
const parsed = deviceSchema.safeParse(fields(form));
|
||||||
|
if (!parsed.success) return problem(parsed.error);
|
||||||
|
const { id, price, monthly, budget, sortOrder, ...d } = parsed.data;
|
||||||
|
const values = {
|
||||||
|
...d,
|
||||||
|
priceCents: price,
|
||||||
|
monthlyCents: monthly,
|
||||||
|
budgetCents: budget,
|
||||||
|
sortOrder: sortOrder ?? 100
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (id) await db.update(hardwareDevices).set(values).where(eq(hardwareDevices.id, id));
|
||||||
|
else await db.insert(hardwareDevices).values(values);
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, message: dbMessage(e) };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveGame(form: FormData): Promise<Result> {
|
||||||
|
const parsed = gameSchema.safeParse(fields(form));
|
||||||
|
if (!parsed.success) return problem(parsed.error);
|
||||||
|
const { id, sortOrder, ...g } = parsed.data;
|
||||||
|
const values = { ...g, sortOrder: sortOrder ?? 100 };
|
||||||
|
try {
|
||||||
|
if (id)
|
||||||
|
await db.update(hardwareSensitivities).set(values).where(eq(hardwareSensitivities.id, id));
|
||||||
|
else await db.insert(hardwareSensitivities).values(values);
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, message: dbMessage(e) };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowId = (form: FormData) => {
|
||||||
|
const n = Number(form.get('id'));
|
||||||
|
return Number.isInteger(n) && n > 0 ? n : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function deleteRow(
|
||||||
|
table: 'part' | 'device' | 'game',
|
||||||
|
form: FormData
|
||||||
|
): Promise<Result> {
|
||||||
|
const id = rowId(form);
|
||||||
|
if (!id) return { ok: false, message: 'Missing id' };
|
||||||
|
if (table === 'part') await db.delete(hardwareComponents).where(eq(hardwareComponents.id, id));
|
||||||
|
else if (table === 'game')
|
||||||
|
await db.delete(hardwareSensitivities).where(eq(hardwareSensitivities.id, id));
|
||||||
|
else await db.delete(hardwareDevices).where(eq(hardwareDevices.id, id));
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbMessage(e: unknown): string {
|
||||||
|
const code =
|
||||||
|
(e as { code?: string; cause?: { code?: string } })?.code ??
|
||||||
|
(e as { cause?: { code?: string } })?.cause?.code;
|
||||||
|
if (code === '23505') return 'That already exists (same name, or same device + model + order).';
|
||||||
|
if (code === '23503') return 'Unknown device or mouse.';
|
||||||
|
if (code === '23514') return 'A value is out of range.';
|
||||||
|
console.error('hardware write failed', e);
|
||||||
|
return 'Could not save';
|
||||||
|
}
|
||||||
@@ -41,6 +41,8 @@ describe('owner gate', () => {
|
|||||||
expect(isOwnerWritePath('/trackers/health/import')).toBe(true);
|
expect(isOwnerWritePath('/trackers/health/import')).toBe(true);
|
||||||
expect(isOwnerWritePath('/trackers/cars/ford/mustang')).toBe(true);
|
expect(isOwnerWritePath('/trackers/cars/ford/mustang')).toBe(true);
|
||||||
expect(isOwnerOnlyPath('/trackers/cars/ford/mustang')).toBe(false);
|
expect(isOwnerOnlyPath('/trackers/cars/ford/mustang')).toBe(false);
|
||||||
|
expect(isOwnerWritePath('/trackers/pc')).toBe(true);
|
||||||
|
expect(isOwnerOnlyPath('/trackers/pc')).toBe(false);
|
||||||
expect(isOwnerWritePath('/trackers/bookshelf')).toBe(false);
|
expect(isOwnerWritePath('/trackers/bookshelf')).toBe(false);
|
||||||
expect(isOwnerWritePath('/lists/books')).toBe(false);
|
expect(isOwnerWritePath('/lists/books')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ export const ownerWritePrefixes = [
|
|||||||
'/trackers/houses',
|
'/trackers/houses',
|
||||||
'/trackers/books',
|
'/trackers/books',
|
||||||
'/trackers/cars',
|
'/trackers/cars',
|
||||||
'/trackers/music'
|
'/trackers/music',
|
||||||
|
'/trackers/pc'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');
|
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
|
|||||||
'/trackers': 'Trackers',
|
'/trackers': 'Trackers',
|
||||||
'/trackers/books': 'Books',
|
'/trackers/books': 'Books',
|
||||||
'/trackers/cars': 'Cars',
|
'/trackers/cars': 'Cars',
|
||||||
|
'/trackers/pc': 'PC setup',
|
||||||
'/blog': 'Blog',
|
'/blog': 'Blog',
|
||||||
'/lists': 'Lists',
|
'/lists': 'Lists',
|
||||||
'/login': 'Sign in',
|
'/login': 'Sign in',
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ export const trackerIcons: Record<string, TrackerIcon> = {
|
|||||||
ink: WHITE,
|
ink: WHITE,
|
||||||
paths: ['M9 18V5l12-2v13', c(6, 18, 3), c(18, 16, 3)]
|
paths: ['M9 18V5l12-2v13', c(6, 18, 3), c(18, 16, 3)]
|
||||||
},
|
},
|
||||||
|
pc: {
|
||||||
|
from: '#22d3ee',
|
||||||
|
to: '#0891b2',
|
||||||
|
ink: WHITE,
|
||||||
|
paths: ['M4 4h16v11H4z', 'M9 19h6M12 15v4', 'M8 8h3v3H8z', 'M14 8.5h2M14 11h2']
|
||||||
|
},
|
||||||
health: {
|
health: {
|
||||||
from: '#f472b6',
|
from: '#f472b6',
|
||||||
to: '#db2777',
|
to: '#db2777',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const staticPaths = [
|
|||||||
'/trackers/cars',
|
'/trackers/cars',
|
||||||
'/trackers/cars/brands',
|
'/trackers/cars/brands',
|
||||||
'/trackers/music',
|
'/trackers/music',
|
||||||
|
'/trackers/pc',
|
||||||
'/projects/game-tracker',
|
'/projects/game-tracker',
|
||||||
'/projects/game-tracker/pokemon',
|
'/projects/game-tracker/pokemon',
|
||||||
'/projects/game-tracker/osu',
|
'/projects/game-tracker/osu',
|
||||||
|
|||||||
53
src/routes/trackers/pc/+page.server.ts
Normal file
53
src/routes/trackers/pc/+page.server.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { isOwner, requireOwner } from '#lib/server/owner.js';
|
||||||
|
import { deleteRow, loadSetup, saveDevice, saveGame, savePart } from '#lib/server/hardware.js';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
// Public page; only the owner sees notes/order numbers and the edit controls.
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
const owner = isOwner(event);
|
||||||
|
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||||
|
const { devices, games } = await loadSetup(owner);
|
||||||
|
return {
|
||||||
|
owner,
|
||||||
|
devices,
|
||||||
|
games,
|
||||||
|
seo: {
|
||||||
|
title: 'PC setup',
|
||||||
|
description:
|
||||||
|
'Every part in my PC with specs and prices, my other devices and servers, the planned next build and my mouse sensitivities.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = async (result: Promise<{ ok: true } | { ok: false; message: string }>) => {
|
||||||
|
const r = await result;
|
||||||
|
return r.ok ? { saved: true } : fail(400, { message: r.message });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
savePart: async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
return run(savePart(await event.request.formData()));
|
||||||
|
},
|
||||||
|
deletePart: async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
return run(deleteRow('part', await event.request.formData()));
|
||||||
|
},
|
||||||
|
saveDevice: async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
return run(saveDevice(await event.request.formData()));
|
||||||
|
},
|
||||||
|
deleteDevice: async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
return run(deleteRow('device', await event.request.formData()));
|
||||||
|
},
|
||||||
|
saveGame: async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
return run(saveGame(await event.request.formData()));
|
||||||
|
},
|
||||||
|
deleteGame: async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
return run(deleteRow('game', await event.request.formData()));
|
||||||
|
}
|
||||||
|
};
|
||||||
868
src/routes/trackers/pc/+page.svelte
Normal file
868
src/routes/trackers/pc/+page.svelte
Normal file
@@ -0,0 +1,868 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { SubmitFunction } from '@sveltejs/kit';
|
||||||
|
import Plus from 'lucide-svelte/icons/plus';
|
||||||
|
import Pencil from 'lucide-svelte/icons/pencil';
|
||||||
|
import Crosshair from 'lucide-svelte/icons/crosshair';
|
||||||
|
import Modal from '#lib/pc/Modal.svelte';
|
||||||
|
import PartCard from '#lib/pc/PartCard.svelte';
|
||||||
|
import PcIcon from '#lib/pc/PcIcon.svelte';
|
||||||
|
import {
|
||||||
|
CATEGORIES,
|
||||||
|
DEVICE_KINDS,
|
||||||
|
DEVICE_KIND_META,
|
||||||
|
DEVICE_STATUSES,
|
||||||
|
PART_STATUSES,
|
||||||
|
categoryLabel,
|
||||||
|
money,
|
||||||
|
sensFor,
|
||||||
|
shortDate,
|
||||||
|
specKey,
|
||||||
|
specsToText,
|
||||||
|
statusLabel,
|
||||||
|
type Device,
|
||||||
|
type Game,
|
||||||
|
type Part
|
||||||
|
} from '#lib/pc/shared.js';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const main = $derived(
|
||||||
|
data.devices.find((d) => d.status === 'active' && d.kind === 'desktop') ?? null
|
||||||
|
);
|
||||||
|
const planned = $derived(data.devices.filter((d) => d.status === 'planned'));
|
||||||
|
const others = $derived(
|
||||||
|
data.devices.filter((d) => d !== main && (d.status === 'active' || d.status === 'spare'))
|
||||||
|
);
|
||||||
|
const retired = $derived(
|
||||||
|
data.devices.filter((d) => d.status === 'retired' || d.status === 'sold')
|
||||||
|
);
|
||||||
|
const navDevices = $derived([main, ...planned, ...others].filter((d): d is Device => !!d));
|
||||||
|
|
||||||
|
const sum = (parts: Part[]) => parts.reduce((s, p) => s + (p.totalPriceCents ?? 0), 0);
|
||||||
|
const inUse = (d: Device) => d.parts.filter((p) => p.status === 'in_use');
|
||||||
|
const mainParts = $derived(main ? inUse(main) : []);
|
||||||
|
const build = $derived(
|
||||||
|
mainParts.filter((p) =>
|
||||||
|
[
|
||||||
|
'case',
|
||||||
|
'cpu',
|
||||||
|
'motherboard',
|
||||||
|
'ram',
|
||||||
|
'cpu_cooler',
|
||||||
|
'gpu',
|
||||||
|
'psu',
|
||||||
|
'storage',
|
||||||
|
'fan',
|
||||||
|
'lighting'
|
||||||
|
].includes(p.category)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const desk = $derived(mainParts.filter((p) => !build.includes(p)));
|
||||||
|
const mice = $derived(
|
||||||
|
data.devices
|
||||||
|
.filter((d) => d.status !== 'retired' && d.status !== 'sold')
|
||||||
|
.flatMap((d) => d.parts)
|
||||||
|
.filter((p) => p.category === 'mouse' && p.status !== 'sold' && p.status !== 'retired')
|
||||||
|
);
|
||||||
|
const monthly = $derived(
|
||||||
|
data.devices.filter((d) => d.status === 'active').reduce((s, d) => s + (d.monthlyCents ?? 0), 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const deviceSpecs = (d: Device) =>
|
||||||
|
Object.entries(d.specs).filter(([, v]) => v !== null && v !== '');
|
||||||
|
const fmtSens = (n: number | null) =>
|
||||||
|
n === null ? null : n.toLocaleString('en-GB', { maximumFractionDigits: 3 });
|
||||||
|
|
||||||
|
// ---- owner editing ----
|
||||||
|
let editPart = $state<Partial<Part> | null>(null);
|
||||||
|
let editDevice = $state<Partial<Device> | null>(null);
|
||||||
|
let editGame = $state<Partial<Game> | null>(null);
|
||||||
|
let problem = $state('');
|
||||||
|
|
||||||
|
const submit =
|
||||||
|
(close: () => void): SubmitFunction =>
|
||||||
|
() => {
|
||||||
|
problem = '';
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'failure')
|
||||||
|
problem = String((result.data as { message?: string })?.message ?? 'Could not save');
|
||||||
|
else if (result.type === 'success') {
|
||||||
|
close();
|
||||||
|
await update({ reset: false });
|
||||||
|
} else await update();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const confirmDelete = (what: string) => (event: SubmitEvent) => {
|
||||||
|
if (!confirm(`Delete ${what}?`)) event.preventDefault();
|
||||||
|
};
|
||||||
|
const euros = (cents: number | null | undefined) =>
|
||||||
|
cents === null || cents === undefined ? '' : (cents / 100).toFixed(2);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
|
<header class="mb-8 flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">pc setup</h1>
|
||||||
|
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
|
||||||
|
Every part in my PC with specs and what I paid, the rest of my devices and servers, the next
|
||||||
|
build, and the mouse sensitivities I keep the same everywhere.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if data.owner}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action"
|
||||||
|
onclick={() =>
|
||||||
|
(editPart = {
|
||||||
|
deviceId: main?.id,
|
||||||
|
category: 'other',
|
||||||
|
status: 'in_use',
|
||||||
|
verified: true,
|
||||||
|
quantity: 1,
|
||||||
|
specs: {}
|
||||||
|
})}><Plus size={15} /> part</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action"
|
||||||
|
onclick={() =>
|
||||||
|
(editDevice = { kind: 'desktop', status: 'active', specs: {}, hostnames: [] })}
|
||||||
|
><Plus size={15} /> device</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<dl class="stats">
|
||||||
|
<div>
|
||||||
|
<dt>parts in my PC</dt>
|
||||||
|
<dd>{build.length}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>PC cost (known prices)</dt>
|
||||||
|
<dd>{money(sum(build)) ?? '–'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>desk + peripherals</dt>
|
||||||
|
<dd>{money(sum(desk)) ?? '–'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>servers / month</dt>
|
||||||
|
<dd>{money(monthly) ?? '–'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<nav class="devices" aria-label="Devices">
|
||||||
|
{#each navDevices as d (d.id)}
|
||||||
|
<a href="#{d.name}" class="device-tile">
|
||||||
|
<PcIcon kind={d.kind} class="size-11" />
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span class="block truncate font-medium">{d.label ?? d.name}</span>
|
||||||
|
<span class="block truncate text-xs text-[var(--site-muted)]"
|
||||||
|
>{[d.brand, d.model].filter(Boolean).join(' ') || DEVICE_KIND_META[d.kind]?.label}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
{#if d.status !== 'active'}<span class="pill">{d.status}</span>{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
<a href="#sensitivity" class="device-tile">
|
||||||
|
<span
|
||||||
|
class="flex size-11 shrink-0 items-center justify-center rounded-xl text-white shadow-sm"
|
||||||
|
style:background-image="linear-gradient(135deg, #f472b6, #7c3aed)"
|
||||||
|
aria-hidden="true"><Crosshair size={22} /></span
|
||||||
|
>
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span class="block font-medium">Sensitivity</span>
|
||||||
|
<span class="block text-xs text-[var(--site-muted)]">cm/360 per game</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{#snippet deviceHeader(d: Device, title: string)}
|
||||||
|
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<PcIcon kind={d.kind} class="size-12" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h2 class="text-2xl font-semibold tracking-tight">{title}</h2>
|
||||||
|
<p class="text-sm text-[var(--site-muted)]">
|
||||||
|
<code>{d.name}</code>{#each d.hostnames as h (h)} · <code>{h}</code>{/each}
|
||||||
|
{#if d.brand || d.model} · {[d.brand, d.model].filter(Boolean).join(' ')}{/if}
|
||||||
|
{#if d.monthlyCents} · {money(d.monthlyCents)}/month{/if}
|
||||||
|
{#if d.priceCents} · {money(d.priceCents)}{/if}
|
||||||
|
{#if d.purchaseDate} · since {shortDate(d.purchaseDate)}{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if data.owner}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action"
|
||||||
|
onclick={() =>
|
||||||
|
(editPart = {
|
||||||
|
deviceId: d.id,
|
||||||
|
category: 'other',
|
||||||
|
status: d.status === 'planned' ? 'planned' : 'in_use',
|
||||||
|
verified: true,
|
||||||
|
quantity: 1,
|
||||||
|
specs: {}
|
||||||
|
})}><Plus size={15} /> part</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action"
|
||||||
|
onclick={() => (editDevice = d)}
|
||||||
|
aria-label="Edit {d.name}"><Pencil size={15} /></button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if deviceSpecs(d).length}
|
||||||
|
<ul class="mb-4 flex flex-wrap gap-2">
|
||||||
|
{#each deviceSpecs(d) as [k, v] (k)}
|
||||||
|
<li class="spec"><span>{specKey(k)}</span>{v}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{#if d.notes}<p class="mb-4 max-w-3xl text-sm text-[var(--site-muted)]">{d.notes}</p>{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet grid(parts: Part[])}
|
||||||
|
<ul class="parts">
|
||||||
|
{#each parts as p (p.id)}
|
||||||
|
<li><PartCard part={p} owner={data.owner} {today} onedit={(x) => (editPart = x)} /></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if main}
|
||||||
|
<section id={main.name} class="section">
|
||||||
|
{@render deviceHeader(main, main.label ?? main.name)}
|
||||||
|
{@render grid(build)}
|
||||||
|
{#if desk.length}
|
||||||
|
<h3 class="mt-8 mb-3 text-lg font-semibold">Desk and peripherals</h3>
|
||||||
|
{@render grid(desk)}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each planned as d (d.id)}
|
||||||
|
{@const spent = sum(d.parts)}
|
||||||
|
<section id={d.name} class="section">
|
||||||
|
{@render deviceHeader(d, d.label ?? d.name)}
|
||||||
|
{#if d.budgetCents}
|
||||||
|
<div class="budget" aria-label="Budget">
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span>picked so far <strong class="tabular-nums">{money(spent)}</strong></span>
|
||||||
|
<span class="text-[var(--site-muted)]">budget {money(d.budgetCents)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bar">
|
||||||
|
<span style:width="{Math.min(100, (spent / d.budgetCents) * 100)}%"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{@render grid(d.parts)}
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<section id="sensitivity" class="section">
|
||||||
|
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="flex size-12 shrink-0 items-center justify-center rounded-xl text-white shadow-sm"
|
||||||
|
style:background-image="linear-gradient(135deg, #f472b6, #7c3aed)"
|
||||||
|
aria-hidden="true"><Crosshair size={24} /></span
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="text-2xl font-semibold tracking-tight">Mice and sensitivity</h2>
|
||||||
|
<p class="text-sm text-[var(--site-muted)]">
|
||||||
|
One physical sensitivity per game (cm per 360° turn), converted per mouse from its
|
||||||
|
measured DPI, so every mouse feels the same on every PC.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if data.owner}
|
||||||
|
<button type="button" class="action" onclick={() => (editGame = {})}
|
||||||
|
><Plus size={15} /> game</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mice">
|
||||||
|
{#each mice as m (m.id)}
|
||||||
|
<div class="mouse">
|
||||||
|
<PcIcon category="mouse" class="size-9" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="truncate font-medium">{m.brand} {m.model}</p>
|
||||||
|
<p class="text-sm text-[var(--site-muted)]">
|
||||||
|
set {m.dpi ?? '?'} DPI · measured
|
||||||
|
<strong class="text-[var(--site-text)]">{m.measuredDpi ?? '?'}</strong> DPI
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if data.owner}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action"
|
||||||
|
onclick={() => (editPart = m)}
|
||||||
|
aria-label="Edit {m.model}"><Pencil size={14} /></button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-[var(--site-muted)]">No mice yet.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>game</th>
|
||||||
|
<th>cm/360</th>
|
||||||
|
<th>yaw</th>
|
||||||
|
<th>set now</th>
|
||||||
|
{#each mice as m (m.id)}<th>{m.model}</th>{/each}
|
||||||
|
{#if data.owner}<th><span class="sr-only">edit</span></th>{/if}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.games as g (g.id)}
|
||||||
|
{@const setWith = mice.find((m) => m.id === g.mouseId)}
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium">{g.game}</td>
|
||||||
|
<td class="tabular-nums">{g.cmPer360 ?? '–'}</td>
|
||||||
|
<td class="tabular-nums text-[var(--site-muted)]">{g.yaw ?? '–'}</td>
|
||||||
|
<td class="tabular-nums"
|
||||||
|
>{g.inGameSens ?? '–'}{#if setWith}<span class="text-xs text-[var(--site-muted)]">
|
||||||
|
· {setWith.model}</span
|
||||||
|
>{/if}</td
|
||||||
|
>
|
||||||
|
{#each mice as m (m.id)}
|
||||||
|
<td class="tabular-nums font-semibold"
|
||||||
|
>{fmtSens(sensFor(g.cmPer360, g.yaw, m.measuredDpi ?? m.dpi)) ?? '–'}</td
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
{#if data.owner}
|
||||||
|
<td
|
||||||
|
><button
|
||||||
|
type="button"
|
||||||
|
class="action"
|
||||||
|
onclick={() => (editGame = g)}
|
||||||
|
aria-label="Edit {g.game}"><Pencil size={14} /></button
|
||||||
|
></td
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-xs text-[var(--site-muted)]">
|
||||||
|
Per-mouse values use in-game sens = 360 × 2.54 / (yaw × DPI × cm/360), with the measured DPI
|
||||||
|
when known. Measure DPI by moving the mouse exactly 10 cm and counting the cursor's pixels:
|
||||||
|
DPI = pixels / 3.937.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#each others as d (d.id)}
|
||||||
|
<section id={d.name} class="section">
|
||||||
|
{@render deviceHeader(d, d.label ?? d.name)}
|
||||||
|
{#if d.parts.length}{@render grid(d.parts)}{/if}
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if retired.length}
|
||||||
|
<details class="section retired">
|
||||||
|
<summary class="cursor-pointer text-lg font-semibold">Retired ({retired.length})</summary>
|
||||||
|
{#each retired as d (d.id)}
|
||||||
|
<section id={d.name} class="mt-6">
|
||||||
|
{@render deviceHeader(d, d.label ?? d.name)}
|
||||||
|
{#if d.parts.length}{@render grid(d.parts)}{/if}
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if editPart}
|
||||||
|
{@const p = editPart}
|
||||||
|
<Modal
|
||||||
|
title={p.id ? `Edit ${p.model}` : 'Add a part'}
|
||||||
|
onclose={() => {
|
||||||
|
editPart = null;
|
||||||
|
problem = '';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#snippet children(close)}
|
||||||
|
<form method="POST" action="?/savePart" use:enhance={submit(close)} class="form">
|
||||||
|
<input type="hidden" name="id" value={p.id ?? ''} />
|
||||||
|
<label
|
||||||
|
>device<select name="deviceId" value={p.deviceId}>
|
||||||
|
{#each data.devices as d (d.id)}<option value={d.id}>{d.label ?? d.name}</option>{/each}
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>category<select name="category" value={p.category}>
|
||||||
|
{#each CATEGORIES as c (c)}<option value={c}>{categoryLabel(c)}</option>{/each}
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<label>brand<input name="brand" value={p.brand ?? ''} maxlength="120" /></label>
|
||||||
|
<label class="wide"
|
||||||
|
>model<input name="model" value={p.model ?? ''} required maxlength="300" /></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>quantity<input name="quantity" type="number" min="1" value={p.quantity ?? 1} /></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>price per unit (€)<input
|
||||||
|
name="unitPrice"
|
||||||
|
inputmode="decimal"
|
||||||
|
value={euros(p.unitPriceCents)}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label>shop<input name="shop" value={p.shop ?? ''} /></label>
|
||||||
|
<label>order date<input name="orderDate" type="date" value={p.orderDate ?? ''} /></label>
|
||||||
|
<label
|
||||||
|
>warranty until<input
|
||||||
|
name="warrantyUntil"
|
||||||
|
type="date"
|
||||||
|
value={p.warrantyUntil ?? ''}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>status<select name="status" value={p.status}>
|
||||||
|
{#each PART_STATUSES as s (s)}<option value={s}>{statusLabel(s)}</option>{/each}
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<label>order number<input name="orderNumber" value={p.orderNumber ?? ''} /></label>
|
||||||
|
<label>invoice number<input name="invoiceNumber" value={p.invoiceNumber ?? ''} /></label>
|
||||||
|
<label>DPI (set)<input name="dpi" type="number" min="1" value={p.dpi ?? ''} /></label>
|
||||||
|
<label
|
||||||
|
>DPI (measured)<input
|
||||||
|
name="measuredDpi"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={p.measuredDpi ?? ''}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label class="wide">link<input name="url" type="url" value={p.url ?? ''} /></label>
|
||||||
|
<label class="wide"
|
||||||
|
>image link<input name="imageUrl" type="url" value={p.imageUrl ?? ''} /></label
|
||||||
|
>
|
||||||
|
<label class="wide"
|
||||||
|
>specs (one "key: value" per line)<textarea name="specs" rows="4"
|
||||||
|
>{specsToText(p.specs ?? {})}</textarea
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
<label class="wide"
|
||||||
|
>notes (only you)<textarea name="notes" rows="2">{p.notes ?? ''}</textarea></label
|
||||||
|
>
|
||||||
|
<label class="check"
|
||||||
|
><input type="checkbox" name="verified" checked={p.verified ?? true} /> confirmed still in this
|
||||||
|
setup</label
|
||||||
|
>
|
||||||
|
{#if problem}<p class="wide text-sm text-red-500" role="alert">{problem}</p>{/if}
|
||||||
|
<div class="buttons wide">
|
||||||
|
<button type="button" class="action" onclick={close}>Cancel</button>
|
||||||
|
<button type="submit" class="action primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{#if p.id}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deletePart"
|
||||||
|
use:enhance={submit(close)}
|
||||||
|
onsubmit={confirmDelete(p.model ?? 'this part')}
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={p.id} />
|
||||||
|
<button type="submit" class="text-sm text-red-500 hover:underline">Delete part</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editDevice}
|
||||||
|
{@const d = editDevice}
|
||||||
|
<Modal
|
||||||
|
title={d.id ? `Edit ${d.name}` : 'Add a device'}
|
||||||
|
onclose={() => {
|
||||||
|
editDevice = null;
|
||||||
|
problem = '';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#snippet children(close)}
|
||||||
|
<form method="POST" action="?/saveDevice" use:enhance={submit(close)} class="form">
|
||||||
|
<input type="hidden" name="id" value={d.id ?? ''} />
|
||||||
|
<label
|
||||||
|
>name (hostname)<input
|
||||||
|
name="name"
|
||||||
|
value={d.name ?? ''}
|
||||||
|
required
|
||||||
|
pattern="[a-z0-9][a-z0-9\-]*"
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label>label<input name="label" value={d.label ?? ''} /></label>
|
||||||
|
<label
|
||||||
|
>kind<select name="kind" value={d.kind}>
|
||||||
|
{#each DEVICE_KINDS as k (k)}<option value={k}>{DEVICE_KIND_META[k].label}</option
|
||||||
|
>{/each}
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>status<select name="status" value={d.status}>
|
||||||
|
{#each DEVICE_STATUSES as s (s)}<option value={s}>{s}</option>{/each}
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<label>brand<input name="brand" value={d.brand ?? ''} /></label>
|
||||||
|
<label>model<input name="model" value={d.model ?? ''} /></label>
|
||||||
|
<label class="wide"
|
||||||
|
>other hostnames (comma separated)<input
|
||||||
|
name="hostnames"
|
||||||
|
value={(d.hostnames ?? []).join(', ')}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>price (€)<input name="price" inputmode="decimal" value={euros(d.priceCents)} /></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>per month (€)<input
|
||||||
|
name="monthly"
|
||||||
|
inputmode="decimal"
|
||||||
|
value={euros(d.monthlyCents)}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>budget (€)<input name="budget" inputmode="decimal" value={euros(d.budgetCents)} /></label
|
||||||
|
>
|
||||||
|
<label>shop<input name="shop" value={d.shop ?? ''} /></label>
|
||||||
|
<label>since<input name="purchaseDate" type="date" value={d.purchaseDate ?? ''} /></label>
|
||||||
|
<label
|
||||||
|
>sort order<input
|
||||||
|
name="sortOrder"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={d.sortOrder ?? 100}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label class="wide">link<input name="url" type="url" value={d.url ?? ''} /></label>
|
||||||
|
<label class="wide"
|
||||||
|
>specs (one "key: value" per line)<textarea name="specs" rows="4"
|
||||||
|
>{specsToText(d.specs ?? {})}</textarea
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
<label class="wide"
|
||||||
|
>notes (public)<textarea name="notes" rows="3">{d.notes ?? ''}</textarea></label
|
||||||
|
>
|
||||||
|
{#if problem}<p class="wide text-sm text-red-500" role="alert">{problem}</p>{/if}
|
||||||
|
<div class="buttons wide">
|
||||||
|
<button type="button" class="action" onclick={close}>Cancel</button>
|
||||||
|
<button type="submit" class="action primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{#if d.id}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteDevice"
|
||||||
|
use:enhance={submit(close)}
|
||||||
|
onsubmit={confirmDelete(`${d.name} and all its parts`)}
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={d.id} />
|
||||||
|
<button type="submit" class="text-sm text-red-500 hover:underline"
|
||||||
|
>Delete device and its parts</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editGame}
|
||||||
|
{@const g = editGame}
|
||||||
|
<Modal
|
||||||
|
title={g.id ? `Edit ${g.game}` : 'Add a game'}
|
||||||
|
onclose={() => {
|
||||||
|
editGame = null;
|
||||||
|
problem = '';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#snippet children(close)}
|
||||||
|
<form method="POST" action="?/saveGame" use:enhance={submit(close)} class="form">
|
||||||
|
<input type="hidden" name="id" value={g.id ?? ''} />
|
||||||
|
<label class="wide">game<input name="game" value={g.game ?? ''} required /></label>
|
||||||
|
<label>cm/360<input name="cmPer360" inputmode="decimal" value={g.cmPer360 ?? ''} /></label>
|
||||||
|
<label
|
||||||
|
>yaw (°/count at sens 1)<input
|
||||||
|
name="yaw"
|
||||||
|
inputmode="decimal"
|
||||||
|
value={g.yaw ?? ''}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>in-game sens now<input
|
||||||
|
name="inGameSens"
|
||||||
|
inputmode="decimal"
|
||||||
|
value={g.inGameSens ?? ''}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>set with mouse<select name="mouseId" value={g.mouseId ?? ''}>
|
||||||
|
<option value="">–</option>
|
||||||
|
{#each mice as m (m.id)}<option value={m.id}>{m.brand} {m.model}</option>{/each}
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<label>FOV<input name="fov" value={g.fov ?? ''} /></label>
|
||||||
|
<label
|
||||||
|
>sort order<input
|
||||||
|
name="sortOrder"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={g.sortOrder ?? 100}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label class="wide"
|
||||||
|
>notes (only you)<textarea name="notes" rows="2">{g.notes ?? ''}</textarea></label
|
||||||
|
>
|
||||||
|
{#if problem}<p class="wide text-sm text-red-500" role="alert">{problem}</p>{/if}
|
||||||
|
<div class="buttons wide">
|
||||||
|
<button type="button" class="action" onclick={close}>Cancel</button>
|
||||||
|
<button type="submit" class="action primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{#if g.id}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteGame"
|
||||||
|
use:enhance={submit(close)}
|
||||||
|
onsubmit={confirmDelete(g.game ?? 'this game')}
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={g.id} />
|
||||||
|
<button type="submit" class="text-sm text-red-500 hover:underline">Delete game</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.stats {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stats > div {
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
background: var(--site-surface);
|
||||||
|
}
|
||||||
|
.stats dt {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.stats dd {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.devices {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
.device-tile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background: var(--site-surface);
|
||||||
|
transition:
|
||||||
|
transform 0.15s ease,
|
||||||
|
background 0.15s ease,
|
||||||
|
border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.device-tile:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
background: var(--site-hover);
|
||||||
|
border-color: color-mix(in srgb, var(--site-text) 35%, var(--site-border));
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
margin-left: auto;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 0.45rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-bottom: 3.5rem;
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
}
|
||||||
|
.section :global(code) {
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
.parts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.spec {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.spec span {
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
}
|
||||||
|
.action:hover {
|
||||||
|
background: var(--site-hover);
|
||||||
|
border-color: color-mix(in srgb, var(--site-text) 35%, var(--site-border));
|
||||||
|
}
|
||||||
|
.action.primary {
|
||||||
|
background: var(--site-text);
|
||||||
|
color: var(--site-bg);
|
||||||
|
border-color: var(--site-text);
|
||||||
|
}
|
||||||
|
.action.primary:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.budget {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
max-width: 36rem;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--site-hover);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bar span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, #22d3ee, #7c3aed, #db2777);
|
||||||
|
}
|
||||||
|
.mice {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.mouse {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background: var(--site-surface);
|
||||||
|
}
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-bottom: 1px solid var(--site-border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border-bottom: 1px solid var(--site-border);
|
||||||
|
}
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
tbody tr:hover {
|
||||||
|
background: var(--site-hover);
|
||||||
|
}
|
||||||
|
.retired summary {
|
||||||
|
list-style: none;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.retired summary:hover {
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
.form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
.form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.form .wide,
|
||||||
|
.form .check,
|
||||||
|
.form .buttons {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.form .check {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
.form input:not([type='checkbox']),
|
||||||
|
.form select,
|
||||||
|
.form textarea {
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--site-bg);
|
||||||
|
color: var(--site-text);
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.form textarea {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.form .buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user