pc setup v2: one-screen layout with a time slider, photo grid and lightbox
All checks were successful
Deploy production / deploy (push) Successful in 1m7s

- hardware: slots, sections, usage, install/removal dates, images and a hidden flag on
  components and devices; setup_photos table; setup_overview view gains the new columns
- data: hide the 2021 friends-PC build and the wrong 140mm / unknown fans (kept, not deleted);
  7x SL-INF + 3 Kraken stock fans; mice, audio, desk extras; MacBook Pro 16 M2 Pro 1 TB;
  dates for every swap so the slider can rebuild the setup on any day
- self-hosted product images in static/hardware (webp + thumbs)
- prices and order details are owner-only (in a popover)
This commit is contained in:
2026-10-25 16:15:51 +00:00
parent d414e7190e
commit eff5a24985
204 changed files with 32374 additions and 776 deletions

View File

@@ -0,0 +1,195 @@
CREATE TABLE "hardware"."setup_photos" (
"id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "hardware"."setup_photos_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"url" text NOT NULL,
"caption" text,
"taken_at" date,
"sort_order" integer DEFAULT 100 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "hardware"."components" ADD COLUMN "slug" text;--> statement-breakpoint
ALTER TABLE "hardware"."components" ADD COLUMN "section" text;--> statement-breakpoint
ALTER TABLE "hardware"."components" ADD COLUMN "slot" text;--> statement-breakpoint
ALTER TABLE "hardware"."components" ADD COLUMN "installed_at" date;--> statement-breakpoint
ALTER TABLE "hardware"."components" ADD COLUMN "removed_at" date;--> statement-breakpoint
ALTER TABLE "hardware"."components" ADD COLUMN "replaces_id" bigint;--> statement-breakpoint
ALTER TABLE "hardware"."components" ADD COLUMN "usage" text;--> statement-breakpoint
ALTER TABLE "hardware"."components" ADD COLUMN "image_urls" text[] DEFAULT '{}'::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "hardware"."components" ADD COLUMN "image_sources" text[] DEFAULT '{}'::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "hardware"."components" ADD COLUMN "hidden" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "hardware"."devices" ADD COLUMN "installed_at" date;--> statement-breakpoint
ALTER TABLE "hardware"."devices" ADD COLUMN "removed_at" date;--> statement-breakpoint
ALTER TABLE "hardware"."devices" ADD COLUMN "image_urls" text[] DEFAULT '{}'::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "hardware"."devices" ADD COLUMN "image_sources" text[] DEFAULT '{}'::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "hardware"."devices" ADD COLUMN "hidden" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_slug_key" UNIQUE("slug");--> statement-breakpoint
CREATE INDEX "components_timeline_idx" ON "hardware"."components" ("installed_at","removed_at");--> statement-breakpoint
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_replaces_id_components_id_fkey" FOREIGN KEY ("replaces_id") REFERENCES "hardware"."components"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_section_check" CHECK (section is null or section in ('pc', 'desk', 'audio', 'sim'));--> statement-breakpoint
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_usage_check" CHECK (usage is null or usage in ('current', 'stored', 'previous', 'work-issued', 'placeholder', 'planned', 'legacy', 'legacy-previous'));--> statement-breakpoint
ALTER TABLE "hardware"."components" ADD CONSTRAINT "components_dates_check" CHECK (removed_at is null or installed_at is null or removed_at >= installed_at);--> statement-breakpoint
ALTER TABLE "hardware"."components" DROP CONSTRAINT "components_category_check", 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', 'microphone', 'mousepad', 'webcam', 'chair', 'other'));--> statement-breakpoint
-- Hand-written: grants (same pattern as the other hardware tables).
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON hardware.setup_photos TO personal_app;
GRANT USAGE, SELECT ON SEQUENCE hardware.setup_photos_id_seq TO personal_app;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT SELECT ON hardware.setup_photos TO personal_ro;
END IF;
END $$;
--> statement-breakpoint
-- setup_overview: same columns plus the timeline fields (appended, so grants survive).
CREATE OR REPLACE 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,
c.section,
c.slot,
c.usage,
c.installed_at,
c.removed_at,
c.image_urls,
(c.hidden OR d.hidden) AS hidden
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','microphone','controller','mousepad','webcam','chair','accessory','other'], c.category),
c.order_date NULLS LAST, c.model;
--> statement-breakpoint
-- Hand-written data (Stan via Setup Guy, 2026-10-25): slots, slider dates, self-hosted images,
-- corrections. Nothing is deleted: wrong/foreign rows get hidden = true.
UPDATE hardware.components SET slug='case-o11dxl', section='pc', slot='case', installed_at='2021-10-04', removed_at=NULL, usage='current', image_urls=ARRAY['/hardware/case-o11dxl/1.webp','/hardware/case-o11dxl/2.webp','/hardware/case-o11dxl/3.webp','/hardware/case-o11dxl/4.webp']::text[], image_sources=ARRAY['https://lian-li.com/wp-content/uploads/2020/11/O11D-XL_white_front.jpg','https://lian-li.com/wp-content/uploads/2020/11/O11D-XL_white_side45.jpg','https://lian-li.com/wp-content/uploads/2020/11/O11D-XL_white_side.jpg','https://m.media-amazon.com/images/I/61C8fINNthL._AC_SL1000_.jpg']::text[], url=coalesce(url, 'https://lian-li.com/product/pc-o11d-rog/') WHERE id=24;
--> statement-breakpoint
UPDATE hardware.components SET slug='cpu-12900kf', section='pc', slot='cpu', installed_at='2021-12-23', removed_at=NULL, usage='current', image_urls=ARRAY['/hardware/cpu-12900kf/1.webp','/hardware/cpu-12900kf/2.webp']::text[], image_sources=ARRAY['https://m.media-amazon.com/images/I/51CUAct4KVL._AC_SL1000_.jpg','https://m.media-amazon.com/images/I/51TZr5s8OkL._AC_SL1057_.jpg']::text[], url=coalesce(url, 'https://www.intel.com/content/www/us/en/products/sku/134600/intel-core-i912900kf-processor-30m-cache-up-to-5-20-ghz/specifications.html') WHERE id=20;
--> statement-breakpoint
UPDATE hardware.components SET slug='mb-z690a', section='pc', slot='motherboard', installed_at='2021-12-23', removed_at=NULL, usage='current', image_urls=ARRAY['/hardware/mb-z690a/1.webp','/hardware/mb-z690a/2.webp','/hardware/mb-z690a/3.webp']::text[], image_sources=ARRAY['https://storage-asset.msi.com/global/picture/product/product_163705377693ef948ad0d360a8ab6da624f664819e.webp','https://storage-asset.msi.com/global/picture/product/product_1637053776ec2f34081a469c724488e25ac6017811.webp','https://storage-asset.msi.com/global/picture/product/product_163532337099b51ebc934878850ada1bb0d1ecd256.webp']::text[], url=coalesce(url, 'https://www.msi.com/Motherboard/PRO-Z690-A-WIFI-DDR4') WHERE id=19;
--> statement-breakpoint
UPDATE hardware.components SET slug='ram-vengeance', section='pc', slot='memory', installed_at='2021-12-10', removed_at=NULL, usage='current', image_urls=ARRAY['/hardware/ram-vengeance/1.webp','/hardware/ram-vengeance/2.webp','/hardware/ram-vengeance/3.webp','/hardware/ram-vengeance/4.webp']::text[], image_sources=ARRAY['https://res.cloudinary.com/corsair-pwa/image/upload/f_auto,q_auto,w_1600/products/Memory/CMW32GX4M4D3600C18/Gallery/Vengeance_RGB_Pro_01.png','https://res.cloudinary.com/corsair-pwa/image/upload/f_auto,q_auto,w_1600/products/Memory/CMW32GX4M4D3600C18/Gallery/Vengeance_RGB_Pro_02.png','https://res.cloudinary.com/corsair-pwa/image/upload/f_auto,q_auto,w_1600/products/Memory/CMW32GX4M4D3600C18/Gallery/Vengeance_RGB_Pro_03.png','https://res.cloudinary.com/corsair-pwa/image/upload/f_auto,q_auto,w_1600/products/Memory/CMW32GX4M4D3600C18/Gallery/Vengeance_RGB_Pro_04.png']::text[], url=coalesce(url, 'https://www.corsair.com/us/en/p/memory/cmw32gx4m4d3600c18/vengeance-rgb-pro-32gb-4x8gb-ddr4-dram-3600mhz-c18-memory-kit-black-cmw32gx4m4d3600c18') WHERE id=21;
--> statement-breakpoint
UPDATE hardware.components SET slug='cooler-z73', section='pc', slot='cooler', installed_at='2021-12-10', removed_at=NULL, usage='current', image_urls=ARRAY['/hardware/cooler-z73/1.webp','/hardware/cooler-z73/2.webp','/hardware/cooler-z73/3.webp','/hardware/cooler-z73/4.webp']::text[], image_sources=ARRAY['https://web.archive.org/web/2022id_/https://www.datocms-assets.com/34299/1631128727-kraken-z73-rgb-black-keyshot.png','https://web.archive.org/web/2022id_/https://www.datocms-assets.com/34299/1631128741-kraken-z73-rgb-black-system1.png','https://web.archive.org/web/2022id_/https://www.datocms-assets.com/34299/1631128772-kraken-z73-rgb-black-top.png','https://web.archive.org/web/2022id_/https://www.datocms-assets.com/34299/1632169832-kraken-z73-rgb-black-radiator.png']::text[], url=coalesce(url, 'https://web.archive.org/web/2022/https://nzxt.com/product/kraken-z73-rgb') WHERE id=23;
--> statement-breakpoint
UPDATE hardware.components SET slug='acc-nzxt-1700', section='pc', slot='cooler-mount', installed_at='2021-12-10', removed_at=NULL, usage='current', image_urls='{}'::text[], image_sources='{}'::text[], url=coalesce(url, NULL) WHERE id=22;
--> statement-breakpoint
UPDATE hardware.components SET slug='gpu-7900xt', section='pc', slot='gpu', installed_at='2022-12-22', removed_at=NULL, usage='current', image_urls=ARRAY['/hardware/gpu-7900xt/1.webp']::text[], image_sources=ARRAY['https://static.gigabyte.com/StaticFile/Image/Global/868436ba12e23fc0d98e26322a618a2c/ProductRemoveBg/32810']::text[], url=coalesce(url, 'https://www.gigabyte.com/Graphics-Card/GV-R79XT-20GC-B') WHERE id=15;
--> statement-breakpoint
UPDATE hardware.components SET slug='psu-rm850x', section='pc', slot='psu', installed_at='2022-12-24', removed_at=NULL, usage='current', image_urls=ARRAY['/hardware/psu-rm850x/1.webp','/hardware/psu-rm850x/2.webp','/hardware/psu-rm850x/3.webp']::text[], image_sources=ARRAY['https://res.cloudinary.com/corsair-pwa/image/upload/f_auto,q_auto,w_1600/products/Power-Supply-Units/base-rmx-2021-config/Gallery/RM850x_01.png','https://res.cloudinary.com/corsair-pwa/image/upload/f_auto,q_auto,w_1600/products/Power-Supply-Units/base-rmx-2021-config/Gallery/RM850x_02.png','https://res.cloudinary.com/corsair-pwa/image/upload/f_auto,q_auto,w_1600/products/Power-Supply-Units/base-rmx-2021-config/Gallery/RM850x_03.png']::text[], url=coalesce(url, 'https://www.corsair.com/us/en/p/psu/cp-9020200-na/rmx-series-rm850x-850-watt-80-plus-gold-certified-fully-modular-psu-cp-9020200-na') WHERE id=14;
--> statement-breakpoint
UPDATE hardware.components SET slug='ssd-980', section='pc', slot='storage', installed_at='2022-11-13', removed_at=NULL, usage='current', image_urls=ARRAY['/hardware/ssd-980/1.webp','/hardware/ssd-980/2.webp','/hardware/ssd-980/3.webp']::text[], image_sources=ARRAY['https://m.media-amazon.com/images/I/419dpKuUuiL._AC_SL1109_.jpg','https://m.media-amazon.com/images/I/510vd1oMDZL._AC_SL1200_.jpg','https://m.media-amazon.com/images/I/61fdIjQtgEL._AC_SL1200_.jpg']::text[], url=coalesce(url, 'https://semiconductor.samsung.com/consumer-storage/internal-ssd/980/') WHERE id=16;
--> statement-breakpoint
UPDATE hardware.components SET slug='fans-slinf', section='pc', slot='fans-case', installed_at='2026-03-03', removed_at=NULL, usage='current', image_urls=ARRAY['/hardware/fans-slinf/1.webp','/hardware/fans-slinf/2.webp','/hardware/fans-slinf/3.webp','/hardware/fans-slinf/4.webp']::text[], image_sources=ARRAY['https://m.media-amazon.com/images/I/619CatWyjBL._SL1500_.jpg','https://m.media-amazon.com/images/I/61lGLXNWqoL._SL1500_.jpg','https://m.media-amazon.com/images/I/61ZpoUb+SdL._SL1500_.jpg','https://lian-li.com/wp-content/uploads/2025/05/sl2-inf-w-pg-02.jpg']::text[], url=coalesce(url, 'https://lian-li.com/product/uni-fan-sl-inf-wireless/') WHERE id=13;
--> statement-breakpoint
UPDATE hardware.components SET slug='light-neon-m5', section='pc', slot='lighting', installed_at='2026-03-03', removed_at=NULL, usage='current', image_urls=ARRAY['/hardware/light-neon-m5/1.webp','/hardware/light-neon-m5/2.webp','/hardware/light-neon-m5/3.webp']::text[], image_sources=ARRAY['https://m.media-amazon.com/images/I/51N8rVgWibL._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/81PCZlv0PgL._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/71QhOCduLWL._AC_SL1500_.jpg']::text[], url=coalesce(url, 'https://phanteks.com/product/neon-digital-rgb-led-m5/') WHERE id=10;
--> statement-breakpoint
UPDATE hardware.components SET slug='acc-bcf', section='pc', slot='contact-frame', installed_at=NULL, removed_at=NULL, usage='current', image_urls=ARRAY['/hardware/acc-bcf/1.webp','/hardware/acc-bcf/2.webp','/hardware/acc-bcf/3.webp']::text[], image_sources=ARRAY['https://www.thermalright.com/wp-content/uploads/2022/06/1-9.jpg','https://www.thermalright.com/wp-content/uploads/2022/06/2-7.jpg','https://www.thermalright.com/wp-content/uploads/2022/06/3-8.jpg']::text[], url=coalesce(url, 'https://www.thermalright.com/product/lga1700-bcf-black/') WHERE id=76;
--> statement-breakpoint
UPDATE hardware.components SET slug='acc-kryonaut', section='pc', slot='thermal-paste', installed_at='2026-03-03', removed_at=NULL, usage='current', image_urls=ARRAY['/hardware/acc-kryonaut/1.webp','/hardware/acc-kryonaut/2.webp']::text[], image_sources=ARRAY['https://m.media-amazon.com/images/I/71lDzoaNkEL._SL1500_.jpg','https://m.media-amazon.com/images/I/61iYwVQJD+L._SL1500_.jpg']::text[], url=coalesce(url, 'https://www.thermal-grizzly.com/en/kryonaut/s-tg-k-001-rs') WHERE id=9;
--> statement-breakpoint
UPDATE hardware.components SET slug='mon-g9', section='desk', slot='monitor', installed_at='2022-04-07', removed_at=NULL, usage='current', image_urls=ARRAY['/hardware/mon-g9/1.webp','/hardware/mon-g9/2.webp']::text[], image_sources=ARRAY['https://m.media-amazon.com/images/I/711HTxM8v2L._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/61231crcJsL._AC_SL1398_.jpg']::text[], url=coalesce(url, 'https://www.amazon.de/dp/B088HH6LW5') WHERE id=18;
--> statement-breakpoint
UPDATE hardware.components SET slug='arm-ergotron', section='desk', slot='monitor-arm', installed_at='2022-06-20', removed_at=NULL, usage='current', image_urls=ARRAY['/hardware/arm-ergotron/1.webp','/hardware/arm-ergotron/2.webp']::text[], image_sources=ARRAY['https://m.media-amazon.com/images/I/61zfWuUa6IL._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/71rk9JFuczL._AC_SL1500_.jpg']::text[], url=coalesce(url, 'https://www.ergotron.com/en-us/products/product-details/98-540-216') WHERE id=7;
--> statement-breakpoint
UPDATE hardware.components SET slug='kb-alloy', section='desk', slot='keyboard', installed_at='2021-05-06', removed_at=NULL, usage='current', image_urls=ARRAY['/hardware/kb-alloy/1.webp','/hardware/kb-alloy/2.webp','/hardware/kb-alloy/3.webp']::text[], image_sources=ARRAY['https://m.media-amazon.com/images/I/61MjTWnX-+L._AC_SL1000_.jpg','https://m.media-amazon.com/images/I/71rk1KhPNKL._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/61eOYDRynLL._AC_SL1500_.jpg']::text[], url=coalesce(url, 'https://row.hyperx.com/products/hyperx-alloy-origins-mechanical-gaming-keyboard') WHERE id=4;
--> statement-breakpoint
UPDATE hardware.components SET slug='kb-pudding', section='desk', slot='keycaps', installed_at='2021-05-15', removed_at=NULL, usage='current', image_urls=ARRAY['/hardware/kb-pudding/1.webp','/hardware/kb-pudding/2.webp','/hardware/kb-pudding/3.webp']::text[], image_sources=ARRAY['https://m.media-amazon.com/images/I/61CYPEU-E-L._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/71TuqudaBRL._AC_SL1000_.jpg','https://m.media-amazon.com/images/I/81V0SI4NXKL._AC_SL1500_.jpg']::text[], url=coalesce(url, 'https://row.hyperx.com/products/hyperx-pudding-keycaps') WHERE id=3;
--> statement-breakpoint
UPDATE hardware.components SET slug='kb-uwu', section='desk', slot='keypad', installed_at='2023-10-23', removed_at=NULL, usage='current', image_urls=ARRAY['/hardware/kb-uwu/1.webp','/hardware/kb-uwu/2.webp','/hardware/kb-uwu/3.webp','/hardware/kb-uwu/4.webp']::text[], image_sources=ARRAY['https://wooting-website.ams3.cdn.digitaloceanspaces.com/products/keyboards/UwU/configurator-images/uwu-stealth.jpg','https://wooting-website.ams3.cdn.digitaloceanspaces.com/products/cases/uwu-brass-case/UwU_Brass_case.webp','https://wooting-website.ams3.cdn.digitaloceanspaces.com/products/cases/uwu-brass-case/Brass_case_top_view.webp','https://wooting-website.ams3.cdn.digitaloceanspaces.com/products/keyboards/UwU/uwu-multiple-front.webp']::text[], url=coalesce(url, 'https://wooting.io/uwu') WHERE id=2;
--> statement-breakpoint
UPDATE hardware.components SET slug='mouse-mxm3', section='desk', slot='mouse-work', installed_at='2021-12-21', removed_at=NULL, usage='current', image_urls=ARRAY['/hardware/mouse-mxm3/1.webp','/hardware/mouse-mxm3/2.webp','/hardware/mouse-mxm3/3.webp']::text[], image_sources=ARRAY['https://m.media-amazon.com/images/I/61cGVHDEj5S._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/61Rb7EPlvpL._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/61BQr1EnpKL._AC_SL1500_.jpg']::text[], url=coalesce(url, 'https://www.logitech.com/en-us/products/mice/mx-master-3.910-005620.html') WHERE id=8;
--> statement-breakpoint
UPDATE hardware.components SET slug='arm-ak35', section='audio', slot='mic-arm', installed_at='2025-10-23', removed_at=NULL, usage='current', image_urls=ARRAY['/hardware/arm-ak35/1.webp','/hardware/arm-ak35/2.webp','/hardware/arm-ak35/3.webp']::text[], image_sources=ARRAY['https://m.media-amazon.com/images/I/61I8KtMpsYL._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/71roWtsJqCL._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/71A2y+-Z-4L._AC_SL1500_.jpg']::text[], url=coalesce(url, 'https://www.amazon.de/dp/B01MZ99Y67') WHERE id=1;
--> statement-breakpoint
UPDATE hardware.components SET slug='sim-t300', section='sim', slot='wheel', installed_at='2022-05-11', removed_at=NULL, usage='stored', image_urls=ARRAY['/hardware/sim-t300/1.webp','/hardware/sim-t300/2.webp','/hardware/sim-t300/3.webp']::text[], image_sources=ARRAY['https://m.media-amazon.com/images/I/71vf7FTHtAL._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/81hzeAcdA7L._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/71rBrUnMPSL._AC_SL1500_.jpg']::text[], url=coalesce(url, 'https://www.thrustmaster.com/en-us/products/t300-rs-gt-edition/') WHERE id=6;
--> statement-breakpoint
UPDATE hardware.components SET slug='sim-th8a', section='sim', slot='shifter', installed_at='2022-05-11', removed_at=NULL, usage='stored', image_urls=ARRAY['/hardware/sim-th8a/1.webp','/hardware/sim-th8a/2.webp','/hardware/sim-th8a/3.webp']::text[], image_sources=ARRAY['https://www.thrustmaster.com/wp-content/uploads/2021/09/P_List-TH8A-SHIFTER-add-on-1.png','https://www.thrustmaster.com/wp-content/uploads/2021/09/UTH_TH8A-add-on-SHIFTER-1.jpg','https://m.media-amazon.com/images/I/71QuLkYRE3L._SL1500_.jpg']::text[], url=coalesce(url, 'https://www.thrustmaster.com/en-us/products/th8a/') WHERE id=5;
--> statement-breakpoint
UPDATE hardware.components SET slug='fans-slinf-3pack-b', section='pc', slot='fans-case', installed_at='2026-03-03', usage='current' WHERE id=12;
--> statement-breakpoint
UPDATE hardware.components SET slug='fans-slinf-1pack', section='pc', slot='fans-case', installed_at='2026-03-03', usage='current' WHERE id=11;
--> statement-breakpoint
UPDATE hardware.components SET verified=true, notes='Main keyboard (Stan, 2026-10-25). Paid iDEAL. A bol.com order for the same keyboard (2021-05-04) was cancelled and is not counted.' WHERE id=4;
--> statement-breakpoint
UPDATE hardware.components SET verified=true WHERE id=3;
--> statement-breakpoint
UPDATE hardware.components SET verified=true, model='MX Master 3 Graphite (work mouse)', notes='Work mouse (Stan, 2026-10-25); the G Pro Wireless is the main mouse.' WHERE id=8;
--> statement-breakpoint
UPDATE hardware.components SET status='spare', verified=true, notes=coalesce(notes,'') || ' Stan 2026-10-25: owned but stored, not set up.' WHERE id IN (5,6);
--> statement-breakpoint
UPDATE hardware.components SET notes='Holds the Blue Snowball (Stan, 2026-10-25).' WHERE id=1;
--> statement-breakpoint
UPDATE hardware.components SET notes='Order ME101221000534 total EUR 493.80 (free shipping). Paid iDEAL (Knab). Black (RL-KRZ73-R1). Came with 3x NZXT Aer RGB 2 120mm stock radiator fans, still on the radiator (Stan 2026-10-25: no 140 mm Aer fans). As of 2026-10 the cooler is ~5 years old and the CPU idles/loads around 80-90 C (per Stan).' WHERE id=23;
--> statement-breakpoint
UPDATE hardware.components SET notes='Case fans. 7 SL-INF in total (3+3+1); exactly 7 Lian Li fans (Stan, 2026-10-25). Order total EUR 265.50, free shipping, paid by credit card, delivered 2026-03-03.' WHERE id=13;
--> statement-breakpoint
UPDATE hardware.components SET hidden=true, status='retired', notes=coalesce(notes,'') || ' HIDDEN 2026-10-25: Stan says there are no 140 mm Aer fans; the radiator has the Kraken''s 3 stock Aer RGB 2 120mm. This order was probably the Megekko return R221001735. Row kept for history.' WHERE id=17;
--> statement-breakpoint
UPDATE hardware.components SET hidden=true, status='retired', notes=coalesce(notes,'') || ' HIDDEN 2026-10-25: wrong placeholder. Stan has exactly 7 Lian Li fans (the SL-INF packs). Row kept for history.' WHERE id=75;
--> statement-breakpoint
UPDATE hardware.devices SET hidden=true, label='Friend''s PC (2021)', notes='Budget build Stan bought and built for a friend/client at Megekko on 2021-01-31 (Ryzen 5 1600 / GTX 1650 / Q300L). Never Stan''s own PC (Stan, 2026-10-25), so it is hidden on /trackers/pc. Rows kept for history.' WHERE id=2;
--> statement-breakpoint
UPDATE hardware.components SET hidden=true WHERE device_id=2;
--> statement-breakpoint
UPDATE hardware.devices SET notes='Stan''s main PC: Lian Li O11 Dynamic XL ROG White, i9-12900KF, RX 7900 XT, 7 Lian Li UNI FAN SL-INF 120 Wireless around the case + the Kraken Z73''s 3 stock Aer RGB 2 120mm 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 id=1;
--> statement-breakpoint
INSERT INTO hardware.components (device_id, category, brand, model, quantity, specs, status, verified, url, notes, source, slug, section, slot, installed_at, removed_at, usage, image_urls, image_sources) VALUES (1, 'fan', 'NZXT', 'Aer RGB 2 120mm Black (Kraken Z73 stock radiator fans)', 3, '{"rgb": true, "color": "black", "size_mm": 120, "mounted_on": "Kraken Z73 radiator", "included_with": "Kraken Z73 RGB"}'::jsonb, 'in_use', true, 'https://support.nzxt.com/hc/en-us/articles/37914228751899-Kraken-Z3-Series-Specs', 'Came in the box with the Kraken Z73 RGB (black); image is the official radiator+fans render.', 'user_statement', 'fans-kraken-stock', 'pc', 'fans-radiator', '2021-12-10', NULL, 'current', ARRAY['/hardware/fans-kraken-stock/1.webp']::text[], ARRAY['https://web.archive.org/web/2022id_/https://www.datocms-assets.com/34299/1632169832-kraken-z73-rgb-black-radiator.png']::text[]) ON CONFLICT (slug) DO NOTHING;
--> statement-breakpoint
INSERT INTO hardware.components (device_id, category, brand, model, quantity, specs, status, verified, url, notes, source, slug, section, slot, installed_at, removed_at, usage, image_urls, image_sources) VALUES (1, 'monitor', 'Acer', '1080p 144Hz monitor (model TBD)', 1, '{"refresh_hz": 144, "resolution": "1920x1080"}'::jsonb, 'retired', true, NULL, 'Owned long ago, dates unknown; replaced by the G9 at some point. Exact model unknown.', 'user_statement', 'mon-acer', 'desk', 'monitor', NULL, NULL, 'legacy-previous', '{}'::text[], '{}'::text[]) ON CONFLICT (slug) DO NOTHING;
--> statement-breakpoint
INSERT INTO hardware.components (device_id, category, brand, model, quantity, specs, status, verified, url, notes, source, slug, section, slot, installed_at, removed_at, usage, image_urls, image_sources) VALUES (1, 'mouse', 'Mad Catz', 'R.A.T. 9', 1, '{"type": "wireless gaming mouse"}'::jsonb, 'retired', true, 'https://www.newegg.com/global/uk-en/mad-catz-mcb437090001-02-1-r-a-t-9-usb-rf-wireless/p/N82E16826618018', 'Owned long ago, dates unknown. amazon.co.uk listing B00A72ZS4Y; colour Stan owned unknown.', 'user_statement', 'mouse-rat9', 'desk', 'mouse-gaming', NULL, NULL, 'legacy-previous', ARRAY['/hardware/mouse-rat9/1.webp','/hardware/mouse-rat9/2.webp','/hardware/mouse-rat9/3.webp']::text[], ARRAY['https://m.media-amazon.com/images/I/717Yp6X2wuL._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/71K1PxsYoFL._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/61qGrRhhxbL._AC_SL1500_.jpg']::text[]) ON CONFLICT (slug) DO NOTHING;
--> statement-breakpoint
INSERT INTO hardware.components (device_id, category, brand, model, quantity, specs, status, verified, url, notes, source, slug, section, slot, installed_at, removed_at, usage, image_urls, image_sources) VALUES (1, 'mouse', 'Logitech', 'G Pro HERO (wired)', 1, '{"type": "wired gaming mouse", "sensor": "HERO"}'::jsonb, 'retired', true, 'https://www.logitechg.com/en-au/products/gaming-mice/pro-hero.910-005442.html', 'Dates unknown.', 'user_statement', 'mouse-gpro-hero', 'desk', 'mouse-gaming', NULL, NULL, 'previous', ARRAY['/hardware/mouse-gpro-hero/1.webp','/hardware/mouse-gpro-hero/2.webp','/hardware/mouse-gpro-hero/3.webp']::text[], ARRAY['https://m.media-amazon.com/images/I/51l21Sg2joL._AC_SL1384_.jpg','https://m.media-amazon.com/images/I/51kKoLc0RyL._AC_SL1384_.jpg','https://m.media-amazon.com/images/I/51i2fRVG0JL._AC_SL1384_.jpg']::text[]) ON CONFLICT (slug) DO NOTHING;
--> statement-breakpoint
INSERT INTO hardware.components (device_id, category, brand, model, quantity, specs, status, verified, url, notes, source, slug, section, slot, installed_at, removed_at, usage, image_urls, image_sources) VALUES (1, 'mouse', 'Logitech', 'G Pro Wireless (main mouse)', 1, '{"type": "wireless gaming mouse", "sensor": "HERO 25K"}'::jsonb, 'in_use', true, 'https://www.logitechg.com/en-us/products/gaming-mice/pro-wireless-mouse.910-005270.html', 'Main gaming mouse (Stan, 2026-10-25). Sensitivity/DPI TBD; date unknown.', 'user_statement', 'mouse-gpro-wl', 'desk', 'mouse-gaming', NULL, NULL, 'current', ARRAY['/hardware/mouse-gpro-wl/1.webp','/hardware/mouse-gpro-wl/2.webp','/hardware/mouse-gpro-wl/3.webp']::text[], ARRAY['https://m.media-amazon.com/images/I/51ySu55JzAL._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/71xHNVGJd6L._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/715znQLTHKL._AC_SL1500_.jpg']::text[]) ON CONFLICT (slug) DO NOTHING;
--> statement-breakpoint
INSERT INTO hardware.components (device_id, category, brand, model, quantity, specs, status, verified, url, notes, source, slug, section, slot, installed_at, removed_at, usage, image_urls, image_sources) VALUES (1, 'mousepad', 'Razer', 'Gigantus V2 Large', 1, '{"size_mm": "450x400x3", "surface": "soft cloth"}'::jsonb, 'in_use', true, 'https://www.razer.com/gaming-mouse-mats/razer-gigantus-v2', 'Install date unknown but recent (2025-2026).', 'user_statement', 'pad-razer', 'desk', 'mousepad', NULL, NULL, 'current', ARRAY['/hardware/pad-razer/1.webp','/hardware/pad-razer/2.webp','/hardware/pad-razer/3.webp']::text[], ARRAY['https://m.media-amazon.com/images/I/81hx6hPSijL._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/81rnOSHnZlL._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/71RFdpWxIpL._AC_SL1500_.jpg']::text[]) ON CONFLICT (slug) DO NOTHING;
--> statement-breakpoint
INSERT INTO hardware.components (device_id, category, brand, model, quantity, specs, status, verified, url, notes, source, slug, section, slot, installed_at, removed_at, usage, image_urls, image_sources) VALUES (1, 'webcam', 'Logitech', '1080p webcam (model TBD)', 1, '{"resolution": "1080p"}'::jsonb, 'in_use', true, NULL, 'Owned long ago, date unknown; model TBD.', 'user_statement', 'cam-logi', 'desk', 'webcam', NULL, NULL, 'legacy', '{}'::text[], '{}'::text[]) ON CONFLICT (slug) DO NOTHING;
--> statement-breakpoint
INSERT INTO hardware.components (device_id, category, brand, model, quantity, specs, status, verified, url, notes, source, slug, section, slot, installed_at, removed_at, usage, image_urls, image_sources) VALUES (1, 'chair', 'IKEA', 'Chair (model TBD)', 1, '{}'::jsonb, 'in_use', true, NULL, 'Owned long ago, date unknown; model TBD.', 'user_statement', 'chair-ikea', 'desk', 'chair', NULL, NULL, 'legacy', '{}'::text[], '{}'::text[]) ON CONFLICT (slug) DO NOTHING;
--> statement-breakpoint
INSERT INTO hardware.components (device_id, category, brand, model, quantity, specs, status, verified, url, notes, source, slug, section, slot, installed_at, removed_at, usage, image_urls, image_sources) VALUES (1, 'microphone', 'Blue', 'Snowball USB microphone', 1, '{"connection": "USB"}'::jsonb, 'in_use', true, 'https://www.logitechg.com/en-us/products/streaming-gear/snowball-usb-microphone.988-000070.html', 'Owned long ago, date unknown; images show the white version (Stan''s colour unknown).', 'user_statement', 'mic-snowball', 'audio', 'mic', NULL, NULL, 'legacy', ARRAY['/hardware/mic-snowball/1.webp','/hardware/mic-snowball/2.webp','/hardware/mic-snowball/3.webp']::text[], ARRAY['https://m.media-amazon.com/images/I/51DbHowQxmL._AC_SL1134_.jpg','https://m.media-amazon.com/images/I/51Uw3IiBR7L._AC_SL1434_.jpg','https://m.media-amazon.com/images/I/71Wkv6pufkL._AC_SL1500_.jpg']::text[]) ON CONFLICT (slug) DO NOTHING;
--> statement-breakpoint
INSERT INTO hardware.components (device_id, category, brand, model, quantity, specs, status, verified, url, notes, source, slug, section, slot, installed_at, removed_at, usage, image_urls, image_sources) VALUES (1, 'headset', 'Sony', 'WH-1000XM3', 1, '{"type": "wireless ANC headphones"}'::jsonb, 'in_use', true, 'https://www.sony.com/electronics/support/wireless-headphones-bluetooth-headphones/wh-1000xm3', 'Owned long ago, date unknown.', 'user_statement', 'hp-xm3', 'audio', 'headphones', NULL, NULL, 'legacy', ARRAY['/hardware/hp-xm3/1.webp','/hardware/hp-xm3/2.webp','/hardware/hp-xm3/3.webp']::text[], ARRAY['https://m.media-amazon.com/images/I/51mhf2-LjLL._AC_SL1200_.jpg','https://m.media-amazon.com/images/I/51DqcnxBoNL._AC_SL1200_.jpg','https://m.media-amazon.com/images/I/51v5xAsPMSL._AC_SL1200_.jpg']::text[]) ON CONFLICT (slug) DO NOTHING;
--> statement-breakpoint
INSERT INTO hardware.components (device_id, category, brand, model, quantity, specs, status, verified, url, notes, source, slug, section, slot, installed_at, removed_at, usage, image_urls, image_sources) VALUES (1, 'headset', 'Dell', 'Premier Wireless ANC Headset WL7024 (work-issued)', 1, '{"type": "wireless ANC headset"}'::jsonb, 'in_use', true, 'https://www.dell.com/en-us/shop/dell-premier-wireless-anc-headset-wl7024/apd/520-bbgn/audio', 'Work-issued (Cargill). Date unknown.', 'user_statement', 'hp-wl7024', 'audio', 'headset-work', NULL, NULL, 'work-issued', ARRAY['/hardware/hp-wl7024/1.webp','/hardware/hp-wl7024/2.webp','/hardware/hp-wl7024/3.webp']::text[], ARRAY['https://m.media-amazon.com/images/I/713XOzjmXVL._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/61oBhKYbT7L._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/719VWqm3Q5L._AC_SL1500_.jpg']::text[]) ON CONFLICT (slug) DO NOTHING;
--> statement-breakpoint
INSERT INTO hardware.components (device_id, category, brand, model, quantity, specs, status, verified, url, notes, source, slug, section, slot, installed_at, removed_at, usage, image_urls, image_sources) VALUES (1, 'headset', NULL, 'IEMs (model TBD)', 1, '{"type": "in-ear monitors"}'::jsonb, 'in_use', true, NULL, 'Owned long ago, date unknown; model TBD, no images yet.', 'user_statement', 'iem-tbd', 'audio', 'iems', NULL, NULL, 'legacy', '{}'::text[], '{}'::text[]) ON CONFLICT (slug) DO NOTHING;
--> statement-breakpoint
UPDATE hardware.devices SET installed_at='2026-10-24', removed_at=NULL, image_urls=ARRAY['/hardware/dev-ovh/1.webp']::text[], image_sources=ARRAY['https://www.ovhcloud.com/sites/default/files/styles/large_screens_1x/public/2024-11/rise-s_1200x800_transparent.png']::text[], url=coalesce(url, 'https://www.ovhcloud.com/en/bare-metal/rise/rise-s/') WHERE id=6;
--> statement-breakpoint
UPDATE hardware.devices SET installed_at='2023-07-17', removed_at=NULL, image_urls=ARRAY['/hardware/dev-mbp/1.webp','/hardware/dev-mbp/2.webp','/hardware/dev-mbp/3.webp']::text[], image_sources=ARRAY['https://cdsassets.apple.com/live/SZLF0YNV/images/sp/111838_macbook-pro-2023-16in.png','https://m.media-amazon.com/images/I/61qTygx9BNL._AC_SL1500_.jpg','https://m.media-amazon.com/images/I/51zAOYaYsJL._AC_SL1500_.jpg']::text[], url=coalesce(url, 'https://support.apple.com/en-us/111838') WHERE id=8;
--> statement-breakpoint
UPDATE hardware.devices SET installed_at='2026-10-19', removed_at=NULL, image_urls=ARRAY['/hardware/dev-pixel/1.webp','/hardware/dev-pixel/2.webp','/hardware/dev-pixel/3.webp']::text[], image_sources=ARRAY['https://lh3.googleusercontent.com/2njFzqo44n0xZAlrpvPuB0okDCafUhS9mUZk6E8Z0BHuJS6dvZ3TblSnhiWqxAALGtHm6UP3VaE65zQM4G8O9dPvqd_lKrN6Vx0=s1200','https://lh3.googleusercontent.com/pOHHSl1kjZ8XYeB53K6Kit2VVrxPuCavLKiRqcUdorde1GELNCHYmqlYyOWA_ZDjvbBM2fhAWg6fpc9C5NwTI8KkvkmoNnZB4fU=s1200','https://lh3.googleusercontent.com/5G3FCI9a3fiM_Lh6GHL6K2r4I3kcT4PSJmFNw0SBAbS8q1eZkihRK6zJm8R5nWsvKB_J2Pq_wt5UL5KV2LYtBg_8DVGYjotuhow=s1200']::text[], url=coalesce(url, 'https://store.google.com/product/pixel_11_pro') WHERE id=9;
--> statement-breakpoint
UPDATE hardware.devices SET installed_at=NULL, removed_at='2026-10-19', image_urls=ARRAY['/hardware/dev-iphone/1.webp','/hardware/dev-iphone/2.webp']::text[], image_sources=ARRAY['https://cdsassets.apple.com/live/SZLF0YNV/images/sp/111846_sp875-sp876-iphone14-pro-promax.png','https://m.media-amazon.com/images/I/51uD1lmrV8L._AC_SL1000_.jpg']::text[], url=coalesce(url, 'https://support.apple.com/en-us/111846') WHERE id=10;
--> statement-breakpoint
UPDATE hardware.devices SET installed_at=NULL, removed_at=NULL, image_urls=ARRAY['/hardware/dev-ipad/1.webp','/hardware/dev-ipad/2.webp']::text[], image_sources=ARRAY['https://cdsassets.apple.com/live/7WUAS350/images/tech-specs/ipad-air-11-inch-m2.png','https://cdsassets.apple.com/live/7WUAS350/images/tech-specs/ipad-air-13-inch-m2-portimage-1.png']::text[], url=coalesce(url, 'https://support.apple.com/en-us/119893') WHERE id=11;
--> statement-breakpoint
UPDATE hardware.devices SET installed_at=NULL, removed_at=NULL, image_urls='{}'::text[], image_sources='{}'::text[], url=coalesce(url, NULL) WHERE id=12;
--> statement-breakpoint
UPDATE hardware.devices SET installed_at=NULL, removed_at='2026-10-24', image_urls='{}'::text[], image_sources='{}'::text[], url=coalesce(url, NULL) WHERE id=7;
--> statement-breakpoint
UPDATE hardware.devices SET model='MacBook Pro 16" (M2 Pro, 1 TB)', specs='{"chip": "Apple M2 Pro", "screen_in": 16, "storage": "1 TB", "year": 2023}'::jsonb, purchase_date=coalesce(purchase_date, '2023-07-17'), notes='16-inch MacBook Pro, M2 Pro, 1 TB (Stan, 2026-10-25). An M5 Max MacBook Pro is planned.' WHERE id=8;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,435 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import Modal from './Modal.svelte';
import {
CATEGORIES,
DEVICE_KINDS,
DEVICE_KIND_META,
DEVICE_STATUSES,
PART_STATUSES,
SECTIONS,
SLOT_META,
USAGES,
categoryLabel,
specsToText,
statusLabel,
type Device,
type Game,
type Part
} from './shared';
// Owner-only edit dialogs for parts, devices and games (the page sets one of them to open it).
let {
editPart = $bindable(null),
editDevice = $bindable(null),
editGame = $bindable(null),
devices,
mice
}: {
editPart?: Partial<Part> | null;
editDevice?: Partial<Device> | null;
editGame?: Partial<Game> | null;
devices: Device[];
mice: Part[];
} = $props();
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>
{#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 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>
<input type="hidden" name="imageUrl" value={p.imageUrl ?? ''} />
<label
>section<select name="section" value={p.section ?? ''}>
<option value="">–</option>
{#each SECTIONS as s (s)}<option value={s}>{s}</option>{/each}
</select></label
>
<label
>slot<input
name="slot"
value={p.slot ?? ''}
list="pc-slots"
placeholder="cpu, fans-case, mouse-gaming…"
/></label
>
<datalist id="pc-slots">
{#each Object.keys(SLOT_META) as s (s)}<option value={s}></option>{/each}
</datalist>
<label
>usage<select name="usage" value={p.usage ?? ''}>
<option value="">–</option>
{#each USAGES as u (u)}<option value={u}>{u}</option>{/each}
</select></label
>
<label
>in setup since<input name="installedAt" type="date" value={p.installedAt ?? ''} /></label
>
<label>removed on<input name="removedAt" type="date" value={p.removedAt ?? ''} /></label>
<label class="wide"
>images (one per line, /hardware/… or https://)<textarea name="imageUrls" rows="3"
>{(p.imageUrls ?? []).join('\n')}</textarea
></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
>
<label class="check"
><input type="checkbox" name="hidden" checked={p.hidden ?? false} /> hidden (kept for history,
not shown)</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
>in setup since<input name="installedAt" type="date" value={d.installedAt ?? ''} /></label
>
<label>removed on<input name="removedAt" type="date" value={d.removedAt ?? ''} /></label>
<label class="wide"
>images (one per line)<textarea name="imageUrls" rows="2"
>{(d.imageUrls ?? []).join('\n')}</textarea
></label
>
<label class="wide"
>notes (public)<textarea name="notes" rows="3">{d.notes ?? ''}</textarea></label
>
<label class="check"
><input type="checkbox" name="hidden" checked={d.hidden ?? false} /> hidden (kept for history,
not shown)</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>
.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;
}
.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);
}
.action.primary {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
}
.action.primary:hover {
opacity: 0.85;
}
</style>

257
src/lib/pc/Lightbox.svelte Normal file
View File

@@ -0,0 +1,257 @@
<script lang="ts">
import { onMount } from 'svelte';
import ChevronLeft from 'lucide-svelte/icons/chevron-left';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import ExternalLink from 'lucide-svelte/icons/external-link';
import X from 'lucide-svelte/icons/x';
import PcIcon from './PcIcon.svelte';
import { shortDate, specKey, type LightboxItem } from './shared';
// Native <dialog>: focus trap, Esc and backdrop for free. Arrow keys / swipe page the photos.
let { item, icon, onclose }: { item: LightboxItem; icon: string; onclose: () => void } = $props();
let dialog = $state<HTMLDialogElement>();
let i = $state(0);
onMount(() => dialog?.showModal());
const n = $derived(item.images.length);
const go = (d: number) => n && (i = (i + d + n) % n);
let touchX = 0;
</script>
<dialog
bind:this={dialog}
{onclose}
onclick={(e) => e.target === dialog && dialog?.close()}
onkeydown={(e) => {
if (e.key === 'ArrowRight') go(1);
if (e.key === 'ArrowLeft') go(-1);
}}
aria-label={item.title}
class="lightbox"
>
<div class="grid gap-0 md:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)]">
<div
class="stage"
role="region"
aria-label="Photos"
ontouchstart={(e) => (touchX = e.touches[0].clientX)}
ontouchend={(e) => {
const dx = e.changedTouches[0].clientX - touchX;
if (Math.abs(dx) > 40) go(dx < 0 ? 1 : -1);
}}
>
{#if n}
<img src={item.images[i]} alt="{item.title} ({i + 1} of {n})" decoding="async" />
{#if n > 1}
<button type="button" class="nav left" onclick={() => go(-1)} aria-label="Previous photo"
><ChevronLeft size={20} /></button
>
<button type="button" class="nav right" onclick={() => go(1)} aria-label="Next photo"
><ChevronRight size={20} /></button
>
{/if}
{:else}
<div class="flex h-full items-center justify-center">
<PcIcon category={icon} class="size-24" />
</div>
{/if}
</div>
<div class="info">
<div class="mb-3 flex items-start gap-2">
<h2 class="flex-1 text-lg leading-snug font-semibold">{item.title}</h2>
<button type="button" class="close" onclick={() => dialog?.close()} aria-label="Close"
><X size={18} /></button
>
</div>
{#if n > 1}
<div class="thumbs">
{#each item.images as src, j (src)}
<button
type="button"
class:on={j === i}
onclick={() => (i = j)}
aria-label="Photo {j + 1}"
>
<img src={src.replace(/\.webp$/, '.t.webp')} alt="" loading="lazy" />
</button>
{/each}
</div>
{/if}
<div class="space-y-3">
{#each item.entries as e, k (e.id ?? `d${k}`)}
<section class="entry">
<p class="kicker">
{e.kicker}{#if e.qty > 1}&nbsp;· ×{e.qty}{/if}
{#each e.badges as b (b)}<span class="badge">{b}</span>{/each}
</p>
<p class="font-medium">{e.name}</p>
<p class="text-xs text-[var(--site-muted)]">
{#if e.since}in the setup since {shortDate(e.since)}{:else}since ?{/if}{#if e.until}&nbsp;·
until
{shortDate(e.until)}{/if}
</p>
{#if Object.keys(e.specs).length}
<ul class="mt-1.5 flex flex-wrap gap-1">
{#each Object.entries(e.specs) as [key, v] (key)}
<li class="chip">
<span>{specKey(key)}</span>{v === true ? 'yes' : v === false ? 'no' : v}
</li>
{/each}
</ul>
{/if}
{#if e.note}<p class="mt-1.5 text-xs text-[var(--site-muted)]">{e.note}</p>{/if}
{#if e.url}
<a href={e.url} target="_blank" rel="noreferrer" class="link"
>product page <ExternalLink size={12} /></a
>
{/if}
</section>
{/each}
</div>
</div>
</div>
</dialog>
<style>
.lightbox {
margin: auto;
width: min(64rem, calc(100vw - 2rem));
max-height: calc(100dvh - 2rem);
overflow: hidden;
border: 1px solid var(--site-border);
border-radius: 1.25rem;
background: var(--site-bg);
color: var(--site-text);
padding: 0;
box-shadow: 0 30px 80px -20px rgb(0 0 0 / 0.7);
}
.lightbox::backdrop {
background: rgb(0 0 0 / 0.7);
backdrop-filter: blur(6px);
}
.stage {
position: relative;
height: min(34rem, 60dvh);
background: #fff;
}
.stage img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 1.5rem;
}
.nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
padding: 0.5rem;
border-radius: 999px;
background: rgb(0 0 0 / 0.55);
color: #fff;
transition: background 0.15s;
}
.nav:hover {
background: rgb(0 0 0 / 0.8);
}
.nav.left {
left: 0.75rem;
}
.nav.right {
right: 0.75rem;
}
.info {
max-height: min(34rem, 60dvh);
overflow-y: auto;
padding: 1.1rem 1.2rem;
}
@media (max-width: 767px) {
.stage {
height: 45dvh;
}
.info {
max-height: 40dvh;
}
}
.close {
border-radius: 0.5rem;
padding: 0.3rem;
color: var(--site-muted);
}
.close:hover {
background: var(--site-hover);
color: var(--site-text);
}
.thumbs {
display: flex;
gap: 0.4rem;
margin-bottom: 0.9rem;
overflow-x: auto;
}
.thumbs button {
flex-shrink: 0;
width: 3.4rem;
height: 3.4rem;
border-radius: 0.6rem;
background: #fff;
border: 2px solid transparent;
overflow: hidden;
opacity: 0.7;
transition: opacity 0.15s;
}
.thumbs button.on,
.thumbs button:hover {
opacity: 1;
border-color: #7c3aed;
}
.thumbs img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 0.2rem;
}
.entry + .entry {
border-top: 1px solid var(--site-border);
padding-top: 0.75rem;
}
.kicker {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--site-muted);
}
.badge {
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0 0.4rem;
font-size: 0.62rem;
letter-spacing: 0;
text-transform: none;
}
.chip {
display: inline-flex;
gap: 0.3rem;
border-radius: 999px;
background: var(--site-hover);
padding: 0.05rem 0.5rem;
font-size: 0.72rem;
}
.chip span {
color: var(--site-muted);
}
.link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.4rem;
font-size: 0.78rem;
color: var(--site-muted);
}
.link:hover {
color: var(--site-text);
text-decoration: underline;
}
</style>

View File

@@ -22,6 +22,10 @@
import Speaker from 'lucide-svelte/icons/speaker';
import Tablet from 'lucide-svelte/icons/tablet';
import Zap from 'lucide-svelte/icons/zap';
import Mic from 'lucide-svelte/icons/mic';
import RectangleHorizontal from 'lucide-svelte/icons/rectangle-horizontal';
import Webcam from 'lucide-svelte/icons/webcam';
import Armchair from 'lucide-svelte/icons/armchair';
import { CATEGORY_META, DEVICE_KIND_META } from './shared';
// A gradient tile with a white icon, for a part category or a device kind.
@@ -49,6 +53,10 @@
audio: Speaker,
controller: Gamepad2,
accessory: Package,
microphone: Mic,
mousepad: RectangleHorizontal,
webcam: Webcam,
chair: Armchair,
other: Box,
desktop: PcCase,
laptop: Laptop,
@@ -63,7 +71,9 @@
CATEGORY_META.other
);
const Icon = $derived(icons[key as keyof typeof icons] ?? Box);
const light = $derived(key === 'case' || key === 'psu' || key === 'controller');
const light = $derived(
key === 'case' || key === 'psu' || key === 'controller' || key === 'chair'
);
</script>
<span

317
src/lib/pc/SetupCard.svelte Normal file
View File

@@ -0,0 +1,317 @@
<script lang="ts">
import Ellipsis from 'lucide-svelte/icons/ellipsis';
import Pencil from 'lucide-svelte/icons/pencil';
import PcIcon from './PcIcon.svelte';
import {
displayName,
keySpec,
money,
shortDate,
slotIcon,
slotLabel,
thumb,
units,
usageBadges,
type Part
} from './shared';
// One slot of the setup on the chosen day: thumb, slot label (×qty), name, one key spec.
// The ⋯ popover (owner only, never rendered for visitors) holds the bookkeeping per part.
let {
parts,
slot,
owner = false,
compact = false,
onopen,
onedit
}: {
parts: Part[];
slot: string;
owner?: boolean;
compact?: boolean;
onopen: () => void;
onedit: (p: Part) => void;
} = $props();
const first = $derived(parts[0]);
const qty = $derived(parts.reduce((s, p) => s + units(p), 0));
const image = $derived(parts.flatMap((p) => p.imageUrls)[0] ?? null);
const names = $derived([...new Set(parts.map(displayName))]);
const spec = $derived(keySpec(first));
const badges = $derived([...new Set(parts.flatMap(usageBadges))]);
const flag = $derived(badges.find((b) => b !== 'long ago') ?? null);
const popId = $derived(`pop-${first.id}`);
</script>
<div class="card" class:compact>
<button
type="button"
class="open"
onclick={onopen}
aria-label="{slotLabel(slot, first.category)}: {names.join(', ')}"
>
{#if image}
<span class="thumb"><img src={thumb(image)} alt="" loading="lazy" decoding="async" /></span>
{:else}
<span class="thumb empty"
><PcIcon category={slotIcon(slot, first.category)} class="size-8" /></span
>
{/if}
{#if compact && flag}<span class="flag" class:warn={flag === 'model TBD'}
>{flag.replace('model ', '')}</span
>{/if}
<span class="min-w-0 flex-1">
<span class="label">
{slotLabel(slot, first.category)}{#if qty > 1}<span class="qty">×{qty}</span>{/if}
{#if !compact}{#each badges as b (b)}<span class="badge" class:warn={b === 'model TBD'}
>{b}</span
>{/each}{/if}
</span>
<span class="name" title={names.join(' + ')}>{names.join(' + ')}</span>
{#if spec && !compact}<span class="spec">{spec}</span>{/if}
</span>
</button>
{#if owner}
<button type="button" class="more" popovertarget={popId} aria-label="Owner details"
><Ellipsis size={15} /></button
>
<div id={popId} popover="auto" class="pop">
{#each parts as p (p.id)}
<div class="row">
<div class="flex items-start gap-2">
<p class="flex-1 font-medium">{p.brand} {p.model}</p>
<button type="button" class="edit" onclick={() => onedit(p)} aria-label="Edit {p.model}"
><Pencil size={13} /></button
>
</div>
<dl>
{#if p.totalPriceCents != null}<dt>paid</dt>
<dd>{money(p.totalPriceCents)}</dd>{/if}
{#if p.shop}<dt>shop</dt>
<dd>{p.shop}</dd>{/if}
{#if p.orderDate}<dt>ordered</dt>
<dd>{shortDate(p.orderDate)}</dd>{/if}
{#if p.orderNumber}<dt>order</dt>
<dd>{p.orderNumber}</dd>{/if}
{#if p.invoiceNumber}<dt>invoice</dt>
<dd>{p.invoiceNumber}</dd>{/if}
{#if p.warrantyUntil}<dt>warranty</dt>
<dd>{shortDate(p.warrantyUntil)}</dd>{/if}
<dt>status</dt>
<dd>{p.status}{p.verified ? '' : ' · unverified'}{p.usage ? ` · ${p.usage}` : ''}</dd>
{#if p.source}<dt>source</dt>
<dd>{p.source}</dd>{/if}
</dl>
{#if p.notes}<p class="note">{p.notes}</p>{/if}
</div>
{/each}
</div>
{/if}
</div>
<style>
.card {
position: relative;
height: 100%;
}
.open {
display: flex;
align-items: center;
gap: 0.65rem;
width: 100%;
height: 100%;
min-height: 3.9rem;
padding: 0.45rem 0.6rem 0.45rem 0.45rem;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
background: var(--site-surface);
text-align: left;
transition:
background 0.15s,
border-color 0.15s,
transform 0.15s;
}
.compact .open {
position: relative;
min-height: 3.2rem;
padding: 0.3rem 0.5rem 0.3rem 0.3rem;
}
.open:hover {
background: var(--site-hover);
border-color: color-mix(in srgb, var(--site-text) 30%, var(--site-border));
}
.open:active {
transform: scale(0.985);
}
.open:focus-visible {
outline: 2px solid #7c3aed;
outline-offset: 2px;
}
.thumb {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 3rem;
height: 3rem;
border-radius: 0.65rem;
background: #fff;
overflow: hidden;
}
.compact .thumb {
width: 2.5rem;
height: 2.5rem;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 0.2rem;
}
.thumb.empty {
background: transparent;
border: 1px dashed var(--site-border);
}
.label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--site-muted);
white-space: nowrap;
overflow: hidden;
}
.qty {
color: var(--site-text);
}
.badge {
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0 0.35rem;
font-size: 0.58rem;
letter-spacing: 0;
text-transform: none;
}
.badge.warn {
border-color: #ca8a04;
color: #fde047;
}
.name {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 0.83rem;
font-weight: 500;
line-height: 1.2;
}
.compact .label {
letter-spacing: 0.03em;
}
.compact .name {
font-size: 0.74rem;
line-height: 1.15;
}
.flag {
position: absolute;
left: 0.3rem;
bottom: 0.3rem;
width: 2.5rem;
border-radius: 0 0 0.5rem 0.5rem;
background: rgb(0 0 0 / 0.72);
color: #fff;
font-size: 0.55rem;
line-height: 1.4;
text-align: center;
pointer-events: none;
}
.flag.warn {
color: #fde047;
}
.spec {
display: block;
margin-top: 0.1rem;
font-size: 0.7rem;
color: var(--site-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.more {
position: absolute;
top: 0.3rem;
right: 0.3rem;
display: flex;
padding: 0.15rem;
border-radius: 0.4rem;
color: var(--site-muted);
opacity: 0;
transition: opacity 0.15s;
}
.card:hover .more,
.more:focus-visible {
opacity: 1;
}
.more:hover {
background: var(--site-bg);
color: var(--site-text);
}
.pop {
position: fixed;
inset: auto;
margin: auto;
width: min(24rem, calc(100vw - 2rem));
max-height: 70dvh;
overflow-y: auto;
padding: 0.8rem 0.9rem;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
background: var(--site-bg);
color: var(--site-text);
font-size: 0.8rem;
box-shadow: 0 20px 50px -15px rgb(0 0 0 / 0.7);
}
.pop:popover-open {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.pop::backdrop {
background: rgb(0 0 0 / 0.35);
}
.row + .row {
margin-top: 0.7rem;
padding-top: 0.7rem;
border-top: 1px solid var(--site-border);
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.1rem 0.7rem;
margin-top: 0.3rem;
}
dt {
color: var(--site-muted);
}
dd {
overflow-wrap: anywhere;
}
.note {
margin-top: 0.35rem;
color: var(--site-muted);
font-size: 0.75rem;
}
.edit {
display: flex;
padding: 0.25rem;
border: 1px solid var(--site-border);
border-radius: 0.4rem;
}
.edit:hover {
background: var(--site-hover);
}
</style>

View File

@@ -0,0 +1,308 @@
<script lang="ts">
import RotateCcw from 'lucide-svelte/icons/rotate-ccw';
import History from 'lucide-svelte/icons/history';
import { eventLabel, monthYear, shortDate, type TimelineEvent } from './shared';
// One timeline for the whole setup: parts were swapped one at a time, so every install or
// removal date is a tick. Drag snaps to ticks within ±10px; arrow keys jump between them.
let {
min,
max,
events,
value = $bindable()
}: { min: string; max: string; events: TimelineEvent[]; value: string } = $props();
const DAY = 86_400_000;
const toDay = (d: string) => Math.round(Date.parse(`${d}T00:00:00Z`) / DAY);
const fromDay = (n: number) => new Date(n * DAY).toISOString().slice(0, 10);
const lo = $derived(toDay(min));
const hi = $derived(toDay(max));
const span = $derived(Math.max(1, hi - lo));
const ticks = $derived(
events
.filter((e) => e.date >= min && e.date <= max)
.map((e) => ({ ...e, day: toDay(e.date), pct: ((toDay(e.date) - lo) / span) * 100 }))
);
const years = $derived.by(() => {
const out: { y: number; pct: number }[] = [];
for (let y = Number(min.slice(0, 4)) + 1; y <= Number(max.slice(0, 4)); y++)
out.push({ y, pct: ((toDay(`${y}-01-01`) - lo) / span) * 100 });
return out;
});
const current = $derived(toDay(value));
/** Latest change on or before the chosen day. */
const last = $derived([...ticks].reverse().find((t) => t.day <= current) ?? null);
let width = $state(0);
let hover = $state<(typeof ticks)[number] | null>(null);
function oninput(e: Event) {
let day = Number((e.currentTarget as HTMLInputElement).value);
const perPx = span / Math.max(1, width);
let best: number | null = null;
for (const t of ticks)
if (
Math.abs(t.day - day) <= 10 * perPx &&
(best === null || Math.abs(t.day - day) < Math.abs(best - day))
)
best = t.day;
if (best !== null) day = best;
value = fromDay(day);
}
function onkeydown(e: KeyboardEvent) {
if (e.shiftKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
e.preventDefault();
const next =
e.key === 'ArrowRight'
? (ticks.find((t) => t.day > current)?.day ?? hi)
: ([...ticks].reverse().find((t) => t.day < current)?.day ?? lo);
value = fromDay(next);
}
</script>
<div class="slider">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 sm:flex-nowrap">
<span class="icon" aria-hidden="true"><History size={16} /></span>
<div class="track order-last sm:order-none" bind:clientWidth={width}>
<div class="rail"></div>
<div class="fill" style:width="{((current - lo) / span) * 100}%"></div>
{#each years.filter((y, i) => width > 520 || i % 2 === 0) as y (y.y)}
<span class="year" style:left="{y.pct}%">{y.y}</span>
{/each}
{#each ticks as t (t.date)}
<button
type="button"
class="tick"
class:past={t.day <= current}
class:on={t.day === current}
style:left="{t.pct}%"
aria-label="{shortDate(t.date)}: {eventLabel(t)}"
onmouseenter={() => (hover = t)}
onmouseleave={() => (hover = null)}
onfocus={() => (hover = t)}
onblur={() => (hover = null)}
onclick={() => (value = t.date)}
></button>
{/each}
<input
type="range"
min={lo}
max={hi}
step="1"
value={current}
{oninput}
{onkeydown}
aria-label="Show the setup on"
aria-valuetext={shortDate(value)}
/>
{#if hover}
<div class="tip" style:left="clamp(8rem, {hover.pct}%, calc(100% - 8rem))">
<strong>{monthYear(hover.date)}</strong>
{eventLabel(hover)}
</div>
{/if}
</div>
<div class="when ml-auto sm:ml-0">
<span class="date tabular-nums">{shortDate(value)}</span>
<button type="button" class="today" disabled={value === max} onclick={() => (value = max)}
><RotateCcw size={13} /> today</button
>
</div>
</div>
<p class="last" aria-live="polite">
{#if last}
<span class="text-[var(--site-text)]">{monthYear(last.date)}</span> · {eventLabel(last)}
{:else}
before the first part
{/if}
</p>
</div>
<style>
.slider {
padding: 0.4rem 0.9rem 0.3rem;
border: 1px solid var(--site-border);
border-radius: 1rem;
background: color-mix(in srgb, var(--site-surface) 92%, transparent);
backdrop-filter: blur(10px);
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
flex-shrink: 0;
border-radius: 0.6rem;
color: #fff;
background: linear-gradient(135deg, #22d3ee, #7c3aed);
}
.track {
position: relative;
flex: 1 1 100%;
height: 2.1rem;
}
@media (min-width: 640px) {
.track {
flex: 1 1 0;
}
}
.rail,
.fill {
position: absolute;
top: 0.55rem;
left: 0;
height: 0.3rem;
border-radius: 999px;
}
.rail {
right: 0;
background: var(--site-hover);
}
.fill {
background: linear-gradient(90deg, #22d3ee, #7c3aed, #db2777);
}
.year {
position: absolute;
top: 1.15rem;
transform: translateX(-50%);
font-size: 0.66rem;
color: var(--site-muted);
pointer-events: none;
}
.year::before {
content: '';
position: absolute;
left: 50%;
top: -0.35rem;
width: 1px;
height: 0.25rem;
background: var(--site-border);
}
.tick {
position: absolute;
top: 0.3rem;
z-index: 2;
width: 0.5rem;
height: 0.8rem;
margin-left: -0.25rem;
border-radius: 999px;
background: var(--site-muted);
opacity: 0.6;
transition:
transform 0.12s,
opacity 0.12s,
background 0.12s;
}
.tick.past {
background: var(--site-text);
opacity: 0.9;
}
.tick.on,
.tick:hover,
.tick:focus-visible {
transform: scaleY(1.45);
background: #db2777;
opacity: 1;
}
input[type='range'] {
position: absolute;
inset: 0 0 auto 0;
width: 100%;
height: 1.4rem;
margin: 0;
background: transparent;
appearance: none;
cursor: pointer;
z-index: 1;
}
input[type='range']::-webkit-slider-runnable-track {
height: 1.4rem;
background: transparent;
}
input[type='range']::-moz-range-track {
background: transparent;
}
input[type='range']::-webkit-slider-thumb {
appearance: none;
width: 1.15rem;
height: 1.15rem;
margin-top: 0.125rem;
border-radius: 999px;
background: var(--site-text);
border: 3px solid var(--site-bg);
box-shadow:
0 0 0 1px var(--site-border),
0 4px 12px rgb(0 0 0 / 0.4);
}
input[type='range']::-moz-range-thumb {
width: 1rem;
height: 1rem;
border-radius: 999px;
background: var(--site-text);
border: 3px solid var(--site-bg);
}
input[type='range']:focus-visible {
outline: none;
}
input[type='range']:focus-visible::-webkit-slider-thumb {
box-shadow: 0 0 0 3px #7c3aed;
}
.tip {
position: absolute;
bottom: calc(100% + 0.35rem);
z-index: 30;
transform: translateX(-50%);
max-width: 22rem;
width: max-content;
padding: 0.35rem 0.6rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
background: var(--site-bg);
font-size: 0.75rem;
color: var(--site-muted);
box-shadow: 0 10px 30px -10px rgb(0 0 0 / 0.6);
pointer-events: none;
}
.tip strong {
color: var(--site-text);
margin-right: 0.3rem;
}
.when {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.date {
min-width: 6.6rem;
text-align: right;
font-weight: 600;
font-size: 0.95rem;
}
.today {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
padding: 0.25rem 0.55rem;
font-size: 0.78rem;
transition: background 0.15s;
}
.today:hover:not(:disabled) {
background: var(--site-hover);
}
.today:disabled {
opacity: 0.4;
}
.last {
margin: 0.05rem 0 0 2.65rem;
font-size: 0.72rem;
color: var(--site-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -27,6 +27,16 @@ export type Part = {
orderNumber: string | null;
invoiceNumber: string | null;
source: string | null;
/** Time slider fields (see SLOT_META and presentAt). */
slug: string | null;
section: string | null;
slot: string | null;
installedAt: string | null;
removedAt: string | null;
replacesId: number | null;
usage: string | null;
imageUrls: string[];
hidden: boolean;
};
export type Device = {
@@ -48,9 +58,21 @@ export type Device = {
imageUrl: string | null;
sortOrder: number;
notes: string | null;
installedAt: string | null;
removedAt: string | null;
imageUrls: string[];
hidden: boolean;
parts: Part[];
};
export type SetupPhoto = {
id: number;
url: string;
caption: string | null;
takenAt: string | null;
sortOrder: number;
};
export type Game = {
id: number;
game: string;
@@ -82,6 +104,10 @@ export const CATEGORY_META: Record<string, { label: string; from: string; to: st
audio: { label: 'Audio', from: '#fda4af', to: '#e11d48' },
controller: { label: 'Controller', from: '#bef264', to: '#65a30d' },
accessory: { label: 'Accessory', from: '#cbd5e1', to: '#64748b' },
microphone: { label: 'Microphone', from: '#fca5a5', to: '#ef4444' },
mousepad: { label: 'Mousepad', from: '#86efac', to: '#16a34a' },
webcam: { label: 'Webcam', from: '#93c5fd', to: '#3b82f6' },
chair: { label: 'Chair', from: '#fcd34d', to: '#d97706' },
other: { label: 'Other', from: '#d4d4d8', to: '#71717a' }
};
export const CATEGORIES = Object.keys(CATEGORY_META);
@@ -100,6 +126,139 @@ export const DEVICE_KIND_META: Record<string, { label: string; from: string; to:
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 SECTIONS = ['pc', 'desk', 'audio', 'sim'] as const;
export const USAGES = [
'current',
'stored',
'previous',
'work-issued',
'placeholder',
'planned',
'legacy',
'legacy-previous'
] as const;
/**
* Slots on the setup page, in display order. `icon` is a PcIcon category; several DB slots can
* share one card (`card`), e.g. the three small cooler accessories.
*/
export const SLOT_META: Record<string, { label: string; icon: string; card?: string }> = {
case: { label: 'Case', icon: 'case' },
cpu: { label: 'CPU', icon: 'cpu' },
motherboard: { label: 'Motherboard', icon: 'motherboard' },
memory: { label: 'Memory', icon: 'ram' },
gpu: { label: 'Graphics', icon: 'gpu' },
cooler: { label: 'Cooler', icon: 'cpu_cooler' },
psu: { label: 'Power', icon: 'psu' },
storage: { label: 'Storage', icon: 'storage' },
'fans-case': { label: 'Case fans', icon: 'fan' },
'fans-radiator': { label: 'Radiator fans', icon: 'fan' },
lighting: { label: 'Lighting', icon: 'lighting' },
'contact-frame': { label: 'Accessories', icon: 'accessory', card: 'accessories' },
'cooler-mount': { label: 'Accessories', icon: 'accessory', card: 'accessories' },
'thermal-paste': { label: 'Accessories', icon: 'accessory', card: 'accessories' },
monitor: { label: 'Monitor', icon: 'monitor' },
'monitor-arm': { label: 'Arm', icon: 'accessory' },
keyboard: { label: 'Keyboard', icon: 'keyboard' },
keycaps: { label: 'Keycaps', icon: 'keyboard' },
keypad: { label: 'Keypad', icon: 'keyboard' },
'mouse-gaming': { label: 'Mouse', icon: 'mouse' },
'mouse-work': { label: 'Work mouse', icon: 'mouse' },
mousepad: { label: 'Mousepad', icon: 'mousepad' },
webcam: { label: 'Webcam', icon: 'webcam' },
chair: { label: 'Chair', icon: 'chair' },
mic: { label: 'Microphone', icon: 'microphone' },
'mic-arm': { label: 'Mic arm', icon: 'audio' },
headphones: { label: 'Headphones', icon: 'headset' },
'headset-work': { label: 'Work headset', icon: 'headset' },
iems: { label: 'IEMs', icon: 'headset' },
wheel: { label: 'Wheel', icon: 'controller' },
shifter: { label: 'Shifter', icon: 'controller' }
};
const SLOT_ORDER = Object.keys(SLOT_META);
export const slotLabel = (slot: string | null, category: string) =>
(slot && SLOT_META[slot]?.label) ?? categoryLabel(category);
export const slotIcon = (slot: string | null, category: string) =>
(slot && SLOT_META[slot]?.icon) ?? category;
/** Thumbnail next to each self-hosted image: /hardware/x/1.webp -> /hardware/x/1.t.webp. */
export const thumb = (url: string) =>
url.startsWith('/hardware/') ? url.replace(/\.webp$/, '.t.webp') : url;
/** Owned long ago and gone, date unknown: not placeable on the timeline (the "earlier" tray). */
export const isUndatedPast = (p: {
usage?: string | null;
installedAt: string | null;
removedAt: string | null;
}) => p.usage === 'legacy-previous' || (p.usage === 'previous' && !p.removedAt && !p.installedAt);
/** In the setup on day `at` (YYYY-MM-DD). Undated items count as always there. */
export const presentAt = (
p: { installedAt: string | null; removedAt: string | null },
at: string
) => (!p.installedAt || p.installedAt <= at) && (!p.removedAt || p.removedAt > at);
export type SlotCard = { key: string; slot: string; parts: Part[] };
/** Visible parts of one section on day `at`, one card per slot (several parts may share it). */
export function slotCards(parts: Part[], section: string, at: string): SlotCard[] {
const cards = new Map<string, SlotCard>();
for (const p of parts) {
if (p.section !== section || p.hidden || isUndatedPast(p) || !presentAt(p, at)) continue;
const slot = p.slot ?? p.category;
const key = SLOT_META[slot]?.card ?? slot;
const card = cards.get(key) ?? { key, slot, parts: [] };
card.parts.push(p);
cards.set(key, card);
}
const rank = (s: string) => {
const i = SLOT_ORDER.indexOf(s);
return i < 0 ? 999 : i;
};
return [...cards.values()].sort((a, b) => rank(a.slot) - rank(b.slot));
}
export type TimelineEvent = { date: string; added: string[]; removed: string[] };
const shortName = (p: { brand: string | null; model: string; quantity?: number }) => {
const name = `${p.brand ? `${p.brand} ` : ''}${p.model.replace(/\s*\(.*?\)\s*/g, ' ').trim()}`;
return (p.quantity ?? 1) > 1 ? `${p.quantity}× ${name}` : name;
};
/** One event per distinct install/removal date (parts and devices), oldest first. */
export function buildEvents(
items: {
brand: string | null;
model: string | null;
label?: string | null;
quantity?: number;
installedAt: string | null;
removedAt: string | null;
}[]
): TimelineEvent[] {
const byDate = new Map<string, TimelineEvent>();
const at = (d: string) => {
const e = byDate.get(d) ?? { date: d, added: [], removed: [] };
byDate.set(d, e);
return e;
};
for (const it of items) {
const name = shortName({
brand: it.brand,
model: it.model ?? it.label ?? '?',
quantity: it.quantity
});
if (it.installedAt) at(it.installedAt).added.push(name);
if (it.removedAt) at(it.removedAt).removed.push(name);
}
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
}
export const monthYear = (d: string) =>
new Date(`${d}T12:00:00Z`).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' });
export const eventLabel = (e: TimelineEvent) =>
[...e.added.map((n) => `+ ${n}`), ...e.removed.map((n) => `− ${n}`)].join(', ');
export const statusLabel = (s: string) => s.replace('_', ' ');
@@ -175,3 +334,148 @@ export function cmFor(sens: number | null, yaw: number | null, dpi: number | nul
if (!sens || !yaw || !dpi) return null;
return (360 * 2.54) / (yaw * dpi * sens);
}
/** Physical units: a 3-pack of fans counts as 3. */
export const units = (p: { quantity: number; specs: Specs }) =>
p.quantity * (Number(p.specs.fans_in_pack) || 1);
/** "Lian Li UNI FAN SL-INF 120 Wireless 3-pack White (art 1161338)" -> without packs/brackets. */
export const displayName = (p: { brand: string | null; model: string }) =>
`${p.brand ? `${p.brand} ` : ''}${p.model
.replace(/\s*\(.*?\)\s*/g, ' ')
.replace(/\b\d-pack\s*/gi, '')
.replace(/\s+,/g, ',')
.trim()}`;
const KEY_SPECS: Record<string, [string, (v: string | number | boolean) => string][]> = {
cpu: [
['cores', (v) => `${v} cores`],
['max_turbo_ghz', (v) => `${v} GHz`]
],
gpu: [
['vram_gb', (v) => `${v} GB`],
['architecture', String]
],
ram: [
['capacity_gb', (v) => `${v} GB`],
['speed_mts', (v) => `${v} MT/s`]
],
storage: [
['capacity_gb', (v) => (Number(v) >= 1000 ? `${Number(v) / 1000} TB` : `${v} GB`)],
['interface', String]
],
psu: [
['wattage_w', (v) => `${v} W`],
['efficiency', String]
],
motherboard: [
['chipset', String],
['form_factor', String]
],
case: [
['form_factor', String],
['color', String]
],
cpu_cooler: [
['radiator_mm', (v) => `${v} mm`],
['type', String]
],
fan: [
['size_mm', (v) => `${v} mm`],
['color', String]
],
monitor: [
['size_in', (v) => `${v}"`],
['refresh_hz', (v) => `${v} Hz`],
['resolution', String]
]
};
/** One short spec line for a card, e.g. "16 cores · 5.2 GHz". */
export function keySpec(p: { category: string; specs: Specs }): string | null {
const keys = KEY_SPECS[p.category];
const pairs = keys
? keys.flatMap(([k, f]) => (p.specs[k] != null && p.specs[k] !== '' ? [f(p.specs[k]!)] : []))
: Object.entries(p.specs)
.filter(
([k, v]) =>
v != null &&
v !== '' &&
typeof v !== 'boolean' &&
!k.endsWith('_art') &&
k !== 'sku' &&
k !== 'mfr_part'
)
.slice(0, 1)
.map(([, v]) => String(v));
return pairs.length ? pairs.join(' · ') : null;
}
export type LightboxEntry = {
id: number | null;
kicker: string;
name: string;
qty: number;
specs: Specs;
url: string | null;
since: string | null;
until: string | null;
badges: string[];
note: string | null;
};
export type LightboxItem = { title: string; images: string[]; entries: LightboxEntry[] };
export function usageBadges(p: { usage?: string | null; status: string; model: string | null }) {
const out: string[] = [];
if (p.usage === 'stored' || p.status === 'spare') out.push('stored');
if (p.usage === 'work-issued') out.push('work');
if (p.usage === 'previous' || p.usage === 'legacy-previous' || p.status === 'retired')
out.push('previous');
if (p.usage === 'legacy') out.push('long ago');
if (p.model && /\bTBD\b/.test(p.model)) out.push('model TBD');
if (p.status === 'planned') out.push('planned');
return out;
}
export function partEntry(p: Part): LightboxEntry {
return {
id: p.id,
kicker: slotLabel(p.slot, p.category),
name: displayName(p),
qty: units(p),
specs: Object.fromEntries(
Object.entries(p.specs).filter(
([k, v]) => v != null && v !== '' && !k.endsWith('_art') && k !== 'sku'
)
),
url: p.url,
since: p.installedAt,
until: p.removedAt,
badges: usageBadges(p),
note: null
};
}
export function deviceItem(d: Device): LightboxItem {
return {
title: d.label ?? d.name,
images: d.imageUrls,
entries: [
{
id: null,
kicker: DEVICE_KIND_META[d.kind]?.label ?? d.kind,
name: [d.brand, d.model].filter(Boolean).join(' ') || d.name,
qty: 1,
specs: Object.fromEntries(Object.entries(d.specs).filter(([, v]) => v != null && v !== '')),
url: d.url,
since: d.installedAt ?? d.purchaseDate,
until: d.removedAt,
badges: d.status === 'active' ? [] : [d.status],
note: d.notes
},
...d.parts
.filter((p) => !p.hidden)
.map((p) => ({ ...partEntry(p), kicker: categoryLabel(p.category) }))
]
};
}

View File

@@ -73,7 +73,7 @@ const publicPages: Record<string, { title: string; description: string }> = {
'/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.'
'Stan Runge’s PC setup: his PC, desk and devices on one screen with a time slider back to 2021, the planned next build and mouse sensitivities.'
},
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
'/projects/game-tracker': {

View File

@@ -1,5 +1,6 @@
import { sql } from 'drizzle-orm';
import {
type AnyPgColumn,
bigint,
boolean,
check,
@@ -49,9 +50,30 @@ export const HARDWARE_CATEGORIES = [
'audio',
'controller',
'accessory',
'microphone',
'mousepad',
'webcam',
'chair',
'other'
] as const;
export const HARDWARE_STATUSES = ['in_use', 'planned', 'spare', 'retired', 'sold'] as const;
/** Page sections on /trackers/pc. */
export const HARDWARE_SECTIONS = ['pc', 'desk', 'audio', 'sim'] as const;
/**
* How a part shows on the time slider: current, stored (owned, not set up), previous (gone,
* dated by removed_at), work-issued, placeholder (model TBD), legacy (owned long before the
* mail history, still owned) and legacy-previous (long ago and gone, date unknown).
*/
export const HARDWARE_USAGES = [
'current',
'stored',
'previous',
'work-issued',
'placeholder',
'planned',
'legacy',
'legacy-previous'
] as const;
const list = (values: readonly string[]) => sql.raw(values.map((v) => `'${v}'`).join(', '));
@@ -85,6 +107,15 @@ export const hardwareDevices = hardwareSchema.table(
imageUrl: text('image_url'),
sortOrder: integer('sort_order').notNull().default(100),
notes: text('notes'),
/** Time slider: when it joined / left the setup (null = unknown / still here). */
installedAt: date('installed_at'),
removedAt: date('removed_at'),
/** Self-hosted product photos (/hardware/...webp), first is the thumbnail. */
imageUrls: text('image_urls').array().notNull().default([]),
/** Where each image was downloaded from (attribution), same order as image_urls. */
imageSources: text('image_sources').array().notNull().default([]),
/** Kept for history but not shown (e.g. a PC built for someone else). */
hidden: boolean('hidden').notNull().default(false),
createdAt: timestamptz('created_at').notNull().defaultNow(),
updatedAt: timestamptz('updated_at')
.notNull()
@@ -133,6 +164,23 @@ export const hardwareComponents = hardwareSchema.table(
notes: text('notes'),
/** user_list | gmail | user_statement | manual | plan. */
source: text('source'),
/** Stable slug for merges/seeds, e.g. cpu-12900kf. */
slug: text('slug').unique(),
/** Page section (pc/desk/audio/sim) and slot within it (cpu, fans-case, mouse-gaming…). */
section: text('section'),
slot: text('slot'),
/** Time slider: in the setup from installed_at until removed_at (null = unknown / still). */
installedAt: date('installed_at'),
removedAt: date('removed_at'),
replacesId: bigint('replaces_id', { mode: 'number' }).references(
(): AnyPgColumn => hardwareComponents.id,
{ onDelete: 'set null' }
),
usage: text('usage'),
imageUrls: text('image_urls').array().notNull().default([]),
imageSources: text('image_sources').array().notNull().default([]),
/** Kept for history but not shown (wrong placeholders, a friend's build). */
hidden: boolean('hidden').notNull().default(false),
createdAt: timestamptz('created_at').notNull().defaultNow(),
updatedAt: timestamptz('updated_at')
.notNull()
@@ -143,6 +191,15 @@ export const hardwareComponents = hardwareSchema.table(
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_section_check',
sql`section is null or section in (${list(HARDWARE_SECTIONS)})`
),
check('components_usage_check', sql`usage is null or usage in (${list(HARDWARE_USAGES)})`),
check(
'components_dates_check',
sql`removed_at is null or installed_at is null or removed_at >= installed_at`
),
check('components_unit_price_cents_check', sql`unit_price_cents >= 0`),
check('components_total_price_cents_check', sql`total_price_cents >= 0`),
check(
@@ -152,10 +209,21 @@ export const hardwareComponents = hardwareSchema.table(
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)
index('components_specs_gin').using('gin', t.specs),
index('components_timeline_idx').on(t.installedAt, t.removedAt)
]
);
/** Stan's own photos of the whole setup (hero carousel); taken_at lets the slider pick one. */
export const hardwareSetupPhotos = hardwareSchema.table('setup_photos', {
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
url: text('url').notNull(),
caption: text('caption'),
takenAt: date('taken_at'),
sortOrder: integer('sort_order').notNull().default(100),
createdAt: timestamptz('created_at').notNull().defaultNow()
});
/**
* 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

View File

@@ -5,19 +5,22 @@ import {
HARDWARE_CATEGORIES,
HARDWARE_DEVICE_KINDS,
HARDWARE_DEVICE_STATUSES,
HARDWARE_SECTIONS,
HARDWARE_STATUSES,
HARDWARE_USAGES,
hardwareComponents,
hardwareDevices,
hardwareSensitivities
hardwareSensitivities,
hardwareSetupPhotos
} from '#lib/server/db/schema/index.js';
import { textToSpecs, type Device, type Game, type Part } from '#lib/pc/shared.js';
import { textToSpecs, type Device, type Game, type Part, type SetupPhoto } 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([
const [devices, parts, games, photos] = await Promise.all([
db
.select()
.from(hardwareDevices)
@@ -33,10 +36,18 @@ export async function loadSetup(owner: boolean) {
db
.select()
.from(hardwareSensitivities)
.orderBy(asc(hardwareSensitivities.sortOrder), asc(hardwareSensitivities.game))
.orderBy(asc(hardwareSensitivities.sortOrder), asc(hardwareSensitivities.game)),
db
.select()
.from(hardwareSetupPhotos)
.orderBy(asc(hardwareSetupPhotos.sortOrder), asc(hardwareSetupPhotos.id))
]);
// Hidden rows (a friend's build, wrong placeholders) are kept for history, owner-only.
const hiddenDevices = new Set(devices.filter((d) => d.hidden).map((d) => d.id));
const byDevice = new Map<number, Part[]>();
for (const p of parts) {
const hidden = p.hidden || hiddenDevices.has(p.deviceId);
if (hidden && !owner) continue;
const part: Part = {
id: p.id,
deviceId: p.deviceId,
@@ -45,11 +56,12 @@ export async function loadSetup(owner: boolean) {
model: p.model,
quantity: p.quantity,
specs: p.specs ?? {},
unitPriceCents: p.unitPriceCents,
totalPriceCents: p.totalPriceCents,
shop: p.shop,
// Prices, shops and warranty are bookkeeping: owner-only (the ⋯ popover).
unitPriceCents: owner ? p.unitPriceCents : null,
totalPriceCents: owner ? p.totalPriceCents : null,
shop: owner ? p.shop : null,
orderDate: p.orderDate,
warrantyUntil: p.warrantyUntil,
warrantyUntil: owner ? p.warrantyUntil : null,
status: p.status,
verified: p.verified,
dpi: p.dpi,
@@ -59,32 +71,54 @@ export async function loadSetup(owner: boolean) {
notes: owner ? p.notes : null,
orderNumber: owner ? p.orderNumber : null,
invoiceNumber: owner ? p.invoiceNumber : null,
source: owner ? p.source : null
source: owner ? p.source : null,
slug: p.slug,
section: p.section,
slot: p.slot,
installedAt: p.installedAt,
removedAt: p.removedAt,
replacesId: p.replacesId,
usage: p.usage,
imageUrls: p.imageUrls ?? [],
hidden
};
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 out: Device[] = devices
.filter((d) => owner || !d.hidden)
.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: owner ? d.priceCents : null,
monthlyCents: owner ? d.monthlyCents : null,
budgetCents: d.budgetCents,
shop: d.shop,
purchaseDate: d.purchaseDate,
url: d.url,
imageUrl: d.imageUrl,
sortOrder: d.sortOrder,
notes: d.notes,
installedAt: d.installedAt,
removedAt: d.removedAt,
imageUrls: d.imageUrls ?? [],
hidden: d.hidden,
parts: byDevice.get(d.id) ?? []
}));
const setupPhotos: SetupPhoto[] = photos.map((p) => ({
id: p.id,
url: p.url,
caption: p.caption,
takenAt: p.takenAt,
sortOrder: p.sortOrder
}));
const sens: Game[] = games.map((g) => ({
id: g.id,
@@ -97,7 +131,7 @@ export async function loadSetup(owner: boolean) {
notes: owner ? g.notes : null,
sortOrder: g.sortOrder
}));
return { devices: out, games: sens };
return { devices: out, games: sens, photos: setupPhotos };
}
// ---- form parsing (owner writes) ----
@@ -192,6 +226,41 @@ const specs = z
.default('')
.transform((v) => textToSpecs(v));
const pick = <T extends readonly [string, ...string[]]>(values: T) => z.enum(values);
const pickOrNull = <T extends readonly [string, ...string[]]>(values: T) =>
z
.string()
.default('')
.transform((v) => (v === '' ? null : v))
.pipe(z.enum(values).nullable());
const checkbox = z
.string()
.optional()
.transform((v) => v === 'on' || v === 'true');
/** One image per line: self-hosted /hardware/... paths or http(s) links. */
const images = z
.string()
.max(10_000)
.default('')
.transform((v) =>
v
.split('\n')
.map((l) => l.trim())
.filter((l) => /^(\/|https?:\/\/)/i.test(l))
.slice(0, 30)
);
const slotText = z
.string()
.trim()
.toLowerCase()
.max(60)
.transform((v) => (v === '' ? null : v))
.pipe(
z
.string()
.regex(/^[a-z0-9-]+$/, 'lowercase letters, digits and dashes')
.nullable()
)
.default(null);
const partSchema = z.object({
id: int(1, 2 ** 31),
@@ -216,7 +285,14 @@ const partSchema = z.object({
measuredDpi: int(1, 100_000),
url: link,
imageUrl: link,
notes: str(4000)
notes: str(4000),
section: pickOrNull(HARDWARE_SECTIONS),
slot: slotText,
usage: pickOrNull(HARDWARE_USAGES),
installedAt: day,
removedAt: day,
imageUrls: images,
hidden: checkbox
});
const deviceSchema = z.object({
@@ -249,7 +325,17 @@ const deviceSchema = z.object({
purchaseDate: day,
url: link,
sortOrder: int(0, 10_000),
notes: str(4000)
notes: str(4000),
installedAt: day,
removedAt: day,
imageUrls: images,
hidden: checkbox
});
const photoSchema = z.object({
caption: str(300),
takenAt: day,
sortOrder: int(0, 10_000)
});
const gameSchema = z.object({
@@ -276,6 +362,8 @@ 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;
if (p.installedAt && p.removedAt && p.removedAt < p.installedAt)
return { ok: false, message: 'removed must be after installed' };
const values = {
...p,
unitPriceCents: unitPrice,
@@ -315,6 +403,27 @@ export async function saveDevice(form: FormData): Promise<Result> {
return { ok: true };
}
/** Owner photo of the whole setup: stored in R2 by the caller, one row here. */
export async function addPhoto(url: string, form: FormData): Promise<Result> {
const parsed = photoSchema.safeParse(fields(form));
if (!parsed.success) return problem(parsed.error);
const { sortOrder, ...p } = parsed.data;
await db.insert(hardwareSetupPhotos).values({ ...p, url, sortOrder: sortOrder ?? 100 });
return { ok: true };
}
export async function deletePhoto(
form: FormData
): Promise<{ ok: true; url: string | null } | { ok: false; message: string }> {
const id = rowId(form);
if (!id) return { ok: false, message: 'Missing id' };
const [row] = await db
.delete(hardwareSetupPhotos)
.where(eq(hardwareSetupPhotos.id, id))
.returning({ url: hardwareSetupPhotos.url });
return { ok: true, url: row?.url ?? null };
}
export async function saveGame(form: FormData): Promise<Result> {
const parsed = gameSchema.safeParse(fields(form));
if (!parsed.success) return problem(parsed.error);

View File

@@ -52,7 +52,7 @@ const EXT: Record<AllowedMimeType, string> = {
/** Upload through the site server so browsers do not need R2 CORS access. */
export async function uploadImage(
file: File,
prefix: 'sightings' | 'lists' | 'locations' = 'sightings'
prefix: 'sightings' | 'lists' | 'locations' | 'setup' = 'sightings'
): Promise<{ key: string; publicUrl: string }> {
if (!isAllowedMimeType(file.type)) throw new Error('Unsupported image type');
const mimeType = file.type;
@@ -93,6 +93,14 @@ export function listImageKey(url: string): string | null {
return listImageUrl(key) === url ? key : null;
}
/** R2 key of an uploaded PC setup photo (setup/<uuid>.ext), or null for anything else. */
export function setupImageKey(url: string): string | null {
const base = R2_PUBLIC_URL?.replace(/\/$/, '');
if (!base || !url.startsWith(`${base}/`)) return null;
const key = url.slice(base.length + 1);
return /^setup\/[0-9a-f-]{36}\.(jpg|png|webp)$/.test(key) ? key : null;
}
/**
* Delete a photo from R2 by its key.
*/

View File

@@ -1,5 +1,14 @@
import { isOwner, requireOwner } from '#lib/server/owner.js';
import { deleteRow, loadSetup, saveDevice, saveGame, savePart } from '#lib/server/hardware.js';
import {
addPhoto,
deletePhoto,
deleteRow,
loadSetup,
saveDevice,
saveGame,
savePart
} from '#lib/server/hardware.js';
import { deleteObject, setupImageKey, uploadImage } from '#lib/server/r2.js';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
@@ -7,15 +16,16 @@ import type { Actions, PageServerLoad } from './$types';
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);
const { devices, games, photos } = await loadSetup(owner);
return {
owner,
devices,
games,
photos,
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.'
'My PC, desk and devices on one screen, with a time slider that shows the setup on any day since 2021, plus the planned next build and my mouse sensitivities.'
}
};
};
@@ -49,5 +59,33 @@ export const actions: Actions = {
deleteGame: async (event) => {
requireOwner(event);
return run(deleteRow('game', await event.request.formData()));
},
addPhoto: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const photo = form.get('photo');
if (
!(photo instanceof File) ||
!['image/jpeg', 'image/png', 'image/webp'].includes(photo.type) ||
!photo.size ||
photo.size > 15 * 1024 * 1024
)
return fail(400, { message: 'Choose a JPEG, PNG or WebP image up to 15 MB.' });
let url: string;
try {
url = (await uploadImage(photo, 'setup')).publicUrl;
} catch (cause) {
console.error('setup photo upload failed', cause);
return fail(503, { message: 'Image storage is unavailable.' });
}
return run(addPhoto(url, form));
},
deletePhoto: async (event) => {
requireOwner(event);
const r = await deletePhoto(await event.request.formData());
if (!r.ok) return fail(400, { message: r.message });
const key = r.url ? setupImageKey(r.url) : null;
if (key) await deleteObject(key).catch(() => {});
return { saved: true };
}
};

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Some files were not shown because too many files have changed in this diff Show More