feat: Add plugin configuration editing with template variable parsing
## Backend Changes:
- Enhanced PluginTemplate.save() to auto-parse template variables from plugin code
- Updated PluginInstallationSerializer to expose template metadata (description, category, version, author, logo, template_variables)
- Fixed template variable parser to handle nested {{ }} braces in default values
- Added brace-counting algorithm to properly extract variables with insertion codes
- Fixed explicit type parameter detection (textarea, text, email, etc.)
- Made scheduled_task optional on PluginInstallation model
- Added EventPlugin through model for event-plugin relationships
- Added Event.execute_plugins() method for plugin automation
## Frontend Changes:
- Created Tasks.tsx page for managing scheduled tasks
- Enhanced MyPlugins page with clickable plugin cards
- Added edit configuration modal with dynamic form generation
- Implemented escape sequence handling (convert \n, \', etc. for display)
- Added plugin logos to My Plugins page
- Updated type definitions for PluginInstallation interface
- Added insertion code documentation to Plugin Docs
## Plugin System:
- All platform plugins now have editable email templates with textarea support
- Template variables properly parsed with full default values
- Insertion codes ({{CUSTOMER_NAME}}, {{BUSINESS_NAME}}, etc.) documented
- Plugin logos displayed in marketplace and My Plugins
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
300
frontend/package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"@stripe/connect-js": "^3.3.31",
|
||||
"@stripe/react-connect-js": "^3.3.31",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"axios": "^1.13.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"i18next": "^25.6.3",
|
||||
@@ -25,6 +26,7 @@
|
||||
"react-i18next": "^16.3.5",
|
||||
"react-phone-number-input": "^3.4.14",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"recharts": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1882,6 +1884,15 @@
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -1898,11 +1909,16 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prismjs": {
|
||||
"version": "1.26.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz",
|
||||
"integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz",
|
||||
"integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1918,6 +1934,21 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-syntax-highlighter": {
|
||||
"version": "15.5.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
|
||||
"integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
@@ -2177,6 +2208,36 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/character-entities": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
|
||||
"integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/character-entities-legacy": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
|
||||
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/character-reference-invalid": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
|
||||
"integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
@@ -2222,6 +2283,16 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/comma-separated-tokens": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
|
||||
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -2433,6 +2504,19 @@
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
|
||||
"integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"character-entities": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -2824,6 +2908,19 @@
|
||||
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fault": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
|
||||
"integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"format": "^0.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -2925,6 +3022,14 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/format": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
|
||||
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
|
||||
"engines": {
|
||||
"node": ">=0.4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@@ -3111,6 +3216,36 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-parse-selector": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
|
||||
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hastscript": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
|
||||
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"comma-separated-tokens": "^2.0.0",
|
||||
"hast-util-parse-selector": "^4.0.0",
|
||||
"property-information": "^7.0.0",
|
||||
"space-separated-tokens": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hermes-estree": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||
@@ -3128,6 +3263,21 @@
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "10.7.3",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
|
||||
"integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/highlightjs-vue": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
|
||||
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
@@ -3260,6 +3410,40 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-alphabetical": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||
"integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-alphanumerical": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
|
||||
"integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-alphabetical": "^2.0.0",
|
||||
"is-decimal": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-decimal": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
|
||||
"integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -3281,6 +3465,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-hexadecimal": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
|
||||
"integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -3681,6 +3875,20 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lowlight": {
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
|
||||
"integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fault": "^1.0.0",
|
||||
"highlight.js": "~10.7.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -3888,6 +4096,31 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-entities": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
||||
"integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "^2.0.0",
|
||||
"character-entities-legacy": "^3.0.0",
|
||||
"character-reference-invalid": "^2.0.0",
|
||||
"decode-named-character-reference": "^1.0.0",
|
||||
"is-alphanumerical": "^2.0.0",
|
||||
"is-decimal": "^2.0.0",
|
||||
"is-hexadecimal": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-entities/node_modules/@types/unist": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
|
||||
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -4003,6 +4236,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prismjs": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -4020,6 +4262,16 @@
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
||||
"integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@@ -4195,6 +4447,26 @@
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-syntax-highlighter": {
|
||||
"version": "16.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz",
|
||||
"integrity": "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"highlight.js": "^10.4.1",
|
||||
"highlightjs-vue": "^1.0.0",
|
||||
"lowlight": "^1.17.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"refractor": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.20.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 0.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.5.0.tgz",
|
||||
@@ -4241,6 +4513,22 @@
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/refractor": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
|
||||
"integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/prismjs": "^1.0.0",
|
||||
"hastscript": "^9.0.0",
|
||||
"parse-entities": "^4.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
@@ -4351,6 +4639,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/space-separated-tokens": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
|
||||
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"@stripe/connect-js": "^3.3.31",
|
||||
"@stripe/react-connect-js": "^3.3.31",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"axios": "^1.13.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"i18next": "^25.6.3",
|
||||
@@ -21,6 +22,7 @@
|
||||
"react-i18next": "^16.3.5",
|
||||
"react-phone-number-input": "^3.4.14",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"recharts": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
BIN
frontend/public/plugin-logos/appointment-reminder-24hr.png
Normal file
|
After Width: | Height: | Size: 993 B |
1
frontend/public/plugin-logos/appointment_reminder.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-bell"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>
|
||||
|
After Width: | Height: | Size: 299 B |
1
frontend/public/plugin-logos/backup_database.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-database"><ellipse cx="12" cy="5" rx="9" ry="3"></ellipse><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path><path d="M3 5v14c0 1.66 4 3 9 3s 9-1.34 9-3V5"></path></svg>
|
||||
|
After Width: | Height: | Size: 351 B |
BIN
frontend/public/plugin-logos/birthday-greetings.png
Normal file
|
After Width: | Height: | Size: 840 B |
1
frontend/public/plugin-logos/cleanup_old_events.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-trash-2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>
|
||||
|
After Width: | Height: | Size: 426 B |
BIN
frontend/public/plugin-logos/daily-appointment-summary.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
1
frontend/public/plugin-logos/daily_report.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file-text"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
|
||||
|
After Width: | Height: | Size: 451 B |
132
frontend/public/plugin-logos/generate_all_logos_gemini.py
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
import os
|
||||
import time
|
||||
|
||||
# Setup Client
|
||||
client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
|
||||
|
||||
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
|
||||
|
||||
# Plugin configurations with detailed prompts
|
||||
plugins = [
|
||||
{
|
||||
'filename': 'daily-appointment-summary.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
|
||||
Design: Indigo blue gradient background (#4f46e5 to lighter blue).
|
||||
Icon: Simple white envelope overlaid with a small calendar icon in the corner.
|
||||
Style: Flat design, clean geometric shapes, professional SaaS aesthetic.
|
||||
The icon should be crisp and recognizable at 48x48 pixels.'''
|
||||
},
|
||||
{
|
||||
'filename': 'no-show-tracker.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
|
||||
Design: Red gradient background (#dc2626 to darker red).
|
||||
Icon: Simple white person silhouette with a bold white X symbol overlaid.
|
||||
Style: Flat design, clean geometric shapes, professional warning aesthetic.
|
||||
The icon should clearly convey "missed appointment" at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'birthday-greetings.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
|
||||
Design: Pink gradient background (#ec4899 to lighter pink).
|
||||
Icon: Simple white birthday cake with 3 candles or a white gift box with a bow.
|
||||
Style: Flat design, clean geometric shapes, cheerful yet professional aesthetic.
|
||||
The icon should feel celebratory and friendly at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'monthly-revenue-report.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
|
||||
Design: Green gradient background (#10b981 to darker green).
|
||||
Icon: Simple white upward trending line chart or ascending bar graph.
|
||||
Style: Flat design, clean geometric shapes, professional business analytics aesthetic.
|
||||
The icon should convey growth and success at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'appointment-reminder-24hr.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
|
||||
Design: Amber/orange gradient background (#f59e0b to darker orange).
|
||||
Icon: Simple white notification bell with a small red circular alert badge.
|
||||
Style: Flat design, clean geometric shapes, attention-grabbing yet professional aesthetic.
|
||||
The icon should clearly indicate alerts and reminders at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'inactive-customer-reengagement.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
|
||||
Design: Purple gradient background (#8b5cf6 to darker purple).
|
||||
Icon: Simple white heart symbol with a circular white refresh/return arrow around it.
|
||||
Style: Flat design, clean geometric shapes, warm and welcoming professional aesthetic.
|
||||
The icon should convey customer care and returning customers at small sizes.'''
|
||||
}
|
||||
]
|
||||
|
||||
def generate_logo(prompt, filename):
|
||||
"""Generate a logo using Gemini 2.5 Flash Image"""
|
||||
print(f"\nGenerating {filename}...")
|
||||
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model='gemini-2.5-flash-image',
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
response_modalities=["IMAGE"]
|
||||
)
|
||||
)
|
||||
|
||||
# Save the Image
|
||||
if response.candidates[0].content.parts:
|
||||
for part in response.candidates[0].content.parts:
|
||||
if part.inline_data:
|
||||
image_data = part.inline_data.data
|
||||
image = Image.open(BytesIO(image_data))
|
||||
|
||||
# Save to output directory
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
image.save(output_path)
|
||||
print(f"✓ Saved: {output_path}")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ Model returned text instead of image: {part.text[:100]}")
|
||||
return False
|
||||
else:
|
||||
print("✗ No content parts in response")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "RESOURCE_EXHAUSTED" in error_msg or "quota" in error_msg.lower():
|
||||
print(f"✗ Quota exceeded. Please try again tomorrow when quota resets.")
|
||||
print(f" Error: {error_msg[:200]}...")
|
||||
else:
|
||||
print(f"✗ Error: {type(e).__name__}: {error_msg[:200]}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
print("=" * 70)
|
||||
print("Generating Plugin Logos with Gemini 2.5 Flash Image")
|
||||
print("=" * 70)
|
||||
|
||||
success_count = 0
|
||||
for i, plugin in enumerate(plugins):
|
||||
if i > 0:
|
||||
# Wait 3 seconds between requests to avoid rate limiting
|
||||
print("\nWaiting 3 seconds before next request...")
|
||||
time.sleep(3)
|
||||
|
||||
if generate_logo(plugin['prompt'], plugin['filename']):
|
||||
success_count += 1
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(f"Generation complete: {success_count}/{len(plugins)} successful")
|
||||
print("=" * 70)
|
||||
|
||||
if success_count == 0:
|
||||
print("\nNote: If you hit quota limits, try again tomorrow when the quota resets!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
77
frontend/public/plugin-logos/generate_daily_summary.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw
|
||||
except ImportError:
|
||||
print("Error: Pillow library not found.")
|
||||
print("Please install it using: pip install Pillow")
|
||||
sys.exit(1)
|
||||
|
||||
# Configuration
|
||||
OUTPUT_PATH = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos/daily-appointment-summary.png"
|
||||
SIZE = (144, 144) # Generated at 3x resolution (144px) for high quality on 48px displays
|
||||
BG_COLOR = "#4f46e5" # Indigo/Blue
|
||||
ICON_COLOR = "white"
|
||||
|
||||
def create_logo():
|
||||
# Create a new image with a transparent background
|
||||
img = Image.new('RGBA', SIZE, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 1. Draw Background (Rounded Square)
|
||||
radius = 30
|
||||
rect_bounds = [0, 0, SIZE[0], SIZE[1]]
|
||||
draw.rounded_rectangle(rect_bounds, radius=radius, fill=BG_COLOR)
|
||||
|
||||
# 2. Draw Envelope Icon (Email concept)
|
||||
# Centered, slightly offset up to make room for calendar
|
||||
env_w = 90
|
||||
env_h = 60
|
||||
env_x = (SIZE[0] - env_w) // 2
|
||||
env_y = (SIZE[1] - env_h) // 2 - 10
|
||||
|
||||
# Envelope body
|
||||
draw.rectangle([env_x, env_y, env_x + env_w, env_y + env_h], outline=ICON_COLOR, width=5)
|
||||
|
||||
# Envelope flap (V shape)
|
||||
draw.line([env_x, env_y, env_x + env_w // 2, env_y + env_h // 2 + 5], fill=ICON_COLOR, width=5)
|
||||
draw.line([env_x + env_w, env_y, env_x + env_w // 2, env_y + env_h // 2 + 5], fill=ICON_COLOR, width=5)
|
||||
|
||||
# 3. Draw Calendar Badge (Scheduling concept)
|
||||
# Bottom right corner
|
||||
cal_size = 50
|
||||
cal_x = env_x + env_w - (cal_size // 2)
|
||||
cal_y = env_y + env_h - (cal_size // 2)
|
||||
|
||||
# Clear background behind calendar for separation
|
||||
border = 4
|
||||
draw.rounded_rectangle(
|
||||
[cal_x - border, cal_y - border, cal_x + cal_size + border, cal_y + cal_size + border],
|
||||
radius=10, fill=BG_COLOR
|
||||
)
|
||||
|
||||
# Calendar body
|
||||
draw.rounded_rectangle([cal_x, cal_y, cal_x + cal_size, cal_y + cal_size], radius=8, fill="white")
|
||||
|
||||
# Calendar red header (using a lighter indigo/blue to match theme)
|
||||
header_h = 14
|
||||
draw.rounded_rectangle(
|
||||
[cal_x, cal_y, cal_x + cal_size, cal_y + header_h],
|
||||
radius=8, corners=(True, True, False, False), fill="#818cf8"
|
||||
)
|
||||
|
||||
# Calendar 'lines'
|
||||
line_x = cal_x + 10
|
||||
line_w = cal_size - 20
|
||||
draw.line([line_x, cal_y + 22, line_x + line_w, cal_y + 22], fill=BG_COLOR, width=3)
|
||||
draw.line([line_x, cal_y + 32, line_x + line_w, cal_y + 32], fill=BG_COLOR, width=3)
|
||||
draw.line([line_x, cal_y + 42, line_x + line_w * 0.6, cal_y + 42], fill=BG_COLOR, width=3)
|
||||
|
||||
# Save the file
|
||||
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
|
||||
img.save(OUTPUT_PATH)
|
||||
print(f"Success! Logo saved to: {OUTPUT_PATH}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_logo()
|
||||
155
frontend/public/plugin-logos/generate_plugin_logos.py
Normal file
@@ -0,0 +1,155 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
print("Error: Pillow library not found.")
|
||||
print("Please install it using: pip install Pillow")
|
||||
sys.exit(1)
|
||||
|
||||
# Configuration
|
||||
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
|
||||
SIZE = (144, 144)
|
||||
RADIUS = 30
|
||||
|
||||
def create_base_logo(bg_color):
|
||||
"""Creates the base image with rounded corners and background color."""
|
||||
img = Image.new('RGBA', SIZE, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
rect_bounds = [0, 0, SIZE[0], SIZE[1]]
|
||||
draw.rounded_rectangle(rect_bounds, radius=RADIUS, fill=bg_color)
|
||||
return img, draw
|
||||
|
||||
def save_logo(img, filename):
|
||||
"""Saves the image to the output directory."""
|
||||
path = os.path.join(OUTPUT_DIR, filename)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
img.save(path)
|
||||
print(f"Saved: {path}")
|
||||
|
||||
# 1. No-Show Customer Tracker (Red, Person with X)
|
||||
def create_no_show_logo():
|
||||
img, draw = create_base_logo("#dc2626") # Red
|
||||
|
||||
# Person Silhouette
|
||||
cx, cy = 72, 72
|
||||
# Head
|
||||
draw.ellipse([cx-15, 35, cx+15, 65], fill="white")
|
||||
# Shoulders/Body
|
||||
draw.rounded_rectangle([cx-30, 70, cx+30, 130], radius=10, fill="white")
|
||||
|
||||
# Cancel Symbol (Red X on white circle or just overlaid)
|
||||
# Let's use a thick Red X overlay
|
||||
x_center_y = 85
|
||||
half = 20
|
||||
width = 8
|
||||
|
||||
# Draw X
|
||||
draw.line([cx - half, x_center_y - half, cx + half, x_center_y + half], fill="#dc2626", width=width)
|
||||
draw.line([cx + half, x_center_y - half, cx - half, x_center_y + half], fill="#dc2626", width=width)
|
||||
|
||||
save_logo(img, "no-show-tracker.png")
|
||||
|
||||
# 2. Birthday Greeting Campaign (Pink, Gift)
|
||||
def create_birthday_logo():
|
||||
img, draw = create_base_logo("#ec4899") # Pink
|
||||
|
||||
# Gift Box
|
||||
box_w, box_h = 70, 60
|
||||
box_x = (SIZE[0] - box_w) // 2
|
||||
box_y = (SIZE[1] - box_h) // 2 + 10
|
||||
|
||||
# Box body
|
||||
draw.rectangle([box_x, box_y, box_x + box_w, box_y + box_h], fill="white")
|
||||
|
||||
# Ribbons (Lighter Pink)
|
||||
r_w = 12
|
||||
ribbon_color = "#fbcfe8"
|
||||
# Vertical
|
||||
draw.rectangle([box_x + (box_w - r_w)//2, box_y, box_x + (box_w + r_w)//2, box_y + box_h], fill=ribbon_color)
|
||||
# Horizontal
|
||||
draw.rectangle([box_x, box_y + (box_h - r_w)//2, box_x + box_w, box_y + (box_h + r_w)//2], fill=ribbon_color)
|
||||
|
||||
# Bow
|
||||
bow_w = 20
|
||||
draw.ellipse([72 - bow_w, box_y - 15, 72, box_y], fill="white")
|
||||
draw.ellipse([72, box_y - 15, 72 + bow_w, box_y], fill="white")
|
||||
|
||||
save_logo(img, "birthday-greetings.png")
|
||||
|
||||
# 3. Monthly Revenue Report (Green, Chart)
|
||||
def create_revenue_logo():
|
||||
img, draw = create_base_logo("#10b981") # Green
|
||||
|
||||
# Bar Chart
|
||||
margin_bottom = 110
|
||||
bar_w = 20
|
||||
gap = 10
|
||||
start_x = (144 - (3 * bar_w + 2 * gap)) // 2
|
||||
|
||||
# Bars
|
||||
heights = [30, 50, 75]
|
||||
for i, h in enumerate(heights):
|
||||
x = start_x + i * (bar_w + gap)
|
||||
draw.rectangle([x, margin_bottom - h, x + bar_w, margin_bottom], fill="white")
|
||||
|
||||
# Trend Arrow (Rising)
|
||||
arrow_start = (start_x - 5, margin_bottom - 10)
|
||||
arrow_end = (start_x + 3 * bar_w + 2 * gap + 5, margin_bottom - 90)
|
||||
|
||||
# Simple line for trend
|
||||
# draw.line([arrow_start, arrow_end], fill="white", width=4)
|
||||
|
||||
save_logo(img, "monthly-revenue-report.png")
|
||||
|
||||
# 4. Appointment Reminder (24hr) (Amber, Bell)
|
||||
def create_reminder_logo():
|
||||
img, draw = create_base_logo("#f59e0b") # Amber
|
||||
|
||||
cx, cy = 72, 70
|
||||
r = 30
|
||||
|
||||
# Bell Dome
|
||||
draw.chord([cx - r, cy - r, cx + r, cy + r], 180, 0, fill="white")
|
||||
draw.rectangle([cx - r, cy, cx + r, cy + 20], fill="white")
|
||||
|
||||
# Bell Flare
|
||||
draw.polygon([(cx - r, cy + 20), (cx + r, cy + 20), (cx + r + 5, cy + 30), (cx - r - 5, cy + 30)], fill="white")
|
||||
|
||||
# Clapper
|
||||
draw.ellipse([cx - 8, cy + 28, cx + 8, cy + 42], fill="white")
|
||||
|
||||
# Notification Dot
|
||||
draw.ellipse([cx + 15, cy - 25, cx + 35, cy - 5], fill="#dc2626")
|
||||
|
||||
save_logo(img, "appointment-reminder-24hr.png")
|
||||
|
||||
# 5. Inactive Customer Re-engagement (Purple, Heart)
|
||||
def create_inactive_logo():
|
||||
img, draw = create_base_logo("#8b5cf6") # Purple
|
||||
|
||||
hx, hy = 72, 75
|
||||
size = 18
|
||||
|
||||
# Heart Shape
|
||||
# Left circle
|
||||
draw.ellipse([hx - size * 2, hy - size, hx, hy + size], fill="white")
|
||||
# Right circle
|
||||
draw.ellipse([hx, hy - size, hx + size * 2, hy + size], fill="white")
|
||||
# Bottom triangle
|
||||
draw.polygon([(hx - size * 2 + 1, hy + 4), (hx + size * 2 - 1, hy + 4), (hx, hy + size * 2 + 8)], fill="white")
|
||||
|
||||
# Refresh/Loop Arrow (Subtle arc above)
|
||||
bbox = [25, 25, 119, 119]
|
||||
draw.arc(bbox, start=180, end=0, fill="#ddd6fe", width=5)
|
||||
draw.polygon([(119, 72), (110, 60), (128, 60)], fill="#ddd6fe")
|
||||
|
||||
save_logo(img, "inactive-customer-reengagement.png")
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_no_show_logo()
|
||||
create_birthday_logo()
|
||||
create_revenue_logo()
|
||||
create_reminder_logo()
|
||||
create_inactive_logo()
|
||||
39
frontend/public/plugin-logos/generate_with_2_0_flash.py
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
|
||||
# Setup Client
|
||||
client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
|
||||
|
||||
# Test with a simple prompt
|
||||
prompt = "Create a simple modern app icon with a blue background and white envelope symbol, square format, flat design, minimalist"
|
||||
|
||||
print(f"Testing gemini-2.0-flash-exp...")
|
||||
print(f"Prompt: '{prompt}'...")
|
||||
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model='gemini-2.0-flash-exp',
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
response_modalities=["IMAGE"]
|
||||
)
|
||||
)
|
||||
|
||||
# Save the Image
|
||||
if response.candidates[0].content.parts:
|
||||
for part in response.candidates[0].content.parts:
|
||||
if part.inline_data:
|
||||
image_data = part.inline_data.data
|
||||
image = Image.open(BytesIO(image_data))
|
||||
image.save("test_2_0_flash.png")
|
||||
print("✓ Success! Saved test_2_0_flash.png")
|
||||
else:
|
||||
print(f"✗ Model returned text: {part.text[:100]}")
|
||||
else:
|
||||
print("✗ No content parts in response")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error: {type(e).__name__}: {str(e)[:300]}")
|
||||
194
frontend/public/plugin-logos/generate_with_gemini.py
Normal file
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
# Gemini API configuration
|
||||
API_KEY = "AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw"
|
||||
API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent"
|
||||
|
||||
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
|
||||
|
||||
# Plugin configurations
|
||||
plugins = [
|
||||
{
|
||||
'filename': 'daily-appointment-summary.png',
|
||||
'prompt': '''Create a modern, professional icon/logo for a plugin called "Daily Appointment Summary Email".
|
||||
|
||||
Design requirements:
|
||||
- Square icon, 512x512 pixels
|
||||
- Flat design style with a slight gradient
|
||||
- Primary color: Indigo/blue (#4f46e5)
|
||||
- Icon should combine email and calendar/scheduling concepts
|
||||
- Clean, minimalist design suitable for a SaaS application
|
||||
- White or light elements on the colored background
|
||||
- Rounded corners (similar to modern app icons)
|
||||
- Professional and trustworthy appearance
|
||||
|
||||
The icon represents a plugin that sends daily email summaries of appointments to staff members.'''
|
||||
},
|
||||
{
|
||||
'filename': 'no-show-tracker.png',
|
||||
'prompt': '''Create a modern, professional icon/logo for a plugin called "No-Show Customer Tracker".
|
||||
|
||||
Design requirements:
|
||||
- Square icon, 512x512 pixels
|
||||
- Flat design style with a slight gradient
|
||||
- Primary color: Red (#dc2626)
|
||||
- Icon should represent missed appointments or absent customers
|
||||
- Could show a person silhouette with an X, slash, or cancel symbol
|
||||
- Clean, minimalist design suitable for a SaaS application
|
||||
- White or light elements on the colored background
|
||||
- Rounded corners (similar to modern app icons)
|
||||
- Professional appearance
|
||||
|
||||
The icon represents a plugin that tracks customers who miss their appointments.'''
|
||||
},
|
||||
{
|
||||
'filename': 'birthday-greetings.png',
|
||||
'prompt': '''Create a modern, professional icon/logo for a plugin called "Birthday Greeting Campaign".
|
||||
|
||||
Design requirements:
|
||||
- Square icon, 512x512 pixels
|
||||
- Flat design style with a slight gradient
|
||||
- Primary color: Pink (#ec4899)
|
||||
- Icon should represent birthdays and celebrations
|
||||
- Could show a birthday cake, gift, party hat, or balloon
|
||||
- Clean, minimalist design suitable for a SaaS application
|
||||
- White or light elements on the colored background
|
||||
- Rounded corners (similar to modern app icons)
|
||||
- Friendly and celebratory appearance
|
||||
|
||||
The icon represents a plugin that sends birthday emails with special offers to customers.'''
|
||||
},
|
||||
{
|
||||
'filename': 'monthly-revenue-report.png',
|
||||
'prompt': '''Create a modern, professional icon/logo for a plugin called "Monthly Revenue Report".
|
||||
|
||||
Design requirements:
|
||||
- Square icon, 512x512 pixels
|
||||
- Flat design style with a slight gradient
|
||||
- Primary color: Green (#10b981)
|
||||
- Icon should represent business growth, analytics, and financial reporting
|
||||
- Could show an upward trending chart, graph, or money symbol
|
||||
- Clean, minimalist design suitable for a SaaS application
|
||||
- White or light elements on the colored background
|
||||
- Rounded corners (similar to modern app icons)
|
||||
- Professional and successful appearance
|
||||
|
||||
The icon represents a plugin that generates comprehensive monthly business statistics and revenue reports.'''
|
||||
},
|
||||
{
|
||||
'filename': 'appointment-reminder-24hr.png',
|
||||
'prompt': '''Create a modern, professional icon/logo for a plugin called "Appointment Reminder (24hr)".
|
||||
|
||||
Design requirements:
|
||||
- Square icon, 512x512 pixels
|
||||
- Flat design style with a slight gradient
|
||||
- Primary color: Amber/Orange (#f59e0b)
|
||||
- Icon should represent notifications, alerts, and reminders
|
||||
- Could show a bell with a notification badge, clock, or alarm
|
||||
- Clean, minimalist design suitable for a SaaS application
|
||||
- White or light elements on the colored background
|
||||
- Rounded corners (similar to modern app icons)
|
||||
- Attention-grabbing but professional appearance
|
||||
|
||||
The icon represents a plugin that sends reminder emails to customers 24 hours before their appointments.'''
|
||||
},
|
||||
{
|
||||
'filename': 'inactive-customer-reengagement.png',
|
||||
'prompt': '''Create a modern, professional icon/logo for a plugin called "Inactive Customer Re-engagement".
|
||||
|
||||
Design requirements:
|
||||
- Square icon, 512x512 pixels
|
||||
- Flat design style with a slight gradient
|
||||
- Primary color: Purple (#8b5cf6)
|
||||
- Icon should represent customer retention, returning customers, or re-engagement
|
||||
- Could show a heart, person with return arrow, refresh symbol, or comeback concept
|
||||
- Clean, minimalist design suitable for a SaaS application
|
||||
- White or light elements on the colored background
|
||||
- Rounded corners (similar to modern app icons)
|
||||
- Warm and welcoming appearance
|
||||
|
||||
The icon represents a plugin that wins back customers who haven't booked appointments recently.'''
|
||||
}
|
||||
]
|
||||
|
||||
def generate_image(prompt, filename):
|
||||
"""Generate an image using Gemini API"""
|
||||
print(f"\nGenerating {filename}...")
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"contents": [{
|
||||
"parts": [{
|
||||
"text": prompt
|
||||
}]
|
||||
}],
|
||||
"generationConfig": {
|
||||
"temperature": 1,
|
||||
"topK": 40,
|
||||
"topP": 0.95,
|
||||
"maxOutputTokens": 8192,
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{API_URL}?key={API_KEY}",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=120
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
|
||||
# Check if there's an image in the response
|
||||
if 'candidates' in result and len(result['candidates']) > 0:
|
||||
candidate = result['candidates'][0]
|
||||
if 'content' in candidate and 'parts' in candidate['content']:
|
||||
for part in candidate['content']['parts']:
|
||||
if 'inlineData' in part:
|
||||
# Extract and save the image
|
||||
image_data = base64.b64decode(part['inlineData']['data'])
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(image_data)
|
||||
print(f"✓ Saved: {output_path}")
|
||||
return True
|
||||
elif 'text' in part:
|
||||
print(f"Response: {part['text'][:200]}...")
|
||||
|
||||
print(f"✗ No image generated. Response: {result}")
|
||||
return False
|
||||
else:
|
||||
print(f"✗ API Error ({response.status_code}): {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error: {str(e)}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
# Create output directory
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
print("Starting logo generation with Gemini API...")
|
||||
print("=" * 60)
|
||||
|
||||
success_count = 0
|
||||
for plugin in plugins:
|
||||
if generate_image(plugin['prompt'], plugin['filename']):
|
||||
success_count += 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"Generation complete: {success_count}/{len(plugins)} successful")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
112
frontend/public/plugin-logos/generate_with_gemini_sdk.py
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
import google.generativeai as genai
|
||||
import os
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# Configure API Key
|
||||
genai.configure(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
|
||||
|
||||
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
|
||||
|
||||
# Plugin configurations
|
||||
plugins = [
|
||||
{
|
||||
'filename': 'daily-appointment-summary.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
|
||||
Design: Indigo blue gradient background (#4f46e5). White simple envelope icon combined with a small calendar symbol.
|
||||
Style: Flat design, clean geometric shapes, professional SaaS application aesthetic.
|
||||
The icon should be instantly recognizable at 48x48 pixels.'''
|
||||
},
|
||||
{
|
||||
'filename': 'no-show-tracker.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
|
||||
Design: Red gradient background (#dc2626). White simple person silhouette with a bold X or cancel symbol overlay.
|
||||
Style: Flat design, clean geometric shapes, professional SaaS application aesthetic.
|
||||
The icon should clearly convey "missed appointment" at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'birthday-greetings.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
|
||||
Design: Pink gradient background (#ec4899). White simple birthday cake with candles or gift box with bow.
|
||||
Style: Flat design, clean geometric shapes, cheerful yet professional aesthetic.
|
||||
The icon should feel celebratory and friendly at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'monthly-revenue-report.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
|
||||
Design: Green gradient background (#10b981). White simple upward trending line chart or bar graph showing growth.
|
||||
Style: Flat design, clean geometric shapes, professional business analytics aesthetic.
|
||||
The icon should convey success and growth at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'appointment-reminder-24hr.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
|
||||
Design: Amber/orange gradient background (#f59e0b). White simple notification bell with a small red alert dot or badge.
|
||||
Style: Flat design, clean geometric shapes, attention-grabbing yet professional aesthetic.
|
||||
The icon should clearly indicate alerts and reminders at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'inactive-customer-reengagement.png',
|
||||
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
|
||||
Design: Purple gradient background (#8b5cf6). White simple heart symbol with a circular refresh/return arrow around it.
|
||||
Style: Flat design, clean geometric shapes, warm and welcoming professional aesthetic.
|
||||
The icon should convey customer care and comeback at small sizes.'''
|
||||
}
|
||||
]
|
||||
|
||||
def generate_image(prompt, filename):
|
||||
"""Generate an image using Gemini 2.0 Flash Image Generation"""
|
||||
print(f"\nGenerating {filename}...")
|
||||
|
||||
try:
|
||||
# Use the image generation model
|
||||
model = genai.GenerativeModel('gemini-2.0-flash-exp-image-generation')
|
||||
|
||||
# Generate the image
|
||||
response = model.generate_content(prompt)
|
||||
|
||||
# Check for generated image
|
||||
if hasattr(response, 'parts') and response.parts:
|
||||
for part in response.parts:
|
||||
if hasattr(part, 'inline_data') and part.inline_data:
|
||||
# Extract image data
|
||||
image_data = part.inline_data.data
|
||||
|
||||
# Save the image
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(image_data)
|
||||
|
||||
print(f"✓ Saved: {output_path} ({len(image_data)} bytes)")
|
||||
return True
|
||||
|
||||
# If no image data found, check if there's text response
|
||||
if hasattr(response, 'text'):
|
||||
print(f"✗ No image generated. Response text: {response.text[:200]}")
|
||||
else:
|
||||
print(f"✗ No image generated. Response: {response}")
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error: {type(e).__name__}: {str(e)}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
# Create output directory
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
print("Starting logo generation with Gemini 2.0 Flash Image Generation...")
|
||||
print("=" * 60)
|
||||
|
||||
success_count = 0
|
||||
for plugin in plugins:
|
||||
if generate_image(plugin['prompt'], plugin['filename']):
|
||||
success_count += 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"Generation complete: {success_count}/{len(plugins)} successful")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
146
frontend/public/plugin-logos/generate_with_imagen.py
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
import base64
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Imagen API configuration
|
||||
API_KEY = "AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw"
|
||||
MODEL = "imagen-4.0-generate-preview-06-06"
|
||||
API_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:predict"
|
||||
|
||||
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
|
||||
|
||||
# Plugin configurations
|
||||
plugins = [
|
||||
{
|
||||
'filename': 'daily-appointment-summary.png',
|
||||
'prompt': '''A modern, minimalist app icon for "Daily Appointment Summary Email" plugin.
|
||||
Square format with rounded corners. Indigo blue gradient background (#4f46e5).
|
||||
White icon showing a combination of an envelope and a calendar.
|
||||
Flat design, clean lines, professional SaaS application style.
|
||||
The icon should be simple and recognizable at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'no-show-tracker.png',
|
||||
'prompt': '''A modern, minimalist app icon for "No-Show Customer Tracker" plugin.
|
||||
Square format with rounded corners. Red gradient background (#dc2626).
|
||||
White icon showing a person silhouette with an X or cancel symbol overlay.
|
||||
Flat design, clean lines, professional SaaS application style.
|
||||
The icon should convey missed appointments clearly at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'birthday-greetings.png',
|
||||
'prompt': '''A modern, minimalist app icon for "Birthday Greeting Campaign" plugin.
|
||||
Square format with rounded corners. Pink gradient background (#ec4899).
|
||||
White icon showing a birthday cake or gift box with a bow.
|
||||
Flat design, clean lines, cheerful yet professional style.
|
||||
The icon should be celebratory and friendly at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'monthly-revenue-report.png',
|
||||
'prompt': '''A modern, minimalist app icon for "Monthly Revenue Report" plugin.
|
||||
Square format with rounded corners. Green gradient background (#10b981).
|
||||
White icon showing an upward trending chart or bar graph.
|
||||
Flat design, clean lines, professional business analytics style.
|
||||
The icon should convey growth and success at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'appointment-reminder-24hr.png',
|
||||
'prompt': '''A modern, minimalist app icon for "Appointment Reminder" plugin.
|
||||
Square format with rounded corners. Amber/orange gradient background (#f59e0b).
|
||||
White icon showing a notification bell with a small red badge or alert dot.
|
||||
Flat design, clean lines, attention-grabbing yet professional style.
|
||||
The icon should convey alerts and reminders clearly at small sizes.'''
|
||||
},
|
||||
{
|
||||
'filename': 'inactive-customer-reengagement.png',
|
||||
'prompt': '''A modern, minimalist app icon for "Inactive Customer Re-engagement" plugin.
|
||||
Square format with rounded corners. Purple gradient background (#8b5cf6).
|
||||
White icon showing a heart with a circular refresh arrow around it.
|
||||
Flat design, clean lines, warm and welcoming professional style.
|
||||
The icon should convey customer care and return at small sizes.'''
|
||||
}
|
||||
]
|
||||
|
||||
def generate_image(prompt, filename):
|
||||
"""Generate an image using Imagen API"""
|
||||
print(f"\nGenerating {filename}...")
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"instances": [{
|
||||
"prompt": prompt
|
||||
}],
|
||||
"parameters": {
|
||||
"sampleCount": 1
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{API_URL}?key={API_KEY}",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=120
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
|
||||
# Check if there's an image in the predictions
|
||||
if 'predictions' in result and len(result['predictions']) > 0:
|
||||
prediction = result['predictions'][0]
|
||||
if 'bytesBase64Encoded' in prediction:
|
||||
# Extract and save the image
|
||||
image_data = base64.b64decode(prediction['bytesBase64Encoded'])
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(image_data)
|
||||
print(f"✓ Saved: {output_path} ({len(image_data)} bytes)")
|
||||
return True
|
||||
elif 'image' in prediction and 'bytesBase64Encoded' in prediction['image']:
|
||||
image_data = base64.b64decode(prediction['image']['bytesBase64Encoded'])
|
||||
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(image_data)
|
||||
print(f"✓ Saved: {output_path} ({len(image_data)} bytes)")
|
||||
return True
|
||||
|
||||
print(f"✗ No image generated. Response: {str(result)[:300]}...")
|
||||
return False
|
||||
else:
|
||||
print(f"✗ API Error ({response.status_code}): {response.text[:500]}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error: {str(e)}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
# Create output directory
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
print("Starting logo generation with Imagen 4.0 API...")
|
||||
print("=" * 60)
|
||||
|
||||
success_count = 0
|
||||
for i, plugin in enumerate(plugins):
|
||||
if i > 0:
|
||||
# Add delay between requests to avoid rate limiting
|
||||
print("Waiting 2 seconds before next request...")
|
||||
time.sleep(2)
|
||||
|
||||
if generate_image(plugin['prompt'], plugin['filename']):
|
||||
success_count += 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"Generation complete: {success_count}/{len(plugins)} successful")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
frontend/public/plugin-logos/inactive-customer-reengagement.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
frontend/public/plugin-logos/monthly-revenue-report.png
Normal file
|
After Width: | Height: | Size: 706 B |
BIN
frontend/public/plugin-logos/no-show-tracker.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
1
frontend/public/plugin-logos/send_email.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-mail"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
|
||||
|
After Width: | Height: | Size: 332 B |
43
frontend/public/plugin-logos/test_gemini_image.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import google.generativeai as genai
|
||||
import os
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# Configure API Key
|
||||
genai.configure(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
|
||||
|
||||
# Try to initialize the model
|
||||
try:
|
||||
generation_model = genai.GenerativeModel('gemini-pro-vision')
|
||||
print("Model initialized successfully")
|
||||
|
||||
# Define prompt
|
||||
prompt = "A modern app icon with a blue background and white envelope symbol"
|
||||
print(f"Generating image for: '{prompt}'...")
|
||||
|
||||
# Try to generate
|
||||
response = generation_model.generate_content(prompt)
|
||||
|
||||
print(f"Response type: {type(response)}")
|
||||
print(f"Response: {response}")
|
||||
|
||||
if hasattr(response, 'parts') and response.parts:
|
||||
print(f"Parts: {response.parts}")
|
||||
if hasattr(response.parts[0], 'inline_data'):
|
||||
print("Has inline_data!")
|
||||
image_data = response.parts[0].inline_data.data
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
image.save("test_output.png")
|
||||
print("Success! Image saved to test_output.png")
|
||||
else:
|
||||
print("No inline_data attribute")
|
||||
print(f"Part attributes: {dir(response.parts[0])}")
|
||||
else:
|
||||
print("No parts in response or response.parts doesn't exist")
|
||||
if hasattr(response, 'text'):
|
||||
print(f"Response text: {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {type(e).__name__}: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
41
frontend/public/plugin-logos/test_gemini_native_image.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
import os
|
||||
|
||||
# Setup Client
|
||||
client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
|
||||
|
||||
# Define the Prompt
|
||||
prompt = "Create a simple modern app icon with a blue background and white envelope symbol, square format, flat design, minimalist"
|
||||
|
||||
print(f"Asking Gemini to generate: '{prompt}'...")
|
||||
|
||||
# Call the Gemini Model
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model='gemini-2.5-flash-image',
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
response_modalities=["IMAGE"] # Tell Gemini to draw, not talk
|
||||
)
|
||||
)
|
||||
|
||||
# Save the Image
|
||||
if response.candidates[0].content.parts:
|
||||
for part in response.candidates[0].content.parts:
|
||||
if part.inline_data:
|
||||
image_data = part.inline_data.data
|
||||
image = Image.open(BytesIO(image_data))
|
||||
image.save("test_gemini_native.png")
|
||||
print("Success! Saved test_gemini_native.png")
|
||||
else:
|
||||
print("Model returned text instead of image:", part.text)
|
||||
else:
|
||||
print("No content parts in response")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
41
frontend/public/plugin-logos/test_google_genai.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
import os
|
||||
|
||||
# Initialize the Client
|
||||
client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
|
||||
|
||||
# Define the prompt
|
||||
prompt = "A simple modern app icon with a blue background and white envelope symbol, square format, flat design"
|
||||
|
||||
print(f"Generating image for: '{prompt}'...")
|
||||
|
||||
# Call the API
|
||||
try:
|
||||
response = client.models.generate_images(
|
||||
model='gemini-2.5-flash-image',
|
||||
prompt=prompt,
|
||||
config=types.GenerateImagesConfig(
|
||||
number_of_images=1,
|
||||
aspect_ratio="1:1",
|
||||
safety_filter_level="BLOCK_LOW_AND_ABOVE",
|
||||
person_generation="ALLOW_ADULT"
|
||||
)
|
||||
)
|
||||
|
||||
# Handle the response
|
||||
for i, generated_image in enumerate(response.generated_images):
|
||||
# Convert raw bytes to an image
|
||||
image = Image.open(BytesIO(generated_image.image.image_bytes))
|
||||
|
||||
# Save to disk
|
||||
filename = f"test_generated_image_{i}.png"
|
||||
image.save(filename)
|
||||
print(f"Success! Saved {filename}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error occurred: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
1
frontend/public/plugin-logos/webhook.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
|
After Width: | Height: | Size: 349 B |
@@ -65,6 +65,7 @@ import HelpPluginDocs from './pages/HelpPluginDocs'; // Import Plugin documentat
|
||||
import PlatformSupport from './pages/PlatformSupport'; // Import Platform Support page (for businesses to contact SmoothSchedule)
|
||||
import PluginMarketplace from './pages/PluginMarketplace'; // Import Plugin Marketplace page
|
||||
import MyPlugins from './pages/MyPlugins'; // Import My Plugins page
|
||||
import Tasks from './pages/Tasks'; // Import Tasks page for scheduled plugin executions
|
||||
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -536,6 +537,16 @@ const AppContent: React.FC = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tasks"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<Tasks />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="/support" element={<PlatformSupport />} />
|
||||
<Route
|
||||
path="/customers"
|
||||
|
||||
@@ -765,6 +765,20 @@ result = {'total': len(appointments), 'by_status': stats}`}
|
||||
<code className="text-purple-600 dark:text-purple-400">{'{{PROMPT:variable|description|default}}'}</code>
|
||||
<span className="text-gray-500 ml-2">- Optional field with default value</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-purple-600 dark:text-purple-400">{'{{PROMPT:variable|description|default|textarea}}'}</code>
|
||||
<span className="text-gray-500 ml-2">- Multi-line text input (for email bodies, long messages)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 p-3 bg-amber-50 dark:bg-amber-900/20 rounded border border-amber-200 dark:border-amber-800">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200 font-semibold mb-1">
|
||||
Field Type Detection
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
The system automatically detects field types from variable names and descriptions: <code>email</code> for email validation,
|
||||
<code> number</code> for numeric inputs, <code>message/body/content</code> for textareas, <code>url/webhook</code> for URLs.
|
||||
You can override this by explicitly specifying the type as the 4th parameter.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
@@ -813,9 +827,65 @@ result = {'total': len(appointments), 'by_status': stats}`}
|
||||
<div className="text-blue-600 dark:text-blue-400">{'{{DATE:friday}}'} - Next Friday</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Validation & Types */}
|
||||
{/* 4. Insertion Codes */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mt-6">
|
||||
4. Automatic Validation
|
||||
4. Insertion Codes (Dynamic Content)
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm">
|
||||
Use insertion codes within your PROMPT template text (like email bodies) to inject dynamic content
|
||||
at runtime. These are automatically replaced with actual values when the plugin executes.
|
||||
</p>
|
||||
<div className="mt-3 p-3 bg-green-50 dark:bg-green-900/20 rounded border border-green-200 dark:border-green-800">
|
||||
<p className="text-sm text-green-800 dark:text-green-200 mb-2">
|
||||
<strong>Business Information:</strong>
|
||||
</p>
|
||||
<div className="space-y-1 text-xs text-green-700 dark:text-green-300 pl-3">
|
||||
<div><code>{'{{BUSINESS_NAME}}'}</code> - Your business name</div>
|
||||
<div><code>{'{{BUSINESS_EMAIL}}'}</code> - Business contact email</div>
|
||||
<div><code>{'{{BUSINESS_PHONE}}'}</code> - Business phone number</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 p-3 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 mb-2">
|
||||
<strong>Customer & Appointment Data:</strong>
|
||||
</p>
|
||||
<div className="space-y-1 text-xs text-blue-700 dark:text-blue-300 pl-3">
|
||||
<div><code>{'{{CUSTOMER_NAME}}'}</code> - Customer's name (in appointment contexts)</div>
|
||||
<div><code>{'{{CUSTOMER_EMAIL}}'}</code> - Customer's email address</div>
|
||||
<div><code>{'{{APPOINTMENT_TIME}}'}</code> - Full appointment date and time</div>
|
||||
<div><code>{'{{APPOINTMENT_DATE}}'}</code> - Appointment date only</div>
|
||||
<div><code>{'{{APPOINTMENT_SERVICE}}'}</code> - Service name</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 p-3 bg-purple-50 dark:bg-purple-900/20 rounded border border-purple-200 dark:border-purple-800">
|
||||
<p className="text-sm text-purple-800 dark:text-purple-200 mb-2">
|
||||
<strong>Date & Time:</strong>
|
||||
</p>
|
||||
<div className="space-y-1 text-xs text-purple-700 dark:text-purple-300 pl-3">
|
||||
<div><code>{'{{TODAY}}'}</code> - Today's date (YYYY-MM-DD)</div>
|
||||
<div><code>{'{{NOW}}'}</code> - Current date and time</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 p-4 bg-amber-50 dark:bg-amber-900/20 rounded border border-amber-200 dark:border-amber-800">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200 font-semibold mb-2">Example: Email Template with Insertion Codes</p>
|
||||
<code className="text-xs block bg-white dark:bg-gray-900 p-3 rounded font-mono text-amber-900 dark:text-amber-100 whitespace-pre-wrap">
|
||||
{`email_body = '{{PROMPT:email_body|Email Message|Hi {{CUSTOMER_NAME}},
|
||||
|
||||
This is a reminder about your appointment:
|
||||
|
||||
Date/Time: {{APPOINTMENT_TIME}}
|
||||
Service: {{APPOINTMENT_SERVICE}}
|
||||
|
||||
If you have questions, contact us at {{BUSINESS_EMAIL}}
|
||||
|
||||
Best regards,
|
||||
{{BUSINESS_NAME}}||textarea}}'`}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* 5. Validation & Types */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mt-6">
|
||||
5. Automatic Validation
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm">
|
||||
The system automatically detects field types and validates input:
|
||||
@@ -830,7 +900,8 @@ result = {'total': len(appointments), 'by_status': stats}`}
|
||||
<div className="mt-6 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<p className="text-sm text-purple-800 dark:text-purple-200">
|
||||
<strong>Pro Tip:</strong> Combine all template types for maximum power! Use CONTEXT
|
||||
for business info, DATE for time logic, and PROMPT only when user input is truly needed.
|
||||
for business info, DATE for time logic, PROMPT for user configuration, and Insertion Codes
|
||||
within your email templates for personalized dynamic content.
|
||||
</p>
|
||||
</div>
|
||||
</ApiContent>
|
||||
|
||||
@@ -52,8 +52,10 @@ const MyPlugins: React.FC = () => {
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginInstallation | null>(null);
|
||||
const [showUninstallModal, setShowUninstallModal] = useState(false);
|
||||
const [showRatingModal, setShowRatingModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [rating, setRating] = useState(0);
|
||||
const [review, setReview] = useState('');
|
||||
const [configValues, setConfigValues] = useState<Record<string, any>>({});
|
||||
|
||||
// Fetch installed plugins
|
||||
const { data: plugins = [], isLoading, error } = useQuery<PluginInstallation[]>({
|
||||
@@ -67,6 +69,10 @@ const MyPlugins: React.FC = () => {
|
||||
templateDescription: p.template_description || p.templateDescription,
|
||||
category: p.category,
|
||||
version: p.version,
|
||||
authorName: p.author_name || p.authorName,
|
||||
logoUrl: p.logo_url || p.logoUrl,
|
||||
templateVariables: p.template_variables || p.templateVariables || {},
|
||||
configValues: p.config_values || p.configValues || {},
|
||||
isActive: p.is_active !== undefined ? p.is_active : p.isActive,
|
||||
installedAt: p.installed_at || p.installedAt,
|
||||
hasUpdate: p.has_update !== undefined ? p.has_update : p.hasUpdate || false,
|
||||
@@ -117,6 +123,22 @@ const MyPlugins: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Edit config mutation
|
||||
const editConfigMutation = useMutation({
|
||||
mutationFn: async ({ pluginId, configValues }: { pluginId: string; configValues: Record<string, any> }) => {
|
||||
const { data } = await api.patch(`/api/plugin-installations/${pluginId}/`, {
|
||||
config_values: configValues,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['plugin-installations'] });
|
||||
setShowEditModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setConfigValues({});
|
||||
},
|
||||
});
|
||||
|
||||
const handleUninstall = (plugin: PluginInstallation) => {
|
||||
setSelectedPlugin(plugin);
|
||||
setShowUninstallModal(true);
|
||||
@@ -149,6 +171,59 @@ const MyPlugins: React.FC = () => {
|
||||
updateMutation.mutate(plugin.id);
|
||||
};
|
||||
|
||||
const unescapeString = (str: string): string => {
|
||||
return str
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\r/g, '\r')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\\\/g, '\\');
|
||||
};
|
||||
|
||||
const handleEdit = (plugin: PluginInstallation) => {
|
||||
setSelectedPlugin(plugin);
|
||||
// Convert escape sequences to actual characters for display
|
||||
const displayValues = { ...plugin.configValues || {} };
|
||||
if (plugin.templateVariables) {
|
||||
Object.entries(plugin.templateVariables).forEach(([key, variable]: [string, any]) => {
|
||||
if (displayValues[key]) {
|
||||
displayValues[key] = unescapeString(displayValues[key]);
|
||||
}
|
||||
});
|
||||
}
|
||||
setConfigValues(displayValues);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const escapeString = (str: string): string => {
|
||||
return str
|
||||
.replace(/\\/g, '\\\\') // Escape backslashes first
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\t/g, '\\t')
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/"/g, '\\"');
|
||||
};
|
||||
|
||||
const submitConfigEdit = () => {
|
||||
if (selectedPlugin) {
|
||||
// Convert actual characters back to escape sequences for storage
|
||||
const storageValues = { ...configValues };
|
||||
if (selectedPlugin.templateVariables) {
|
||||
Object.entries(selectedPlugin.templateVariables).forEach(([key, variable]: [string, any]) => {
|
||||
if (storageValues[key]) {
|
||||
storageValues[key] = escapeString(storageValues[key]);
|
||||
}
|
||||
});
|
||||
}
|
||||
editConfigMutation.mutate({
|
||||
pluginId: selectedPlugin.id,
|
||||
configValues: storageValues,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
@@ -213,13 +288,25 @@ const MyPlugins: React.FC = () => {
|
||||
{plugins.map((plugin) => (
|
||||
<div
|
||||
key={plugin.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer"
|
||||
onClick={() => handleEdit(plugin)}
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
{/* Plugin Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{plugin.logoUrl && (
|
||||
<img
|
||||
src={plugin.logoUrl}
|
||||
alt={`${plugin.templateName} logo`}
|
||||
className="w-10 h-10 rounded-lg object-cover flex-shrink-0"
|
||||
onError={(e) => {
|
||||
// Hide image if it fails to load
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{plugin.templateName}
|
||||
</h3>
|
||||
@@ -240,6 +327,14 @@ const MyPlugins: React.FC = () => {
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
{plugin.authorName && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">
|
||||
{t('plugins.author', 'Author')}:
|
||||
</span>
|
||||
<span>{plugin.authorName}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">
|
||||
{t('plugins.version', 'Version')}:
|
||||
@@ -284,7 +379,10 @@ const MyPlugins: React.FC = () => {
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{plugin.hasUpdate && (
|
||||
<button
|
||||
onClick={() => handleUpdate(plugin)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdate(plugin);
|
||||
}}
|
||||
disabled={updateMutation.isPending}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium"
|
||||
title={t('plugins.update', 'Update')}
|
||||
@@ -298,7 +396,10 @@ const MyPlugins: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleRating(plugin)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRating(plugin);
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-sm font-medium"
|
||||
title={plugin.rating ? t('plugins.editRating', 'Edit Rating') : t('plugins.rate', 'Rate')}
|
||||
>
|
||||
@@ -306,7 +407,10 @@ const MyPlugins: React.FC = () => {
|
||||
{plugin.rating ? t('plugins.editRating', 'Edit Rating') : t('plugins.rate', 'Rate')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUninstall(plugin)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUninstall(plugin);
|
||||
}}
|
||||
className="p-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
title={t('plugins.uninstall', 'Uninstall')}
|
||||
>
|
||||
@@ -519,6 +623,115 @@ const MyPlugins: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Config Modal */}
|
||||
{showEditModal && selectedPlugin && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full overflow-hidden max-h-[80vh] flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('plugins.editConfig', 'Edit Plugin Configuration')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEditModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setConfigValues({});
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
{selectedPlugin.templateName}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{selectedPlugin.templateDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Config Fields */}
|
||||
{selectedPlugin.templateVariables && Object.keys(selectedPlugin.templateVariables).length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(selectedPlugin.templateVariables).map(([key, variable]: [string, any]) => (
|
||||
<div key={key}>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{variable.description || key}
|
||||
{variable.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
|
||||
{variable.type === 'textarea' ? (
|
||||
<textarea
|
||||
value={configValues[key] !== undefined ? configValues[key] : (variable.default ? unescapeString(variable.default) : '')}
|
||||
onChange={(e) => setConfigValues({ ...configValues, [key]: e.target.value })}
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 resize-none font-mono text-sm"
|
||||
placeholder={variable.default ? unescapeString(variable.default) : ''}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={configValues[key] || variable.default || ''}
|
||||
onChange={(e) => setConfigValues({ ...configValues, [key]: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
placeholder={variable.default || ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
{variable.help_text && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{variable.help_text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
{t('plugins.noConfigOptions', 'This plugin has no configuration options')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEditModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setConfigValues({});
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={submitConfigEdit}
|
||||
disabled={editConfigMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{editConfigMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{t('plugins.saving', 'Saving...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
{t('common.save', 'Save')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
@@ -17,8 +17,12 @@ import {
|
||||
Link as LinkIcon,
|
||||
Bot,
|
||||
Package,
|
||||
Eye
|
||||
Eye,
|
||||
ChevronDown,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import api from '../api/client';
|
||||
import { PluginTemplate, PluginCategory } from '../types';
|
||||
|
||||
@@ -44,6 +48,32 @@ const categoryColors: Record<PluginCategory, string> = {
|
||||
OTHER: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
};
|
||||
|
||||
// Helper function to detect platform-only code
|
||||
const detectPlatformOnlyCode = (code: string): { hasPlatformCode: boolean; warnings: string[] } => {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (code.includes('{{PLATFORM:')) {
|
||||
warnings.push('This code uses platform-only features that require special permissions');
|
||||
}
|
||||
|
||||
if (code.includes('{{WHITELIST:')) {
|
||||
warnings.push('This code requires whitelisting by platform administrators');
|
||||
}
|
||||
|
||||
if (code.includes('{{SUPERUSER:')) {
|
||||
warnings.push('This code requires superuser privileges to execute');
|
||||
}
|
||||
|
||||
if (code.includes('{{SYSTEM:')) {
|
||||
warnings.push('This code accesses system-level features');
|
||||
}
|
||||
|
||||
return {
|
||||
hasPlatformCode: warnings.length > 0,
|
||||
warnings
|
||||
};
|
||||
};
|
||||
|
||||
const PluginMarketplace: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -51,6 +81,8 @@ const PluginMarketplace: React.FC = () => {
|
||||
const [selectedCategory, setSelectedCategory] = useState<PluginCategory | 'ALL'>('ALL');
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginTemplate | null>(null);
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
const [showCode, setShowCode] = useState(false);
|
||||
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
|
||||
|
||||
// Fetch marketplace plugins
|
||||
const { data: plugins = [], isLoading, error } = useQuery<PluginTemplate[]>({
|
||||
@@ -71,6 +103,8 @@ const PluginMarketplace: React.FC = () => {
|
||||
isFeatured: p.is_featured || false,
|
||||
createdAt: p.created_at,
|
||||
updatedAt: p.updated_at,
|
||||
logoUrl: p.logo_url,
|
||||
pluginCode: p.plugin_code,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -122,9 +156,25 @@ const PluginMarketplace: React.FC = () => {
|
||||
});
|
||||
}, [filteredPlugins]);
|
||||
|
||||
const handleInstall = (plugin: PluginTemplate) => {
|
||||
const handleInstall = async (plugin: PluginTemplate) => {
|
||||
setSelectedPlugin(plugin);
|
||||
setShowDetailsModal(true);
|
||||
setShowCode(false);
|
||||
|
||||
// Fetch full plugin details including plugin_code
|
||||
setIsLoadingDetails(true);
|
||||
try {
|
||||
const { data } = await api.get(`/api/plugin-templates/${plugin.id}/`);
|
||||
setSelectedPlugin({
|
||||
...plugin,
|
||||
pluginCode: data.plugin_code,
|
||||
logoUrl: data.logo_url,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch plugin details:', error);
|
||||
} finally {
|
||||
setIsLoadingDetails(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmInstall = () => {
|
||||
@@ -245,22 +295,39 @@ const PluginMarketplace: React.FC = () => {
|
||||
>
|
||||
{/* Plugin Card Header */}
|
||||
<div className="p-6 flex-1">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ${categoryColors[plugin.category]}`}>
|
||||
{categoryIcons[plugin.category]}
|
||||
{plugin.category}
|
||||
</span>
|
||||
{plugin.isFeatured && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
<Zap className="h-3 w-3" />
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{plugin.isVerified && (
|
||||
<CheckCircle className="h-5 w-5 text-blue-500" title="Verified" />
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
{/* Logo */}
|
||||
{plugin.logoUrl ? (
|
||||
<img
|
||||
src={plugin.logoUrl}
|
||||
alt={`${plugin.name} logo`}
|
||||
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
|
||||
<Package className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ${categoryColors[plugin.category]}`}>
|
||||
{categoryIcons[plugin.category]}
|
||||
{plugin.category}
|
||||
</span>
|
||||
{plugin.isFeatured && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
<Zap className="h-3 w-3" />
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{plugin.isVerified && (
|
||||
<CheckCircle className="h-5 w-5 text-blue-500 flex-shrink-0" title="Verified" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
@@ -292,10 +359,7 @@ const PluginMarketplace: React.FC = () => {
|
||||
{/* Plugin Card Actions */}
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPlugin(plugin);
|
||||
setShowDetailsModal(true);
|
||||
}}
|
||||
onClick={() => handleInstall(plugin)}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium text-sm"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
@@ -314,17 +378,32 @@ const PluginMarketplace: React.FC = () => {
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{selectedPlugin.name}
|
||||
</h3>
|
||||
{selectedPlugin.isVerified && (
|
||||
<CheckCircle className="h-5 w-5 text-blue-500" title="Verified" />
|
||||
{/* Logo in modal header */}
|
||||
{selectedPlugin.logoUrl ? (
|
||||
<img
|
||||
src={selectedPlugin.logoUrl}
|
||||
alt={`${selectedPlugin.name} logo`}
|
||||
className="w-10 h-10 rounded-lg object-cover flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
|
||||
<Package className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{selectedPlugin.name}
|
||||
</h3>
|
||||
{selectedPlugin.isVerified && (
|
||||
<CheckCircle className="h-5 w-5 text-blue-500" title="Verified" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDetailsModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setShowCode(false);
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
@@ -334,60 +413,131 @@ const PluginMarketplace: React.FC = () => {
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6 space-y-6 overflow-y-auto flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium ${categoryColors[selectedPlugin.category]}`}>
|
||||
{categoryIcons[selectedPlugin.category]}
|
||||
{selectedPlugin.category}
|
||||
</span>
|
||||
{selectedPlugin.isFeatured && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
<Zap className="h-4 w-4" />
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isLoadingDetails ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium ${categoryColors[selectedPlugin.category]}`}>
|
||||
{categoryIcons[selectedPlugin.category]}
|
||||
{selectedPlugin.category}
|
||||
</span>
|
||||
{selectedPlugin.isFeatured && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
<Zap className="h-4 w-4" />
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('plugins.description', 'Description')}
|
||||
</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{selectedPlugin.description}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('plugins.description', 'Description')}
|
||||
</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{selectedPlugin.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('plugins.version', 'Version')}
|
||||
</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{selectedPlugin.version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('plugins.author', 'Author')}
|
||||
</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{selectedPlugin.author}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('plugins.version', 'Version')}
|
||||
</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{selectedPlugin.version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('plugins.author', 'Author')}
|
||||
</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{selectedPlugin.author}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-amber-500 fill-amber-500" />
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{selectedPlugin.rating.toFixed(1)}
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
({selectedPlugin.ratingCount} {t('plugins.ratings', 'ratings')})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-5 w-5 text-gray-400" />
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{selectedPlugin.installCount.toLocaleString()} {t('plugins.installs', 'installs')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-amber-500 fill-amber-500" />
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{selectedPlugin.rating.toFixed(1)}
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
({selectedPlugin.ratingCount} {t('plugins.ratings', 'ratings')})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-5 w-5 text-gray-400" />
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{selectedPlugin.installCount.toLocaleString()} {t('plugins.installs', 'installs')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Viewer Section */}
|
||||
{selectedPlugin.pluginCode && (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setShowCode(!showCode)}
|
||||
className="flex items-center justify-between w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{t('plugins.viewCode', 'View Code')}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-5 w-5 text-gray-600 dark:text-gray-400 transition-transform ${showCode ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{showCode && (
|
||||
<div className="space-y-3">
|
||||
{/* Platform-only code warnings */}
|
||||
{(() => {
|
||||
const { hasPlatformCode, warnings } = detectPlatformOnlyCode(selectedPlugin.pluginCode || '');
|
||||
return hasPlatformCode ? (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h5 className="font-semibold text-amber-900 dark:text-amber-200 mb-2">
|
||||
{t('plugins.platformOnlyWarning', 'Platform-Only Features Detected')}
|
||||
</h5>
|
||||
<ul className="space-y-1 text-sm text-amber-800 dark:text-amber-300">
|
||||
{warnings.map((warning, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<span className="text-amber-600 dark:text-amber-400 mt-0.5">•</span>
|
||||
<span>{warning}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{/* Code display with syntax highlighting */}
|
||||
<div className="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||
<SyntaxHighlighter
|
||||
language="javascript"
|
||||
style={vscDarkPlus}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
fontSize: '0.875rem',
|
||||
maxHeight: '400px'
|
||||
}}
|
||||
showLineNumbers
|
||||
>
|
||||
{selectedPlugin.pluginCode}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
@@ -396,6 +546,7 @@ const PluginMarketplace: React.FC = () => {
|
||||
onClick={() => {
|
||||
setShowDetailsModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setShowCode(false);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
|
||||
>
|
||||
@@ -403,7 +554,7 @@ const PluginMarketplace: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmInstall}
|
||||
disabled={installMutation.isPending}
|
||||
disabled={installMutation.isPending || isLoadingDetails}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{installMutation.isPending ? (
|
||||
|
||||
365
frontend/src/pages/Tasks.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from '../api/client';
|
||||
import {
|
||||
Plus,
|
||||
Play,
|
||||
Pause,
|
||||
Trash2,
|
||||
Edit,
|
||||
Clock,
|
||||
Calendar,
|
||||
RotateCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// Types
|
||||
interface ScheduledTask {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
plugin_name: string;
|
||||
plugin_display_name: string;
|
||||
schedule_type: 'ONE_TIME' | 'INTERVAL' | 'CRON';
|
||||
cron_expression?: string;
|
||||
interval_minutes?: number;
|
||||
run_at?: string;
|
||||
next_run_time?: string;
|
||||
last_run_time?: string;
|
||||
is_active: boolean;
|
||||
plugin_config: Record<string, any>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const Tasks: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
|
||||
// Fetch tasks
|
||||
const { data: tasks = [], isLoading } = useQuery<ScheduledTask[]>({
|
||||
queryKey: ['scheduled-tasks'],
|
||||
queryFn: async () => {
|
||||
const { data } = await axios.get('/api/scheduled-tasks/');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Delete task
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (taskId: string) => {
|
||||
await axios.delete(`/api/scheduled-tasks/${taskId}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] });
|
||||
toast.success('Task deleted successfully');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.detail || 'Failed to delete task');
|
||||
},
|
||||
});
|
||||
|
||||
// Toggle task active status
|
||||
const toggleActiveMutation = useMutation({
|
||||
mutationFn: async ({ taskId, isActive }: { taskId: string; isActive: boolean }) => {
|
||||
await axios.patch(`/api/scheduled-tasks/${taskId}/`, { is_active: isActive });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] });
|
||||
toast.success('Task updated successfully');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.detail || 'Failed to update task');
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger task manually
|
||||
const triggerMutation = useMutation({
|
||||
mutationFn: async (taskId: string) => {
|
||||
await axios.post(`/api/scheduled-tasks/${taskId}/trigger/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Task triggered successfully');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.detail || 'Failed to trigger task');
|
||||
},
|
||||
});
|
||||
|
||||
const getScheduleDisplay = (task: ScheduledTask): string => {
|
||||
if (task.schedule_type === 'ONE_TIME') {
|
||||
return `Once on ${new Date(task.run_at!).toLocaleString()}`;
|
||||
} else if (task.schedule_type === 'INTERVAL') {
|
||||
const hours = Math.floor(task.interval_minutes! / 60);
|
||||
const mins = task.interval_minutes! % 60;
|
||||
if (hours > 0 && mins > 0) {
|
||||
return `Every ${hours}h ${mins}m`;
|
||||
} else if (hours > 0) {
|
||||
return `Every ${hours} hour${hours > 1 ? 's' : ''}`;
|
||||
} else {
|
||||
return `Every ${mins} minute${mins > 1 ? 's' : ''}`;
|
||||
}
|
||||
} else {
|
||||
return task.cron_expression || 'Custom schedule';
|
||||
}
|
||||
};
|
||||
|
||||
const getScheduleIcon = (task: ScheduledTask) => {
|
||||
if (task.schedule_type === 'ONE_TIME') return Calendar;
|
||||
if (task.schedule_type === 'INTERVAL') return RotateCw;
|
||||
return Clock;
|
||||
};
|
||||
|
||||
const getStatusColor = (task: ScheduledTask): string => {
|
||||
if (!task.is_active) return 'text-gray-400 dark:text-gray-500';
|
||||
if (task.last_run_time) return 'text-green-600 dark:text-green-400';
|
||||
return 'text-blue-600 dark:text-blue-400';
|
||||
};
|
||||
|
||||
const getStatusIcon = (task: ScheduledTask) => {
|
||||
if (!task.is_active) return Pause;
|
||||
if (task.last_run_time) return CheckCircle2;
|
||||
return AlertCircle;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{t('Tasks')}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Schedule and manage automated plugin executions
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
New Task
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<Zap className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Total Tasks</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{tasks.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Active</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{tasks.filter(t => t.is_active).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||
<Pause className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Paused</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{tasks.filter(t => !t.is_active).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<RotateCw className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Recurring</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{tasks.filter(t => t.schedule_type !== 'ONE_TIME').length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks List */}
|
||||
{tasks.length === 0 ? (
|
||||
<div className="text-center py-16 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<Zap className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
No tasks yet
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Create your first automated task to get started
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Create Task
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{tasks.map((task) => {
|
||||
const ScheduleIcon = getScheduleIcon(task);
|
||||
const StatusIcon = getStatusIcon(task);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{task.name}
|
||||
</h3>
|
||||
<StatusIcon className={`w-5 h-5 ${getStatusColor(task)}`} />
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mb-3">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>{task.plugin_display_name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
|
||||
<ScheduleIcon className="w-4 h-4" />
|
||||
<span>{getScheduleDisplay(task)}</span>
|
||||
</div>
|
||||
|
||||
{task.next_run_time && task.is_active && (
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>Next: {new Date(task.next_run_time).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.last_run_time && (
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>Last: {new Date(task.last_run_time).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => triggerMutation.mutate(task.id)}
|
||||
className="p-2 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
||||
title="Run now"
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => toggleActiveMutation.mutate({
|
||||
taskId: task.id,
|
||||
isActive: !task.is_active
|
||||
})}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
task.is_active
|
||||
? 'text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20'
|
||||
: 'text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20'
|
||||
}`}
|
||||
title={task.is_active ? 'Pause' : 'Resume'}
|
||||
>
|
||||
{task.is_active ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {/* TODO: Edit modal */}}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this task?')) {
|
||||
deleteMutation.mutate(task.id);
|
||||
}
|
||||
}}
|
||||
className="p-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TODO: Create/Edit Task Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Create New Task
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Task creation form coming soon...
|
||||
</p>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tasks;
|
||||
@@ -314,6 +314,8 @@ export interface PluginTemplate {
|
||||
category: PluginCategory;
|
||||
version: string;
|
||||
author: string;
|
||||
logoUrl?: string;
|
||||
pluginCode?: string;
|
||||
rating: number;
|
||||
ratingCount: number;
|
||||
installCount: number;
|
||||
@@ -330,6 +332,10 @@ export interface PluginInstallation {
|
||||
templateDescription: string;
|
||||
category: PluginCategory;
|
||||
version: string;
|
||||
authorName?: string;
|
||||
logoUrl?: string;
|
||||
templateVariables?: Record<string, any>;
|
||||
configValues?: Record<string, any>;
|
||||
isActive: boolean;
|
||||
installedAt: string;
|
||||
hasUpdate: boolean;
|
||||
|
||||