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)
195
drizzle/20261025161619_hardware_timeline/migration.sql
Normal 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;
|
||||
29466
drizzle/20261025161619_hardware_timeline/snapshot.json
Normal file
435
src/lib/pc/EditDialogs.svelte
Normal 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
@@ -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} · ×{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} ·
|
||||
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>
|
||||
@@ -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
@@ -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>
|
||||
308
src/lib/pc/TimeSlider.svelte
Normal 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>
|
||||
@@ -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) }))
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,13 +71,24 @@ 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) => ({
|
||||
const out: Device[] = devices
|
||||
.filter((d) => owner || !d.hidden)
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
label: d.label,
|
||||
@@ -75,8 +98,8 @@ export async function loadSetup(owner: boolean) {
|
||||
brand: d.brand,
|
||||
model: d.model,
|
||||
specs: d.specs ?? {},
|
||||
priceCents: d.priceCents,
|
||||
monthlyCents: d.monthlyCents,
|
||||
priceCents: owner ? d.priceCents : null,
|
||||
monthlyCents: owner ? d.monthlyCents : null,
|
||||
budgetCents: d.budgetCents,
|
||||
shop: d.shop,
|
||||
purchaseDate: d.purchaseDate,
|
||||
@@ -84,8 +107,19 @@ export async function loadSetup(owner: boolean) {
|
||||
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,
|
||||
game: g.game,
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
BIN
static/hardware/acc-bcf/1.t.webp
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
static/hardware/acc-bcf/1.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
static/hardware/acc-bcf/2.t.webp
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
static/hardware/acc-bcf/2.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
static/hardware/acc-bcf/3.t.webp
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
static/hardware/acc-bcf/3.webp
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
static/hardware/acc-kryonaut/1.t.webp
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
static/hardware/acc-kryonaut/1.webp
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
static/hardware/acc-kryonaut/2.t.webp
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
static/hardware/acc-kryonaut/2.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
static/hardware/arm-ak35/1.t.webp
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
static/hardware/arm-ak35/1.webp
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
static/hardware/arm-ak35/2.t.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
static/hardware/arm-ak35/2.webp
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
static/hardware/arm-ak35/3.t.webp
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
static/hardware/arm-ak35/3.webp
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
static/hardware/arm-ergotron/1.t.webp
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
static/hardware/arm-ergotron/1.webp
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
static/hardware/arm-ergotron/2.t.webp
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
static/hardware/arm-ergotron/2.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
static/hardware/case-o11dxl/1.t.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
static/hardware/case-o11dxl/1.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
static/hardware/case-o11dxl/2.t.webp
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
static/hardware/case-o11dxl/2.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
static/hardware/case-o11dxl/3.t.webp
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
static/hardware/case-o11dxl/3.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
static/hardware/case-o11dxl/4.t.webp
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
static/hardware/case-o11dxl/4.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
static/hardware/cooler-z73/1.t.webp
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
static/hardware/cooler-z73/1.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
static/hardware/cooler-z73/2.t.webp
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
static/hardware/cooler-z73/2.webp
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
static/hardware/cooler-z73/3.t.webp
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
static/hardware/cooler-z73/3.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
static/hardware/cooler-z73/4.t.webp
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
static/hardware/cooler-z73/4.webp
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
static/hardware/cpu-12900kf/1.t.webp
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
static/hardware/cpu-12900kf/1.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
static/hardware/cpu-12900kf/2.t.webp
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
static/hardware/cpu-12900kf/2.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
static/hardware/dev-ipad/1.t.webp
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
static/hardware/dev-ipad/1.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
static/hardware/dev-ipad/2.t.webp
Normal file
|
After Width: | Height: | Size: 770 B |
BIN
static/hardware/dev-ipad/2.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
static/hardware/dev-iphone/1.t.webp
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
static/hardware/dev-iphone/1.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
static/hardware/dev-iphone/2.t.webp
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
static/hardware/dev-iphone/2.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
static/hardware/dev-mbp/1.t.webp
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
static/hardware/dev-mbp/1.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
static/hardware/dev-mbp/2.t.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
static/hardware/dev-mbp/2.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
static/hardware/dev-mbp/3.t.webp
Normal file
|
After Width: | Height: | Size: 752 B |
BIN
static/hardware/dev-mbp/3.webp
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
static/hardware/dev-ovh/1.t.webp
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
static/hardware/dev-ovh/1.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
static/hardware/dev-pixel/1.t.webp
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
static/hardware/dev-pixel/1.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
static/hardware/dev-pixel/2.t.webp
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
static/hardware/dev-pixel/2.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
static/hardware/dev-pixel/3.t.webp
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
static/hardware/dev-pixel/3.webp
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
static/hardware/fans-kraken-stock/1.t.webp
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
static/hardware/fans-kraken-stock/1.webp
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
static/hardware/fans-slinf/1.t.webp
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
static/hardware/fans-slinf/1.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
static/hardware/fans-slinf/2.t.webp
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
static/hardware/fans-slinf/2.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
static/hardware/fans-slinf/3.t.webp
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
static/hardware/fans-slinf/3.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
static/hardware/fans-slinf/4.t.webp
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
static/hardware/fans-slinf/4.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
static/hardware/gpu-7900xt/1.t.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
static/hardware/gpu-7900xt/1.webp
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
static/hardware/hp-wl7024/1.t.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
static/hardware/hp-wl7024/1.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
static/hardware/hp-wl7024/2.t.webp
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
static/hardware/hp-wl7024/2.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
static/hardware/hp-wl7024/3.t.webp
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
static/hardware/hp-wl7024/3.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
static/hardware/hp-xm3/1.t.webp
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
static/hardware/hp-xm3/1.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
static/hardware/hp-xm3/2.t.webp
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
static/hardware/hp-xm3/2.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
static/hardware/hp-xm3/3.t.webp
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
static/hardware/hp-xm3/3.webp
Normal file
|
After Width: | Height: | Size: 31 KiB |