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>
This commit is contained in:
poduck
2025-11-28 23:45:55 -05:00
parent 0f46862125
commit 9b106bf129
38 changed files with 2859 additions and 116 deletions

View File

@@ -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",

View File

@@ -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": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 B

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View 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

View 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()

View 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()

View 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()

View 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]}")

View 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()

View 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()

View 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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View 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

View 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()

View 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()

View 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()

View 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

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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 ? (

View 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;

View File

@@ -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;