diff --git a/.gitignore b/.gitignore index 5ef6a52..031ed9c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# database +/data/*.db diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..84b80e6 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/lib/db/schema.ts', + out: './drizzle', + dialect: 'sqlite', + dbCredentials: { + url: './data/battery_tracker.db', + }, +}); diff --git a/drizzle/0000_blue_anthem.sql b/drizzle/0000_blue_anthem.sql new file mode 100644 index 0000000..247e4fe --- /dev/null +++ b/drizzle/0000_blue_anthem.sql @@ -0,0 +1,47 @@ +CREATE TABLE `battery_groups` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `brand_id` integer NOT NULL, + `type_id` integer NOT NULL, + `available_count` integer DEFAULT 0 NOT NULL, + `charging_count` integer DEFAULT 0 NOT NULL, + `notes` text, + `created_at` text DEFAULT (datetime('now')), + `updated_at` text DEFAULT (datetime('now')), + FOREIGN KEY (`brand_id`) REFERENCES `brands`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`type_id`) REFERENCES `battery_types`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `brand_type_idx` ON `battery_groups` (`brand_id`,`type_id`);--> statement-breakpoint +CREATE TABLE `battery_types` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `is_custom` integer DEFAULT false, + `created_at` text DEFAULT (datetime('now')) +); +--> statement-breakpoint +CREATE UNIQUE INDEX `battery_types_name_unique` ON `battery_types` (`name`);--> statement-breakpoint +CREATE TABLE `brands` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `created_at` text DEFAULT (datetime('now')) +); +--> statement-breakpoint +CREATE UNIQUE INDEX `brands_name_unique` ON `brands` (`name`);--> statement-breakpoint +CREATE TABLE `device_batteries` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `device_id` integer NOT NULL, + `battery_group_id` integer NOT NULL, + `quantity` integer DEFAULT 1 NOT NULL, + `assigned_at` text DEFAULT (datetime('now')), + FOREIGN KEY (`device_id`) REFERENCES `devices`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`battery_group_id`) REFERENCES `battery_groups`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `device_battery_idx` ON `device_batteries` (`device_id`,`battery_group_id`);--> statement-breakpoint +CREATE TABLE `devices` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `description` text, + `created_at` text DEFAULT (datetime('now')), + `updated_at` text DEFAULT (datetime('now')) +); diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..a8679db --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,342 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e977fded-def0-45ae-92e8-0aca8a9f29e0", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "battery_groups": { + "name": "battery_groups", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "brand_id": { + "name": "brand_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type_id": { + "name": "type_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "available_count": { + "name": "available_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "charging_count": { + "name": "charging_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "brand_type_idx": { + "name": "brand_type_idx", + "columns": [ + "brand_id", + "type_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "battery_groups_brand_id_brands_id_fk": { + "name": "battery_groups_brand_id_brands_id_fk", + "tableFrom": "battery_groups", + "tableTo": "brands", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "battery_groups_type_id_battery_types_id_fk": { + "name": "battery_groups_type_id_battery_types_id_fk", + "tableFrom": "battery_groups", + "tableTo": "battery_types", + "columnsFrom": [ + "type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "battery_types": { + "name": "battery_types", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_custom": { + "name": "is_custom", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "battery_types_name_unique": { + "name": "battery_types_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "brands": { + "name": "brands", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "brands_name_unique": { + "name": "brands_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_batteries": { + "name": "device_batteries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "device_id": { + "name": "device_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "battery_group_id": { + "name": "battery_group_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "assigned_at": { + "name": "assigned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "device_battery_idx": { + "name": "device_battery_idx", + "columns": [ + "device_id", + "battery_group_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "device_batteries_device_id_devices_id_fk": { + "name": "device_batteries_device_id_devices_id_fk", + "tableFrom": "device_batteries", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "device_batteries_battery_group_id_battery_groups_id_fk": { + "name": "device_batteries_battery_group_id_battery_groups_id_fk", + "tableFrom": "device_batteries", + "tableTo": "battery_groups", + "columnsFrom": [ + "battery_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "devices": { + "name": "devices", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..a43509c --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1768838495290, + "tag": "0000_blue_anthem", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cfcc07c..85a2e50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,15 +8,20 @@ "name": "battery_tracker", "version": "0.1.0", "dependencies": { + "better-sqlite3": "^12.6.2", + "drizzle-orm": "^0.45.1", + "lucide-react": "^0.562.0", "next": "16.1.3", "react": "19.2.3", "react-dom": "19.2.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "drizzle-kit": "^0.31.8", "eslint": "^9", "eslint-config-next": "16.1.3", "tailwindcss": "^4", @@ -276,6 +281,13 @@ "node": ">=6.9.0" } }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -309,6 +321,884 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1524,6 +2414,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1549,7 +2449,7 @@ "version": "20.19.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2406,6 +3306,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.9.15", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", @@ -2415,6 +3335,40 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/better-sqlite3": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2473,6 +3427,37 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2570,6 +3555,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2711,6 +3702,30 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2758,7 +3773,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -2777,6 +3791,147 @@ "node": ">=0.10.0" } }, + "node_modules/drizzle-kit": { + "version": "0.31.8", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.8.tgz", + "integrity": "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "esbuild-register": "^3.5.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", + "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2806,6 +3961,15 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", @@ -2997,6 +4161,61 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esbuild-register": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", + "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "esbuild": ">=0.12 <1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3444,6 +4663,15 @@ "node": ">=0.10.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3518,6 +4746,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3585,6 +4819,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3716,6 +4956,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3890,6 +5136,26 @@ "hermes-estree": "0.25.1" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3927,6 +5193,18 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4833,6 +6111,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4877,6 +6164,18 @@ "node": ">=8.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4894,12 +6193,17 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4925,6 +6229,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -5029,6 +6339,30 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-abi": { + "version": "3.86.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.86.0.tgz", + "integrity": "sha512-sn9Et4N3ynsetj3spsZR729DVlGH6iBG4RiDMV7HEp3guyOW6W3S0unGpLDxT50mXortGUMax/ykUNQXdqc/Xg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -5159,6 +6493,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5325,6 +6668,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5347,6 +6716,16 @@ "react-is": "^16.13.1" } }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5378,6 +6757,30 @@ ], "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", @@ -5406,6 +6809,20 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5546,6 +6963,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -5803,6 +7240,61 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5812,6 +7304,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -5833,6 +7336,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -6039,6 +7551,34 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6145,6 +7685,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6297,7 +7849,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -6376,6 +7928,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6491,6 +8049,12 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index fad8b9b..3dc2a16 100644 --- a/package.json +++ b/package.json @@ -9,15 +9,20 @@ "lint": "eslint" }, "dependencies": { + "better-sqlite3": "^12.6.2", + "drizzle-orm": "^0.45.1", + "lucide-react": "^0.562.0", "next": "16.1.3", "react": "19.2.3", "react-dom": "19.2.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "drizzle-kit": "^0.31.8", "eslint": "^9", "eslint-config-next": "16.1.3", "tailwindcss": "^4", diff --git a/src/app/api/batteries/[id]/assign/route.ts b/src/app/api/batteries/[id]/assign/route.ts new file mode 100644 index 0000000..a3d57fc --- /dev/null +++ b/src/app/api/batteries/[id]/assign/route.ts @@ -0,0 +1,98 @@ +import { NextResponse } from 'next/server'; +import { db, schema } from '@/lib/db'; +import { eq, and, sql } from 'drizzle-orm'; + +// Assign batteries from available to a device +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const batteryId = parseInt(id); + + if (isNaN(batteryId)) { + return NextResponse.json({ error: 'Invalid ID' }, { status: 400 }); + } + + const body = await request.json(); + const { deviceId, quantity } = body; + + if (!deviceId || !quantity || quantity < 1) { + return NextResponse.json({ error: 'Device ID and quantity are required' }, { status: 400 }); + } + + // Check available count + const battery = await db + .select() + .from(schema.batteryGroups) + .where(eq(schema.batteryGroups.id, batteryId)) + .limit(1); + + if (battery.length === 0) { + return NextResponse.json({ error: 'Battery group not found' }, { status: 404 }); + } + + if (battery[0].availableCount < quantity) { + return NextResponse.json({ error: 'Not enough available batteries' }, { status: 400 }); + } + + // Check device exists + const device = await db + .select() + .from(schema.devices) + .where(eq(schema.devices.id, deviceId)) + .limit(1); + + if (device.length === 0) { + return NextResponse.json({ error: 'Device not found' }, { status: 404 }); + } + + // Check if there's an existing assignment + const existingAssignment = await db + .select() + .from(schema.deviceBatteries) + .where( + and( + eq(schema.deviceBatteries.deviceId, deviceId), + eq(schema.deviceBatteries.batteryGroupId, batteryId) + ) + ) + .limit(1); + + // Start transaction-like operations + // Decrement available count + await db + .update(schema.batteryGroups) + .set({ + availableCount: sql`${schema.batteryGroups.availableCount} - ${quantity}`, + updatedAt: sql`datetime('now')`, + }) + .where(eq(schema.batteryGroups.id, batteryId)); + + if (existingAssignment.length > 0) { + // Update existing assignment + await db + .update(schema.deviceBatteries) + .set({ + quantity: sql`${schema.deviceBatteries.quantity} + ${quantity}`, + assignedAt: sql`datetime('now')`, + }) + .where(eq(schema.deviceBatteries.id, existingAssignment[0].id)); + } else { + // Create new assignment + await db + .insert(schema.deviceBatteries) + .values({ + deviceId, + batteryGroupId: batteryId, + quantity, + }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Failed to assign batteries:', error); + return NextResponse.json({ error: 'Failed to assign batteries' }, { status: 500 }); + } +} diff --git a/src/app/api/batteries/[id]/available/route.ts b/src/app/api/batteries/[id]/available/route.ts new file mode 100644 index 0000000..5ca6956 --- /dev/null +++ b/src/app/api/batteries/[id]/available/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from 'next/server'; +import { db, schema } from '@/lib/db'; +import { eq, sql } from 'drizzle-orm'; + +// Move batteries from charging to available (charging complete) +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const batteryId = parseInt(id); + + if (isNaN(batteryId)) { + return NextResponse.json({ error: 'Invalid ID' }, { status: 400 }); + } + + const body = await request.json(); + const { count } = body; + + if (!count || count < 1) { + return NextResponse.json({ error: 'Count must be at least 1' }, { status: 400 }); + } + + // Check charging count + const battery = await db + .select() + .from(schema.batteryGroups) + .where(eq(schema.batteryGroups.id, batteryId)) + .limit(1); + + if (battery.length === 0) { + return NextResponse.json({ error: 'Battery group not found' }, { status: 404 }); + } + + if (battery[0].chargingCount < count) { + return NextResponse.json({ error: 'Not enough batteries charging' }, { status: 400 }); + } + + const result = await db + .update(schema.batteryGroups) + .set({ + chargingCount: sql`${schema.batteryGroups.chargingCount} - ${count}`, + availableCount: sql`${schema.batteryGroups.availableCount} + ${count}`, + updatedAt: sql`datetime('now')`, + }) + .where(eq(schema.batteryGroups.id, batteryId)) + .returning(); + + return NextResponse.json(result[0]); + } catch (error) { + console.error('Failed to mark available:', error); + return NextResponse.json({ error: 'Failed to mark available' }, { status: 500 }); + } +} diff --git a/src/app/api/batteries/[id]/charge/route.ts b/src/app/api/batteries/[id]/charge/route.ts new file mode 100644 index 0000000..f003c59 --- /dev/null +++ b/src/app/api/batteries/[id]/charge/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from 'next/server'; +import { db, schema } from '@/lib/db'; +import { eq, sql } from 'drizzle-orm'; + +// Move batteries from available to charging +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const batteryId = parseInt(id); + + if (isNaN(batteryId)) { + return NextResponse.json({ error: 'Invalid ID' }, { status: 400 }); + } + + const body = await request.json(); + const { count } = body; + + if (!count || count < 1) { + return NextResponse.json({ error: 'Count must be at least 1' }, { status: 400 }); + } + + // Check available count + const battery = await db + .select() + .from(schema.batteryGroups) + .where(eq(schema.batteryGroups.id, batteryId)) + .limit(1); + + if (battery.length === 0) { + return NextResponse.json({ error: 'Battery group not found' }, { status: 404 }); + } + + if (battery[0].availableCount < count) { + return NextResponse.json({ error: 'Not enough available batteries' }, { status: 400 }); + } + + const result = await db + .update(schema.batteryGroups) + .set({ + availableCount: sql`${schema.batteryGroups.availableCount} - ${count}`, + chargingCount: sql`${schema.batteryGroups.chargingCount} + ${count}`, + updatedAt: sql`datetime('now')`, + }) + .where(eq(schema.batteryGroups.id, batteryId)) + .returning(); + + return NextResponse.json(result[0]); + } catch (error) { + console.error('Failed to start charging:', error); + return NextResponse.json({ error: 'Failed to start charging' }, { status: 500 }); + } +} diff --git a/src/app/api/batteries/[id]/route.ts b/src/app/api/batteries/[id]/route.ts new file mode 100644 index 0000000..7925341 --- /dev/null +++ b/src/app/api/batteries/[id]/route.ts @@ -0,0 +1,151 @@ +import { NextResponse } from 'next/server'; +import { db, schema } from '@/lib/db'; +import { eq, sql } from 'drizzle-orm'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const batteryId = parseInt(id); + + if (isNaN(batteryId)) { + return NextResponse.json({ error: 'Invalid ID' }, { status: 400 }); + } + + const result = await db + .select({ + id: schema.batteryGroups.id, + brandId: schema.batteryGroups.brandId, + typeId: schema.batteryGroups.typeId, + availableCount: schema.batteryGroups.availableCount, + chargingCount: schema.batteryGroups.chargingCount, + notes: schema.batteryGroups.notes, + createdAt: schema.batteryGroups.createdAt, + updatedAt: schema.batteryGroups.updatedAt, + brandName: schema.brands.name, + typeName: schema.batteryTypes.name, + }) + .from(schema.batteryGroups) + .innerJoin(schema.brands, eq(schema.batteryGroups.brandId, schema.brands.id)) + .innerJoin(schema.batteryTypes, eq(schema.batteryGroups.typeId, schema.batteryTypes.id)) + .where(eq(schema.batteryGroups.id, batteryId)) + .limit(1); + + if (result.length === 0) { + return NextResponse.json({ error: 'Battery group not found' }, { status: 404 }); + } + + // Get in-use count + const inUseResult = await db + .select({ + inUseCount: sql`SUM(${schema.deviceBatteries.quantity})`.as('in_use_count'), + }) + .from(schema.deviceBatteries) + .where(eq(schema.deviceBatteries.batteryGroupId, batteryId)); + + // Get devices using this battery + const deviceAssignments = await db + .select({ + deviceId: schema.devices.id, + deviceName: schema.devices.name, + quantity: schema.deviceBatteries.quantity, + assignedAt: schema.deviceBatteries.assignedAt, + }) + .from(schema.deviceBatteries) + .innerJoin(schema.devices, eq(schema.deviceBatteries.deviceId, schema.devices.id)) + .where(eq(schema.deviceBatteries.batteryGroupId, batteryId)); + + return NextResponse.json({ + ...result[0], + inUseCount: inUseResult[0]?.inUseCount || 0, + deviceAssignments, + }); + } catch (error) { + console.error('Failed to fetch battery:', error); + return NextResponse.json({ error: 'Failed to fetch battery' }, { status: 500 }); + } +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const batteryId = parseInt(id); + + if (isNaN(batteryId)) { + return NextResponse.json({ error: 'Invalid ID' }, { status: 400 }); + } + + const body = await request.json(); + const { availableCount, chargingCount, notes } = body; + + const updateData: Record = { + updatedAt: sql`datetime('now')`, + }; + + if (availableCount !== undefined) updateData.availableCount = availableCount; + if (chargingCount !== undefined) updateData.chargingCount = chargingCount; + if (notes !== undefined) updateData.notes = notes; + + const result = await db + .update(schema.batteryGroups) + .set(updateData) + .where(eq(schema.batteryGroups.id, batteryId)) + .returning(); + + if (result.length === 0) { + return NextResponse.json({ error: 'Battery group not found' }, { status: 404 }); + } + + return NextResponse.json(result[0]); + } catch (error) { + console.error('Failed to update battery:', error); + return NextResponse.json({ error: 'Failed to update battery' }, { status: 500 }); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const batteryId = parseInt(id); + + if (isNaN(batteryId)) { + return NextResponse.json({ error: 'Invalid ID' }, { status: 400 }); + } + + // Check if any devices are using this battery group + const assignments = await db + .select() + .from(schema.deviceBatteries) + .where(eq(schema.deviceBatteries.batteryGroupId, batteryId)) + .limit(1); + + if (assignments.length > 0) { + return NextResponse.json( + { error: 'Cannot delete battery group while batteries are assigned to devices' }, + { status: 400 } + ); + } + + const result = await db + .delete(schema.batteryGroups) + .where(eq(schema.batteryGroups.id, batteryId)) + .returning(); + + if (result.length === 0) { + return NextResponse.json({ error: 'Battery group not found' }, { status: 404 }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Failed to delete battery:', error); + return NextResponse.json({ error: 'Failed to delete battery' }, { status: 500 }); + } +} diff --git a/src/app/api/batteries/route.ts b/src/app/api/batteries/route.ts new file mode 100644 index 0000000..c039ec9 --- /dev/null +++ b/src/app/api/batteries/route.ts @@ -0,0 +1,74 @@ +import { NextResponse } from 'next/server'; +import { db, schema } from '@/lib/db'; +import { eq, sql } from 'drizzle-orm'; + +export async function GET() { + try { + // Get all battery groups with brand and type info, plus in-use count from device_batteries + const groups = await db + .select({ + id: schema.batteryGroups.id, + brandId: schema.batteryGroups.brandId, + typeId: schema.batteryGroups.typeId, + availableCount: schema.batteryGroups.availableCount, + chargingCount: schema.batteryGroups.chargingCount, + notes: schema.batteryGroups.notes, + createdAt: schema.batteryGroups.createdAt, + updatedAt: schema.batteryGroups.updatedAt, + brandName: schema.brands.name, + typeName: schema.batteryTypes.name, + }) + .from(schema.batteryGroups) + .innerJoin(schema.brands, eq(schema.batteryGroups.brandId, schema.brands.id)) + .innerJoin(schema.batteryTypes, eq(schema.batteryGroups.typeId, schema.batteryTypes.id)) + .orderBy(schema.brands.name, schema.batteryTypes.name); + + // Get in-use counts for each battery group + const inUseCounts = await db + .select({ + batteryGroupId: schema.deviceBatteries.batteryGroupId, + inUseCount: sql`SUM(${schema.deviceBatteries.quantity})`.as('in_use_count'), + }) + .from(schema.deviceBatteries) + .groupBy(schema.deviceBatteries.batteryGroupId); + + const inUseMap = new Map(inUseCounts.map((r) => [r.batteryGroupId, r.inUseCount || 0])); + + const result = groups.map((g) => ({ + ...g, + inUseCount: inUseMap.get(g.id) || 0, + })); + + return NextResponse.json(result); + } catch (error) { + console.error('Failed to fetch batteries:', error); + return NextResponse.json({ error: 'Failed to fetch batteries' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { brandId, typeId, availableCount, chargingCount, notes } = body; + + if (!brandId || !typeId) { + return NextResponse.json({ error: 'Brand and type are required' }, { status: 400 }); + } + + const result = await db + .insert(schema.batteryGroups) + .values({ + brandId, + typeId, + availableCount: availableCount || 0, + chargingCount: chargingCount || 0, + notes, + }) + .returning(); + + return NextResponse.json(result[0], { status: 201 }); + } catch (error) { + console.error('Failed to create battery group:', error); + return NextResponse.json({ error: 'Failed to create battery group' }, { status: 500 }); + } +} diff --git a/src/app/api/brands/route.ts b/src/app/api/brands/route.ts new file mode 100644 index 0000000..c32f56c --- /dev/null +++ b/src/app/api/brands/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; +import { db, schema } from '@/lib/db'; + +export async function GET() { + try { + const brands = await db.select().from(schema.brands).orderBy(schema.brands.name); + return NextResponse.json(brands); + } catch (error) { + console.error('Failed to fetch brands:', error); + return NextResponse.json({ error: 'Failed to fetch brands' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { name } = body; + + if (!name || typeof name !== 'string') { + return NextResponse.json({ error: 'Name is required' }, { status: 400 }); + } + + const result = await db + .insert(schema.brands) + .values({ name: name.trim() }) + .returning(); + + return NextResponse.json(result[0], { status: 201 }); + } catch (error) { + console.error('Failed to create brand:', error); + return NextResponse.json({ error: 'Failed to create brand' }, { status: 500 }); + } +} diff --git a/src/app/api/devices/[id]/batteries/route.ts b/src/app/api/devices/[id]/batteries/route.ts new file mode 100644 index 0000000..da05bc4 --- /dev/null +++ b/src/app/api/devices/[id]/batteries/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from 'next/server'; +import { db, schema } from '@/lib/db'; +import { eq, and, sql } from 'drizzle-orm'; + +// Remove batteries from device (return to available or charging) +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const deviceId = parseInt(id); + + if (isNaN(deviceId)) { + return NextResponse.json({ error: 'Invalid device ID' }, { status: 400 }); + } + + const { searchParams } = new URL(request.url); + const batteryGroupId = parseInt(searchParams.get('batteryGroupId') || ''); + const quantity = parseInt(searchParams.get('quantity') || '0'); + const destination = searchParams.get('destination') || 'available'; // 'available' or 'charging' + + if (isNaN(batteryGroupId)) { + return NextResponse.json({ error: 'Invalid battery group ID' }, { status: 400 }); + } + + // Get current assignment + const assignment = await db + .select() + .from(schema.deviceBatteries) + .where( + and( + eq(schema.deviceBatteries.deviceId, deviceId), + eq(schema.deviceBatteries.batteryGroupId, batteryGroupId) + ) + ) + .limit(1); + + if (assignment.length === 0) { + return NextResponse.json({ error: 'Assignment not found' }, { status: 404 }); + } + + const removeCount = quantity > 0 ? Math.min(quantity, assignment[0].quantity) : assignment[0].quantity; + + // Update or delete assignment + if (removeCount >= assignment[0].quantity) { + // Remove entire assignment + await db + .delete(schema.deviceBatteries) + .where(eq(schema.deviceBatteries.id, assignment[0].id)); + } else { + // Reduce quantity + await db + .update(schema.deviceBatteries) + .set({ + quantity: sql`${schema.deviceBatteries.quantity} - ${removeCount}`, + }) + .where(eq(schema.deviceBatteries.id, assignment[0].id)); + } + + // Return batteries to the specified destination + if (destination === 'charging') { + await db + .update(schema.batteryGroups) + .set({ + chargingCount: sql`${schema.batteryGroups.chargingCount} + ${removeCount}`, + updatedAt: sql`datetime('now')`, + }) + .where(eq(schema.batteryGroups.id, batteryGroupId)); + } else { + await db + .update(schema.batteryGroups) + .set({ + availableCount: sql`${schema.batteryGroups.availableCount} + ${removeCount}`, + updatedAt: sql`datetime('now')`, + }) + .where(eq(schema.batteryGroups.id, batteryGroupId)); + } + + return NextResponse.json({ success: true, removed: removeCount }); + } catch (error) { + console.error('Failed to remove batteries from device:', error); + return NextResponse.json({ error: 'Failed to remove batteries' }, { status: 500 }); + } +} diff --git a/src/app/api/devices/[id]/route.ts b/src/app/api/devices/[id]/route.ts new file mode 100644 index 0000000..34afd48 --- /dev/null +++ b/src/app/api/devices/[id]/route.ts @@ -0,0 +1,136 @@ +import { NextResponse } from 'next/server'; +import { db, schema } from '@/lib/db'; +import { eq, sql } from 'drizzle-orm'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const deviceId = parseInt(id); + + if (isNaN(deviceId)) { + return NextResponse.json({ error: 'Invalid ID' }, { status: 400 }); + } + + const device = await db + .select() + .from(schema.devices) + .where(eq(schema.devices.id, deviceId)) + .limit(1); + + if (device.length === 0) { + return NextResponse.json({ error: 'Device not found' }, { status: 404 }); + } + + // Get battery assignments + const assignments = await db + .select({ + id: schema.deviceBatteries.id, + batteryGroupId: schema.deviceBatteries.batteryGroupId, + quantity: schema.deviceBatteries.quantity, + assignedAt: schema.deviceBatteries.assignedAt, + brandName: schema.brands.name, + typeName: schema.batteryTypes.name, + }) + .from(schema.deviceBatteries) + .innerJoin(schema.batteryGroups, eq(schema.deviceBatteries.batteryGroupId, schema.batteryGroups.id)) + .innerJoin(schema.brands, eq(schema.batteryGroups.brandId, schema.brands.id)) + .innerJoin(schema.batteryTypes, eq(schema.batteryGroups.typeId, schema.batteryTypes.id)) + .where(eq(schema.deviceBatteries.deviceId, deviceId)); + + return NextResponse.json({ + ...device[0], + batteries: assignments, + }); + } catch (error) { + console.error('Failed to fetch device:', error); + return NextResponse.json({ error: 'Failed to fetch device' }, { status: 500 }); + } +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const deviceId = parseInt(id); + + if (isNaN(deviceId)) { + return NextResponse.json({ error: 'Invalid ID' }, { status: 400 }); + } + + const body = await request.json(); + const { name, description } = body; + + const updateData: Record = { + updatedAt: sql`datetime('now')`, + }; + + if (name !== undefined) updateData.name = name.trim(); + if (description !== undefined) updateData.description = description; + + const result = await db + .update(schema.devices) + .set(updateData) + .where(eq(schema.devices.id, deviceId)) + .returning(); + + if (result.length === 0) { + return NextResponse.json({ error: 'Device not found' }, { status: 404 }); + } + + return NextResponse.json(result[0]); + } catch (error) { + console.error('Failed to update device:', error); + return NextResponse.json({ error: 'Failed to update device' }, { status: 500 }); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const deviceId = parseInt(id); + + if (isNaN(deviceId)) { + return NextResponse.json({ error: 'Invalid ID' }, { status: 400 }); + } + + // Get all battery assignments before deleting + const assignments = await db + .select() + .from(schema.deviceBatteries) + .where(eq(schema.deviceBatteries.deviceId, deviceId)); + + // Return batteries to available + for (const assignment of assignments) { + await db + .update(schema.batteryGroups) + .set({ + availableCount: sql`${schema.batteryGroups.availableCount} + ${assignment.quantity}`, + updatedAt: sql`datetime('now')`, + }) + .where(eq(schema.batteryGroups.id, assignment.batteryGroupId)); + } + + // Delete device (cascade will remove assignments) + const result = await db + .delete(schema.devices) + .where(eq(schema.devices.id, deviceId)) + .returning(); + + if (result.length === 0) { + return NextResponse.json({ error: 'Device not found' }, { status: 404 }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Failed to delete device:', error); + return NextResponse.json({ error: 'Failed to delete device' }, { status: 500 }); + } +} diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts new file mode 100644 index 0000000..627531e --- /dev/null +++ b/src/app/api/devices/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from 'next/server'; +import { db, schema } from '@/lib/db'; +import { eq, sql } from 'drizzle-orm'; + +export async function GET() { + try { + // Get all devices + const devices = await db + .select() + .from(schema.devices) + .orderBy(schema.devices.name); + + // Get battery assignments for all devices + const assignments = await db + .select({ + deviceId: schema.deviceBatteries.deviceId, + batteryGroupId: schema.deviceBatteries.batteryGroupId, + quantity: schema.deviceBatteries.quantity, + assignedAt: schema.deviceBatteries.assignedAt, + brandName: schema.brands.name, + typeName: schema.batteryTypes.name, + }) + .from(schema.deviceBatteries) + .innerJoin(schema.batteryGroups, eq(schema.deviceBatteries.batteryGroupId, schema.batteryGroups.id)) + .innerJoin(schema.brands, eq(schema.batteryGroups.brandId, schema.brands.id)) + .innerJoin(schema.batteryTypes, eq(schema.batteryGroups.typeId, schema.batteryTypes.id)); + + // Group assignments by device + const assignmentsByDevice = new Map(); + for (const assignment of assignments) { + const existing = assignmentsByDevice.get(assignment.deviceId) || []; + existing.push(assignment); + assignmentsByDevice.set(assignment.deviceId, existing); + } + + const result = devices.map((device) => ({ + ...device, + batteries: assignmentsByDevice.get(device.id) || [], + totalBatteries: (assignmentsByDevice.get(device.id) || []).reduce( + (sum, a) => sum + a.quantity, + 0 + ), + })); + + return NextResponse.json(result); + } catch (error) { + console.error('Failed to fetch devices:', error); + return NextResponse.json({ error: 'Failed to fetch devices' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { name, description } = body; + + if (!name || typeof name !== 'string') { + return NextResponse.json({ error: 'Name is required' }, { status: 400 }); + } + + const result = await db + .insert(schema.devices) + .values({ name: name.trim(), description }) + .returning(); + + return NextResponse.json(result[0], { status: 201 }); + } catch (error) { + console.error('Failed to create device:', error); + return NextResponse.json({ error: 'Failed to create device' }, { status: 500 }); + } +} diff --git a/src/app/api/stats/route.ts b/src/app/api/stats/route.ts new file mode 100644 index 0000000..cf570ca --- /dev/null +++ b/src/app/api/stats/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from 'next/server'; +import { db, schema } from '@/lib/db'; +import { sql } from 'drizzle-orm'; + +export async function GET() { + try { + // Get total available and charging counts + const batteryCounts = await db + .select({ + available: sql`COALESCE(SUM(${schema.batteryGroups.availableCount}), 0)`, + charging: sql`COALESCE(SUM(${schema.batteryGroups.chargingCount}), 0)`, + }) + .from(schema.batteryGroups); + + // Get total in-use count + const inUseCounts = await db + .select({ + inUse: sql`COALESCE(SUM(${schema.deviceBatteries.quantity}), 0)`, + }) + .from(schema.deviceBatteries); + + // Get counts by type + const byType = await db + .select({ + typeName: schema.batteryTypes.name, + available: sql`COALESCE(SUM(${schema.batteryGroups.availableCount}), 0)`, + charging: sql`COALESCE(SUM(${schema.batteryGroups.chargingCount}), 0)`, + }) + .from(schema.batteryGroups) + .innerJoin(schema.batteryTypes, sql`${schema.batteryGroups.typeId} = ${schema.batteryTypes.id}`) + .groupBy(schema.batteryTypes.name) + .orderBy(schema.batteryTypes.name); + + // Get device count + const deviceCount = await db + .select({ + count: sql`COUNT(*)`, + }) + .from(schema.devices); + + // Get battery group count + const groupCount = await db + .select({ + count: sql`COUNT(*)`, + }) + .from(schema.batteryGroups); + + return NextResponse.json({ + totals: { + available: batteryCounts[0]?.available || 0, + charging: batteryCounts[0]?.charging || 0, + inUse: inUseCounts[0]?.inUse || 0, + total: + (batteryCounts[0]?.available || 0) + + (batteryCounts[0]?.charging || 0) + + (inUseCounts[0]?.inUse || 0), + }, + byType, + counts: { + devices: deviceCount[0]?.count || 0, + batteryGroups: groupCount[0]?.count || 0, + }, + }); + } catch (error) { + console.error('Failed to fetch stats:', error); + return NextResponse.json({ error: 'Failed to fetch stats' }, { status: 500 }); + } +} diff --git a/src/app/api/types/route.ts b/src/app/api/types/route.ts new file mode 100644 index 0000000..898f713 --- /dev/null +++ b/src/app/api/types/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; +import { db, schema } from '@/lib/db'; + +export async function GET() { + try { + const types = await db.select().from(schema.batteryTypes).orderBy(schema.batteryTypes.name); + return NextResponse.json(types); + } catch (error) { + console.error('Failed to fetch battery types:', error); + return NextResponse.json({ error: 'Failed to fetch battery types' }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { name } = body; + + if (!name || typeof name !== 'string') { + return NextResponse.json({ error: 'Name is required' }, { status: 400 }); + } + + const result = await db + .insert(schema.batteryTypes) + .values({ name: name.trim(), isCustom: true }) + .returning(); + + return NextResponse.json(result[0], { status: 201 }); + } catch (error) { + console.error('Failed to create battery type:', error); + return NextResponse.json({ error: 'Failed to create battery type' }, { status: 500 }); + } +} diff --git a/src/app/batteries/page.tsx b/src/app/batteries/page.tsx new file mode 100644 index 0000000..2655e8b --- /dev/null +++ b/src/app/batteries/page.tsx @@ -0,0 +1,68 @@ +import { db, schema } from '@/lib/db'; +import { eq, sql } from 'drizzle-orm'; +import { BatteryListClient } from '@/components/battery/BatteryListClient'; + +async function getBatteries() { + const groups = await db + .select({ + id: schema.batteryGroups.id, + brandId: schema.batteryGroups.brandId, + typeId: schema.batteryGroups.typeId, + availableCount: schema.batteryGroups.availableCount, + chargingCount: schema.batteryGroups.chargingCount, + notes: schema.batteryGroups.notes, + brandName: schema.brands.name, + typeName: schema.batteryTypes.name, + }) + .from(schema.batteryGroups) + .innerJoin(schema.brands, eq(schema.batteryGroups.brandId, schema.brands.id)) + .innerJoin(schema.batteryTypes, eq(schema.batteryGroups.typeId, schema.batteryTypes.id)) + .orderBy(schema.brands.name, schema.batteryTypes.name); + + const inUseCounts = await db + .select({ + batteryGroupId: schema.deviceBatteries.batteryGroupId, + inUseCount: sql`SUM(${schema.deviceBatteries.quantity})`.as('in_use_count'), + }) + .from(schema.deviceBatteries) + .groupBy(schema.deviceBatteries.batteryGroupId); + + const inUseMap = new Map(inUseCounts.map((r) => [r.batteryGroupId, r.inUseCount || 0])); + + return groups.map((g) => ({ + ...g, + inUseCount: inUseMap.get(g.id) || 0, + })); +} + +async function getTypes() { + return db.select().from(schema.batteryTypes).orderBy(schema.batteryTypes.name); +} + +async function getBrands() { + return db.select().from(schema.brands).orderBy(schema.brands.name); +} + +async function getDevices() { + return db.select({ id: schema.devices.id, name: schema.devices.name }) + .from(schema.devices) + .orderBy(schema.devices.name); +} + +export default async function BatteriesPage() { + const [batteries, types, brands, devices] = await Promise.all([ + getBatteries(), + getTypes(), + getBrands(), + getDevices(), + ]); + + return ( + + ); +} diff --git a/src/app/devices/page.tsx b/src/app/devices/page.tsx new file mode 100644 index 0000000..1eed277 --- /dev/null +++ b/src/app/devices/page.tsx @@ -0,0 +1,45 @@ +import { db, schema } from '@/lib/db'; +import { eq } from 'drizzle-orm'; +import { DeviceListClient } from '@/components/device/DeviceListClient'; + +async function getDevices() { + const devices = await db + .select() + .from(schema.devices) + .orderBy(schema.devices.name); + + const assignments = await db + .select({ + deviceId: schema.deviceBatteries.deviceId, + batteryGroupId: schema.deviceBatteries.batteryGroupId, + quantity: schema.deviceBatteries.quantity, + brandName: schema.brands.name, + typeName: schema.batteryTypes.name, + }) + .from(schema.deviceBatteries) + .innerJoin(schema.batteryGroups, eq(schema.deviceBatteries.batteryGroupId, schema.batteryGroups.id)) + .innerJoin(schema.brands, eq(schema.batteryGroups.brandId, schema.brands.id)) + .innerJoin(schema.batteryTypes, eq(schema.batteryGroups.typeId, schema.batteryTypes.id)); + + const assignmentsByDevice = new Map(); + for (const assignment of assignments) { + const existing = assignmentsByDevice.get(assignment.deviceId) || []; + existing.push(assignment); + assignmentsByDevice.set(assignment.deviceId, existing); + } + + return devices.map((device) => { + const deviceBatteries = assignmentsByDevice.get(device.id) || []; + return { + ...device, + batteries: deviceBatteries, + totalBatteries: deviceBatteries.reduce((sum, a) => sum + a.quantity, 0), + }; + }); +} + +export default async function DevicesPage() { + const devices = await getDevices(); + + return ; +} diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..f5d1e94 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,8 +1,8 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --background: #f8fafc; + --foreground: #0f172a; } @theme inline { @@ -12,15 +12,32 @@ --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-sans), Arial, Helvetica, sans-serif; +} + +/* Animation for toasts */ +@keyframes slide-in-from-right { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.animate-in { + animation-fill-mode: both; +} + +.slide-in-from-right-5 { + animation-name: slide-in-from-right; +} + +.duration-300 { + animation-duration: 300ms; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..80ce4bc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { Navigation } from "@/components/Navigation"; +import { ToastProvider } from "@/components/ui/Toast"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -13,8 +15,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Battery Tracker", + description: "Track your rechargeable batteries across devices", }; export default function RootLayout({ @@ -25,9 +27,14 @@ export default function RootLayout({ return ( - {children} + + +
+ {children} +
+
); diff --git a/src/app/page.tsx b/src/app/page.tsx index 295f8fd..3d42b3a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,284 @@ -import Image from "next/image"; +import { db, schema } from '@/lib/db'; +import { sql } from 'drizzle-orm'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import { Battery, BatteryCharging, Package, Monitor } from 'lucide-react'; +import Link from 'next/link'; + +async function getStats() { + // Get total available and charging counts + const batteryCounts = await db + .select({ + available: sql`COALESCE(SUM(${schema.batteryGroups.availableCount}), 0)`, + charging: sql`COALESCE(SUM(${schema.batteryGroups.chargingCount}), 0)`, + }) + .from(schema.batteryGroups); + + // Get total in-use count + const inUseCounts = await db + .select({ + inUse: sql`COALESCE(SUM(${schema.deviceBatteries.quantity}), 0)`, + }) + .from(schema.deviceBatteries); + + // Get device count + const deviceCount = await db + .select({ + count: sql`COUNT(*)`, + }) + .from(schema.devices); + + // Get battery group count + const groupCount = await db + .select({ + count: sql`COUNT(*)`, + }) + .from(schema.batteryGroups); + + return { + available: Number(batteryCounts[0]?.available) || 0, + charging: Number(batteryCounts[0]?.charging) || 0, + inUse: Number(inUseCounts[0]?.inUse) || 0, + devices: Number(deviceCount[0]?.count) || 0, + batteryGroups: Number(groupCount[0]?.count) || 0, + }; +} + +async function getRecentBatteries() { + const batteries = await db.query.batteryGroups.findMany({ + with: { + brand: true, + type: true, + }, + orderBy: (batteryGroups, { desc }) => [desc(batteryGroups.updatedAt)], + limit: 5, + }); + return batteries; +} + +async function getRecentDevices() { + const devices = await db.query.devices.findMany({ + with: { + deviceBatteries: { + with: { + batteryGroup: { + with: { + brand: true, + type: true, + }, + }, + }, + }, + }, + orderBy: (devices, { desc }) => [desc(devices.updatedAt)], + limit: 5, + }); + return devices; +} + +export default async function Dashboard() { + const stats = await getStats(); + const recentBatteries = await getRecentBatteries(); + const recentDevices = await getRecentDevices(); + + const total = stats.available + stats.charging + stats.inUse; -export default function Home() { return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. +

+ {/* Header */} +
+

Dashboard

+

Overview of your battery inventory

+
+ + {/* Stats Grid */} +
+ + +
+
+

Available

+

{stats.available}

+
+
+ +
+
+
+
+ + + +
+
+

In Use

+

{stats.inUse}

+
+
+ +
+
+
+
+ + + +
+
+

Charging

+

{stats.charging}

+
+
+ +
+
+
+
+ + + +
+
+

Total

+

{total}

+
+
+ +
+
+
+
+
+ + {/* Quick Stats */} +
+ {/* Recent Batteries */} + + +
+ Recent Batteries + + View all + +
+
+ + {recentBatteries.length === 0 ? ( +
+ +

No batteries yet

+ + Add your first battery + +
+ ) : ( +
+ {recentBatteries.map((battery) => ( +
+
+

+ {battery.brand.name} {battery.type.name} +

+
+ + {battery.availableCount} available + + {battery.chargingCount > 0 && ( + + {battery.chargingCount} charging + + )} +
+
+
+ ))} +
+ )} +
+
+ + {/* Recent Devices */} + + +
+ Devices with Batteries + + View all + +
+
+ + {recentDevices.length === 0 ? ( +
+ +

No devices yet

+ + Add your first device + +
+ ) : ( +
+ {recentDevices.map((device) => { + const totalBatteries = device.deviceBatteries.reduce( + (sum, db) => sum + db.quantity, + 0 + ); + return ( +
+
+

{device.name}

+ {device.description && ( +

{device.description}

+ )} +
+ + {totalBatteries} {totalBatteries === 1 ? 'battery' : 'batteries'} + +
+ ); + })} +
+ )} +
+
+
+ + {/* Empty State */} + {stats.batteryGroups === 0 && ( + + +

+ Get started with Battery Tracker +

+

+ Start by adding your batteries to track their status across your devices.

-
- -
+ Add Batteries + + + )}
); } diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx new file mode 100644 index 0000000..383fd72 --- /dev/null +++ b/src/components/Navigation.tsx @@ -0,0 +1,105 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { Battery, Home, Monitor, Menu, X } from 'lucide-react'; +import { useState } from 'react'; + +const navItems = [ + { href: '/', label: 'Dashboard', icon: Home }, + { href: '/batteries', label: 'Batteries', icon: Battery }, + { href: '/devices', label: 'Devices', icon: Monitor }, +]; + +export function Navigation() { + const pathname = usePathname(); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + + return ( + + ); +} diff --git a/src/components/battery/AddBatteryModal.tsx b/src/components/battery/AddBatteryModal.tsx new file mode 100644 index 0000000..6a3376b --- /dev/null +++ b/src/components/battery/AddBatteryModal.tsx @@ -0,0 +1,200 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Select } from '@/components/ui/Select'; +import { Modal } from '@/components/ui/Modal'; +import { useToast } from '@/components/ui/Toast'; +import { useRouter } from 'next/navigation'; + +interface BatteryType { + id: number; + name: string; +} + +interface Brand { + id: number; + name: string; +} + +interface AddBatteryModalProps { + isOpen: boolean; + onClose: () => void; + types: BatteryType[]; + brands: Brand[]; +} + +export function AddBatteryModal({ isOpen, onClose, types, brands }: AddBatteryModalProps) { + const router = useRouter(); + const { showToast } = useToast(); + const [loading, setLoading] = useState(false); + const [newBrandName, setNewBrandName] = useState(''); + const [showNewBrand, setShowNewBrand] = useState(false); + const [formData, setFormData] = useState({ + brandId: '', + typeId: '', + availableCount: '0', + chargingCount: '0', + notes: '', + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + + try { + let brandId = formData.brandId; + + if (showNewBrand && newBrandName.trim()) { + const brandRes = await fetch('/api/brands', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newBrandName.trim() }), + }); + + if (!brandRes.ok) { + const error = await brandRes.json(); + throw new Error(error.error || 'Failed to create brand'); + } + + const newBrand = await brandRes.json(); + brandId = newBrand.id.toString(); + } + + if (!brandId || !formData.typeId) { + showToast('error', 'Please select a brand and type'); + setLoading(false); + return; + } + + const res = await fetch('/api/batteries', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + brandId: parseInt(brandId), + typeId: parseInt(formData.typeId), + availableCount: parseInt(formData.availableCount) || 0, + chargingCount: parseInt(formData.chargingCount) || 0, + notes: formData.notes || null, + }), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Failed to add battery'); + } + + showToast('success', 'Battery added successfully'); + onClose(); + router.refresh(); + + setFormData({ + brandId: '', + typeId: '', + availableCount: '0', + chargingCount: '0', + notes: '', + }); + setNewBrandName(''); + setShowNewBrand(false); + } catch (error) { + showToast('error', error instanceof Error ? error.message : 'Failed to add battery'); + } finally { + setLoading(false); + } + }; + + const brandOptions = brands.map((b) => ({ value: b.id, label: b.name })); + const typeOptions = types.map((t) => ({ value: t.id, label: t.name })); + + return ( + + + + + } + > +
+ {!showNewBrand ? ( +
+ setNewBrandName(e.target.value)} + placeholder="e.g., Panasonic, Eneloop" + /> + +
+ )} + + setFormData({ ...formData, availableCount: e.target.value })} + /> + setFormData({ ...formData, chargingCount: e.target.value })} + /> + + + setFormData({ ...formData, notes: e.target.value })} + placeholder="Any additional notes..." + /> +
+
+ ); +} diff --git a/src/components/battery/BatteryCard.tsx b/src/components/battery/BatteryCard.tsx new file mode 100644 index 0000000..5b9c3e0 --- /dev/null +++ b/src/components/battery/BatteryCard.tsx @@ -0,0 +1,360 @@ +'use client'; + +import { useState } from 'react'; +import { Card } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { Modal } from '@/components/ui/Modal'; +import { Input } from '@/components/ui/Input'; +import { Select } from '@/components/ui/Select'; +import { useToast } from '@/components/ui/Toast'; +import { useRouter } from 'next/navigation'; +import { Battery, BatteryCharging, Monitor, Trash2, ArrowRight } from 'lucide-react'; + +interface Device { + id: number; + name: string; +} + +interface BatteryGroup { + id: number; + brandName: string; + typeName: string; + availableCount: number; + chargingCount: number; + inUseCount: number; + notes: string | null; +} + +interface BatteryCardProps { + battery: BatteryGroup; + devices: Device[]; +} + +export function BatteryCard({ battery, devices }: BatteryCardProps) { + const router = useRouter(); + const { showToast } = useToast(); + const [actionModal, setActionModal] = useState<'charge' | 'available' | 'assign' | 'delete' | null>(null); + const [count, setCount] = useState('1'); + const [deviceId, setDeviceId] = useState(''); + const [loading, setLoading] = useState(false); + + const total = battery.availableCount + battery.chargingCount + battery.inUseCount; + + const handleCharge = async () => { + setLoading(true); + try { + const res = await fetch(`/api/batteries/${battery.id}/charge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ count: parseInt(count) }), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error); + } + + showToast('success', `${count} battery(s) moved to charging`); + setActionModal(null); + router.refresh(); + } catch (error) { + showToast('error', error instanceof Error ? error.message : 'Failed to start charging'); + } finally { + setLoading(false); + } + }; + + const handleAvailable = async () => { + setLoading(true); + try { + const res = await fetch(`/api/batteries/${battery.id}/available`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ count: parseInt(count) }), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error); + } + + showToast('success', `${count} battery(s) marked as available`); + setActionModal(null); + router.refresh(); + } catch (error) { + showToast('error', error instanceof Error ? error.message : 'Failed to mark as available'); + } finally { + setLoading(false); + } + }; + + const handleAssign = async () => { + setLoading(true); + try { + const res = await fetch(`/api/batteries/${battery.id}/assign`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deviceId: parseInt(deviceId), quantity: parseInt(count) }), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error); + } + + showToast('success', `${count} battery(s) assigned to device`); + setActionModal(null); + router.refresh(); + } catch (error) { + showToast('error', error instanceof Error ? error.message : 'Failed to assign batteries'); + } finally { + setLoading(false); + } + }; + + const handleDelete = async () => { + setLoading(true); + try { + const res = await fetch(`/api/batteries/${battery.id}`, { + method: 'DELETE', + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error); + } + + showToast('success', 'Battery group deleted'); + setActionModal(null); + router.refresh(); + } catch (error) { + showToast('error', error instanceof Error ? error.message : 'Failed to delete'); + } finally { + setLoading(false); + } + }; + + const deviceOptions = devices.map((d) => ({ value: d.id, label: d.name })); + + return ( + <> + +
+
+

+ {battery.brandName} {battery.typeName} +

+

Total: {total} batteries

+
+ +
+ +
+ + + {battery.availableCount} available + + + + {battery.inUseCount} in use + + + + {battery.chargingCount} charging + +
+ + {battery.notes && ( +

{battery.notes}

+ )} + +
+ {battery.availableCount > 0 && ( + <> + + {devices.length > 0 && ( + + )} + + )} + {battery.chargingCount > 0 && ( + + )} +
+
+ + {/* Charge Modal */} + setActionModal(null)} + title="Start Charging" + footer={ +
+ + +
+ } + > +
+

+ Move batteries from available to charging status. +

+ setCount(e.target.value)} + /> +
+ {battery.availableCount} + + {battery.chargingCount} +
+
+
+ + {/* Available Modal */} + setActionModal(null)} + title="Mark as Available" + footer={ +
+ + +
+ } + > +
+

+ Move batteries from charging to available status. +

+ setCount(e.target.value)} + /> +
+ {battery.chargingCount} + + {battery.availableCount} +
+
+
+ + {/* Assign Modal */} + setActionModal(null)} + title="Assign to Device" + footer={ +
+ + +
+ } + > +
+

+ Assign batteries to a device. +

+ setCount(e.target.value)} + /> +
+
+ + {/* Delete Modal */} + setActionModal(null)} + title="Delete Battery Group" + footer={ +
+ + +
+ } + > +

+ Are you sure you want to delete {battery.brandName} {battery.typeName}? + This action cannot be undone. +

+ {battery.inUseCount > 0 && ( +

+ Note: {battery.inUseCount} batteries are currently in use. You must remove them from devices first. +

+ )} +
+ + ); +} diff --git a/src/components/battery/BatteryListClient.tsx b/src/components/battery/BatteryListClient.tsx new file mode 100644 index 0000000..f1c6082 --- /dev/null +++ b/src/components/battery/BatteryListClient.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useState } from 'react'; +import { Plus } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { BatteryCard } from './BatteryCard'; +import { AddBatteryModal } from './AddBatteryModal'; + +interface BatteryType { + id: number; + name: string; +} + +interface Brand { + id: number; + name: string; +} + +interface Device { + id: number; + name: string; +} + +interface BatteryGroup { + id: number; + brandName: string; + typeName: string; + availableCount: number; + chargingCount: number; + inUseCount: number; + notes: string | null; +} + +interface BatteryListClientProps { + batteries: BatteryGroup[]; + types: BatteryType[]; + brands: Brand[]; + devices: Device[]; +} + +export function BatteryListClient({ batteries, types, brands, devices }: BatteryListClientProps) { + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [filter, setFilter] = useState('all'); + + const filteredBatteries = batteries.filter((b) => { + if (filter === 'all') return true; + if (filter === 'available') return b.availableCount > 0; + if (filter === 'charging') return b.chargingCount > 0; + if (filter === 'inUse') return b.inUseCount > 0; + return true; + }); + + return ( +
+ {/* Header */} +
+
+

Batteries

+

Manage your battery inventory

+
+ +
+ + {/* Filters */} +
+ {['all', 'available', 'charging', 'inUse'].map((f) => ( + + ))} +
+ + {/* Battery Grid */} + {filteredBatteries.length === 0 ? ( +
+

+ {batteries.length === 0 + ? 'No batteries yet. Add your first battery to get started.' + : 'No batteries match the current filter.'} +

+
+ ) : ( +
+ {filteredBatteries.map((battery) => ( + + ))} +
+ )} + + {/* Add Battery Modal */} + setIsAddModalOpen(false)} + types={types} + brands={brands} + /> +
+ ); +} diff --git a/src/components/device/AddDeviceModal.tsx b/src/components/device/AddDeviceModal.tsx new file mode 100644 index 0000000..9d3dd40 --- /dev/null +++ b/src/components/device/AddDeviceModal.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Modal } from '@/components/ui/Modal'; +import { useToast } from '@/components/ui/Toast'; +import { useRouter } from 'next/navigation'; + +interface AddDeviceModalProps { + isOpen: boolean; + onClose: () => void; +} + +export function AddDeviceModal({ isOpen, onClose }: AddDeviceModalProps) { + const router = useRouter(); + const { showToast } = useToast(); + const [loading, setLoading] = useState(false); + const [formData, setFormData] = useState({ + name: '', + description: '', + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!formData.name.trim()) { + showToast('error', 'Please enter a device name'); + return; + } + + setLoading(true); + + try { + const res = await fetch('/api/devices', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: formData.name.trim(), + description: formData.description.trim() || null, + }), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || 'Failed to add device'); + } + + showToast('success', 'Device added successfully'); + onClose(); + router.refresh(); + + setFormData({ name: '', description: '' }); + } catch (error) { + showToast('error', error instanceof Error ? error.message : 'Failed to add device'); + } finally { + setLoading(false); + } + }; + + return ( + + + + + } + > +
+ setFormData({ ...formData, name: e.target.value })} + placeholder="e.g., Wireless Keyboard, TV Remote" + /> + + setFormData({ ...formData, description: e.target.value })} + placeholder="e.g., Living room, Office desk" + /> +
+
+ ); +} diff --git a/src/components/device/DeviceCard.tsx b/src/components/device/DeviceCard.tsx new file mode 100644 index 0000000..93b364e --- /dev/null +++ b/src/components/device/DeviceCard.tsx @@ -0,0 +1,247 @@ +'use client'; + +import { useState } from 'react'; +import { Card } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { Modal } from '@/components/ui/Modal'; +import { useToast } from '@/components/ui/Toast'; +import { useRouter } from 'next/navigation'; +import { Monitor, Trash2, ArrowRight, Battery } from 'lucide-react'; + +interface BatteryAssignment { + batteryGroupId: number; + quantity: number; + brandName: string; + typeName: string; +} + +interface Device { + id: number; + name: string; + description: string | null; + batteries: BatteryAssignment[]; + totalBatteries: number; +} + +interface DeviceCardProps { + device: Device; +} + +export function DeviceCard({ device }: DeviceCardProps) { + const router = useRouter(); + const { showToast } = useToast(); + const [actionModal, setActionModal] = useState<'delete' | 'remove' | null>(null); + const [selectedBattery, setSelectedBattery] = useState(null); + const [removeDestination, setRemoveDestination] = useState<'available' | 'charging'>('available'); + const [loading, setLoading] = useState(false); + + const handleDelete = async () => { + setLoading(true); + try { + const res = await fetch(`/api/devices/${device.id}`, { + method: 'DELETE', + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error); + } + + showToast('success', 'Device deleted, batteries returned to available'); + setActionModal(null); + router.refresh(); + } catch (error) { + showToast('error', error instanceof Error ? error.message : 'Failed to delete'); + } finally { + setLoading(false); + } + }; + + const handleRemoveBatteries = async () => { + if (!selectedBattery) return; + + setLoading(true); + try { + const params = new URLSearchParams({ + batteryGroupId: selectedBattery.batteryGroupId.toString(), + destination: removeDestination, + }); + + const res = await fetch(`/api/devices/${device.id}/batteries?${params}`, { + method: 'DELETE', + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error); + } + + showToast('success', `Batteries moved to ${removeDestination}`); + setActionModal(null); + setSelectedBattery(null); + router.refresh(); + } catch (error) { + showToast('error', error instanceof Error ? error.message : 'Failed to remove batteries'); + } finally { + setLoading(false); + } + }; + + return ( + <> + +
+
+
+ +
+
+

{device.name}

+ {device.description && ( +

{device.description}

+ )} +
+
+ +
+ + {device.batteries.length === 0 ? ( +

No batteries assigned

+ ) : ( +
+ {device.batteries.map((battery) => ( +
+
+ + + {battery.brandName} {battery.typeName} + + + x{battery.quantity} + +
+ +
+ ))} +
+ )} + +
+

+ Total: {device.totalBatteries} {device.totalBatteries === 1 ? 'battery' : 'batteries'} +

+
+
+ + {/* Delete Device Modal */} + setActionModal(null)} + title="Delete Device" + footer={ +
+ + +
+ } + > +

+ Are you sure you want to delete {device.name}? +

+ {device.totalBatteries > 0 && ( +

+ The {device.totalBatteries} assigned {device.totalBatteries === 1 ? 'battery' : 'batteries'} will be + returned to available status. +

+ )} +
+ + {/* Remove Batteries Modal */} + { + setActionModal(null); + setSelectedBattery(null); + }} + title="Remove Batteries" + footer={ +
+ + +
+ } + > + {selectedBattery && ( +
+

+ Remove {selectedBattery.quantity} {selectedBattery.brandName} {selectedBattery.typeName} from {device.name}? +

+ +
+

Send batteries to:

+
+ + +
+
+ +
+ {device.name} + + + {removeDestination === 'available' ? 'Storage' : 'Charger'} + +
+
+ )} +
+ + ); +} diff --git a/src/components/device/DeviceListClient.tsx b/src/components/device/DeviceListClient.tsx new file mode 100644 index 0000000..d6b2fce --- /dev/null +++ b/src/components/device/DeviceListClient.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useState } from 'react'; +import { Plus, Monitor } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { DeviceCard } from './DeviceCard'; +import { AddDeviceModal } from './AddDeviceModal'; + +interface BatteryAssignment { + batteryGroupId: number; + quantity: number; + brandName: string; + typeName: string; +} + +interface Device { + id: number; + name: string; + description: string | null; + batteries: BatteryAssignment[]; + totalBatteries: number; +} + +interface DeviceListClientProps { + devices: Device[]; +} + +export function DeviceListClient({ devices }: DeviceListClientProps) { + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + + return ( +
+ {/* Header */} +
+
+

Devices

+

Manage your devices and their batteries

+
+ +
+ + {/* Device Grid */} + {devices.length === 0 ? ( +
+ +

+ No devices yet. Add your first device to start tracking battery usage. +

+ +
+ ) : ( +
+ {devices.map((device) => ( + + ))} +
+ )} + + {/* Add Device Modal */} + setIsAddModalOpen(false)} + /> +
+ ); +} diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx new file mode 100644 index 0000000..df32849 --- /dev/null +++ b/src/components/ui/Badge.tsx @@ -0,0 +1,42 @@ +import { HTMLAttributes, forwardRef } from 'react'; + +type BadgeVariant = 'available' | 'inUse' | 'charging' | 'default'; +type BadgeSize = 'sm' | 'md'; + +interface BadgeProps extends HTMLAttributes { + variant?: BadgeVariant; + size?: BadgeSize; +} + +const variantStyles: Record = { + available: 'bg-green-100 text-green-800', + inUse: 'bg-blue-100 text-blue-800', + charging: 'bg-amber-100 text-amber-800', + default: 'bg-slate-100 text-slate-800', +}; + +const sizeStyles: Record = { + sm: 'px-2 py-0.5 text-xs', + md: 'px-2.5 py-1 text-sm', +}; + +export const Badge = forwardRef( + ({ className = '', variant = 'default', size = 'md', children, ...props }, ref) => { + return ( + + {children} + + ); + } +); + +Badge.displayName = 'Badge'; diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 0000000..063dfcd --- /dev/null +++ b/src/components/ui/Button.tsx @@ -0,0 +1,47 @@ +import { ButtonHTMLAttributes, forwardRef } from 'react'; + +type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'; +type ButtonSize = 'sm' | 'md' | 'lg'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; +} + +const variantStyles: Record = { + primary: 'bg-slate-900 text-white hover:bg-slate-800 focus:ring-slate-500', + secondary: 'bg-white text-slate-900 border border-slate-300 hover:bg-slate-50 focus:ring-slate-500', + danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500', + ghost: 'bg-transparent text-slate-600 hover:bg-slate-100 focus:ring-slate-500', +}; + +const sizeStyles: Record = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-sm', + lg: 'px-6 py-3 text-base', +}; + +export const Button = forwardRef( + ({ className = '', variant = 'primary', size = 'md', disabled, children, ...props }, ref) => { + return ( + + ); + } +); + +Button.displayName = 'Button'; diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx new file mode 100644 index 0000000..286add8 --- /dev/null +++ b/src/components/ui/Card.tsx @@ -0,0 +1,68 @@ +import { HTMLAttributes, forwardRef } from 'react'; + +interface CardProps extends HTMLAttributes { + padding?: 'none' | 'sm' | 'md' | 'lg'; +} + +const paddingStyles = { + none: '', + sm: 'p-3', + md: 'p-4', + lg: 'p-6', +}; + +export const Card = forwardRef( + ({ className = '', padding = 'md', children, ...props }, ref) => { + return ( +
+ {children} +
+ ); + } +); + +Card.displayName = 'Card'; + +export const CardHeader = forwardRef>( + ({ className = '', children, ...props }, ref) => { + return ( +
+ {children} +
+ ); + } +); + +CardHeader.displayName = 'CardHeader'; + +export const CardTitle = forwardRef>( + ({ className = '', children, ...props }, ref) => { + return ( +

+ {children} +

+ ); + } +); + +CardTitle.displayName = 'CardTitle'; + +export const CardContent = forwardRef>( + ({ className = '', children, ...props }, ref) => { + return ( +
+ {children} +
+ ); + } +); + +CardContent.displayName = 'CardContent'; diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx new file mode 100644 index 0000000..980e5a8 --- /dev/null +++ b/src/components/ui/Input.tsx @@ -0,0 +1,43 @@ +import { InputHTMLAttributes, forwardRef } from 'react'; + +interface InputProps extends InputHTMLAttributes { + label?: string; + error?: string; +} + +export const Input = forwardRef( + ({ className = '', label, error, id, ...props }, ref) => { + const inputId = id || label?.toLowerCase().replace(/\s+/g, '-'); + + return ( +
+ {label && ( + + )} + + {error && ( +

{error}

+ )} +
+ ); + } +); + +Input.displayName = 'Input'; diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx new file mode 100644 index 0000000..0694403 --- /dev/null +++ b/src/components/ui/Modal.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { Fragment, ReactNode } from 'react'; +import { X } from 'lucide-react'; +import { Button } from './Button'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: ReactNode; + footer?: ReactNode; +} + +export function Modal({ isOpen, onClose, title, children, footer }: ModalProps) { + if (!isOpen) return null; + + return ( + + {/* Backdrop */} +
+ + {/* Modal */} +
+
e.stopPropagation()} + > + {/* Header */} +
+

{title}

+ +
+ + {/* Content */} +
+ {children} +
+ + {/* Footer */} + {footer && ( +
+ {footer} +
+ )} +
+
+ + ); +} diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx new file mode 100644 index 0000000..1d9dea7 --- /dev/null +++ b/src/components/ui/Select.tsx @@ -0,0 +1,61 @@ +import { SelectHTMLAttributes, forwardRef } from 'react'; + +interface SelectOption { + value: string | number; + label: string; +} + +interface SelectProps extends SelectHTMLAttributes { + label?: string; + error?: string; + options: SelectOption[]; + placeholder?: string; +} + +export const Select = forwardRef( + ({ className = '', label, error, options, placeholder, id, ...props }, ref) => { + const selectId = id || label?.toLowerCase().replace(/\s+/g, '-'); + + return ( +
+ {label && ( + + )} + + {error && ( +

{error}

+ )} +
+ ); + } +); + +Select.displayName = 'Select'; diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx new file mode 100644 index 0000000..6bdd1c1 --- /dev/null +++ b/src/components/ui/Toast.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { createContext, useContext, useState, ReactNode, useCallback } from 'react'; +import { X, CheckCircle, AlertCircle, Info } from 'lucide-react'; + +type ToastType = 'success' | 'error' | 'info'; + +interface Toast { + id: string; + type: ToastType; + message: string; +} + +interface ToastContextType { + showToast: (type: ToastType, message: string) => void; +} + +const ToastContext = createContext(undefined); + +export function useToast() { + const context = useContext(ToastContext); + if (!context) { + throw new Error('useToast must be used within a ToastProvider'); + } + return context; +} + +const icons: Record = { + success: CheckCircle, + error: AlertCircle, + info: Info, +}; + +const styles: Record = { + success: 'bg-green-50 text-green-800 border-green-200', + error: 'bg-red-50 text-red-800 border-red-200', + info: 'bg-blue-50 text-blue-800 border-blue-200', +}; + +const iconStyles: Record = { + success: 'text-green-500', + error: 'text-red-500', + info: 'text-blue-500', +}; + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + + const showToast = useCallback((type: ToastType, message: string) => { + const id = Math.random().toString(36).substring(2, 9); + setToasts((prev) => [...prev, { id, type, message }]); + + // Auto dismiss after 4 seconds + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, 4000); + }, []); + + const dismissToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + return ( + + {children} + + {/* Toast container */} +
+ {toasts.map((toast) => { + const Icon = icons[toast.type]; + return ( +
+ +

{toast.message}

+ +
+ ); + })} +
+
+ ); +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts new file mode 100644 index 0000000..0130d5a --- /dev/null +++ b/src/components/ui/index.ts @@ -0,0 +1,7 @@ +export { Button } from './Button'; +export { Card, CardHeader, CardTitle, CardContent } from './Card'; +export { Badge } from './Badge'; +export { Input } from './Input'; +export { Select } from './Select'; +export { Modal } from './Modal'; +export { ToastProvider, useToast } from './Toast'; diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts new file mode 100644 index 0000000..1902942 --- /dev/null +++ b/src/lib/db/index.ts @@ -0,0 +1,14 @@ +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import * as schema from './schema'; +import path from 'path'; + +const dbPath = path.join(process.cwd(), 'data', 'battery_tracker.db'); +const sqlite = new Database(dbPath); + +// Enable foreign keys +sqlite.pragma('foreign_keys = ON'); + +export const db = drizzle(sqlite, { schema }); + +export { schema }; diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts new file mode 100644 index 0000000..425c35e --- /dev/null +++ b/src/lib/db/schema.ts @@ -0,0 +1,99 @@ +import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core'; +import { relations, sql } from 'drizzle-orm'; + +// Battery types (AA, AAA, 18650, etc.) +export const batteryTypes = sqliteTable('battery_types', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull().unique(), + isCustom: integer('is_custom', { mode: 'boolean' }).default(false), + createdAt: text('created_at').default(sql`(datetime('now'))`), +}); + +// Brands (Panasonic, Eneloop, etc.) +export const brands = sqliteTable('brands', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull().unique(), + createdAt: text('created_at').default(sql`(datetime('now'))`), +}); + +// Battery groups (Brand + Type combination with counts) +export const batteryGroups = sqliteTable('battery_groups', { + id: integer('id').primaryKey({ autoIncrement: true }), + brandId: integer('brand_id').notNull().references(() => brands.id), + typeId: integer('type_id').notNull().references(() => batteryTypes.id), + availableCount: integer('available_count').default(0).notNull(), + chargingCount: integer('charging_count').default(0).notNull(), + notes: text('notes'), + createdAt: text('created_at').default(sql`(datetime('now'))`), + updatedAt: text('updated_at').default(sql`(datetime('now'))`), +}, (table) => [ + uniqueIndex('brand_type_idx').on(table.brandId, table.typeId), +]); + +// Devices +export const devices = sqliteTable('devices', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + description: text('description'), + createdAt: text('created_at').default(sql`(datetime('now'))`), + updatedAt: text('updated_at').default(sql`(datetime('now'))`), +}); + +// Device-Battery assignments +export const deviceBatteries = sqliteTable('device_batteries', { + id: integer('id').primaryKey({ autoIncrement: true }), + deviceId: integer('device_id').notNull().references(() => devices.id, { onDelete: 'cascade' }), + batteryGroupId: integer('battery_group_id').notNull().references(() => batteryGroups.id), + quantity: integer('quantity').notNull().default(1), + assignedAt: text('assigned_at').default(sql`(datetime('now'))`), +}, (table) => [ + uniqueIndex('device_battery_idx').on(table.deviceId, table.batteryGroupId), +]); + +// Relations +export const batteryTypesRelations = relations(batteryTypes, ({ many }) => ({ + batteryGroups: many(batteryGroups), +})); + +export const brandsRelations = relations(brands, ({ many }) => ({ + batteryGroups: many(batteryGroups), +})); + +export const batteryGroupsRelations = relations(batteryGroups, ({ one, many }) => ({ + brand: one(brands, { + fields: [batteryGroups.brandId], + references: [brands.id], + }), + type: one(batteryTypes, { + fields: [batteryGroups.typeId], + references: [batteryTypes.id], + }), + deviceBatteries: many(deviceBatteries), +})); + +export const devicesRelations = relations(devices, ({ many }) => ({ + deviceBatteries: many(deviceBatteries), +})); + +export const deviceBatteriesRelations = relations(deviceBatteries, ({ one }) => ({ + device: one(devices, { + fields: [deviceBatteries.deviceId], + references: [devices.id], + }), + batteryGroup: one(batteryGroups, { + fields: [deviceBatteries.batteryGroupId], + references: [batteryGroups.id], + }), +})); + +// Type exports +export type BatteryType = typeof batteryTypes.$inferSelect; +export type NewBatteryType = typeof batteryTypes.$inferInsert; +export type Brand = typeof brands.$inferSelect; +export type NewBrand = typeof brands.$inferInsert; +export type BatteryGroup = typeof batteryGroups.$inferSelect; +export type NewBatteryGroup = typeof batteryGroups.$inferInsert; +export type Device = typeof devices.$inferSelect; +export type NewDevice = typeof devices.$inferInsert; +export type DeviceBattery = typeof deviceBatteries.$inferSelect; +export type NewDeviceBattery = typeof deviceBatteries.$inferInsert; diff --git a/src/lib/db/seed.ts b/src/lib/db/seed.ts new file mode 100644 index 0000000..9c6e70e --- /dev/null +++ b/src/lib/db/seed.ts @@ -0,0 +1,41 @@ +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; +import * as schema from './schema'; +import path from 'path'; +import fs from 'fs'; + +const dbPath = path.join(process.cwd(), 'data', 'battery_tracker.db'); + +// Ensure data directory exists +const dataDir = path.dirname(dbPath); +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); +} + +const sqlite = new Database(dbPath); +sqlite.pragma('foreign_keys = ON'); + +const db = drizzle(sqlite, { schema }); + +// Run migrations +console.log('Running migrations...'); +migrate(db, { migrationsFolder: './drizzle' }); +console.log('Migrations complete.'); + +// Seed default battery types +const defaultTypes = ['AA', 'AAA', '18650', 'CR2032', '9V', 'C', 'D', '14500', '16340', '21700']; + +console.log('Seeding default battery types...'); +for (const typeName of defaultTypes) { + try { + db.insert(schema.batteryTypes).values({ name: typeName, isCustom: false }).run(); + console.log(` Added: ${typeName}`); + } catch { + // Already exists, skip + console.log(` Skipped (exists): ${typeName}`); + } +} + +console.log('Seed complete!'); +sqlite.close();