Add Agent 3: File Upload & Storage System

Implements cloud-based file storage for plant photos, documents, and certificates:

Storage Layer:
- Multi-provider support (AWS S3, Cloudflare R2, MinIO, local filesystem)
- S3-compatible provider with presigned URL generation
- Local storage provider for development with signed URL verification
- Configurable via environment variables

Image Processing:
- Automatic thumbnail generation (150x150, 300x300, 600x600, 1200x1200)
- WebP conversion for optimized file sizes
- EXIF data extraction for image metadata
- Image optimization with Sharp

API Endpoints:
- POST /api/upload/image - Upload images with automatic processing
- POST /api/upload/document - Upload documents (PDF, DOC, DOCX)
- POST /api/upload/presigned - Get presigned URLs for direct uploads
- GET/DELETE /api/upload/[fileId] - File management

UI Components:
- ImageUploader - Drag & drop image upload with preview
- PhotoGallery - Grid gallery with lightbox view
- DocumentUploader - Document upload with file type icons
- ProgressBar - Animated upload progress indicator

Database:
- FileStore service with in-memory storage (Prisma schema ready for Agent 2)
- File metadata tracking with soft delete support
- Category-based file organization
This commit is contained in:
Claude 2025-11-23 03:51:31 +00:00
parent 705105d9b6
commit d74128d3cd
No known key found for this signature in database
19 changed files with 2926 additions and 0 deletions

330
bun.lock
View file

@ -5,25 +5,31 @@
"": {
"name": "localgreenchain",
"dependencies": {
"@aws-sdk/client-s3": "^3.937.0",
"@aws-sdk/s3-request-presigner": "^3.937.0",
"@tailwindcss/forms": "^0.4.0",
"@tailwindcss/typography": "^0.5.1",
"@tanstack/react-query": "^4.0.10",
"classnames": "^2.3.1",
"drupal-jsonapi-params": "^1.2.2",
"html-react-parser": "^1.2.7",
"multer": "^2.0.2",
"next": "^12.2.3",
"next-drupal": "^1.6.0",
"nprogress": "^0.2.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-hook-form": "^7.8.6",
"sharp": "^0.34.5",
"socks-proxy-agent": "^8.0.2",
},
"devDependencies": {
"@babel/core": "^7.12.9",
"@types/jest": "^29.5.0",
"@types/multer": "^2.0.0",
"@types/node": "^17.0.21",
"@types/react": "^17.0.0",
"@types/sharp": "^0.32.0",
"autoprefixer": "^10.4.2",
"eslint-config-next": "^12.0.10",
"jest": "^29.5.0",
@ -37,6 +43,90 @@
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
"@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="],
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.937.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.936.0", "@aws-sdk/credential-provider-node": "3.936.0", "@aws-sdk/middleware-bucket-endpoint": "3.936.0", "@aws-sdk/middleware-expect-continue": "3.936.0", "@aws-sdk/middleware-flexible-checksums": "3.936.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-location-constraint": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-sdk-s3": "3.936.0", "@aws-sdk/middleware-ssec": "3.936.0", "@aws-sdk/middleware-user-agent": "3.936.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/signature-v4-multi-region": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.936.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/eventstream-serde-browser": "^4.2.5", "@smithy/eventstream-serde-config-resolver": "^4.3.5", "@smithy/eventstream-serde-node": "^4.2.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-blob-browser": "^4.2.6", "@smithy/hash-node": "^4.2.5", "@smithy/hash-stream-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/md5-js": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-ioeNe6HSc7PxjsUQY7foSHmgesxM5KwAeUtPhIHgKx99nrM+7xYCfW4FMvHypUzz7ZOvqlCdH7CEAZ8ParBvVg=="],
"@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.936.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.936.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.936.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.936.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-0G73S2cDqYwJVvqL08eakj79MZG2QRaB56Ul8/Ps9oQxllr7DMI1IQ/N3j3xjxgpq/U36pkoFZ8aK1n7Sbr3IQ=="],
"@aws-sdk/core": ["@aws-sdk/core@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@aws-sdk/xml-builder": "3.930.0", "@smithy/core": "^3.18.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-eGJ2ySUMvgtOziHhDRDLCrj473RJoL4J1vPjVM3NrKC/fF3/LoHjkut8AAnKmrW6a2uTzNKubigw8dEnpmpERw=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-dKajFuaugEA5i9gCKzOaVy9uTeZcApE+7Z5wdcZ6j40523fY1a56khDAUYkCfwqa7sHci4ccmxBkAo+fW1RChA=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/node-http-handler": "^4.4.5", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/util-stream": "^4.5.6", "tslib": "^2.6.2" } }, "sha512-5FguODLXG1tWx/x8fBxH+GVrk7Hey2LbXV5h9SFzYCx/2h50URBm0+9hndg0Rd23+xzYe14F6SI9HA9c1sPnjg=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/credential-provider-env": "3.936.0", "@aws-sdk/credential-provider-http": "3.936.0", "@aws-sdk/credential-provider-login": "3.936.0", "@aws-sdk/credential-provider-process": "3.936.0", "@aws-sdk/credential-provider-sso": "3.936.0", "@aws-sdk/credential-provider-web-identity": "3.936.0", "@aws-sdk/nested-clients": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/credential-provider-imds": "^4.2.5", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-TbUv56ERQQujoHcLMcfL0Q6bVZfYF83gu/TjHkVkdSlHPOIKaG/mhE2XZSQzXv1cud6LlgeBbfzVAxJ+HPpffg=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/nested-clients": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-8DVrdRqPyUU66gfV7VZNToh56ZuO5D6agWrkLQE/xbLJOm2RbeRgh6buz7CqV8ipRd6m+zCl9mM4F3osQLZn8Q=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.936.0", "", { "dependencies": { "@aws-sdk/credential-provider-env": "3.936.0", "@aws-sdk/credential-provider-http": "3.936.0", "@aws-sdk/credential-provider-ini": "3.936.0", "@aws-sdk/credential-provider-process": "3.936.0", "@aws-sdk/credential-provider-sso": "3.936.0", "@aws-sdk/credential-provider-web-identity": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/credential-provider-imds": "^4.2.5", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-rk/2PCtxX9xDsQW8p5Yjoca3StqmQcSfkmD7nQ61AqAHL1YgpSQWqHE+HjfGGiHDYKG7PvE33Ku2GyA7lEIJAw=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-GpA4AcHb96KQK2PSPUyvChvrsEKiLhQ5NWjeef2IZ3Jc8JoosiedYqp6yhZR+S8cTysuvx56WyJIJc8y8OTrLA=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.936.0", "", { "dependencies": { "@aws-sdk/client-sso": "3.936.0", "@aws-sdk/core": "3.936.0", "@aws-sdk/token-providers": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-wHlEAJJvtnSyxTfNhN98JcU4taA1ED2JvuI2eePgawqBwS/Tzi0mhED1lvNIaWOkjfLd+nHALwszGrtJwEq4yQ=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/nested-clients": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-v3qHAuoODkoRXsAF4RG+ZVO6q2P9yYBT4GMpMEfU9wXVNn7AIfwZgTwzSUfnjNiGva5BKleWVpRpJ9DeuLFbUg=="],
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@aws-sdk/util-arn-parser": "3.893.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-XLSVVfAorUxZh6dzF+HTOp4R1B5EQcdpGcPliWr0KUj2jukgjZEcqbBmjyMF/p9bmyQsONX80iURF1HLAlW0qg=="],
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-Eb4ELAC23bEQLJmUMYnPWcjD3FZIsmz2svDiXEcxRkQU9r7NRID7pM7C5NPH94wOfiCk0b2Y8rVyFXW0lGQwbA=="],
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.936.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/is-array-buffer": "^4.2.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-l3GG6CrSQtMCM6fWY7foV3JQv0WJWT+3G6PSP3Ceb/KEE/5Lz5PrYFXTBf+bVoYL1b0bGjGajcgAXpstBmtHtQ=="],
"@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw=="],
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-SCMPenDtQMd9o5da9JzkHz838w3327iqXk3cbNnXWqnNRx6unyW8FL0DZ84gIY12kAyVHz5WEqlWuekc15ehfw=="],
"@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw=="],
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@aws/lambda-invoke-store": "^0.2.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-l4aGbHpXM45YNgXggIux1HgsCVAvvBoqHPkqLnqMl9QVapfuSTjJHfDYDsx1Xxct6/m7qSMUzanBALhiaGO2fA=="],
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-arn-parser": "3.893.0", "@smithy/core": "^3.18.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-UQs/pVq4cOygsnKON0pOdSKIWkfgY0dzq4h+fR+xHi/Ng3XzxPJhWeAE6tDsKrcyQc1X8UdSbS70XkfGYr5hng=="],
"@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-/GLC9lZdVp05ozRik5KsuODR/N7j+W+2TbfdFL3iS+7un+gnP6hC8RDOZd6WhpZp7drXQ9guKiTAxkZQwzS8DA=="],
"@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@smithy/core": "^3.18.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-YB40IPa7K3iaYX0lSnV9easDOLPLh+fJyUDF3BH8doX4i1AOSsYn86L4lVldmOaSX+DwiaqKHpvk4wPBdcIPWw=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.936.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.936.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.936.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.936.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-eyj2tz1XmDSLSZQ5xnB7cLTVKkSJnYAEoNDSUNhzWPxrBDYeJzIbatecOKceKCU8NBf8gWWZCK/CSY0mDxMO0A=="],
"@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/config-resolver": "^4.4.3", "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw=="],
"@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.937.0", "", { "dependencies": { "@aws-sdk/signature-v4-multi-region": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-format-url": "3.936.0", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-AvsCt6FnnKTpkmzDA1pFzmXPyxbGBdtllOIY0mL1iNSVZ3d7SoJKZH4NaqlcgUtbYG9zVh6QfLWememj1yEAmw=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.936.0", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-8qS0GFUqkmwO7JZ0P8tdluBmt1UTfYUah8qJXGzNh9n1Pcb0AIeT117cCSiCUtwk+gDbJvd4hhRIhJCNr5wgjg=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/nested-clients": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-vvw8+VXk0I+IsoxZw0mX9TMJawUJvEsg3EF7zcCSetwhNPAU8Xmlhv7E/sN/FgSmm7b7DsqKoW6rVtQiCs1PWQ=="],
"@aws-sdk/types": ["@aws-sdk/types@3.936.0", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg=="],
"@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.893.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA=="],
"@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-endpoints": "^3.2.5", "tslib": "^2.6.2" } }, "sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w=="],
"@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/querystring-builder": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-MS5eSEtDUFIAMHrJaMERiHAvDPdfxc/T869ZjDNFAIiZhyc037REw0aoTNeimNXDNy2txRNZJaAUn/kE4RwN+g=="],
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.893.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg=="],
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw=="],
"@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.936.0", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-XOEc7PF9Op00pWV2AYCGDSu5iHgYjIO53Py2VUQTIvP7SRCaCsXmA33mjBvC2Ms6FhSyWNa4aK4naUGIz0hQcw=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.930.0", "", { "dependencies": { "@smithy/types": "^4.9.0", "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" } }, "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA=="],
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.1", "", {}, "sha512-sIyFcoPZkTtNu9xFeEoynMef3bPJIAbOfUh+ueYcfhVl6xm2VRtMcMclSxmZCMnHHd4hlYKJeq/aggmBEWynww=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
@ -107,6 +197,8 @@
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
@ -121,6 +213,56 @@
"@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="],
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", "get-package-type": "^0.1.0", "js-yaml": "^3.13.1", "resolve-from": "^5.0.0" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="],
"@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="],
@ -209,6 +351,108 @@
"@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="],
"@smithy/abort-controller": ["@smithy/abort-controller@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA=="],
"@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA=="],
"@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.1", "", { "dependencies": { "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ=="],
"@smithy/config-resolver": ["@smithy/config-resolver@4.4.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw=="],
"@smithy/core": ["@smithy/core@3.18.5", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.6", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-6gnIz3h+PEPQGDj8MnRSjDvKBah042jEoPgjFGJ4iJLBE78L4lY/n98x14XyPF4u3lN179Ub/ZKFY5za9GeLQw=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.5", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ=="],
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.5", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.9.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Ogt4Zi9hEbIP17oQMd68qYOHUzmH47UkK7q7Gl55iIm9oKt27MUGrC5JfpMroeHjdkOliOA4Qt3NQ1xMq/nrlA=="],
"@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.5", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-HohfmCQZjppVnKX2PnXlf47CW3j92Ki6T/vkAT2DhBR47e89pen3s4fIa7otGTtrVxmj7q+IhH0RnC5kpR8wtw=="],
"@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-ibjQjM7wEXtECiT6my1xfiMH9IcEczMOS6xiCQXoUIYSj5b1CpBbJ3VYbdwDy8Vcg5JHN7eFpOCGk8nyZAltNQ=="],
"@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.5", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-+elOuaYx6F2H6x1/5BQP5ugv12nfJl66GhxON8+dWVUEDJ9jah/A0tayVdkLRP0AeSac0inYkDz5qBFKfVp2Gg=="],
"@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.5", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-G9WSqbST45bmIFaeNuP/EnC19Rhp54CcVdX9PDL1zyEB514WsDVXhlyihKlGXnRycmHNmVv88Bvvt4EYxWef/Q=="],
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.6", "", { "dependencies": { "@smithy/protocol-http": "^5.3.5", "@smithy/querystring-builder": "^4.2.5", "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg=="],
"@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.6", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.0", "@smithy/chunked-blob-reader-native": "^4.2.1", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-8P//tA8DVPk+3XURk2rwcKgYwFvwGwmJH/wJqQiSKwXZtf/LiZK+hbUZmPj/9KzM+OVSwe4o85KTp5x9DUZTjw=="],
"@smithy/hash-node": ["@smithy/hash-node@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA=="],
"@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6+do24VnEyvWcGdHXomlpd0m8bfZePpUKBy7m311n+JuRwug8J4dCanJdTymx//8mi0nlkflZBvJe+dEO/O12Q=="],
"@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A=="],
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ=="],
"@smithy/md5-js": ["@smithy/md5-js@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Bt6jpSTMWfjCtC0s79gZ/WZ1w90grfmopVOWqkI2ovhjpD5Q2XRXuecIPB9689L2+cCySMbaXDhBPU56FKNDNg=="],
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.5", "", { "dependencies": { "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A=="],
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.3.12", "", { "dependencies": { "@smithy/core": "^3.18.5", "@smithy/middleware-serde": "^4.2.6", "@smithy/node-config-provider": "^4.3.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-middleware": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-9pAX/H+VQPzNbouhDhkW723igBMLgrI8OtX+++M7iKJgg/zY/Ig3i1e6seCcx22FWhE6Q/S61BRdi2wXBORT+A=="],
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/service-error-classification": "^4.2.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-S4kWNKFowYd0lID7/DBqWHOQxmxlsf0jBaos9chQZUWTVOjSW1Ogyh8/ib5tM+agFDJ/TCxuCTvrnlc+9cIBcQ=="],
"@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.6", "", { "dependencies": { "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-VkLoE/z7e2g8pirwisLz8XJWedUSY8my/qrp81VmAdyrhi94T+riBfwP+AOEEFR9rFTSonC/5D2eWNmFabHyGQ=="],
"@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ=="],
"@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.5", "", { "dependencies": { "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg=="],
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.5", "", { "dependencies": { "@smithy/abort-controller": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/querystring-builder": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw=="],
"@smithy/property-provider": ["@smithy/property-provider@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg=="],
"@smithy/protocol-http": ["@smithy/protocol-http@5.3.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ=="],
"@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg=="],
"@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ=="],
"@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0" } }, "sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ=="],
"@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.0", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA=="],
"@smithy/signature-v4": ["@smithy/signature-v4@5.3.5", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w=="],
"@smithy/smithy-client": ["@smithy/smithy-client@4.9.8", "", { "dependencies": { "@smithy/core": "^3.18.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-stack": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-stream": "^4.5.6", "tslib": "^2.6.2" } }, "sha512-8xgq3LgKDEFoIrLWBho/oYKyWByw9/corz7vuh1upv7ZBm0ZMjGYBhbn6v643WoIqA9UTcx5A5htEp/YatUwMA=="],
"@smithy/types": ["@smithy/types@4.9.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA=="],
"@smithy/url-parser": ["@smithy/url-parser@4.2.5", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ=="],
"@smithy/util-base64": ["@smithy/util-base64@4.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ=="],
"@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg=="],
"@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA=="],
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew=="],
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q=="],
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.11", "", { "dependencies": { "@smithy/property-provider": "^4.2.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-yHv+r6wSQXEXTPVCIQTNmXVWs7ekBTpMVErjqZoWkYN75HIFN5y9+/+sYOejfAuvxWGvgzgxbTHa/oz61YTbKw=="],
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.14", "", { "dependencies": { "@smithy/config-resolver": "^4.4.3", "@smithy/credential-provider-imds": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-ljZN3iRvaJUgulfvobIuG97q1iUuCMrvXAlkZ4msY+ZuVHQHDIqn7FKZCEj+bx8omz6kF5yQXms/xhzjIO5XiA=="],
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.2.5", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A=="],
"@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw=="],
"@smithy/util-middleware": ["@smithy/util-middleware@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA=="],
"@smithy/util-retry": ["@smithy/util-retry@4.2.5", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg=="],
"@smithy/util-stream": ["@smithy/util-stream@4.5.6", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.6", "@smithy/node-http-handler": "^4.4.5", "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ=="],
"@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA=="],
"@smithy/util-utf8": ["@smithy/util-utf8@4.2.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw=="],
"@smithy/util-waiter": ["@smithy/util-waiter@4.2.5", "", { "dependencies": { "@smithy/abort-controller": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-Dbun99A3InifQdIrsXZ+QLcC0PGBPAdrl4cj1mTgJvyc9N2zf7QSxg8TBkzsCmGJdE3TLbO9ycwpY0EkWahQ/g=="],
"@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="],
"@swc/helpers": ["@swc/helpers@0.4.11", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-rEUrBSGIoSFuYxwBYtlUFMlE2CwGhmW+w9355/5oduSw8e5h2+Tj4UrAGNNgP9915++wj5vkQo0UuOBqOAq4nw=="],
"@tailwindcss/forms": ["@tailwindcss/forms@0.4.1", "", { "dependencies": { "mini-svg-data-uri": "^1.2.3" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" } }, "sha512-gS9xjCmJjUBz/eP12QlENPLnf0tCx68oYE3mri0GMP5jdtVwLbGUNSRpjsp6NzLAZzZy3ueOwrcqB78Ax6Z84A=="],
@ -227,8 +471,18 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/express": ["@types/express@5.0.5", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^1" } }, "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ=="],
"@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.0", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA=="],
"@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="],
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
"@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="],
@ -239,14 +493,28 @@
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
"@types/multer": ["@types/multer@2.0.0", "", { "dependencies": { "@types/express": "*" } }, "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw=="],
"@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
"@types/react": ["@types/react@17.0.90", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", "csstype": "^3.2.2" } }, "sha512-P9beVR/x06U9rCJzSxtENnOr4BrbJ6VrsrDTc+73TtHv9XHhryXKbjGRB+6oooB2r0G/pQkD/S4dHo/7jUfwFw=="],
"@types/scheduler": ["@types/scheduler@0.16.8", "", {}, "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A=="],
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
"@types/sharp": ["@types/sharp@0.32.0", "", { "dependencies": { "sharp": "*" } }, "sha512-OOi3kL+FZDnPhVzsfD37J88FNeZh6gQsGcLc95NbeURRGvmSjeXiDcyWzF2o3yh/gQAUn2uhh/e+CPCa5nwAxw=="],
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
"@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="],
@ -283,6 +551,8 @@
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"append-field": ["append-field@1.0.0", "", {}, "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@ -335,6 +605,8 @@
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"bowser": ["bowser@2.12.1", "", {}, "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
@ -347,6 +619,8 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
@ -389,6 +663,8 @@
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"create-jest": ["create-jest@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "chalk": "^4.0.0", "exit": "^0.1.2", "graceful-fs": "^4.2.9", "jest-config": "^29.7.0", "jest-util": "^29.7.0", "prompts": "^2.0.1" }, "bin": { "create-jest": "bin/create-jest.js" } }, "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q=="],
@ -419,6 +695,8 @@
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
@ -521,6 +799,8 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="],
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
"fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="],
@ -813,12 +1093,18 @@
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"mini-svg-data-uri": ["mini-svg-data-uri@1.4.4", "", { "bin": { "mini-svg-data-uri": "cli.js" } }, "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="],
@ -827,8 +1113,12 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"multer": ["multer@2.0.2", "", { "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", "mkdirp": "^0.5.6", "object-assign": "^4.1.1", "type-is": "^1.6.18", "xtend": "^4.0.2" } }, "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@ -955,6 +1245,8 @@
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
@ -979,6 +1271,8 @@
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
@ -993,6 +1287,8 @@
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
@ -1029,6 +1325,8 @@
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
"string-length": ["string-length@4.0.2", "", { "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" } }, "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@ -1045,6 +1343,8 @@
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
@ -1053,6 +1353,8 @@
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="],
"style-to-js": ["style-to-js@1.1.1", "", { "dependencies": { "style-to-object": "0.3.0" } }, "sha512-RJ18Z9t2B02sYhZtfWKQq5uplVctgvjTfLWT7+Eb1zjUjIrWzX5SdlkwLGQozrqarTmEzJJ/YmdNJCUNI47elg=="],
"style-to-object": ["style-to-object@0.3.0", "", { "dependencies": { "inline-style-parser": "0.1.1" } }, "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA=="],
@ -1097,6 +1399,8 @@
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
"typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
@ -1105,6 +1409,8 @@
"typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="],
"typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
"typescript": ["typescript@4.9.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g=="],
"uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
@ -1143,6 +1449,8 @@
"write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
@ -1153,6 +1461,12 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
@ -1163,6 +1477,8 @@
"@tailwindcss/typography/postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
@ -1217,6 +1533,8 @@
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
"string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@ -1233,12 +1551,24 @@
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@istanbuljs/load-nyc-config/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],

View file

@ -0,0 +1,236 @@
/**
* Document Uploader Component
* Agent 3: File Upload & Storage System
*
* Upload interface for documents (PDF, DOC, etc.)
*/
import React, { useState, useCallback, useRef } from 'react';
import type { FileCategory } from '../../lib/storage/types';
import ProgressBar from './ProgressBar';
interface UploadedDocument {
id: string;
url: string;
size: number;
mimeType: string;
originalName: string;
}
interface DocumentUploaderProps {
category?: FileCategory;
plantId?: string;
farmId?: string;
userId?: string;
onUpload?: (file: UploadedDocument) => void;
onError?: (error: string) => void;
accept?: string;
className?: string;
}
export function DocumentUploader({
category = 'document',
plantId,
farmId,
userId,
onUpload,
onError,
accept = '.pdf,.doc,.docx',
className = '',
}: DocumentUploaderProps) {
const [isUploading, setIsUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string>();
const [uploadedFile, setUploadedFile] = useState<UploadedDocument | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadFile = async (file: File) => {
setIsUploading(true);
setProgress(10);
setError(undefined);
const formData = new FormData();
formData.append('file', file);
formData.append('category', category);
if (plantId) formData.append('plantId', plantId);
if (farmId) formData.append('farmId', farmId);
if (userId) formData.append('userId', userId);
try {
setProgress(30);
const response = await fetch('/api/upload/document', {
method: 'POST',
body: formData,
});
setProgress(80);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Upload failed');
}
setProgress(100);
setUploadedFile(data.file);
onUpload?.(data.file);
} catch (error) {
const message = error instanceof Error ? error.message : 'Upload failed';
setError(message);
onError?.(message);
} finally {
setIsUploading(false);
}
};
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
await uploadFile(file);
}
},
[]
);
const handleClick = () => {
fileInputRef.current?.click();
};
const handleRemove = () => {
setUploadedFile(null);
setProgress(0);
setError(undefined);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const getFileIcon = (mimeType: string) => {
if (mimeType === 'application/pdf') {
return (
<svg className="w-8 h-8 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zm-3 9.5c0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5.67-1.5 1.5-1.5 1.5.67 1.5 1.5zm3 3c0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5.67-1.5 1.5-1.5 1.5.67 1.5 1.5z" />
</svg>
);
}
return (
<svg className="w-8 h-8 text-blue-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm4 18H6V4h7v5h5v11z" />
</svg>
);
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<div className={className}>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleFileChange}
className="hidden"
/>
{uploadedFile ? (
<div className="flex items-center p-4 border rounded-lg bg-gray-50">
{getFileIcon(uploadedFile.mimeType)}
<div className="ml-3 flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{uploadedFile.originalName}
</p>
<p className="text-xs text-gray-500">
{formatFileSize(uploadedFile.size)}
</p>
</div>
<div className="flex items-center space-x-2">
<a
href={uploadedFile.url}
target="_blank"
rel="noopener noreferrer"
className="text-green-600 hover:text-green-700"
title="Download"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</a>
<button
onClick={handleRemove}
className="text-red-500 hover:text-red-600"
title="Remove"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
) : (
<button
onClick={handleClick}
disabled={isUploading}
className={`
w-full border-2 border-dashed rounded-lg p-6 text-center
transition-colors duration-200
${isUploading
? 'border-gray-200 bg-gray-50 cursor-not-allowed'
: 'border-gray-300 hover:border-green-400 hover:bg-gray-50 cursor-pointer'
}
`}
>
<svg
className="mx-auto h-10 w-10 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p className="mt-2 text-sm text-gray-600">
<span className="text-green-600 font-medium">Click to upload</span>
{' '}a document
</p>
<p className="mt-1 text-xs text-gray-500">
PDF, DOC, DOCX up to 10MB
</p>
</button>
)}
{isUploading && (
<div className="mt-3">
<ProgressBar progress={progress} />
<p className="text-xs text-gray-500 mt-1 text-center">Uploading...</p>
</div>
)}
{error && (
<p className="mt-2 text-sm text-red-500 text-center">{error}</p>
)}
</div>
);
}
export default DocumentUploader;

View file

@ -0,0 +1,266 @@
/**
* Image Uploader Component
* Agent 3: File Upload & Storage System
*
* Drag & drop image upload with preview and progress
*/
import React, { useState, useCallback, useRef } from 'react';
import type { FileCategory } from '../../lib/storage/types';
interface UploadedFile {
id: string;
url: string;
thumbnailUrl?: string;
width?: number;
height?: number;
size: number;
}
interface ImageUploaderProps {
category?: FileCategory;
plantId?: string;
farmId?: string;
userId?: string;
onUpload?: (file: UploadedFile) => void;
onError?: (error: string) => void;
maxFiles?: number;
accept?: string;
className?: string;
}
interface UploadState {
isUploading: boolean;
progress: number;
error?: string;
preview?: string;
}
export function ImageUploader({
category = 'plant-photo',
plantId,
farmId,
userId,
onUpload,
onError,
maxFiles = 1,
accept = 'image/*',
className = '',
}: ImageUploaderProps) {
const [uploadState, setUploadState] = useState<UploadState>({
isUploading: false,
progress: 0,
});
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const uploadFile = async (file: File) => {
// Create preview
const reader = new FileReader();
reader.onload = (e) => {
setUploadState((prev) => ({
...prev,
preview: e.target?.result as string,
}));
};
reader.readAsDataURL(file);
// Start upload
setUploadState((prev) => ({
...prev,
isUploading: true,
progress: 0,
error: undefined,
}));
const formData = new FormData();
formData.append('file', file);
formData.append('category', category);
if (plantId) formData.append('plantId', plantId);
if (farmId) formData.append('farmId', farmId);
if (userId) formData.append('userId', userId);
try {
const response = await fetch('/api/upload/image', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Upload failed');
}
setUploadState({
isUploading: false,
progress: 100,
preview: data.file.thumbnailUrl || data.file.url,
});
onUpload?.(data.file);
} catch (error) {
const message = error instanceof Error ? error.message : 'Upload failed';
setUploadState((prev) => ({
...prev,
isUploading: false,
error: message,
}));
onError?.(message);
}
};
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files).slice(0, maxFiles);
if (files.length > 0) {
await uploadFile(files[0]);
}
},
[maxFiles]
);
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []).slice(0, maxFiles);
if (files.length > 0) {
await uploadFile(files[0]);
}
},
[maxFiles]
);
const handleClick = () => {
fileInputRef.current?.click();
};
const handleRemove = () => {
setUploadState({
isUploading: false,
progress: 0,
preview: undefined,
error: undefined,
});
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div className={`relative ${className}`}>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleFileChange}
className="hidden"
/>
{uploadState.preview ? (
<div className="relative rounded-lg overflow-hidden border-2 border-green-200">
<img
src={uploadState.preview}
alt="Uploaded preview"
className="w-full h-48 object-cover"
/>
<button
onClick={handleRemove}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full p-1 hover:bg-red-600 transition-colors"
title="Remove image"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
) : (
<div
onClick={handleClick}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer
transition-colors duration-200
${isDragging
? 'border-green-500 bg-green-50'
: 'border-gray-300 hover:border-green-400 hover:bg-gray-50'
}
${uploadState.isUploading ? 'pointer-events-none opacity-50' : ''}
`}
>
<svg
className="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<p className="mt-2 text-sm text-gray-600">
<span className="text-green-600 font-medium">Click to upload</span>
{' '}or drag and drop
</p>
<p className="mt-1 text-xs text-gray-500">
PNG, JPG, GIF, WEBP up to 5MB
</p>
</div>
)}
{uploadState.isUploading && (
<div className="mt-2">
<div className="bg-gray-200 rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadState.progress}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1 text-center">Uploading...</p>
</div>
)}
{uploadState.error && (
<p className="mt-2 text-sm text-red-500 text-center">{uploadState.error}</p>
)}
</div>
);
}
export default ImageUploader;

View file

@ -0,0 +1,213 @@
/**
* Photo Gallery Component
* Agent 3: File Upload & Storage System
*
* Displays a grid of plant photos with lightbox view
*/
import React, { useState } from 'react';
interface Photo {
id: string;
url: string;
thumbnailUrl?: string;
width?: number;
height?: number;
caption?: string;
uploadedAt?: string;
}
interface PhotoGalleryProps {
photos: Photo[];
onDelete?: (photoId: string) => void;
editable?: boolean;
columns?: 2 | 3 | 4;
className?: string;
}
export function PhotoGallery({
photos,
onDelete,
editable = false,
columns = 3,
className = '',
}: PhotoGalleryProps) {
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null);
const [isDeleting, setIsDeleting] = useState<string | null>(null);
const handleDelete = async (photoId: string) => {
if (!onDelete) return;
setIsDeleting(photoId);
try {
await onDelete(photoId);
} finally {
setIsDeleting(null);
}
};
const gridCols = {
2: 'grid-cols-2',
3: 'grid-cols-2 sm:grid-cols-3',
4: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4',
};
if (photos.length === 0) {
return (
<div className={`text-center py-8 ${className}`}>
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p className="mt-2 text-sm text-gray-500">No photos yet</p>
</div>
);
}
return (
<>
<div className={`grid ${gridCols[columns]} gap-4 ${className}`}>
{photos.map((photo) => (
<div
key={photo.id}
className="relative group aspect-square rounded-lg overflow-hidden bg-gray-100"
>
<img
src={photo.thumbnailUrl || photo.url}
alt={photo.caption || 'Plant photo'}
className="w-full h-full object-cover cursor-pointer transition-transform duration-200 group-hover:scale-105"
onClick={() => setSelectedPhoto(photo)}
loading="lazy"
/>
{/* Overlay on hover */}
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all duration-200 flex items-center justify-center">
<button
onClick={() => setSelectedPhoto(photo)}
className="opacity-0 group-hover:opacity-100 bg-white rounded-full p-2 mx-1 transition-opacity duration-200 hover:bg-gray-100"
title="View full size"
>
<svg
className="w-5 h-5 text-gray-700"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"
/>
</svg>
</button>
{editable && onDelete && (
<button
onClick={() => handleDelete(photo.id)}
disabled={isDeleting === photo.id}
className="opacity-0 group-hover:opacity-100 bg-red-500 text-white rounded-full p-2 mx-1 transition-opacity duration-200 hover:bg-red-600 disabled:opacity-50"
title="Delete photo"
>
{isDeleting === photo.id ? (
<svg
className="w-5 h-5 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
)}
</button>
)}
</div>
{/* Caption */}
{photo.caption && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p className="text-white text-xs truncate">{photo.caption}</p>
</div>
)}
</div>
))}
</div>
{/* Lightbox */}
{selectedPhoto && (
<div
className="fixed inset-0 z-50 bg-black bg-opacity-90 flex items-center justify-center p-4"
onClick={() => setSelectedPhoto(null)}
>
<button
className="absolute top-4 right-4 text-white hover:text-gray-300 transition-colors"
onClick={() => setSelectedPhoto(null)}
>
<svg
className="w-8 h-8"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<img
src={selectedPhoto.url}
alt={selectedPhoto.caption || 'Plant photo'}
className="max-w-full max-h-full object-contain"
onClick={(e) => e.stopPropagation()}
/>
{selectedPhoto.caption && (
<div className="absolute bottom-4 left-4 right-4 text-center">
<p className="text-white text-lg">{selectedPhoto.caption}</p>
</div>
)}
</div>
)}
</>
);
}
export default PhotoGallery;

View file

@ -0,0 +1,63 @@
/**
* Progress Bar Component
* Agent 3: File Upload & Storage System
*
* Animated progress bar for uploads
*/
import React from 'react';
interface ProgressBarProps {
progress: number;
showPercentage?: boolean;
color?: 'green' | 'blue' | 'purple' | 'orange';
size?: 'sm' | 'md' | 'lg';
animated?: boolean;
className?: string;
}
const colorClasses = {
green: 'bg-green-500',
blue: 'bg-blue-500',
purple: 'bg-purple-500',
orange: 'bg-orange-500',
};
const sizeClasses = {
sm: 'h-1',
md: 'h-2',
lg: 'h-3',
};
export function ProgressBar({
progress,
showPercentage = false,
color = 'green',
size = 'md',
animated = true,
className = '',
}: ProgressBarProps) {
const clampedProgress = Math.min(100, Math.max(0, progress));
return (
<div className={className}>
<div className={`bg-gray-200 rounded-full overflow-hidden ${sizeClasses[size]}`}>
<div
className={`
${colorClasses[color]} ${sizeClasses[size]} rounded-full
transition-all duration-300 ease-out
${animated && clampedProgress < 100 ? 'animate-pulse' : ''}
`}
style={{ width: `${clampedProgress}%` }}
/>
</div>
{showPercentage && (
<p className="text-xs text-gray-500 mt-1 text-right">
{Math.round(clampedProgress)}%
</p>
)}
</div>
);
}
export default ProgressBar;

View file

@ -0,0 +1,11 @@
/**
* Upload Components Index
* Agent 3: File Upload & Storage System
*
* Export all upload-related components
*/
export { ImageUploader } from './ImageUploader';
export { PhotoGallery } from './PhotoGallery';
export { DocumentUploader } from './DocumentUploader';
export { ProgressBar } from './ProgressBar';

87
lib/storage/config.ts Normal file
View file

@ -0,0 +1,87 @@
/**
* Storage Configuration for LocalGreenChain
* Agent 3: File Upload & Storage System
*
* Supports multiple storage providers: AWS S3, Cloudflare R2, MinIO, or local filesystem
*/
import { StorageConfig, StorageProvider } from './types';
function getStorageProvider(): StorageProvider {
const provider = process.env.STORAGE_PROVIDER as StorageProvider;
if (provider && ['s3', 'r2', 'minio', 'local'].includes(provider)) {
return provider;
}
return 'local'; // Default to local storage for development
}
export function getStorageConfig(): StorageConfig {
const provider = getStorageProvider();
const baseConfig: StorageConfig = {
provider,
bucket: process.env.STORAGE_BUCKET || 'localgreenchain',
region: process.env.STORAGE_REGION || 'us-east-1',
accessKeyId: process.env.STORAGE_ACCESS_KEY_ID,
secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY,
publicUrl: process.env.STORAGE_PUBLIC_URL,
};
switch (provider) {
case 's3':
return {
...baseConfig,
endpoint: process.env.AWS_S3_ENDPOINT,
};
case 'r2':
return {
...baseConfig,
endpoint: process.env.CLOUDFLARE_R2_ENDPOINT ||
`https://${process.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
region: 'auto',
};
case 'minio':
return {
...baseConfig,
endpoint: process.env.MINIO_ENDPOINT || 'http://localhost:9000',
region: 'us-east-1',
};
case 'local':
default:
return {
...baseConfig,
localPath: process.env.LOCAL_STORAGE_PATH || './uploads',
publicUrl: process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3001',
};
}
}
export const storageConfig = getStorageConfig();
/**
* Environment variable template for storage configuration:
*
* # Storage Provider (s3, r2, minio, local)
* STORAGE_PROVIDER=local
* STORAGE_BUCKET=localgreenchain
* STORAGE_REGION=us-east-1
* STORAGE_ACCESS_KEY_ID=your-access-key
* STORAGE_SECRET_ACCESS_KEY=your-secret-key
* STORAGE_PUBLIC_URL=https://cdn.yourdomain.com
*
* # For AWS S3
* AWS_S3_ENDPOINT=https://s3.amazonaws.com
*
* # For Cloudflare R2
* CLOUDFLARE_ACCOUNT_ID=your-account-id
* CLOUDFLARE_R2_ENDPOINT=https://your-account.r2.cloudflarestorage.com
*
* # For MinIO
* MINIO_ENDPOINT=http://localhost:9000
*
* # For Local Storage
* LOCAL_STORAGE_PATH=./uploads
*/

258
lib/storage/fileStore.ts Normal file
View file

@ -0,0 +1,258 @@
/**
* File Store for LocalGreenChain
* Agent 3: File Upload & Storage System
*
* In-memory file metadata store (to be replaced with Prisma when Agent 2 completes)
*
* Prisma Schema (for Agent 2 to integrate):
*
* model File {
* id String @id @default(cuid())
* filename String // Storage path/key
* originalName String // Original uploaded filename
* mimeType String
* size Int // File size in bytes
* category String // plant-photo, certificate, document, report, avatar
* url String // Public URL
* thumbnailUrl String? // Thumbnail URL (for images)
* urls Json? // All size variants URLs
* width Int? // Image width
* height Int? // Image height
* exifData Json? // EXIF metadata
* uploadedById String? // User who uploaded
* plantId String? // Associated plant
* farmId String? // Associated farm
* createdAt DateTime @default(now())
* updatedAt DateTime @updatedAt
* deletedAt DateTime? // Soft delete
*
* uploadedBy User? @relation(fields: [uploadedById], references: [id])
* plant Plant? @relation(fields: [plantId], references: [id])
* farm VerticalFarm? @relation(fields: [farmId], references: [id])
*
* @@index([category])
* @@index([plantId])
* @@index([farmId])
* @@index([uploadedById])
* }
*/
import { FileMetadata, FileCategory, ImageSize } from './types';
/**
* In-memory file store
* This is a temporary implementation until Prisma is set up by Agent 2
*/
class FileStore {
private files: Map<string, FileMetadata> = new Map();
/**
* Save file metadata
*/
async save(metadata: FileMetadata): Promise<FileMetadata> {
this.files.set(metadata.id, {
...metadata,
updatedAt: new Date(),
});
return metadata;
}
/**
* Get file by ID
*/
async getById(id: string): Promise<FileMetadata | null> {
const file = this.files.get(id);
if (!file || file.deletedAt) {
return null;
}
return file;
}
/**
* Get file by filename/key
*/
async getByFilename(filename: string): Promise<FileMetadata | null> {
for (const file of this.files.values()) {
if (file.filename === filename && !file.deletedAt) {
return file;
}
}
return null;
}
/**
* Get files by plant ID
*/
async getByPlantId(plantId: string): Promise<FileMetadata[]> {
const results: FileMetadata[] = [];
for (const file of this.files.values()) {
if (file.plantId === plantId && !file.deletedAt) {
results.push(file);
}
}
return results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
/**
* Get files by farm ID
*/
async getByFarmId(farmId: string): Promise<FileMetadata[]> {
const results: FileMetadata[] = [];
for (const file of this.files.values()) {
if (file.farmId === farmId && !file.deletedAt) {
results.push(file);
}
}
return results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
/**
* Get files by user ID
*/
async getByUserId(userId: string): Promise<FileMetadata[]> {
const results: FileMetadata[] = [];
for (const file of this.files.values()) {
if (file.uploadedBy === userId && !file.deletedAt) {
results.push(file);
}
}
return results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
/**
* Get files by category
*/
async getByCategory(category: FileCategory): Promise<FileMetadata[]> {
const results: FileMetadata[] = [];
for (const file of this.files.values()) {
if (file.category === category && !file.deletedAt) {
results.push(file);
}
}
return results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
/**
* Update file metadata
*/
async update(id: string, updates: Partial<FileMetadata>): Promise<FileMetadata | null> {
const existing = this.files.get(id);
if (!existing) {
return null;
}
const updated: FileMetadata = {
...existing,
...updates,
id, // Ensure ID cannot be changed
updatedAt: new Date(),
};
this.files.set(id, updated);
return updated;
}
/**
* Soft delete a file
*/
async softDelete(id: string): Promise<boolean> {
const existing = this.files.get(id);
if (!existing) {
return false;
}
this.files.set(id, {
...existing,
deletedAt: new Date(),
updatedAt: new Date(),
});
return true;
}
/**
* Hard delete a file
*/
async hardDelete(id: string): Promise<boolean> {
return this.files.delete(id);
}
/**
* List all files with pagination
*/
async list(options: {
limit?: number;
offset?: number;
includeDeleted?: boolean;
} = {}): Promise<{ files: FileMetadata[]; total: number }> {
const { limit = 50, offset = 0, includeDeleted = false } = options;
let results = Array.from(this.files.values());
if (!includeDeleted) {
results = results.filter((f) => !f.deletedAt);
}
// Sort by creation date descending
results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
const total = results.length;
const files = results.slice(offset, offset + limit);
return { files, total };
}
/**
* Get storage statistics
*/
async getStats(): Promise<{
totalFiles: number;
totalSize: number;
byCategory: Record<FileCategory, { count: number; size: number }>;
}> {
const stats: {
totalFiles: number;
totalSize: number;
byCategory: Record<FileCategory, { count: number; size: number }>;
} = {
totalFiles: 0,
totalSize: 0,
byCategory: {
'plant-photo': { count: 0, size: 0 },
'certificate': { count: 0, size: 0 },
'document': { count: 0, size: 0 },
'report': { count: 0, size: 0 },
'avatar': { count: 0, size: 0 },
},
};
for (const file of this.files.values()) {
if (!file.deletedAt) {
stats.totalFiles++;
stats.totalSize += file.size;
stats.byCategory[file.category].count++;
stats.byCategory[file.category].size += file.size;
}
}
return stats;
}
/**
* Clear all files (for testing)
*/
clear(): void {
this.files.clear();
}
}
// Singleton instance
let fileStoreInstance: FileStore | null = null;
export function getFileStore(): FileStore {
if (!fileStoreInstance) {
fileStoreInstance = new FileStore();
}
return fileStoreInstance;
}
export { FileStore };

View file

@ -0,0 +1,269 @@
/**
* Image Processing Pipeline for LocalGreenChain
* Agent 3: File Upload & Storage System
*
* Handles image optimization, thumbnail generation, and EXIF extraction
*/
import sharp from 'sharp';
import {
ImageSize,
ThumbnailConfig,
THUMBNAIL_CONFIGS,
ExifData,
ImageProcessingOptions,
} from './types';
export interface ImageMetadata {
width: number;
height: number;
format: string;
space?: string;
channels?: number;
hasAlpha?: boolean;
orientation?: number;
}
export class ImageProcessor {
private defaultQuality = 85;
/**
* Get image metadata
*/
async getMetadata(buffer: Buffer): Promise<ImageMetadata> {
const metadata = await sharp(buffer).metadata();
return {
width: metadata.width || 0,
height: metadata.height || 0,
format: metadata.format || 'unknown',
space: metadata.space,
channels: metadata.channels,
hasAlpha: metadata.hasAlpha,
orientation: metadata.orientation,
};
}
/**
* Extract EXIF data from image
*/
async extractExif(buffer: Buffer): Promise<ExifData | undefined> {
try {
const metadata = await sharp(buffer).metadata();
if (!metadata.exif) {
return undefined;
}
// Parse basic EXIF data
const exifData: ExifData = {};
// Sharp provides some EXIF data directly
if (metadata.orientation) {
exifData.orientation = metadata.orientation;
}
// For more detailed EXIF parsing, we would need an EXIF library
// For now, return basic data
return Object.keys(exifData).length > 0 ? exifData : undefined;
} catch (error) {
console.warn('Error extracting EXIF data:', error);
return undefined;
}
}
/**
* Optimize an image for web
*/
async optimize(
buffer: Buffer,
options: ImageProcessingOptions = {}
): Promise<Buffer> {
const {
maxWidth = 2048,
maxHeight = 2048,
quality = this.defaultQuality,
convertToWebP = true,
} = options;
let pipeline = sharp(buffer)
.rotate() // Auto-rotate based on EXIF orientation
.resize(maxWidth, maxHeight, {
fit: 'inside',
withoutEnlargement: true,
});
if (convertToWebP) {
pipeline = pipeline.webp({ quality });
} else {
// Optimize in original format
const metadata = await sharp(buffer).metadata();
switch (metadata.format) {
case 'jpeg':
pipeline = pipeline.jpeg({ quality, mozjpeg: true });
break;
case 'png':
pipeline = pipeline.png({ compressionLevel: 9 });
break;
case 'gif':
pipeline = pipeline.gif();
break;
default:
pipeline = pipeline.webp({ quality });
}
}
return pipeline.toBuffer();
}
/**
* Generate all thumbnail sizes
*/
async generateThumbnails(
buffer: Buffer,
sizes: ImageSize[] = ['thumbnail', 'small', 'medium', 'large']
): Promise<Record<string, Buffer>> {
const thumbnails: Record<string, Buffer> = {};
await Promise.all(
sizes.map(async (size) => {
if (size === 'original') return;
const config = THUMBNAIL_CONFIGS[size];
thumbnails[size] = await this.generateThumbnail(buffer, config);
})
);
return thumbnails;
}
/**
* Generate a single thumbnail
*/
async generateThumbnail(buffer: Buffer, config: ThumbnailConfig): Promise<Buffer> {
return sharp(buffer)
.rotate() // Auto-rotate based on EXIF orientation
.resize(config.width, config.height, {
fit: config.fit,
withoutEnlargement: true,
})
.webp({ quality: 80 })
.toBuffer();
}
/**
* Convert image to WebP format
*/
async toWebP(buffer: Buffer, quality = 85): Promise<Buffer> {
return sharp(buffer)
.rotate()
.webp({ quality })
.toBuffer();
}
/**
* Crop image to specific dimensions
*/
async crop(
buffer: Buffer,
width: number,
height: number,
options: { left?: number; top?: number } = {}
): Promise<Buffer> {
const { left = 0, top = 0 } = options;
return sharp(buffer)
.extract({ left, top, width, height })
.toBuffer();
}
/**
* Smart crop using attention/entropy detection
*/
async smartCrop(buffer: Buffer, width: number, height: number): Promise<Buffer> {
return sharp(buffer)
.resize(width, height, {
fit: 'cover',
position: 'attention', // Focus on the most "interesting" part
})
.toBuffer();
}
/**
* Add a watermark to an image
*/
async addWatermark(
buffer: Buffer,
watermarkBuffer: Buffer,
options: {
position?: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
opacity?: number;
} = {}
): Promise<Buffer> {
const { position = 'bottom-right', opacity = 0.5 } = options;
const image = sharp(buffer);
const metadata = await image.metadata();
const watermark = await sharp(watermarkBuffer)
.resize(Math.round((metadata.width || 500) * 0.2), null, {
withoutEnlargement: true,
})
.ensureAlpha()
.toBuffer();
const watermarkMeta = await sharp(watermark).metadata();
let gravity: sharp.Gravity;
switch (position) {
case 'top-left':
gravity = 'northwest';
break;
case 'top-right':
gravity = 'northeast';
break;
case 'bottom-left':
gravity = 'southwest';
break;
case 'bottom-right':
gravity = 'southeast';
break;
case 'center':
default:
gravity = 'center';
}
return image
.composite([
{
input: watermark,
gravity,
blend: 'over',
},
])
.toBuffer();
}
/**
* Generate a blur placeholder for progressive loading
*/
async generateBlurPlaceholder(buffer: Buffer, size = 10): Promise<string> {
const blurredBuffer = await sharp(buffer)
.resize(size, size, { fit: 'inside' })
.webp({ quality: 20 })
.toBuffer();
return `data:image/webp;base64,${blurredBuffer.toString('base64')}`;
}
/**
* Validate that buffer is a valid image
*/
async isValidImage(buffer: Buffer): Promise<boolean> {
try {
const metadata = await sharp(buffer).metadata();
return !!(metadata.width && metadata.height);
} catch {
return false;
}
}
}

34
lib/storage/index.ts Normal file
View file

@ -0,0 +1,34 @@
/**
* Storage Module for LocalGreenChain
* Agent 3: File Upload & Storage System
*
* Main entry point for file storage functionality
*/
// Types
export * from './types';
// Configuration
export { getStorageConfig, storageConfig } from './config';
// Services
export { getUploadService, UploadService } from './uploadService';
export { ImageProcessor } from './imageProcessor';
export { getFileStore, FileStore } from './fileStore';
// Providers
export { S3StorageProvider } from './providers/s3';
export { LocalStorageProvider } from './providers/local';
// Re-export commonly used types for convenience
export type {
FileMetadata,
FileCategory,
UploadOptions,
UploadResult,
PresignedUrlRequest,
PresignedUrlResponse,
ImageSize,
StorageProvider,
StorageConfig,
} from './types';

View file

@ -0,0 +1,131 @@
/**
* Local Storage Provider for LocalGreenChain
* Agent 3: File Upload & Storage System
*
* Filesystem-based storage for development and testing
*/
import { promises as fs } from 'fs';
import path from 'path';
import crypto from 'crypto';
import { StorageConfig, StorageProviderInterface } from '../types';
export class LocalStorageProvider implements StorageProviderInterface {
private basePath: string;
private publicUrl: string;
constructor(config: StorageConfig) {
this.basePath = config.localPath || './uploads';
this.publicUrl = config.publicUrl || 'http://localhost:3001';
}
private getFilePath(key: string): string {
return path.join(this.basePath, key);
}
private async ensureDirectory(filePath: string): Promise<void> {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
}
async upload(key: string, buffer: Buffer, contentType: string): Promise<string> {
const filePath = this.getFilePath(key);
await this.ensureDirectory(filePath);
await fs.writeFile(filePath, buffer);
// Write metadata file
const metadataPath = `${filePath}.meta.json`;
await fs.writeFile(
metadataPath,
JSON.stringify({
contentType,
size: buffer.length,
uploadedAt: new Date().toISOString(),
})
);
return this.getPublicUrl(key);
}
async delete(key: string): Promise<boolean> {
try {
const filePath = this.getFilePath(key);
await fs.unlink(filePath);
// Also delete metadata file if exists
try {
await fs.unlink(`${filePath}.meta.json`);
} catch {
// Metadata file may not exist
}
return true;
} catch (error) {
console.error('Error deleting local file:', error);
return false;
}
}
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
// For local storage, we generate a signed URL using HMAC
const expires = Date.now() + expiresIn * 1000;
const secret = process.env.LOCAL_STORAGE_SECRET || 'development-secret';
const signature = crypto
.createHmac('sha256', secret)
.update(`${key}:${expires}`)
.digest('hex')
.substring(0, 16);
return `${this.publicUrl}/api/upload/${encodeURIComponent(key)}?expires=${expires}&sig=${signature}`;
}
async getPresignedUploadUrl(key: string, contentType: string, expiresIn = 3600): Promise<string> {
// For local storage, return an API endpoint for upload
const expires = Date.now() + expiresIn * 1000;
const secret = process.env.LOCAL_STORAGE_SECRET || 'development-secret';
const signature = crypto
.createHmac('sha256', secret)
.update(`upload:${key}:${contentType}:${expires}`)
.digest('hex')
.substring(0, 16);
return `${this.publicUrl}/api/upload/presigned?key=${encodeURIComponent(key)}&contentType=${encodeURIComponent(contentType)}&expires=${expires}&sig=${signature}`;
}
async exists(key: string): Promise<boolean> {
try {
await fs.access(this.getFilePath(key));
return true;
} catch {
return false;
}
}
getPublicUrl(key: string): string {
return `${this.publicUrl}/uploads/${key}`;
}
/**
* Verify a signed URL signature
*/
static verifySignature(
key: string,
expires: number,
signature: string,
prefix = ''
): boolean {
if (Date.now() > expires) {
return false;
}
const secret = process.env.LOCAL_STORAGE_SECRET || 'development-secret';
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(`${prefix}${key}:${expires}`)
.digest('hex')
.substring(0, 16);
return signature === expectedSignature;
}
}

115
lib/storage/providers/s3.ts Normal file
View file

@ -0,0 +1,115 @@
/**
* S3 Storage Provider for LocalGreenChain
* Agent 3: File Upload & Storage System
*
* Compatible with AWS S3, Cloudflare R2, and MinIO
*/
import {
S3Client,
PutObjectCommand,
DeleteObjectCommand,
HeadObjectCommand,
GetObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { StorageConfig, StorageProviderInterface } from '../types';
export class S3StorageProvider implements StorageProviderInterface {
private client: S3Client;
private bucket: string;
private publicUrl?: string;
constructor(config: StorageConfig) {
this.bucket = config.bucket;
this.publicUrl = config.publicUrl;
const clientConfig: ConstructorParameters<typeof S3Client>[0] = {
region: config.region || 'us-east-1',
};
// Add endpoint for R2/MinIO
if (config.endpoint) {
clientConfig.endpoint = config.endpoint;
clientConfig.forcePathStyle = config.provider === 'minio';
}
// Add credentials if provided
if (config.accessKeyId && config.secretAccessKey) {
clientConfig.credentials = {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
};
}
this.client = new S3Client(clientConfig);
}
async upload(key: string, buffer: Buffer, contentType: string): Promise<string> {
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: buffer,
ContentType: contentType,
CacheControl: 'public, max-age=31536000', // 1 year cache
});
await this.client.send(command);
return this.getPublicUrl(key);
}
async delete(key: string): Promise<boolean> {
try {
const command = new DeleteObjectCommand({
Bucket: this.bucket,
Key: key,
});
await this.client.send(command);
return true;
} catch (error) {
console.error('Error deleting file from S3:', error);
return false;
}
}
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
const command = new GetObjectCommand({
Bucket: this.bucket,
Key: key,
});
return getSignedUrl(this.client, command, { expiresIn });
}
async getPresignedUploadUrl(key: string, contentType: string, expiresIn = 3600): Promise<string> {
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
ContentType: contentType,
});
return getSignedUrl(this.client, command, { expiresIn });
}
async exists(key: string): Promise<boolean> {
try {
const command = new HeadObjectCommand({
Bucket: this.bucket,
Key: key,
});
await this.client.send(command);
return true;
} catch {
return false;
}
}
getPublicUrl(key: string): string {
if (this.publicUrl) {
return `${this.publicUrl}/${key}`;
}
return `https://${this.bucket}.s3.amazonaws.com/${key}`;
}
}

133
lib/storage/types.ts Normal file
View file

@ -0,0 +1,133 @@
/**
* File Storage Types for LocalGreenChain
* Agent 3: File Upload & Storage System
*/
export type StorageProvider = 's3' | 'r2' | 'minio' | 'local';
export type FileCategory = 'plant-photo' | 'certificate' | 'document' | 'report' | 'avatar';
export type ImageSize = 'thumbnail' | 'small' | 'medium' | 'large' | 'original';
export interface StorageConfig {
provider: StorageProvider;
bucket: string;
region?: string;
endpoint?: string;
accessKeyId?: string;
secretAccessKey?: string;
publicUrl?: string;
localPath?: string;
}
export interface FileMetadata {
id: string;
filename: string;
originalName: string;
mimeType: string;
size: number;
category: FileCategory;
uploadedBy?: string;
plantId?: string;
farmId?: string;
url: string;
thumbnailUrl?: string;
urls?: Record<ImageSize, string>;
width?: number;
height?: number;
exifData?: ExifData;
createdAt: Date;
updatedAt: Date;
deletedAt?: Date;
}
export interface ExifData {
make?: string;
model?: string;
dateTaken?: Date;
gpsLatitude?: number;
gpsLongitude?: number;
orientation?: number;
}
export interface UploadOptions {
category: FileCategory;
plantId?: string;
farmId?: string;
userId?: string;
generateThumbnails?: boolean;
maxSizeBytes?: number;
allowedMimeTypes?: string[];
}
export interface UploadResult {
success: boolean;
file?: FileMetadata;
error?: string;
presignedUrl?: string;
}
export interface PresignedUrlRequest {
filename: string;
contentType: string;
category: FileCategory;
expiresIn?: number;
}
export interface PresignedUrlResponse {
uploadUrl: string;
fileKey: string;
publicUrl: string;
expiresAt: Date;
}
export interface ImageProcessingOptions {
generateThumbnails?: boolean;
convertToWebP?: boolean;
extractExif?: boolean;
maxWidth?: number;
maxHeight?: number;
quality?: number;
}
export interface ThumbnailConfig {
size: ImageSize;
width: number;
height: number;
fit: 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
}
export const THUMBNAIL_CONFIGS: Record<ImageSize, ThumbnailConfig> = {
thumbnail: { size: 'thumbnail', width: 150, height: 150, fit: 'cover' },
small: { size: 'small', width: 300, height: 300, fit: 'inside' },
medium: { size: 'medium', width: 600, height: 600, fit: 'inside' },
large: { size: 'large', width: 1200, height: 1200, fit: 'inside' },
original: { size: 'original', width: 0, height: 0, fit: 'inside' },
};
export const ALLOWED_IMAGE_TYPES = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/heic',
'image/heif',
];
export const ALLOWED_DOCUMENT_TYPES = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
export const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export const DEFAULT_MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
export interface StorageProviderInterface {
upload(key: string, buffer: Buffer, contentType: string): Promise<string>;
delete(key: string): Promise<boolean>;
getSignedUrl(key: string, expiresIn?: number): Promise<string>;
getPresignedUploadUrl(key: string, contentType: string, expiresIn?: number): Promise<string>;
exists(key: string): Promise<boolean>;
getPublicUrl(key: string): string;
}

View file

@ -0,0 +1,270 @@
/**
* Upload Service for LocalGreenChain
* Agent 3: File Upload & Storage System
*
* Core upload functionality with validation and processing
*/
import { v4 as uuidv4 } from 'crypto';
import {
FileMetadata,
FileCategory,
UploadOptions,
UploadResult,
PresignedUrlRequest,
PresignedUrlResponse,
StorageProviderInterface,
ALLOWED_IMAGE_TYPES,
ALLOWED_DOCUMENT_TYPES,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_IMAGE_SIZE,
ImageSize,
} from './types';
import { getStorageConfig, storageConfig } from './config';
import { S3StorageProvider } from './providers/s3';
import { LocalStorageProvider } from './providers/local';
import { ImageProcessor } from './imageProcessor';
// Generate a UUID v4
function generateId(): string {
const bytes = require('crypto').randomBytes(16);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
return bytes.toString('hex').replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5');
}
class UploadService {
private provider: StorageProviderInterface;
private imageProcessor: ImageProcessor;
constructor() {
const config = getStorageConfig();
switch (config.provider) {
case 's3':
case 'r2':
case 'minio':
this.provider = new S3StorageProvider(config);
break;
case 'local':
default:
this.provider = new LocalStorageProvider(config);
break;
}
this.imageProcessor = new ImageProcessor();
}
/**
* Generate a unique file key with path
*/
private generateFileKey(filename: string, category: FileCategory): string {
const id = generateId();
const ext = filename.split('.').pop()?.toLowerCase() || 'bin';
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
return `${category}/${year}/${month}/${id}.${ext}`;
}
/**
* Validate file type and size
*/
private validateFile(
buffer: Buffer,
mimeType: string,
options: UploadOptions
): { valid: boolean; error?: string } {
const isImage = ALLOWED_IMAGE_TYPES.includes(mimeType);
const isDocument = ALLOWED_DOCUMENT_TYPES.includes(mimeType);
// Check allowed types based on category
if (options.category === 'plant-photo' || options.category === 'avatar') {
if (!isImage) {
return { valid: false, error: 'Only image files are allowed for this category' };
}
}
if (options.category === 'document' || options.category === 'certificate') {
if (!isDocument && !isImage) {
return { valid: false, error: 'Only documents and images are allowed for this category' };
}
}
// Check custom allowed types
if (options.allowedMimeTypes && options.allowedMimeTypes.length > 0) {
if (!options.allowedMimeTypes.includes(mimeType)) {
return { valid: false, error: `File type ${mimeType} is not allowed` };
}
}
// Check file size
const maxSize = options.maxSizeBytes ||
(isImage ? DEFAULT_MAX_IMAGE_SIZE : DEFAULT_MAX_FILE_SIZE);
if (buffer.length > maxSize) {
const maxSizeMB = (maxSize / (1024 * 1024)).toFixed(1);
return { valid: false, error: `File size exceeds maximum of ${maxSizeMB}MB` };
}
return { valid: true };
}
/**
* Upload a file with processing
*/
async upload(
buffer: Buffer,
originalName: string,
mimeType: string,
options: UploadOptions
): Promise<UploadResult> {
// Validate file
const validation = this.validateFile(buffer, mimeType, options);
if (!validation.valid) {
return { success: false, error: validation.error };
}
const isImage = ALLOWED_IMAGE_TYPES.includes(mimeType);
const fileKey = this.generateFileKey(originalName, options.category);
const fileId = generateId();
try {
let processedBuffer = buffer;
let width: number | undefined;
let height: number | undefined;
let exifData: FileMetadata['exifData'];
const urls: Partial<Record<ImageSize, string>> = {};
// Process images
if (isImage && options.generateThumbnails !== false) {
// Get image metadata
const metadata = await this.imageProcessor.getMetadata(buffer);
width = metadata.width;
height = metadata.height;
// Extract EXIF data
exifData = await this.imageProcessor.extractExif(buffer);
// Optimize original image
const optimized = await this.imageProcessor.optimize(buffer);
processedBuffer = optimized;
// Generate thumbnails
const thumbnails = await this.imageProcessor.generateThumbnails(buffer);
// Upload thumbnails
for (const [size, thumbBuffer] of Object.entries(thumbnails)) {
const thumbKey = fileKey.replace(/\.[^.]+$/, `-${size}.webp`);
urls[size as ImageSize] = await this.provider.upload(thumbKey, thumbBuffer, 'image/webp');
}
}
// Upload main file
const finalMimeType = isImage ? 'image/webp' : mimeType;
const finalKey = isImage ? fileKey.replace(/\.[^.]+$/, '.webp') : fileKey;
const mainUrl = await this.provider.upload(finalKey, processedBuffer, finalMimeType);
urls.original = mainUrl;
// Create file metadata
const fileMetadata: FileMetadata = {
id: fileId,
filename: finalKey,
originalName,
mimeType: finalMimeType,
size: processedBuffer.length,
category: options.category,
uploadedBy: options.userId,
plantId: options.plantId,
farmId: options.farmId,
url: mainUrl,
thumbnailUrl: urls.thumbnail,
urls: urls as Record<ImageSize, string>,
width,
height,
exifData,
createdAt: new Date(),
updatedAt: new Date(),
};
return { success: true, file: fileMetadata };
} catch (error) {
console.error('Upload error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Upload failed',
};
}
}
/**
* Generate a presigned URL for direct upload
*/
async getPresignedUploadUrl(request: PresignedUrlRequest): Promise<PresignedUrlResponse> {
const fileKey = this.generateFileKey(request.filename, request.category);
const expiresIn = request.expiresIn || 3600; // 1 hour default
const uploadUrl = await this.provider.getPresignedUploadUrl(
fileKey,
request.contentType,
expiresIn
);
return {
uploadUrl,
fileKey,
publicUrl: this.provider.getPublicUrl(fileKey),
expiresAt: new Date(Date.now() + expiresIn * 1000),
};
}
/**
* Delete a file and its thumbnails
*/
async delete(fileKey: string): Promise<boolean> {
try {
// Delete main file
await this.provider.delete(fileKey);
// Delete thumbnails if they exist
const sizes: ImageSize[] = ['thumbnail', 'small', 'medium', 'large'];
for (const size of sizes) {
const thumbKey = fileKey.replace(/\.[^.]+$/, `-${size}.webp`);
await this.provider.delete(thumbKey);
}
return true;
} catch (error) {
console.error('Delete error:', error);
return false;
}
}
/**
* Get a signed URL for private file access
*/
async getSignedUrl(fileKey: string, expiresIn = 3600): Promise<string> {
return this.provider.getSignedUrl(fileKey, expiresIn);
}
/**
* Check if a file exists
*/
async exists(fileKey: string): Promise<boolean> {
return this.provider.exists(fileKey);
}
}
// Singleton instance
let uploadServiceInstance: UploadService | null = null;
export function getUploadService(): UploadService {
if (!uploadServiceInstance) {
uploadServiceInstance = new UploadService();
}
return uploadServiceInstance;
}
export { UploadService };

View file

@ -18,25 +18,31 @@
"test:e2e:ci": "start-server-and-test 'bun run preview' http://localhost:3001 cy:run"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.937.0",
"@aws-sdk/s3-request-presigner": "^3.937.0",
"@tailwindcss/forms": "^0.4.0",
"@tailwindcss/typography": "^0.5.1",
"@tanstack/react-query": "^4.0.10",
"classnames": "^2.3.1",
"drupal-jsonapi-params": "^1.2.2",
"html-react-parser": "^1.2.7",
"multer": "^2.0.2",
"next": "^12.2.3",
"next-drupal": "^1.6.0",
"nprogress": "^0.2.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-hook-form": "^7.8.6",
"sharp": "^0.34.5",
"socks-proxy-agent": "^8.0.2"
},
"devDependencies": {
"@babel/core": "^7.12.9",
"@types/jest": "^29.5.0",
"@types/multer": "^2.0.0",
"@types/node": "^17.0.21",
"@types/react": "^17.0.0",
"@types/sharp": "^0.32.0",
"autoprefixer": "^10.4.2",
"eslint-config-next": "^12.0.10",
"jest": "^29.5.0",

View file

@ -0,0 +1,99 @@
/**
* File Management API Endpoint
* Agent 3: File Upload & Storage System
*
* GET /api/upload/[fileId] - Get file info or signed URL
* DELETE /api/upload/[fileId] - Delete a file
*/
import type { NextApiRequest, NextApiResponse } from 'next';
import { getUploadService } from '../../../lib/storage';
interface FileResponse {
success: boolean;
signedUrl?: string;
exists?: boolean;
error?: string;
}
interface DeleteResponse {
success: boolean;
error?: string;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<FileResponse | DeleteResponse>
) {
const { fileId } = req.query;
if (!fileId || typeof fileId !== 'string') {
return res.status(400).json({ success: false, error: 'File ID is required' });
}
// Decode the file key (it may be URL encoded)
const fileKey = decodeURIComponent(fileId);
const uploadService = getUploadService();
switch (req.method) {
case 'GET': {
try {
// Check if file exists
const exists = await uploadService.exists(fileKey);
if (!exists) {
return res.status(404).json({
success: false,
exists: false,
error: 'File not found',
});
}
// Get expiration time from query params (default 1 hour)
const expiresIn = parseInt(req.query.expiresIn as string) || 3600;
// Get signed URL for private file access
const signedUrl = await uploadService.getSignedUrl(fileKey, expiresIn);
return res.status(200).json({
success: true,
exists: true,
signedUrl,
});
} catch (error) {
console.error('Get file error:', error);
return res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
});
}
}
case 'DELETE': {
try {
const deleted = await uploadService.delete(fileKey);
if (!deleted) {
return res.status(404).json({
success: false,
error: 'File not found or could not be deleted',
});
}
return res.status(200).json({
success: true,
});
} catch (error) {
console.error('Delete file error:', error);
return res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
});
}
}
default:
return res.status(405).json({ success: false, error: 'Method not allowed' });
}
}

View file

@ -0,0 +1,160 @@
/**
* Document Upload API Endpoint
* Agent 3: File Upload & Storage System
*
* POST /api/upload/document - Upload a document file (PDF, DOC, etc.)
*/
import type { NextApiRequest, NextApiResponse } from 'next';
import { getUploadService } from '../../../lib/storage';
import type { FileCategory } from '../../../lib/storage/types';
// Disable body parser to handle multipart/form-data
export const config = {
api: {
bodyParser: false,
},
};
interface UploadResponse {
success: boolean;
file?: {
id: string;
url: string;
size: number;
mimeType: string;
originalName: string;
};
error?: string;
}
async function parseMultipartForm(
req: NextApiRequest
): Promise<{ buffer: Buffer; filename: string; mimeType: string; fields: Record<string, string> }> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let filename = 'document';
let mimeType = 'application/octet-stream';
const fields: Record<string, string> = {};
const contentType = req.headers['content-type'] || '';
const boundary = contentType.split('boundary=')[1];
if (!boundary) {
reject(new Error('Missing multipart boundary'));
return;
}
req.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
req.on('end', () => {
const buffer = Buffer.concat(chunks);
const content = buffer.toString('binary');
const parts = content.split(`--${boundary}`);
for (const part of parts) {
if (part.includes('Content-Disposition: form-data')) {
const nameMatch = part.match(/name="([^"]+)"/);
const filenameMatch = part.match(/filename="([^"]+)"/);
if (filenameMatch) {
filename = filenameMatch[1];
const contentTypeMatch = part.match(/Content-Type: ([^\r\n]+)/);
if (contentTypeMatch) {
mimeType = contentTypeMatch[1].trim();
}
const contentStart = part.indexOf('\r\n\r\n') + 4;
const contentEnd = part.lastIndexOf('\r\n');
if (contentStart > 4 && contentEnd > contentStart) {
const fileContent = part.slice(contentStart, contentEnd);
const fileBuffer = Buffer.from(fileContent, 'binary');
resolve({ buffer: fileBuffer, filename, mimeType, fields });
return;
}
} else if (nameMatch) {
const fieldName = nameMatch[1];
const contentStart = part.indexOf('\r\n\r\n') + 4;
const contentEnd = part.lastIndexOf('\r\n');
if (contentStart > 4 && contentEnd > contentStart) {
fields[fieldName] = part.slice(contentStart, contentEnd).trim();
}
}
}
}
reject(new Error('No file found in request'));
});
req.on('error', reject);
});
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<UploadResponse>
) {
if (req.method !== 'POST') {
return res.status(405).json({ success: false, error: 'Method not allowed' });
}
try {
const { buffer, filename, mimeType, fields } = await parseMultipartForm(req);
// Validate document type
const allowedTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'image/jpeg',
'image/png',
];
if (!allowedTypes.includes(mimeType)) {
return res.status(400).json({
success: false,
error: 'Invalid file type. Only PDF, DOC, DOCX, and images are allowed.',
});
}
const category = (fields.category as FileCategory) || 'document';
const plantId = fields.plantId;
const farmId = fields.farmId;
const userId = fields.userId;
const uploadService = getUploadService();
const result = await uploadService.upload(buffer, filename, mimeType, {
category,
plantId,
farmId,
userId,
generateThumbnails: false, // No thumbnails for documents
});
if (!result.success || !result.file) {
return res.status(400).json({
success: false,
error: result.error || 'Upload failed',
});
}
return res.status(200).json({
success: true,
file: {
id: result.file.id,
url: result.file.url,
size: result.file.size,
mimeType: result.file.mimeType,
originalName: result.file.originalName,
},
});
} catch (error) {
console.error('Document upload error:', error);
return res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
});
}
}

153
pages/api/upload/image.ts Normal file
View file

@ -0,0 +1,153 @@
/**
* Image Upload API Endpoint
* Agent 3: File Upload & Storage System
*
* POST /api/upload/image - Upload an image file
*/
import type { NextApiRequest, NextApiResponse } from 'next';
import { getUploadService } from '../../../lib/storage';
import type { FileCategory, UploadResult } from '../../../lib/storage/types';
// Disable body parser to handle multipart/form-data
export const config = {
api: {
bodyParser: false,
},
};
interface UploadResponse {
success: boolean;
file?: {
id: string;
url: string;
thumbnailUrl?: string;
urls?: Record<string, string>;
width?: number;
height?: number;
size: number;
mimeType: string;
};
error?: string;
}
async function parseMultipartForm(
req: NextApiRequest
): Promise<{ buffer: Buffer; filename: string; mimeType: string; fields: Record<string, string> }> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let filename = 'upload';
let mimeType = 'application/octet-stream';
const fields: Record<string, string> = {};
const contentType = req.headers['content-type'] || '';
const boundary = contentType.split('boundary=')[1];
if (!boundary) {
reject(new Error('Missing multipart boundary'));
return;
}
req.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
req.on('end', () => {
const buffer = Buffer.concat(chunks);
const content = buffer.toString('binary');
const parts = content.split(`--${boundary}`);
for (const part of parts) {
if (part.includes('Content-Disposition: form-data')) {
const nameMatch = part.match(/name="([^"]+)"/);
const filenameMatch = part.match(/filename="([^"]+)"/);
if (filenameMatch) {
// This is a file
filename = filenameMatch[1];
const contentTypeMatch = part.match(/Content-Type: ([^\r\n]+)/);
if (contentTypeMatch) {
mimeType = contentTypeMatch[1].trim();
}
// Extract file content (after double CRLF)
const contentStart = part.indexOf('\r\n\r\n') + 4;
const contentEnd = part.lastIndexOf('\r\n');
if (contentStart > 4 && contentEnd > contentStart) {
const fileContent = part.slice(contentStart, contentEnd);
const fileBuffer = Buffer.from(fileContent, 'binary');
resolve({ buffer: fileBuffer, filename, mimeType, fields });
return;
}
} else if (nameMatch) {
// This is a regular field
const fieldName = nameMatch[1];
const contentStart = part.indexOf('\r\n\r\n') + 4;
const contentEnd = part.lastIndexOf('\r\n');
if (contentStart > 4 && contentEnd > contentStart) {
fields[fieldName] = part.slice(contentStart, contentEnd).trim();
}
}
}
}
reject(new Error('No file found in request'));
});
req.on('error', reject);
});
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<UploadResponse>
) {
if (req.method !== 'POST') {
return res.status(405).json({ success: false, error: 'Method not allowed' });
}
try {
const { buffer, filename, mimeType, fields } = await parseMultipartForm(req);
const category = (fields.category as FileCategory) || 'plant-photo';
const plantId = fields.plantId;
const farmId = fields.farmId;
const userId = fields.userId;
const uploadService = getUploadService();
const result = await uploadService.upload(buffer, filename, mimeType, {
category,
plantId,
farmId,
userId,
generateThumbnails: true,
});
if (!result.success || !result.file) {
return res.status(400).json({
success: false,
error: result.error || 'Upload failed',
});
}
return res.status(200).json({
success: true,
file: {
id: result.file.id,
url: result.file.url,
thumbnailUrl: result.file.thumbnailUrl,
urls: result.file.urls,
width: result.file.width,
height: result.file.height,
size: result.file.size,
mimeType: result.file.mimeType,
},
});
} catch (error) {
console.error('Upload error:', error);
return res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
});
}
}

View file

@ -0,0 +1,92 @@
/**
* Presigned URL API Endpoint
* Agent 3: File Upload & Storage System
*
* POST /api/upload/presigned - Get a presigned URL for direct upload
*/
import type { NextApiRequest, NextApiResponse } from 'next';
import { getUploadService } from '../../../lib/storage';
import type { FileCategory, PresignedUrlResponse } from '../../../lib/storage/types';
interface RequestBody {
filename: string;
contentType: string;
category: FileCategory;
expiresIn?: number;
}
interface PresignedResponse {
success: boolean;
data?: PresignedUrlResponse;
error?: string;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<PresignedResponse>
) {
if (req.method !== 'POST') {
return res.status(405).json({ success: false, error: 'Method not allowed' });
}
try {
const { filename, contentType, category, expiresIn } = req.body as RequestBody;
// Validate required fields
if (!filename || !contentType || !category) {
return res.status(400).json({
success: false,
error: 'Missing required fields: filename, contentType, category',
});
}
// Validate category
const validCategories: FileCategory[] = ['plant-photo', 'certificate', 'document', 'report', 'avatar'];
if (!validCategories.includes(category)) {
return res.status(400).json({
success: false,
error: `Invalid category. Must be one of: ${validCategories.join(', ')}`,
});
}
// Validate content type
const allowedContentTypes = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/heic',
'image/heif',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
if (!allowedContentTypes.includes(contentType)) {
return res.status(400).json({
success: false,
error: `Invalid content type. Must be one of: ${allowedContentTypes.join(', ')}`,
});
}
const uploadService = getUploadService();
const presignedUrl = await uploadService.getPresignedUploadUrl({
filename,
contentType,
category,
expiresIn: expiresIn || 3600, // Default 1 hour
});
return res.status(200).json({
success: true,
data: presignedUrl,
});
} catch (error) {
console.error('Presigned URL error:', error);
return res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
});
}
}