mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
Merge branch 'feat-documentsdb' into feat-vectordb
This commit is contained in:
@@ -45,6 +45,7 @@ jobs:
|
||||
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
|
||||
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
|
||||
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY }}"
|
||||
"PUBLIC_CONSOLE_FINGERPRINT_KEY=${{ secrets.PUBLIC_CONSOLE_FINGERPRINT_KEY }}"
|
||||
"SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}"
|
||||
"SENTRY_RELEASE=${{ github.event.release.tag_name }}"
|
||||
publish-cloud-stage:
|
||||
@@ -87,6 +88,7 @@ jobs:
|
||||
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
|
||||
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
|
||||
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}"
|
||||
"PUBLIC_CONSOLE_FINGERPRINT_KEY=${{ secrets.PUBLIC_CONSOLE_FINGERPRINT_KEY_STAGE }}"
|
||||
publish-self-hosted:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -166,4 +168,5 @@ jobs:
|
||||
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
|
||||
"PUBLIC_CONSOLE_FEATURE_FLAGS="
|
||||
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}"
|
||||
"PUBLIC_CONSOLE_FINGERPRINT_KEY=${{ secrets.PUBLIC_CONSOLE_FINGERPRINT_KEY_STAGE }}"
|
||||
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
|
||||
|
||||
@@ -22,6 +22,7 @@ ARG PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS
|
||||
ARG PUBLIC_APPWRITE_ENDPOINT
|
||||
ARG PUBLIC_GROWTH_ENDPOINT
|
||||
ARG PUBLIC_STRIPE_KEY
|
||||
ARG PUBLIC_CONSOLE_FINGERPRINT_KEY
|
||||
ARG SENTRY_AUTH_TOKEN
|
||||
ARG SENTRY_RELEASE
|
||||
|
||||
@@ -33,6 +34,7 @@ ENV PUBLIC_APPWRITE_MULTI_REGION=$PUBLIC_APPWRITE_MULTI_REGION
|
||||
ENV PUBLIC_CONSOLE_EMAIL_VERIFICATION=$PUBLIC_CONSOLE_EMAIL_VERIFICATION
|
||||
ENV PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=$PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS
|
||||
ENV PUBLIC_STRIPE_KEY=$PUBLIC_STRIPE_KEY
|
||||
ENV PUBLIC_CONSOLE_FINGERPRINT_KEY=$PUBLIC_CONSOLE_FINGERPRINT_KEY
|
||||
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
|
||||
ENV SENTRY_RELEASE=$SENTRY_RELEASE
|
||||
ENV NODE_OPTIONS=--max_old_space_size=8192
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"name": "@appwrite/console",
|
||||
"dependencies": {
|
||||
"@ai-sdk/svelte": "^1.1.24",
|
||||
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@d6a457f",
|
||||
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@13c8c34",
|
||||
"@appwrite.io/pink-icons": "0.25.0",
|
||||
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@df765cc",
|
||||
"@appwrite.io/pink-legacy": "^1.0.3",
|
||||
@@ -89,6 +89,8 @@
|
||||
},
|
||||
},
|
||||
"overrides": {
|
||||
"immutable": "^5.1.5",
|
||||
"minimatch": "10.2.3",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
},
|
||||
"packages": {
|
||||
@@ -120,7 +122,7 @@
|
||||
|
||||
"@analytics/type-utils": ["@analytics/type-utils@0.6.4", "", {}, "sha512-Ou1gQxFakOWLcPnbFVsrPb8g1wLLUZYYJXDPjHkG07+5mustGs5yqACx42UAu4A6NszNN6Z5gGxhyH45zPWRxw=="],
|
||||
|
||||
"@appwrite.io/console": ["@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@d6a457f", { "dependencies": { "bignumber.js": "9.0.0", "json-bigint": "1.0.0" } }],
|
||||
"@appwrite.io/console": ["@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@13c8c34", { "dependencies": { "json-bigint": "1.0.0" } }],
|
||||
|
||||
"@appwrite.io/pink-icons": ["@appwrite.io/pink-icons@0.25.0", "", {}, "sha512-0O3i2oEuh5mWvjO80i+X6rbzrWLJ1m5wmv2/M3a1p2PyBJsFxN8xQMTEmTn3Wl/D26SsM7SpzbdW6gmfgoVU9Q=="],
|
||||
|
||||
@@ -266,7 +268,7 @@
|
||||
|
||||
"@melt-ui/svelte": ["@melt-ui/svelte@0.86.6", "", { "dependencies": { "@floating-ui/core": "^1.3.1", "@floating-ui/dom": "^1.4.5", "@internationalized/date": "^3.5.0", "dequal": "^2.0.3", "focus-trap": "^7.5.2", "nanoid": "^5.0.4" }, "peerDependencies": { "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.118" } }, "sha512-Jer+M7DgIwT5IHfTayb4Iw/fkkxWNmC/mqn/nMh9JrbPbkxmyabfLQnhJ+JDn5HK77f84j34lubO3iqFtYAfMg=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
@@ -342,9 +344,9 @@
|
||||
|
||||
"@opentelemetry/sql-common": ["@opentelemetry/sql-common@0.40.1", "", { "dependencies": { "@opentelemetry/core": "^1.1.0" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0" } }, "sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg=="],
|
||||
|
||||
"@oxc-project/runtime": ["@oxc-project/runtime@0.98.0", "", {}, "sha512-F0ldlBv2orG2YqNL0w77deq9yCaO4zEHbanGnW/jaJxGBR8ImekvZb8x42zAHvdzr8J76psibijvHtXfSjbEIQ=="],
|
||||
"@oxc-project/runtime": ["@oxc-project/runtime@0.101.0", "", {}, "sha512-t3qpfVZIqSiLQ5Kqt/MC4Ge/WCOGrrcagAdzTcDaggupjiGxUx4nJF2v6wUCXWSzWHn5Ns7XLv13fCJEwCOERQ=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.98.0", "", {}, "sha512-Vzmd6FsqVuz5HQVcRC/hrx7Ujo3WEVeQP7C2UNP5uy1hUY4SQvMB+93jxkI1KRHz9a/6cni3glPOtvteN+zpsw=="],
|
||||
"@oxc-project/types": ["@oxc-project/types@0.101.0", "", {}, "sha512-nuFhqlUzJX+gVIPPfuE6xurd4lST3mdcWOhyK/rZO0B9XWMKm79SuszIQEnSMmmDhq1DC8WWVYGVd+6F93o1gQ=="],
|
||||
|
||||
"@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
|
||||
|
||||
@@ -384,35 +386,33 @@
|
||||
|
||||
"@prisma/instrumentation": ["@prisma/instrumentation@5.22.0", "", { "dependencies": { "@opentelemetry/api": "^1.8", "@opentelemetry/instrumentation": "^0.49 || ^0.50 || ^0.51 || ^0.52.0 || ^0.53.0", "@opentelemetry/sdk-trace-base": "^1.22" } }, "sha512-LxccF392NN37ISGxIurUljZSh1YWnphO34V5a0+T7FVQG2u9bhAXRTJpgmQ3483woVhkraQZFF7cbRrpbw/F4Q=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-beta.51", "", { "os": "android", "cpu": "arm64" }, "sha512-Ctn8FUXKWWQI9pWC61P1yumS9WjQtelNS9riHwV7oCkknPGaAry4o7eFx2KgoLMnI2BgFJYpW7Im8/zX3BuONg=="],
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-beta.53", "", { "os": "android", "cpu": "arm64" }, "sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.51", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EL1aRW2Oq15ShUEkBPsDtLMO8GTqfb/ktM/dFaVzXKQiEE96Ss6nexMgfgQrg8dGnNpndFyffVDb5IdSibsu1g=="],
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.53", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yIsKqMz0CtRnVa6x3Pa+mzTihr4Ty+Z6HfPbZ7RVbk1Uxnco4+CUn7Qbm/5SBol1JD/7nvY8rphAgyAi7Lj6Vg=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-beta.51", "", { "os": "darwin", "cpu": "x64" }, "sha512-uGtYKlFen9pMIPvkHPWZVDtmYhMQi5g5Ddsndg1gf3atScKYKYgs5aDP4DhHeTwGXQglhfBG7lEaOIZ4UAIWww=="],
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-beta.53", "", { "os": "darwin", "cpu": "x64" }, "sha512-GTXe+mxsCGUnJOFMhfGWmefP7Q9TpYUseHvhAhr21nCTgdS8jPsvirb0tJwM3lN0/u/cg7bpFNa16fQrjKrCjQ=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-beta.51", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JRoVTQtHYbZj1P07JLiuTuXjiBtIa7ag7/qgKA6CIIXnAcdl4LrOf7nfDuHPJcuRKaP5dzecMgY99itvWfmUFQ=="],
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-beta.53", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Tmp7bBvKqyDkMcL4e089pH3RsjD3SUungjmqWtyhNOxoQMh0fSmINTyYV8KXtE+JkxYMPWvnEt+/mfpVCkk8w=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.51", "", { "os": "linux", "cpu": "arm" }, "sha512-BKATVnpPZ0TYBW9XfDwyd4kPGgvf964HiotIwUgpMrFOFYWqpZ+9ONNzMV4UFAYC7Hb5C2qgYQk/qj2OnAd4RQ=="],
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.53", "", { "os": "linux", "cpu": "arm" }, "sha512-a1y5fiB0iovuzdbjUxa7+Zcvgv+mTmlGGC4XydVIsyl48eoxgaYkA3l9079hyTyhECsPq+mbr0gVQsFU11OJAQ=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-beta.51", "", { "os": "linux", "cpu": "arm64" }, "sha512-xLd7da5jkfbVsBCm1buIRdWtuXY8+hU3+6ESXY/Tk5X5DPHaifrUblhYDgmA34dQt6WyNC2kfXGgrduPEvDI6Q=="],
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-beta.53", "", { "os": "linux", "cpu": "arm64" }, "sha512-bpIGX+ov9PhJYV+wHNXl9rzq4F0QvILiURn0y0oepbQx+7stmQsKA0DhPGwmhfvF856wq+gbM8L92SAa/CBcLg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-beta.51", "", { "os": "linux", "cpu": "arm64" }, "sha512-EQFXTgHxxTzv3t5EmjUP/DfxzFYx9sMndfLsYaAY4DWF6KsK1fXGYsiupif6qPTViPC9eVmRm78q0pZU/kuIPg=="],
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-beta.53", "", { "os": "linux", "cpu": "arm64" }, "sha512-bGe5EBB8FVjHBR1mOLOPEFg1Lp3//7geqWkU5NIhxe+yH0W8FVrQ6WRYOap4SUTKdklD/dC4qPLREkMMQ855FA=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-beta.51", "", { "os": "linux", "cpu": "x64" }, "sha512-p5P6Xpa68w3yFaAdSzIZJbj+AfuDnMDqNSeglBXM7UlJT14Q4zwK+rV+8Mhp9MiUb4XFISZtbI/seBprhkQbiQ=="],
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-beta.53", "", { "os": "linux", "cpu": "x64" }, "sha512-qL+63WKVQs1CMvFedlPt0U9PiEKJOAL/bsHMKUDS6Vp2Q+YAv/QLPu8rcvkfIMvQ0FPU2WL0aX4eWwF6e/GAnA=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-beta.51", "", { "os": "linux", "cpu": "x64" }, "sha512-sNVVyLa8HB8wkFipdfz1s6i0YWinwpbMWk5hO5S+XAYH2UH67YzUT13gs6wZTKg2x/3gtgXzYnHyF5wMIqoDAw=="],
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-beta.53", "", { "os": "linux", "cpu": "x64" }, "sha512-VGl9JIGjoJh3H8Mb+7xnVqODajBmrdOOb9lxWXdcmxyI+zjB2sux69br0hZJDTyLJfvBoYm439zPACYbCjGRmw=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-beta.51", "", { "os": "none", "cpu": "arm64" }, "sha512-e/JMTz9Q8+T3g/deEi8DK44sFWZWGKr9AOCW5e8C8SCVWzAXqYXAG7FXBWBNzWEZK0Rcwo9TQHTQ9Q0gXgdCaA=="],
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-beta.53", "", { "os": "none", "cpu": "arm64" }, "sha512-B4iIserJXuSnNzA5xBLFUIjTfhNy7d9sq4FUMQY3GhQWGVhS2RWWzzDnkSU6MUt7/aHUrep0CdQfXUJI9D3W7A=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-beta.51", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.7" }, "cpu": "none" }, "sha512-We3LWqSu6J9s5Y0MK+N7fUiiu37aBGPG3Pc347EoaROuAwkCS2u9xJ5dpIyLW4B49CIbS3KaPmn4kTgPb3EyPw=="],
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-beta.53", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.0" }, "cpu": "none" }, "sha512-BUjAEgpABEJXilGq/BPh7jeU3WAJ5o15c1ZEgHaDWSz3LB881LQZnbNJHmUiM4d1JQWMYYyR1Y490IBHi2FPJg=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-beta.51", "", { "os": "win32", "cpu": "arm64" }, "sha512-fj56buHRuMM+r/cb6ZYfNjNvO/0xeFybI6cTkTROJatdP4fvmQ1NS8D/Lm10FCSDEOkqIz8hK3TGpbAThbPHsA=="],
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-beta.53", "", { "os": "win32", "cpu": "arm64" }, "sha512-s27uU7tpCWSjHBnxyVXHt3rMrQdJq5MHNv3BzsewCIroIw3DJFjMH1dzCPPMUFxnh1r52Nf9IJ/eWp6LDoyGcw=="],
|
||||
|
||||
"@rolldown/binding-win32-ia32-msvc": ["@rolldown/binding-win32-ia32-msvc@1.0.0-beta.51", "", { "os": "win32", "cpu": "ia32" }, "sha512-fkqEqaeEx8AySXiDm54b/RdINb3C0VovzJA3osMhZsbn6FoD73H0AOIiaVAtGr6x63hefruVKTX8irAm4Jkt2w=="],
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-beta.53", "", { "os": "win32", "cpu": "x64" }, "sha512-cjWL/USPJ1g0en2htb4ssMjIycc36RvdQAx1WlXnS6DpULswiUTVXPDesTifSKYSyvx24E0YqQkEm0K/M2Z/AA=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-beta.51", "", { "os": "win32", "cpu": "x64" }, "sha512-CWuLG/HMtrVcjKGa0C4GnuxONrku89g0+CsH8nT0SNhOtREXuzwgjIXNJImpE/A/DMf9JF+1Xkrq/YRr+F/rCg=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.51", "", {}, "sha512-51/8cNXMrqWqX3o8DZidhwz1uYq0BhHDDSfVygAND1Skx5s1TDw3APSSxCMcFFedwgqGcx34gRouwY+m404BBQ=="],
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="],
|
||||
|
||||
"@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@8.55.0", "", { "dependencies": { "@sentry/core": "8.55.0" } }, "sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw=="],
|
||||
|
||||
@@ -640,7 +640,7 @@
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
"balanced-match": ["balanced-match@4.0.3", "", {}, "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.31", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw=="],
|
||||
|
||||
@@ -650,7 +650,7 @@
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
"brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
@@ -704,8 +704,6 @@
|
||||
|
||||
"commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||
@@ -976,7 +974,7 @@
|
||||
|
||||
"ignore": ["ignore@6.0.2", "", {}, "sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A=="],
|
||||
|
||||
"immutable": ["immutable@5.1.4", "", {}, "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA=="],
|
||||
"immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
@@ -1138,7 +1136,7 @@
|
||||
|
||||
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
"minimatch": ["minimatch@10.2.3", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
@@ -1290,7 +1288,7 @@
|
||||
|
||||
"robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-beta.51", "", { "dependencies": { "@oxc-project/types": "=0.98.0", "@rolldown/pluginutils": "1.0.0-beta.51" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-beta.51", "@rolldown/binding-darwin-arm64": "1.0.0-beta.51", "@rolldown/binding-darwin-x64": "1.0.0-beta.51", "@rolldown/binding-freebsd-x64": "1.0.0-beta.51", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.51", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.51", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.51", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.51", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.51", "@rolldown/binding-openharmony-arm64": "1.0.0-beta.51", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.51", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.51", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.51", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.51" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZRLgPlS91l4JztLYEZnmMcd3Umcla1hkXJgiEiR4HloRJBBoeaX8qogTu5Jfu36rRMVLndzqYv0h+M5gJAkUfg=="],
|
||||
"rolldown": ["rolldown@1.0.0-beta.53", "", { "dependencies": { "@oxc-project/types": "=0.101.0", "@rolldown/pluginutils": "1.0.0-beta.53" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-beta.53", "@rolldown/binding-darwin-arm64": "1.0.0-beta.53", "@rolldown/binding-darwin-x64": "1.0.0-beta.53", "@rolldown/binding-freebsd-x64": "1.0.0-beta.53", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.53", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.53", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.53", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.53", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.53", "@rolldown/binding-openharmony-arm64": "1.0.0-beta.53", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.53", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.53", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.53" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Qd9c2p0XKZdgT5AYd+KgAMggJ8ZmCs3JnS9PTMWkyUfteKlfmKtxJbWTHkVakxwXs1Ub7jrRYVeFeF7N0sQxyw=="],
|
||||
|
||||
"rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
|
||||
|
||||
@@ -1476,7 +1474,7 @@
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["rolldown-vite@7.2.7", "", { "dependencies": { "@oxc-project/runtime": "0.98.0", "fdir": "^6.5.0", "lightningcss": "^1.30.2", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rolldown": "1.0.0-beta.51", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "esbuild": "^0.25.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-N6a9KgNZ0xgCJ6/Ej2FQ7W8D3fOzDwFw7CLWZ2ubZknVrs9NdNkx25AFEuNbSwQO76VEHp4N7YatsZwp/ST1Gg=="],
|
||||
"vite": ["rolldown-vite@7.3.1", "", { "dependencies": { "@oxc-project/runtime": "0.101.0", "fdir": "^6.5.0", "lightningcss": "^1.30.2", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rolldown": "1.0.0-beta.53", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-LYzdNAjRHhF2yA4JUQm/QyARyi216N2rpJ0lJZb8E9FU2y5v6Vk+xq/U4XBOxMefpWixT5H3TslmAHm1rqIq2w=="],
|
||||
|
||||
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
|
||||
|
||||
@@ -1588,8 +1586,6 @@
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.13", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HHG72BN4d+OWTcq2NwTxOm/2qvk1duYsnhCDtsbYwn/h/4zeqURu1S0+Cn0nY2Ysq9a9HGKvrYuMn9bgFhR2Og=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
@@ -1604,8 +1600,6 @@
|
||||
|
||||
"framesync/tslib": ["tslib@2.4.0", "", {}, "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="],
|
||||
|
||||
"glob/minimatch": ["minimatch@8.0.4", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA=="],
|
||||
|
||||
"js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
@@ -1646,14 +1640,10 @@
|
||||
|
||||
"@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"ai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
"node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
+4
-2
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/svelte": "^1.1.24",
|
||||
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@d6a457f",
|
||||
"@appwrite.io/console": "github:appwrite/sdk-for-console#b8d2d50",
|
||||
"@appwrite.io/pink-icons": "0.25.0",
|
||||
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@df765cc",
|
||||
"@appwrite.io/pink-legacy": "^1.0.3",
|
||||
@@ -101,6 +101,8 @@
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@latest"
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"minimatch": "10.2.3",
|
||||
"immutable": "^5.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +254,7 @@ export enum Submit {
|
||||
ProjectUpdateLabels = 'submit_project_update_labels',
|
||||
ProjectService = 'submit_project_service',
|
||||
ProjectUpdateSMTP = 'submit_project_update_smtp',
|
||||
ProjectResume = 'submit_project_resume',
|
||||
MemberCreate = 'submit_member_create',
|
||||
MemberDelete = 'submit_member_delete',
|
||||
MembershipUpdate = 'submit_membership_update',
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Action } from 'svelte/action';
|
||||
|
||||
export type PortalConfig = string | HTMLElement | undefined;
|
||||
|
||||
export const portal: Action<HTMLElement, PortalConfig> = (el, target = 'body') => {
|
||||
export const portal: Action<HTMLElement, PortalConfig> = (el, target: PortalConfig = 'body') => {
|
||||
let targetEl;
|
||||
|
||||
async function update(newTarget: PortalConfig) {
|
||||
@@ -17,7 +17,7 @@ export const portal: Action<HTMLElement, PortalConfig> = (el, target = 'body') =
|
||||
if (targetEl === null) {
|
||||
throw new Error(`No element found matching css selector: "${target}"`);
|
||||
}
|
||||
} else if (target instanceof HTMLElement) {
|
||||
} else if (typeof target === 'object' && target instanceof HTMLElement) {
|
||||
targetEl = target;
|
||||
} else {
|
||||
throw new TypeError(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Action } from 'svelte/action';
|
||||
import type { Instance } from 'tippy.js';
|
||||
import type { Props as TippyProps } from 'tippy.js';
|
||||
import tippy from 'tippy.js';
|
||||
|
||||
@@ -7,7 +8,8 @@ type Props = TippyProps & {
|
||||
};
|
||||
|
||||
export const tooltip: Action<HTMLElement, Partial<Props>> = (node, config) => {
|
||||
const instance = tippy(node, config);
|
||||
const tippyInstance = tippy(node, config);
|
||||
const instance: Instance = Array.isArray(tippyInstance) ? tippyInstance[0] : tippyInstance;
|
||||
if (config.disabled) instance.disable();
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
<script lang="ts">
|
||||
import PaginationWithLimit from './paginationWithLimit.svelte';
|
||||
import { Button, InputText } from '$lib/elements/forms';
|
||||
import { GridItem1, CardContainer, Modal } from '$lib/components';
|
||||
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
|
||||
import {
|
||||
Badge,
|
||||
Icon,
|
||||
Typography,
|
||||
Accordion,
|
||||
ActionMenu,
|
||||
Popover,
|
||||
Layout,
|
||||
Divider
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import {
|
||||
IconAndroid,
|
||||
IconApple,
|
||||
IconCode,
|
||||
IconFlutter,
|
||||
IconReact,
|
||||
IconUnity,
|
||||
IconDotsHorizontal,
|
||||
IconInboxIn,
|
||||
IconSwitchHorizontal,
|
||||
IconTrash
|
||||
} from '@appwrite.io/pink-icons-svelte';
|
||||
import { getPlatformInfo } from '$lib/helpers/platform';
|
||||
import { Status, type Models } from '@appwrite.io/console';
|
||||
import type { ComponentType } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
|
||||
import { isSmallViewport } from '$lib/stores/viewport';
|
||||
import { isCloud } from '$lib/system';
|
||||
import { regions as regionsStore } from '$lib/stores/organization';
|
||||
|
||||
// props
|
||||
interface Props {
|
||||
currentPlan: Models.BillingPlan;
|
||||
organization: Models.Organization;
|
||||
projectsToArchive: Models.Project[];
|
||||
archivedTotalOverall: number;
|
||||
archivedOffset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
let {
|
||||
currentPlan,
|
||||
organization,
|
||||
projectsToArchive,
|
||||
archivedTotalOverall,
|
||||
archivedOffset,
|
||||
limit
|
||||
}: Props = $props();
|
||||
|
||||
// Check if current plan order is less than Pro (order < 1 means FREE plan)
|
||||
let isPlanBelowPro = $derived(currentPlan?.order < 1);
|
||||
|
||||
let showUnarchiveModal = $state(false);
|
||||
let projectToUnarchive = $state<Models.Project | null>(null);
|
||||
let showDeleteModal = $state(false);
|
||||
let projectToDelete = $state<Models.Project | null>(null);
|
||||
let deleteProjectName = $state<string | null>(null);
|
||||
let deleteError = $state<string | null>(null);
|
||||
|
||||
function resetDeleteState() {
|
||||
showDeleteModal = false;
|
||||
projectToDelete = null;
|
||||
deleteProjectName = null;
|
||||
deleteError = null;
|
||||
}
|
||||
|
||||
function filterPlatforms(platforms: { name: string; icon: string }[]) {
|
||||
return platforms.filter(
|
||||
(value, index, self) => index === self.findIndex((t) => t.name === value.name)
|
||||
);
|
||||
}
|
||||
|
||||
function getIconForPlatform(platform: string): ComponentType {
|
||||
switch (platform) {
|
||||
case 'code':
|
||||
return IconCode;
|
||||
case 'flutter':
|
||||
return IconFlutter;
|
||||
case 'apple':
|
||||
return IconApple;
|
||||
case 'android':
|
||||
return IconAndroid;
|
||||
case 'react-native':
|
||||
return IconReact;
|
||||
case 'unity':
|
||||
return IconUnity;
|
||||
default:
|
||||
return IconCode;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if unarchive should be disabled
|
||||
function isUnarchiveDisabled(): boolean {
|
||||
if (!organization || !currentPlan) return true;
|
||||
|
||||
if (isPlanBelowPro) {
|
||||
const currentProjectCount = organization.projects?.length || 0;
|
||||
const projectLimit = currentPlan.projects || 0;
|
||||
|
||||
return currentProjectCount >= projectLimit;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleMigrateProject(project: Models.Project) {
|
||||
goto(`${base}/project-${project.region}-${project.$id}/settings/migrations`);
|
||||
}
|
||||
|
||||
// Handle unarchive project action
|
||||
async function handleUnarchiveProject(project: Models.Project) {
|
||||
projectToUnarchive = project;
|
||||
showUnarchiveModal = true;
|
||||
}
|
||||
|
||||
function handleDeleteProject(project: Models.Project) {
|
||||
projectToDelete = project;
|
||||
deleteProjectName = null;
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
// Confirm unarchive action
|
||||
async function confirmUnarchive() {
|
||||
if (!projectToUnarchive) return;
|
||||
|
||||
try {
|
||||
if (!organization) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: 'Organization not found'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await sdk.forConsole.projects.updateStatus(projectToUnarchive.$id, Status.Active);
|
||||
|
||||
await invalidate(Dependencies.ORGANIZATION);
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `${projectToUnarchive.name} has been unarchived`
|
||||
});
|
||||
|
||||
showUnarchiveModal = false;
|
||||
projectToUnarchive = null;
|
||||
} catch (error) {
|
||||
const msg =
|
||||
error && typeof error === 'object' && 'message' in error
|
||||
? String((error as { message: string }).message)
|
||||
: 'Failed to unarchive project';
|
||||
addNotification({ type: 'error', message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
function cancelUnarchive() {
|
||||
showUnarchiveModal = false;
|
||||
projectToUnarchive = null;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!projectToDelete) return;
|
||||
|
||||
try {
|
||||
await sdk.forConsoleIn(projectToDelete.region).projects.delete({
|
||||
projectId: projectToDelete.$id
|
||||
});
|
||||
|
||||
await invalidate(Dependencies.ORGANIZATION);
|
||||
|
||||
trackEvent(Submit.ProjectDelete);
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `${projectToDelete.name} has been deleted`
|
||||
});
|
||||
|
||||
resetDeleteState();
|
||||
} catch (error) {
|
||||
deleteError = error.message;
|
||||
trackError(error, Submit.ProjectDelete);
|
||||
}
|
||||
}
|
||||
|
||||
function findRegion(project: Models.Project) {
|
||||
return $regionsStore.regions.find((region) => region.$id === project.region);
|
||||
}
|
||||
|
||||
import { formatName as formatNameHelper } from '$lib/helpers/string';
|
||||
function formatName(name: string, limit: number = 16) {
|
||||
return formatNameHelper(name, limit, $isSmallViewport);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if projectsToArchive.length > 0}
|
||||
<div class="archive-projects-margin-top">
|
||||
<Accordion
|
||||
title={isPlanBelowPro ? 'Archived projects' : 'Pending archive'}
|
||||
badge={`${archivedTotalOverall}`}>
|
||||
<Typography.Text tag="p" size="s">
|
||||
{#if isPlanBelowPro}
|
||||
These projects are archived and require a plan upgrade to restore access.
|
||||
{:else}
|
||||
These projects will be archived at the end of your billing cycle.
|
||||
{/if}
|
||||
</Typography.Text>
|
||||
|
||||
<div class="archive-projects-margin">
|
||||
<CardContainer disableEmpty={true} total={archivedTotalOverall}>
|
||||
{#each projectsToArchive as project}
|
||||
{@const platforms = filterPlatforms(
|
||||
project.platforms.map((platform) => getPlatformInfo(platform.type))
|
||||
)}
|
||||
{@const formatted = formatName(project.name)}
|
||||
<GridItem1>
|
||||
<svelte:fragment slot="eyebrow">
|
||||
{project?.platforms?.length ? project?.platforms?.length : 'No'} apps
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="title">{formatted}</svelte:fragment>
|
||||
<svelte:fragment slot="status">
|
||||
<div class="status-container">
|
||||
<Popover let:toggle padding="none" placement="bottom-end">
|
||||
<Button
|
||||
text
|
||||
icon
|
||||
size="s"
|
||||
ariaLabel="more options"
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggle(e);
|
||||
}}>
|
||||
<Icon icon={IconDotsHorizontal} size="s" />
|
||||
</Button>
|
||||
<ActionMenu.Root slot="tooltip">
|
||||
<ActionMenu.Item.Button
|
||||
leadingIcon={IconInboxIn}
|
||||
disabled={isUnarchiveDisabled()}
|
||||
on:click={() => handleUnarchiveProject(project)}
|
||||
>Unarchive project</ActionMenu.Item.Button>
|
||||
<ActionMenu.Item.Button
|
||||
leadingIcon={IconSwitchHorizontal}
|
||||
disabled={isUnarchiveDisabled()}
|
||||
on:click={() => handleMigrateProject(project)}
|
||||
>Migrate project</ActionMenu.Item.Button>
|
||||
<div class="action-menu-divider">
|
||||
<Divider />
|
||||
</div>
|
||||
<ActionMenu.Item.Button
|
||||
status="danger"
|
||||
leadingIcon={IconTrash}
|
||||
on:click={() => handleDeleteProject(project)}
|
||||
>Delete project</ActionMenu.Item.Button>
|
||||
</ActionMenu.Root>
|
||||
</Popover>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
||||
{#each platforms.slice(0, 2) as platform}
|
||||
{@const icon = getIconForPlatform(platform.icon)}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
content={platform.name}
|
||||
style="width: max-content;">
|
||||
<Icon {icon} size="s" slot="start" />
|
||||
</Badge>
|
||||
{/each}
|
||||
|
||||
{#if platforms.length > 2}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
content={`+${platforms.length - 2}`}
|
||||
style="width: max-content;" />
|
||||
{/if}
|
||||
|
||||
<svelte:fragment slot="icons">
|
||||
{#if isCloud && $regionsStore?.regions}
|
||||
{@const region = findRegion(project)}
|
||||
<Typography.Text>{region?.name}</Typography.Text>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</GridItem1>
|
||||
{/each}
|
||||
</CardContainer>
|
||||
|
||||
<PaginationWithLimit
|
||||
name="Archived Projects"
|
||||
{limit}
|
||||
offset={archivedOffset}
|
||||
total={archivedTotalOverall}
|
||||
pageParam="archivedPage"
|
||||
removeOnFirstPage
|
||||
class="pagination-container" />
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Unarchive Confirmation Modal -->
|
||||
<Modal bind:show={showUnarchiveModal} title="Unarchive project" size="s">
|
||||
<p>Are you sure you want to unarchive <strong>{projectToUnarchive?.name}</strong>?</p>
|
||||
<p>This will move the project back to your active projects list.</p>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Layout.Stack direction="row" gap="s" justifyContent="flex-end">
|
||||
<Button secondary on:click={cancelUnarchive}>Cancel</Button>
|
||||
<Button on:click={confirmUnarchive}>Unarchive</Button>
|
||||
</Layout.Stack>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<Modal
|
||||
size="s"
|
||||
bind:show={showDeleteModal}
|
||||
title="Delete project"
|
||||
onSubmit={confirmDelete}
|
||||
bind:error={deleteError}>
|
||||
<svelte:fragment slot="description">
|
||||
The archived project <strong>{projectToDelete?.name}</strong> will be deleted along with all
|
||||
of its metadata, stats, and other resources.
|
||||
<b>This action is irreversible.</b>
|
||||
</svelte:fragment>
|
||||
|
||||
<InputText
|
||||
label={`Enter "${projectToDelete?.name}" to continue`}
|
||||
placeholder="Enter name"
|
||||
id="delete-project-name"
|
||||
autofocus
|
||||
required
|
||||
bind:value={deleteProjectName} />
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Button
|
||||
text
|
||||
on:click={() => {
|
||||
resetDeleteState();
|
||||
}}>Cancel</Button>
|
||||
<Button
|
||||
submissionLoader
|
||||
submit
|
||||
disabled={(deleteProjectName ?? '') !== projectToDelete?.name}>
|
||||
Delete
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.archive-projects-margin-top {
|
||||
margin-top: 36px;
|
||||
}
|
||||
.action-menu-divider {
|
||||
margin-inline: -1rem;
|
||||
padding-block-start: 0.25rem;
|
||||
padding-block-end: 0.25rem;
|
||||
}
|
||||
|
||||
.archive-projects-margin {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.status-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
:global(.pagination-container) {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { currentPlan, organization } from '$lib/stores/organization';
|
||||
import { HeaderAlert } from '$lib/layout';
|
||||
import { isCloud } from '$lib/system';
|
||||
import { getChangePlanUrl } from '$lib/stores/billing';
|
||||
@@ -17,7 +17,7 @@
|
||||
</script>
|
||||
|
||||
{#if $showPolicyAlert && isCloud && $organization?.$id && page.url.pathname.match(/\/databases\/database-[^/]+$/)}
|
||||
{@const areBackupsAvailable = $organization?.billingPlanDetails.backupsEnabled}
|
||||
{@const areBackupsAvailable = $currentPlan?.backupsEnabled}
|
||||
|
||||
{@const subtitle = !areBackupsAvailable
|
||||
? 'Upgrade your plan to ensure your data stays safe and backed up'
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { Click } from '$lib/actions/analytics';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { HeaderAlert } from '$lib/layout';
|
||||
import { hideBillingHeaderRoutes, getChangePlanUrl } from '$lib/stores/billing';
|
||||
import { currentPlan, organization } from '$lib/stores/organization';
|
||||
import SelectProjectCloud from './selectProjectCloud.svelte';
|
||||
import { toLocaleDate } from '$lib/helpers/date';
|
||||
|
||||
let selectedProjects: string[] = $state([]);
|
||||
let showSelectProject: boolean = $state(false);
|
||||
|
||||
const organizationId = $derived(page.data.organization?.$id);
|
||||
|
||||
$effect(() => {
|
||||
if (organizationId) {
|
||||
selectedProjects = page.data.organization?.projects || [];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<SelectProjectCloud bind:showSelectProject bind:selectedProjects {organizationId} />
|
||||
|
||||
{#if organizationId && $currentPlan && $currentPlan.projects > 0 && !hideBillingHeaderRoutes.includes(page.url.pathname)}
|
||||
<HeaderAlert
|
||||
type="warning"
|
||||
title="Action required: You have more than {$currentPlan.projects} projects.">
|
||||
<svelte:fragment>
|
||||
Choose which projects to keep before {toLocaleDate(
|
||||
$organization.billingNextInvoiceDate
|
||||
)} or upgrade to Pro. Projects over the limit will be blocked after this date.
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="buttons">
|
||||
<Button
|
||||
compact
|
||||
on:click={() => {
|
||||
showSelectProject = true;
|
||||
}}>Manage projects</Button>
|
||||
<Button
|
||||
href={getChangePlanUrl(organizationId)}
|
||||
event={Click.OrganizationClickUpgrade}
|
||||
eventData={{
|
||||
from: 'button',
|
||||
source: 'projects_limit_banner'
|
||||
}}
|
||||
secondary
|
||||
fullWidthMobile>
|
||||
<span class="text">Upgrade</span>
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</HeaderAlert>
|
||||
{/if}
|
||||
@@ -1,180 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { type Models, Query } from '@appwrite.io/console';
|
||||
import { Alert, Button, Skeleton, Table } from '@appwrite.io/pink-svelte';
|
||||
import { Modal } from '$lib/components';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
|
||||
import { currentPlan, organization } from '$lib/stores/organization';
|
||||
|
||||
let {
|
||||
showSelectProject = $bindable(false),
|
||||
selectedProjects = $bindable([]),
|
||||
organizationId
|
||||
}: {
|
||||
showSelectProject: boolean;
|
||||
selectedProjects: string[];
|
||||
organizationId: string;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let projectsLoadingError = $state<string | null>(null);
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
let projects = $state<Array<Models.Project>>([]);
|
||||
|
||||
let projectsToArchive = $derived(
|
||||
projects.filter((project) => !selectedProjects.includes(project.$id))
|
||||
);
|
||||
|
||||
async function updateSelected() {
|
||||
try {
|
||||
await sdk.forConsole.organizations.updateProjects({
|
||||
organizationId,
|
||||
projects: selectedProjects
|
||||
});
|
||||
|
||||
showSelectProject = false;
|
||||
invalidate(Dependencies.ORGANIZATION);
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `Projects updated for archiving`
|
||||
});
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function formatProjectsToArchive() {
|
||||
let result = '';
|
||||
|
||||
projectsToArchive.forEach((project, index) => {
|
||||
const text = `${index === 0 ? '' : ' '}<b>${project.name}</b> `;
|
||||
result += text;
|
||||
|
||||
if (index < projectsToArchive.length - 1) {
|
||||
if (index == projectsToArchive.length - 2) {
|
||||
result += 'and ';
|
||||
}
|
||||
if (index < projectsToArchive.length - 2) {
|
||||
result += ', ';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!showSelectProject) return;
|
||||
|
||||
const areProjectsLoaded = projects.length > 0;
|
||||
const teamIdInLoadedProjects = areProjectsLoaded ? projects[0].teamId : null;
|
||||
|
||||
if (organizationId != teamIdInLoadedProjects) {
|
||||
loading = true;
|
||||
|
||||
sdk.forConsole.projects
|
||||
.list({
|
||||
queries: [
|
||||
Query.equal('teamId', organizationId),
|
||||
Query.limit(1000) // Get all projects for organization
|
||||
]
|
||||
})
|
||||
.then((loadedProjects) => (projects = loadedProjects.projects))
|
||||
.catch((err) => (projectsLoadingError = err.message))
|
||||
.finally(() => (loading = false));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal bind:show={showSelectProject} title="Manage projects" onSubmit={updateSelected}>
|
||||
<svelte:fragment slot="description">
|
||||
Choose which {$currentPlan?.projects || 2} projects to keep. Projects over the limit will be
|
||||
blocked after this date.
|
||||
</svelte:fragment>
|
||||
|
||||
{#if loading}
|
||||
<div class="skeleton-projects">
|
||||
<Table.Root let:root allowSelection columns={[{ id: 'name' }, { id: 'created' }]}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="name" {root}>Project Name</Table.Header.Cell>
|
||||
<Table.Header.Cell column="created" {root}>Created</Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
|
||||
{#each Array.from({ length: 5 }) as _}
|
||||
<Table.Row.Base {root} select="disabled">
|
||||
<Table.Cell column="name" {root}>
|
||||
<Skeleton variant="line" height={20} width="80%" />
|
||||
</Table.Cell>
|
||||
<Table.Cell column="created" {root}>
|
||||
<Skeleton variant="line" height={20} width="80%" />
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
</div>
|
||||
{:else if projectsLoadingError}
|
||||
<Alert.Inline status="error" title="Error">{projectsLoadingError}</Alert.Inline>
|
||||
{:else}
|
||||
{#if error}
|
||||
<Alert.Inline status="error" title="Error">{error}</Alert.Inline>
|
||||
{/if}
|
||||
|
||||
<Table.Root
|
||||
let:root
|
||||
allowSelection
|
||||
bind:selectedRows={selectedProjects}
|
||||
columns={[{ id: 'name' }, { id: 'created' }]}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="name" {root}>Project Name</Table.Header.Cell>
|
||||
<Table.Header.Cell column="created" {root}>Created</Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
{#each projects as project}
|
||||
<Table.Row.Base {root} id={project.$id}>
|
||||
<Table.Cell column="name" {root}>{project.name}</Table.Cell>
|
||||
<Table.Cell column="created" {root}
|
||||
>{toLocaleDateTime(project.$createdAt)}</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
|
||||
{#if selectedProjects.length > $currentPlan?.projects}
|
||||
<div class="u-text-warning u-mb-4">
|
||||
You can only select {$currentPlan?.projects} projects. Please deselect others to continue.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedProjects.length === $currentPlan?.projects}
|
||||
{@const difference = projects.length - selectedProjects.length}
|
||||
{@const messagePrefix =
|
||||
difference > 1 ? `${difference} projects` : `${difference} project`}
|
||||
<Alert.Inline
|
||||
status="warning"
|
||||
title={`${messagePrefix} will be archived on ${toLocaleDate($organization.billingNextInvoiceDate)}`}>
|
||||
<span>
|
||||
{@html formatProjectsToArchive()}
|
||||
will be archived.
|
||||
</span>
|
||||
</Alert.Inline>
|
||||
{/if}
|
||||
{/if}
|
||||
<svelte:fragment slot="footer">
|
||||
<Button.Button size="s" variant="secondary" on:click={() => (showSelectProject = false)}
|
||||
>Cancel</Button.Button>
|
||||
<Button.Button size="s" disabled={selectedProjects.length !== $currentPlan?.projects}
|
||||
>Save</Button.Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
/* disable the top select all selector button */
|
||||
.skeleton-projects :global([role='rowheader'] button) {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
background: var(--bgcolor-neutral-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -54,8 +54,6 @@
|
||||
case 'cname':
|
||||
if (service === 'sites') {
|
||||
return $regionalConsoleVariables._APP_DOMAIN_SITES;
|
||||
} else if (service === 'functions') {
|
||||
return $regionalConsoleVariables._APP_DOMAIN_FUNCTIONS;
|
||||
} else {
|
||||
return $regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import type { createTreeView } from '@melt-ui/svelte';
|
||||
import { IconChevronRight } from '@appwrite.io/pink-icons-svelte';
|
||||
import { Icon, Layout, Selector, Spinner, Typography } from '@appwrite.io/pink-svelte';
|
||||
import DirectoryItemSelf from './DirectoryItem.svelte';
|
||||
|
||||
let {
|
||||
directories,
|
||||
level = 0,
|
||||
containerWidth,
|
||||
selectedPath,
|
||||
onSelect
|
||||
}: {
|
||||
directories: Array<{
|
||||
title: string;
|
||||
fileCount?: number;
|
||||
fullPath: string;
|
||||
thumbnailUrl?: string;
|
||||
thumbnailIcon?: typeof Icon;
|
||||
thumbnailHtml?: string;
|
||||
children?: typeof directories;
|
||||
hasChildren?: boolean;
|
||||
showThumbnail?: boolean;
|
||||
loading?: boolean;
|
||||
}>;
|
||||
level?: number;
|
||||
containerWidth?: number;
|
||||
selectedPath?: string;
|
||||
onSelect?: (detail: { title: string; fullPath: string; hasChildren: boolean }) => void;
|
||||
} = $props();
|
||||
|
||||
const Radio = Selector.Radio;
|
||||
|
||||
let radioInputs = $state<Array<HTMLInputElement | undefined>>([]);
|
||||
let value = $state<string | undefined>(undefined);
|
||||
let thumbnailStates = $state<Array<{ loading: boolean; error: boolean }>>([]);
|
||||
|
||||
$effect(() => {
|
||||
if (!directories) return;
|
||||
if (thumbnailStates.length < directories.length) {
|
||||
thumbnailStates = [
|
||||
...thumbnailStates,
|
||||
...Array.from({ length: directories.length - thumbnailStates.length }, () => ({
|
||||
loading: true,
|
||||
error: false
|
||||
}))
|
||||
];
|
||||
} else if (thumbnailStates.length > directories.length) {
|
||||
thumbnailStates = thumbnailStates.slice(0, directories.length);
|
||||
}
|
||||
});
|
||||
|
||||
function handleThumbnailLoad(index: number) {
|
||||
if (!thumbnailStates[index]) return;
|
||||
thumbnailStates[index].loading = false;
|
||||
thumbnailStates[index].error = false;
|
||||
}
|
||||
|
||||
function handleThumbnailError(index: number) {
|
||||
if (!thumbnailStates[index]) return;
|
||||
thumbnailStates[index].loading = false;
|
||||
thumbnailStates[index].error = true;
|
||||
}
|
||||
|
||||
const {
|
||||
elements: { item, group },
|
||||
helpers: { isExpanded }
|
||||
} = getContext<ReturnType<typeof createTreeView>>('tree');
|
||||
|
||||
const paddingLeftStyle = `padding-left: ${32 * level + 8}px`;
|
||||
|
||||
$effect(() => {
|
||||
if (selectedPath && directories?.length) {
|
||||
const idx = directories.findIndex((d) => d.fullPath === selectedPath);
|
||||
if (idx !== -1 && radioInputs[idx]) {
|
||||
radioInputs[idx].checked = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#each directories as { title, fileCount, fullPath, thumbnailUrl, thumbnailIcon, thumbnailHtml, children, hasChildren: explicitHasChildren, showThumbnail = true, loading = false }, i}
|
||||
{@const hasChildren = explicitHasChildren ?? !!children?.length}
|
||||
{@const __MELTUI_BUILDER_1__ = $group({ id: fullPath })}
|
||||
{@const __MELTUI_BUILDER_0__ = $item({
|
||||
id: fullPath,
|
||||
hasChildren
|
||||
})}
|
||||
|
||||
<div class="directory-item-container">
|
||||
<button
|
||||
class="folder"
|
||||
type="button"
|
||||
style={paddingLeftStyle}
|
||||
onclick={() => {
|
||||
if (radioInputs[i]) radioInputs[i].checked = true;
|
||||
onSelect?.({ title, fullPath, hasChildren });
|
||||
}}
|
||||
{...__MELTUI_BUILDER_0__}
|
||||
use:__MELTUI_BUILDER_0__.action>
|
||||
<Layout.Stack direction="row" justifyContent="space-between">
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
justifyContent="flex-start"
|
||||
gap="xxs"
|
||||
alignItems="center">
|
||||
<div>
|
||||
<Layout.Stack direction="row" gap="xxs" alignItems="center">
|
||||
<Radio
|
||||
group="directory"
|
||||
name="directory"
|
||||
size="s"
|
||||
bind:value
|
||||
bind:radioInput={radioInputs[i]} />
|
||||
<div
|
||||
class:folder-open={$isExpanded(fullPath)}
|
||||
class:disabled={!hasChildren}
|
||||
class="chevron-container">
|
||||
<Icon
|
||||
icon={IconChevronRight}
|
||||
size="s"
|
||||
color="--fgcolor-neutral-tertiary" />
|
||||
</div>
|
||||
</Layout.Stack>
|
||||
</div>
|
||||
<span
|
||||
class="title"
|
||||
style={containerWidth
|
||||
? `max-width: ${containerWidth - 100 - level * 40}px`
|
||||
: ''}>{title}</span>
|
||||
{#if fileCount !== undefined}
|
||||
<div class="fileCount">
|
||||
<Typography.Text variant="m-400" color="--fgcolor-neutral-tertiary"
|
||||
>({fileCount} files)</Typography.Text>
|
||||
</div>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
{#if showThumbnail}
|
||||
{#if loading || (thumbnailStates[i]?.loading && !thumbnailIcon && !thumbnailHtml)}
|
||||
<Spinner />
|
||||
{/if}
|
||||
|
||||
{#if thumbnailStates[i]?.error}
|
||||
<div class="thumbnail-fallback"></div>
|
||||
{:else if thumbnailUrl}
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt="Directory thumbnail"
|
||||
class="thumbnail"
|
||||
class:hidden={thumbnailStates[i]?.loading}
|
||||
onload={() => handleThumbnailLoad(i)}
|
||||
onerror={() => handleThumbnailError(i)} />
|
||||
{:else if thumbnailIcon}
|
||||
<div class="thumbnail">
|
||||
<Icon icon={thumbnailIcon} size="l" />
|
||||
</div>
|
||||
{:else if thumbnailHtml}
|
||||
<div class="thumbnail">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html thumbnailHtml}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</button>
|
||||
|
||||
{#if children}
|
||||
<div {...__MELTUI_BUILDER_1__} use:__MELTUI_BUILDER_1__.action>
|
||||
<DirectoryItemSelf
|
||||
directories={children}
|
||||
level={level + 1}
|
||||
{containerWidth}
|
||||
{selectedPath}
|
||||
{onSelect} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
.directory-item-container {
|
||||
width: 100%;
|
||||
}
|
||||
.folder {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
padding: var(--space-3, 6px) var(--space-4, 8px);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-radius: var(--border-radius-s, 8px);
|
||||
background: var(--bgcolor-neutral-secondary, #f4f4f7);
|
||||
}
|
||||
}
|
||||
.chevron-container {
|
||||
width: var(--space-7);
|
||||
height: var(--space-7);
|
||||
transition: transform ease-in-out 0.1s;
|
||||
}
|
||||
.folder-open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.disabled {
|
||||
color: var(--fgcolor-neutral-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.fileCount {
|
||||
display: none;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: var(--icon-size-l, 24px);
|
||||
height: var(--icon-size-l, 24px);
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--border-radius-circle, 99999px);
|
||||
}
|
||||
|
||||
.thumbnail-fallback {
|
||||
width: var(--icon-size-l, 24px);
|
||||
height: var(--icon-size-l, 24px);
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--border-radius-circle, 99999px);
|
||||
border: var(--border-width-s, 1px) dashed var(--border-neutral-strong, #d8d8db);
|
||||
background: var(--bgcolor-neutral-primary, #fff);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import { createTreeView } from '@melt-ui/svelte';
|
||||
import { onMount, setContext } from 'svelte';
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
import DirectoryItem from '$lib/components/git/DirectoryItem.svelte';
|
||||
import type { DirectoryEntry } from '$lib/components/git/types';
|
||||
import { Spinner } from '@appwrite.io/pink-svelte';
|
||||
|
||||
let {
|
||||
expanded = $bindable(writable(['lib-0', 'tree-0'])),
|
||||
selected = $bindable(undefined),
|
||||
openTo,
|
||||
directories,
|
||||
isLoading = true,
|
||||
onSelect,
|
||||
onChange
|
||||
}: {
|
||||
expanded?: Writable<string[]>;
|
||||
selected?: string;
|
||||
openTo?: string;
|
||||
directories: DirectoryEntry[];
|
||||
isLoading?: boolean;
|
||||
onSelect?: (detail: {
|
||||
fullPath: string;
|
||||
hasChildren: boolean;
|
||||
title: string;
|
||||
}) => void | Promise<void>;
|
||||
onChange?: (detail: { fullPath: string }) => void | Promise<void>;
|
||||
} = $props();
|
||||
|
||||
const ctx = createTreeView({ expanded });
|
||||
setContext('tree', ctx);
|
||||
|
||||
const {
|
||||
elements: { tree }
|
||||
} = ctx;
|
||||
|
||||
let rootContainer = $state<HTMLDivElement | undefined>(undefined);
|
||||
let containerWidth = $state<number | undefined>(undefined);
|
||||
let internalSelected = $state<string | undefined>(undefined);
|
||||
|
||||
$effect(() => {
|
||||
internalSelected = selected;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
updateWidth();
|
||||
if (openTo) {
|
||||
const pathSegments = openTo.split('/').filter(Boolean);
|
||||
const pathsToExpand: string[] = [];
|
||||
let currentPath = '';
|
||||
for (const segment of pathSegments) {
|
||||
currentPath += '/' + segment;
|
||||
pathsToExpand.push(currentPath);
|
||||
}
|
||||
if (pathsToExpand.length > 0) {
|
||||
expanded?.update((current) => {
|
||||
const next = [...current];
|
||||
pathsToExpand.forEach((path) => {
|
||||
if (!next.includes(path)) {
|
||||
next.push(path);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function updateWidth() {
|
||||
containerWidth = rootContainer ? rootContainer.getBoundingClientRect().width : undefined;
|
||||
}
|
||||
|
||||
function handleSelect(detail: { fullPath: string; hasChildren: boolean; title: string }) {
|
||||
internalSelected = detail.fullPath;
|
||||
selected = internalSelected;
|
||||
if (onChange) onChange({ fullPath: detail.fullPath });
|
||||
if (onSelect) onSelect(detail);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
containerWidth = rootContainer ? rootContainer.getBoundingClientRect().width : undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onresize={updateWidth} />
|
||||
|
||||
<div class="directory-container" class:isLoading {...$tree} bind:this={rootContainer}>
|
||||
{#if isLoading}
|
||||
<div class="loading-container">
|
||||
<Spinner /><span>Loading directory data...</span>
|
||||
</div>
|
||||
{:else}
|
||||
<DirectoryItem
|
||||
{directories}
|
||||
{containerWidth}
|
||||
selectedPath={internalSelected}
|
||||
onSelect={handleSelect} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.directory-container {
|
||||
width: 560px;
|
||||
max-width: 100%;
|
||||
height: 316px;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
padding: var(--space-2, 4px);
|
||||
|
||||
border-radius: var(--border-radius-m, 12px);
|
||||
border: var(--border-width-s, 1px) solid var(--border-neutral, #ededf0);
|
||||
background: var(--bgcolor-neutral-primary, #fff);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.isLoading {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--gap-m);
|
||||
}
|
||||
</style>
|
||||
@@ -52,6 +52,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($installation?.$id) {
|
||||
selectedInstallationId = $installation.$id;
|
||||
}
|
||||
});
|
||||
|
||||
async function connectRepo() {
|
||||
try {
|
||||
if (repositoryBehaviour === 'new') {
|
||||
@@ -105,13 +111,29 @@
|
||||
{product}
|
||||
action="button"
|
||||
{callbackState}
|
||||
connect={(e) => {
|
||||
connect={async (e) => {
|
||||
trackEvent(Click.ConnectRepositoryClick, {
|
||||
from: product
|
||||
});
|
||||
repository.set(e);
|
||||
repositoryName = e.name;
|
||||
selectedRepository = e.id;
|
||||
if (!selectedInstallationId && $installation?.$id) {
|
||||
selectedInstallationId = $installation.$id;
|
||||
}
|
||||
try {
|
||||
await connect(selectedInstallationId, e.id);
|
||||
show = false;
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Repository connected successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error?.message ?? 'Failed to connect repository'
|
||||
});
|
||||
}
|
||||
}} />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { Query, VCSDetectionType, type Models } from '@appwrite.io/console';
|
||||
import { getFrameworkIcon } from '$lib/stores/sites';
|
||||
import { connectGitHub } from '$lib/stores/git';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { page } from '$app/state';
|
||||
import Card from '../card.svelte';
|
||||
import SkeletonRepoList from './skeletonRepoList.svelte';
|
||||
@@ -274,9 +275,20 @@
|
||||
variant="secondary"
|
||||
style="flex-shrink: 0;"
|
||||
disabled={!!connectingRepositoryId}
|
||||
on:click={() => {
|
||||
on:click={async () => {
|
||||
connectingRepositoryId = repo.id;
|
||||
connect(repo);
|
||||
try {
|
||||
await Promise.resolve(connect(repo));
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message:
|
||||
error?.message ??
|
||||
'Failed to connect repository'
|
||||
});
|
||||
} finally {
|
||||
connectingRepositoryId = null;
|
||||
}
|
||||
}}>
|
||||
Connect
|
||||
</PinkButton.Button>
|
||||
|
||||
@@ -6,140 +6,378 @@
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { installation, repository } from '$lib/stores/vcs';
|
||||
import { VCSDetectionType, type Models } from '@appwrite.io/console';
|
||||
import { DirectoryPicker } from '@appwrite.io/pink-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import DirectoryPicker from '$lib/components/git/DirectoryPicker.svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
type Directory = {
|
||||
title: string;
|
||||
fullPath: string;
|
||||
fileCount: number;
|
||||
thumbnailUrl: string;
|
||||
fileCount?: number;
|
||||
thumbnailUrl?: string;
|
||||
children?: Directory[];
|
||||
hasChildren?: boolean;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export let show = false;
|
||||
export let rootDir: string;
|
||||
export let product: 'sites' | 'functions' = 'functions';
|
||||
export let branch: string;
|
||||
let {
|
||||
show = $bindable(false),
|
||||
rootDir = $bindable(''),
|
||||
product = 'functions' as 'sites' | 'functions',
|
||||
branch
|
||||
}: {
|
||||
show?: boolean;
|
||||
rootDir?: string;
|
||||
product?: 'sites' | 'functions';
|
||||
branch: string;
|
||||
} = $props();
|
||||
|
||||
let isLoading = true;
|
||||
let directories: Directory[] = [
|
||||
let isLoading = $state(true);
|
||||
let directories = $state<Directory[]>([
|
||||
{
|
||||
title: 'Root',
|
||||
fullPath: './',
|
||||
fileCount: 0,
|
||||
thumbnailUrl: 'root',
|
||||
title: 'Repository (root)',
|
||||
fullPath: '/',
|
||||
fileCount: undefined,
|
||||
thumbnailUrl: $iconPath('empty', 'grayscale'),
|
||||
children: [],
|
||||
hasChildren: true,
|
||||
loading: false
|
||||
}
|
||||
];
|
||||
let currentPath: string = './';
|
||||
let currentDir: Directory;
|
||||
export let expanded = writable(['lib-0', 'tree-0']);
|
||||
]);
|
||||
let currentPath = $state('/');
|
||||
let expandedPaths = $state<string[]>([]);
|
||||
const expandedStore = writable<string[]>([]);
|
||||
|
||||
onMount(async () => {
|
||||
$effect(() => {
|
||||
expandedStore.set(expandedPaths);
|
||||
});
|
||||
$effect(() => {
|
||||
const unsub = expandedStore.subscribe((v) => {
|
||||
expandedPaths = v;
|
||||
});
|
||||
return unsub;
|
||||
});
|
||||
|
||||
let initialized = $state(false);
|
||||
let initialPath = $state('/');
|
||||
const inFlightPaths = new Set<string>();
|
||||
const contentsCache = new Map<
|
||||
string,
|
||||
{ fileCount: number; directories: Array<{ name: string }> }
|
||||
>();
|
||||
const iconCache = new Map<string, string | null>();
|
||||
|
||||
let hasChanges = $derived(currentPath !== initialPath);
|
||||
|
||||
const iconAliases = new Map([
|
||||
['svelte-kit', 'svelte'],
|
||||
['sveltekit', 'svelte'],
|
||||
['svelte_kit', 'svelte'],
|
||||
['sveltejs', 'svelte'],
|
||||
['other', 'empty']
|
||||
]);
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
if (!path || path === './' || path === '/') return '/';
|
||||
const trimmed = path.replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/$/, '');
|
||||
return `/${trimmed}`;
|
||||
}
|
||||
|
||||
function toProviderPath(path: string): string {
|
||||
const normalized = normalizePath(path);
|
||||
if (normalized === '/') return './';
|
||||
return `./${normalized.slice(1)}`;
|
||||
}
|
||||
|
||||
function resolveIconUrl(rawIconName: string | null | undefined): string | null {
|
||||
if (!rawIconName) return null;
|
||||
const normalized = rawIconName.toLowerCase();
|
||||
const iconName = iconAliases.get(normalized) ?? normalized;
|
||||
return $iconPath(iconName, 'color');
|
||||
}
|
||||
|
||||
async function detectRuntimeOrFramework(path: string): Promise<string | null> {
|
||||
try {
|
||||
const content = await sdk
|
||||
if (iconCache.has(path)) {
|
||||
return iconCache.get(path) ?? null;
|
||||
}
|
||||
const detection = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.vcs.getRepositoryContents({
|
||||
.vcs.createRepositoryDetection({
|
||||
installationId: $installation.$id,
|
||||
providerRepositoryId: $repository.id,
|
||||
providerRootDirectory: currentPath,
|
||||
providerReference: branch
|
||||
type:
|
||||
product === 'sites' ? VCSDetectionType.Framework : VCSDetectionType.Runtime,
|
||||
providerRootDirectory: toProviderPath(path)
|
||||
});
|
||||
directories[0].fileCount = content.contents?.length ?? 0;
|
||||
directories[0].children = content.contents
|
||||
.filter((e) => e.isDirectory)
|
||||
.map((dir) => ({
|
||||
title: dir.name,
|
||||
fullPath: currentPath + dir.name,
|
||||
fileCount: undefined,
|
||||
thumbnailUrl: dir.name,
|
||||
loading: false
|
||||
}));
|
||||
currentDir = directories[0];
|
||||
isLoading = false;
|
||||
} catch {
|
||||
|
||||
const iconName =
|
||||
product === 'sites'
|
||||
? detection.framework
|
||||
: (detection as unknown as Models.DetectionRuntime).runtime;
|
||||
const resolved = resolveIconUrl(iconName);
|
||||
iconCache.set(path, resolved);
|
||||
return resolved;
|
||||
} catch (err) {
|
||||
iconCache.set(path, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function detectIconsForChildren(parentPath: string) {
|
||||
const targetDir = getDirByPath(parentPath);
|
||||
if (!targetDir?.children?.length) return;
|
||||
|
||||
const children = targetDir.children;
|
||||
const concurrency = 3;
|
||||
let index = 0;
|
||||
|
||||
async function worker() {
|
||||
while (index < children.length) {
|
||||
const current = index;
|
||||
index += 1;
|
||||
const child = children[current];
|
||||
const icon = await detectRuntimeOrFramework(child.fullPath);
|
||||
if (icon && icon !== child.thumbnailUrl) {
|
||||
child.thumbnailUrl = icon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, children.length) }, worker));
|
||||
}
|
||||
|
||||
async function fetchContents(path: string) {
|
||||
const cached = contentsCache.get(path);
|
||||
if (cached) return cached;
|
||||
|
||||
const content = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.vcs.getRepositoryContents({
|
||||
installationId: $installation.$id,
|
||||
providerRepositoryId: $repository.id,
|
||||
providerRootDirectory: toProviderPath(path),
|
||||
providerReference: branch
|
||||
});
|
||||
|
||||
const contents = content.contents ?? [];
|
||||
const fileCount = contents.length;
|
||||
const directories = contents
|
||||
.filter((e) => e.isDirectory)
|
||||
.map((dir) => ({ name: dir.name }));
|
||||
|
||||
const result = { fileCount, directories };
|
||||
contentsCache.set(path, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function ensureChildren(path: string, directories: Array<{ name: string }>) {
|
||||
const targetDir = getDirByPath(path);
|
||||
if (!targetDir) return;
|
||||
|
||||
if (directories.length === 0) {
|
||||
targetDir.hasChildren = false;
|
||||
targetDir.children = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const existingByTitle = new Map(
|
||||
(targetDir.children ?? []).map((child) => [child.title, child])
|
||||
);
|
||||
targetDir.children = directories.map((dir) => {
|
||||
const fullPath = path === '/' ? `/${dir.name}` : `${path}/${dir.name}`;
|
||||
const existing = existingByTitle.get(dir.name);
|
||||
if (existing) {
|
||||
existing.fullPath = fullPath;
|
||||
existing.hasChildren = true;
|
||||
existing.loading = existing.loading ?? false;
|
||||
existing.thumbnailUrl = existing.thumbnailUrl ?? $iconPath('empty', 'grayscale');
|
||||
existing.children = existing.children ?? [];
|
||||
return existing;
|
||||
}
|
||||
return {
|
||||
title: dir.name,
|
||||
fullPath,
|
||||
fileCount: undefined,
|
||||
thumbnailUrl: $iconPath('empty', 'grayscale'),
|
||||
children: [],
|
||||
hasChildren: true,
|
||||
loading: false
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function prefetchPath(path: string) {
|
||||
const normalized = normalizePath(path);
|
||||
const segments = normalized.split('/').filter((s) => s !== '');
|
||||
const pathsToLoad = ['/'];
|
||||
let currentPath = '/';
|
||||
|
||||
for (const segment of segments) {
|
||||
currentPath = currentPath === '/' ? `/${segment}` : `${currentPath}/${segment}`;
|
||||
pathsToLoad.push(currentPath);
|
||||
}
|
||||
|
||||
for (const pathToLoad of pathsToLoad) {
|
||||
const { fileCount, directories } = await fetchContents(pathToLoad);
|
||||
const targetDir = getDirByPath(pathToLoad);
|
||||
if (targetDir) {
|
||||
targetDir.fileCount = fileCount;
|
||||
}
|
||||
ensureChildren(pathToLoad, directories);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!isLoading) return;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const content = await fetchContents('/');
|
||||
|
||||
const repoTitle = $repository?.name
|
||||
? `${$repository.name} (root)`
|
||||
: 'Repository (root)';
|
||||
|
||||
directories[0] = {
|
||||
...directories[0],
|
||||
title: repoTitle,
|
||||
fileCount: content.fileCount
|
||||
};
|
||||
ensureChildren('/', content.directories);
|
||||
|
||||
const detectedIcon = await detectRuntimeOrFramework('/');
|
||||
if (detectedIcon) {
|
||||
directories[0].thumbnailUrl = detectedIcon;
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
expandedPaths = [...new Set([...expandedPaths, '/'])];
|
||||
prefetchPath(rootDir || '/');
|
||||
detectIconsForChildren('/');
|
||||
} catch (error) {
|
||||
console.error('Failed to load root directory:', error);
|
||||
isLoading = false;
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
function getDirByPath(path: string): Directory | null {
|
||||
const segments = path.split('/').filter((s) => s !== '');
|
||||
let node: Directory | null = directories[0] ?? null;
|
||||
for (const seg of segments) {
|
||||
const next = node?.children?.find((d) => d.title === seg) ?? null;
|
||||
if (!next) return null;
|
||||
node = next;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
async function loadPath(path: string) {
|
||||
// skip loading if this directory was done
|
||||
const targetDir = getDirByPath(path);
|
||||
if (!targetDir || targetDir.fileCount !== undefined) return;
|
||||
|
||||
if (!targetDir.children) {
|
||||
targetDir.children = [];
|
||||
}
|
||||
|
||||
if (inFlightPaths.has(path)) return;
|
||||
inFlightPaths.add(path);
|
||||
targetDir.loading = true;
|
||||
|
||||
try {
|
||||
const { fileCount, directories: contentDirectories } = await fetchContents(path);
|
||||
|
||||
if (contentDirectories.length === 0) {
|
||||
targetDir.hasChildren = false;
|
||||
targetDir.children = [];
|
||||
expandedPaths = [...new Set([...expandedPaths, path])];
|
||||
return;
|
||||
}
|
||||
|
||||
targetDir.fileCount = fileCount;
|
||||
|
||||
// set logo only for the current folder, not for the children
|
||||
const detectedIcon = await detectRuntimeOrFramework(path);
|
||||
if (detectedIcon) {
|
||||
targetDir.thumbnailUrl = detectedIcon;
|
||||
}
|
||||
|
||||
ensureChildren(path, contentDirectories);
|
||||
detectIconsForChildren(path);
|
||||
|
||||
expandedPaths = [...new Set([...expandedPaths, path])];
|
||||
} catch (error) {
|
||||
console.error('Failed to load directory:', error);
|
||||
} finally {
|
||||
targetDir.loading = false;
|
||||
inFlightPaths.delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
async function expandToPath(path: string) {
|
||||
const normalized = normalizePath(path);
|
||||
const segments = normalized.split('/').filter((s) => s !== '');
|
||||
|
||||
const pathsToExpand = ['/'];
|
||||
|
||||
let currentDir = directories[0];
|
||||
let walkPath = '/';
|
||||
|
||||
for (const segment of segments) {
|
||||
walkPath = walkPath === '/' ? `/${segment}` : `${walkPath}/${segment}`;
|
||||
pathsToExpand.push(walkPath);
|
||||
|
||||
if (!currentDir.children) {
|
||||
currentDir.children = [];
|
||||
}
|
||||
|
||||
let nextDir = currentDir.children.find((d) => d.title === segment);
|
||||
if (!nextDir) {
|
||||
nextDir = {
|
||||
title: segment,
|
||||
fullPath: walkPath,
|
||||
fileCount: undefined,
|
||||
thumbnailUrl: $iconPath('empty', 'grayscale'),
|
||||
children: [],
|
||||
hasChildren: true
|
||||
};
|
||||
currentDir.children = [...currentDir.children, nextDir];
|
||||
}
|
||||
|
||||
currentDir = nextDir;
|
||||
}
|
||||
|
||||
expandedPaths = [...new Set([...expandedPaths, ...pathsToExpand])];
|
||||
|
||||
// ensure each segment loads in order so deeper children appear
|
||||
for (const pathToLoad of pathsToExpand) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await loadPath(pathToLoad);
|
||||
}
|
||||
|
||||
currentPath = normalized;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (show && !initialized && !isLoading) {
|
||||
initialized = true;
|
||||
const normalized = normalizePath(rootDir || '/');
|
||||
initialPath = normalized;
|
||||
currentPath = normalized;
|
||||
expandToPath(normalized);
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchContents(e: CustomEvent) {
|
||||
const path = e.detail.fullPath as string;
|
||||
currentPath = path;
|
||||
|
||||
const pathSegments = path.split('/').filter((segment) => segment !== '.' && segment !== '');
|
||||
let traversedDir = directories[0]; // Start at root
|
||||
|
||||
for (const segment of pathSegments) {
|
||||
const nextDir = traversedDir.children?.find((dir) => dir.title === segment);
|
||||
if (!nextDir) break;
|
||||
traversedDir = nextDir;
|
||||
// reset state when modal closes
|
||||
$effect(() => {
|
||||
if (!show && initialized) {
|
||||
initialized = false;
|
||||
}
|
||||
});
|
||||
|
||||
currentDir = traversedDir;
|
||||
|
||||
if (!currentDir.fileCount) {
|
||||
currentDir.loading = true;
|
||||
directories = [...directories];
|
||||
|
||||
try {
|
||||
const content = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.vcs.getRepositoryContents({
|
||||
installationId: $installation.$id,
|
||||
providerRepositoryId: $repository.id,
|
||||
providerRootDirectory: path,
|
||||
providerReference: branch
|
||||
});
|
||||
|
||||
const fileCount = content.contents?.length ?? 0;
|
||||
const contentDirectories = content.contents.filter((e) => e.isDirectory);
|
||||
|
||||
if (contentDirectories.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentDir.fileCount = fileCount;
|
||||
currentDir.children = contentDirectories.map((dir) => ({
|
||||
title: dir.name,
|
||||
fullPath: path + '/' + dir.name,
|
||||
fileCount: undefined,
|
||||
thumbnailUrl: undefined
|
||||
}));
|
||||
const runtime = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.vcs.createRepositoryDetection({
|
||||
installationId: $installation.$id,
|
||||
providerRepositoryId: $repository.id,
|
||||
type:
|
||||
product === 'sites'
|
||||
? VCSDetectionType.Framework
|
||||
: VCSDetectionType.Runtime,
|
||||
providerRootDirectory: path
|
||||
});
|
||||
if (product === 'sites') {
|
||||
currentDir.children.forEach((dir) => {
|
||||
dir.thumbnailUrl = $iconPath(runtime.framework, 'color');
|
||||
});
|
||||
} else if (product === 'functions') {
|
||||
currentDir.children.forEach((dir) => {
|
||||
dir.thumbnailUrl = $iconPath(
|
||||
(runtime as unknown as Models.DetectionRuntime).runtime,
|
||||
'color'
|
||||
);
|
||||
});
|
||||
}
|
||||
directories = [...directories];
|
||||
$expanded = [...$expanded, path];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
currentDir.loading = false;
|
||||
}
|
||||
}
|
||||
function handleSelect(detail: { fullPath: string }) {
|
||||
loadPath(detail.fullPath);
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
@@ -155,13 +393,13 @@
|
||||
<DirectoryPicker
|
||||
{directories}
|
||||
{isLoading}
|
||||
bind:expanded
|
||||
selected={currentPath}
|
||||
openTo={currentPath}
|
||||
on:select={fetchContents} />
|
||||
expanded={expandedStore}
|
||||
bind:selected={currentPath}
|
||||
openTo={initialPath}
|
||||
onSelect={handleSelect} />
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Button secondary on:click={() => (show = false)}>Cancel</Button>
|
||||
<Button submit disabled={isLoading}>Save</Button>
|
||||
<Button submit disabled={isLoading || !hasChanges}>Save</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Icon } from '@appwrite.io/pink-svelte';
|
||||
|
||||
export type DirectoryEntry = {
|
||||
title: string;
|
||||
fullPath: string;
|
||||
fileCount?: number;
|
||||
thumbnailUrl?: string;
|
||||
thumbnailIcon?: typeof Icon;
|
||||
thumbnailHtml?: string;
|
||||
children?: DirectoryEntry[];
|
||||
hasChildren?: boolean;
|
||||
showThumbnail?: boolean;
|
||||
loading?: boolean;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { Button, InputText } from '$lib/elements/forms';
|
||||
import { getBasePlanFromGroup, getServiceLimit } from '$lib/stores/billing';
|
||||
import { Click, trackEvent } from '$lib/actions/analytics';
|
||||
import { Badge, Icon, Layout, Table, Typography, Tooltip } from '@appwrite.io/pink-svelte';
|
||||
@@ -8,78 +8,64 @@
|
||||
import { formatNumberWithCommas } from '$lib/helpers/numbers';
|
||||
import { Modal } from '$lib/components';
|
||||
import { Alert } from '@appwrite.io/pink-svelte';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
import { toLocaleDateTime } from '$lib/helpers/date';
|
||||
import { BillingPlanGroup, type Models } from '@appwrite.io/console';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
|
||||
// Props
|
||||
type Props = {
|
||||
let {
|
||||
members = [],
|
||||
storageUsage = 0,
|
||||
projects = $bindable([])
|
||||
}: {
|
||||
storageUsage?: number;
|
||||
projects?: Models.Project[];
|
||||
members?: Models.Membership[];
|
||||
organization: Models.Organization;
|
||||
};
|
||||
|
||||
const { projects = [], members = [], storageUsage = 0 }: Props = $props();
|
||||
} = $props();
|
||||
|
||||
let showSelectProject = $state(false);
|
||||
let selectedProjects = $state<string[]>([]);
|
||||
let error = $state<string | null>(null);
|
||||
let showSelectionReminder = $state(false);
|
||||
let confirmationInput = $state('');
|
||||
|
||||
let isDeletingProjects = $state(false);
|
||||
let selectedProjectsToDelete = $state<Array<string>>([]);
|
||||
const baseFreePlan = getBasePlanFromGroup(BillingPlanGroup.Starter);
|
||||
|
||||
// Derived state using runes
|
||||
let freePlanLimits = $derived({
|
||||
const freePlanLimits = $derived({
|
||||
projects: baseFreePlan?.projects,
|
||||
members: getServiceLimit('members', null, baseFreePlan),
|
||||
storage: getServiceLimit('storage', null, baseFreePlan)
|
||||
});
|
||||
|
||||
// When preparing to downgrade to Free, enforce Free plan limit locally (2)
|
||||
let allowedProjectsToKeep = $derived(freePlanLimits.projects);
|
||||
const allowedProjectsToKeep = $derived(freePlanLimits.projects);
|
||||
|
||||
let currentUsage = $derived({
|
||||
const currentUsage = $derived({
|
||||
projects: projects?.length || 0,
|
||||
members: members?.length || 0,
|
||||
storage: storageUsage || 0
|
||||
});
|
||||
|
||||
let storageUsageGB = $derived(storageUsage / (1024 * 1024 * 1024));
|
||||
const storageUsageGB = $derived(storageUsage / (1024 * 1024 * 1024));
|
||||
|
||||
let isLimitExceeded = $derived({
|
||||
const isLimitExceeded = $derived({
|
||||
projects: currentUsage.projects > freePlanLimits.projects,
|
||||
members: currentUsage.members > freePlanLimits.members,
|
||||
storage: storageUsageGB > freePlanLimits.storage
|
||||
});
|
||||
|
||||
let excessUsage = $derived({
|
||||
const excessUsage = $derived({
|
||||
projects: Math.max(0, currentUsage.projects),
|
||||
members: Math.max(0, currentUsage.members - freePlanLimits.members),
|
||||
storage: Math.max(0, storageUsageGB - freePlanLimits.storage)
|
||||
});
|
||||
|
||||
// projects that would be archived with the current selection
|
||||
let projectsToArchive = $derived(
|
||||
projects.filter((project) => !selectedProjects.includes(project.$id))
|
||||
);
|
||||
|
||||
function formatProjectsToArchive(): string {
|
||||
let result = '';
|
||||
projectsToArchive.forEach((project, index) => {
|
||||
const isLast = index === projectsToArchive.length - 1;
|
||||
const isSecondLast = index === projectsToArchive.length - 2;
|
||||
|
||||
result += `${index === 0 ? '' : ' '}${project.name}`;
|
||||
|
||||
if (!isLast) {
|
||||
if (isSecondLast) result += ' and';
|
||||
else result += ',';
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
const isConfirmationValid = $derived(confirmationInput.trim() === 'I understand');
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return formatNumberWithCommas(num);
|
||||
@@ -91,33 +77,68 @@
|
||||
trackEvent(Click.OrganizationClickUpgrade, { source: 'usage_limits_manage_projects' });
|
||||
}
|
||||
|
||||
// Expose validation for parent to call before submitting downgrade
|
||||
export function validateOrAlert(): boolean {
|
||||
const filteredSelection = selectedProjects.filter((id) =>
|
||||
projects.some((p) => p.$id === id)
|
||||
);
|
||||
const isValid = filteredSelection.length === allowedProjectsToKeep;
|
||||
showSelectionReminder = !isValid && isLimitExceeded.projects;
|
||||
return isValid;
|
||||
}
|
||||
|
||||
export function getSelectedProjects(): string[] {
|
||||
return selectedProjects.filter((id) => projects.some((p) => p.$id === id));
|
||||
}
|
||||
|
||||
function updateSelected() {
|
||||
async function deleteSelected() {
|
||||
error = null;
|
||||
const filteredSelection = selectedProjects.filter((id) =>
|
||||
projects.some((p) => p.$id === id)
|
||||
);
|
||||
if (filteredSelection.length !== allowedProjectsToKeep) {
|
||||
error = `You must select exactly ${allowedProjectsToKeep} projects to keep.`;
|
||||
isDeletingProjects = true;
|
||||
|
||||
const excessBy = isLimitExceeded.projects ? projects.length - allowedProjectsToKeep : 0;
|
||||
const isUnderLimitPostSelection = selectedProjectsToDelete.length >= excessBy;
|
||||
|
||||
if (!isUnderLimitPostSelection) {
|
||||
error = `You can keep a maximum ${allowedProjectsToKeep} projects on the selected plan.`;
|
||||
return;
|
||||
}
|
||||
// Keep selection locally; parent flow will apply after plan change
|
||||
showSelectProject = false;
|
||||
showSelectionReminder = false;
|
||||
addNotification({ type: 'success', message: `Projects selected for archiving` });
|
||||
|
||||
if (selectedProjectsToDelete?.length) {
|
||||
const projectsDeletionPromises = selectedProjectsToDelete.map((projectId) => ({
|
||||
projectId,
|
||||
promise: sdk.forConsole.projects.delete({ projectId })
|
||||
}));
|
||||
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
projectsDeletionPromises.map((p) => p.promise)
|
||||
);
|
||||
|
||||
const failed: string[] = [];
|
||||
const successfullyDeleted: string[] = [];
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const projectId = projectsDeletionPromises[index].projectId;
|
||||
if (result.status === 'fulfilled') {
|
||||
successfullyDeleted.push(projectId);
|
||||
} else {
|
||||
failed.push(projectId);
|
||||
}
|
||||
});
|
||||
|
||||
if (successfullyDeleted.length > 0) {
|
||||
await invalidate(Dependencies.ORGANIZATION);
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `${successfullyDeleted.length} project${successfullyDeleted.length !== 1 ? 's' : ''} deleted successfully`
|
||||
});
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
error = `Failed to delete ${failed.length} project${failed.length !== 1 ? 's' : ''}`;
|
||||
} else {
|
||||
confirmationInput = '';
|
||||
showSelectProject = false;
|
||||
selectedProjectsToDelete = [];
|
||||
showSelectionReminder = false;
|
||||
|
||||
if (successfullyDeleted.length > 0) {
|
||||
projects = projects.filter((p) => !successfullyDeleted.includes(p.$id));
|
||||
}
|
||||
}
|
||||
} catch (exception) {
|
||||
error = exception.message;
|
||||
} finally {
|
||||
isDeletingProjects = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -220,11 +241,7 @@
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{:else}
|
||||
<Typography.Text color="--fgcolor-neutral-secondary">
|
||||
{formatNumber(currentUsage.members)} / {formatNumber(
|
||||
freePlanLimits.members
|
||||
)}
|
||||
</Typography.Text>
|
||||
<Typography.Text color="--fgcolor-neutral-secondary">N/A</Typography.Text>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="manage" {root}></Table.Cell>
|
||||
@@ -259,54 +276,90 @@
|
||||
</Layout.Stack>
|
||||
|
||||
{#if showSelectProject}
|
||||
<Modal bind:show={showSelectProject} title="Manage projects" onSubmit={updateSelected}>
|
||||
<svelte:fragment slot="description">
|
||||
Choose which {freePlanLimits.projects} projects to keep. Projects over the limit will be
|
||||
blocked after your billing cycle ends on {toLocaleDate(
|
||||
$organization.billingNextInvoiceDate
|
||||
)}.
|
||||
</svelte:fragment>
|
||||
{@const requiredToDelete = currentUsage.projects - allowedProjectsToKeep}
|
||||
<Modal
|
||||
bind:show={showSelectProject}
|
||||
title="Delete projects to downgrade"
|
||||
onSubmit={deleteSelected}
|
||||
dismissible={false}>
|
||||
<Typography.Text slot="description">
|
||||
The Free plan lets you keep {allowedProjectsToKeep} projects. Select projects you want to
|
||||
permanently delete.
|
||||
</Typography.Text>
|
||||
|
||||
{#if error}
|
||||
<Alert.Inline status="error" title="Error">{error}</Alert.Inline>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<Table.Root
|
||||
let:root
|
||||
allowSelection
|
||||
bind:selectedRows={selectedProjects}
|
||||
columns={[{ id: 'name' }, { id: 'created' }]}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="name" {root}>Project Name</Table.Header.Cell>
|
||||
<Table.Header.Cell column="created" {root}>Created</Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
{#each projects as project}
|
||||
<Table.Row.Base {root} id={project.$id}>
|
||||
<Table.Cell column="name" {root}
|
||||
><Typography.Text truncate>{project.name}</Typography.Text></Table.Cell>
|
||||
<Table.Cell column="created" {root}>
|
||||
{toLocaleDateTime(project.$createdAt)}
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
</div>
|
||||
{#if selectedProjects.length === allowedProjectsToKeep}
|
||||
{@const difference = projects.length - selectedProjects.length}
|
||||
{@const messagePrefix =
|
||||
difference > 1 ? `${difference} projects` : `${difference} project`}
|
||||
{:else}
|
||||
<Alert.Inline
|
||||
status="warning"
|
||||
title={`${messagePrefix} will be archived on ${toLocaleDate($organization.billingNextInvoiceDate)}`}>
|
||||
{formatProjectsToArchive()} will be archived
|
||||
title="The selected projects will be permanently deleted">
|
||||
The selected projects and all associated data will be permanently deleted and cannot
|
||||
be recovered.
|
||||
</Alert.Inline>
|
||||
{/if}
|
||||
|
||||
<Layout.Stack gap="m">
|
||||
<Layout.Stack gap="s" direction="row">
|
||||
<Typography.Text
|
||||
>Select {requiredToDelete} project{requiredToDelete !== 1 ? 's' : ''} to delete</Typography.Text>
|
||||
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
content={`${selectedProjectsToDelete.length} selected`} />
|
||||
</Layout.Stack>
|
||||
|
||||
<div class="controlled-selection">
|
||||
<Table.Root
|
||||
let:root
|
||||
allowSelection
|
||||
bind:selectedRows={selectedProjectsToDelete}
|
||||
columns={[{ id: 'name' }, { id: 'created' }]}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="name" {root}>Project Name</Table.Header.Cell>
|
||||
<Table.Header.Cell column="created" {root}>Created</Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
{#each projects as project}
|
||||
{@const isRowSelected = selectedProjectsToDelete.includes(project.$id)}
|
||||
{@const shouldDisable =
|
||||
!isRowSelected && selectedProjectsToDelete.length >= requiredToDelete}
|
||||
<Table.Row.Base
|
||||
{root}
|
||||
id={project.$id}
|
||||
select={shouldDisable ? 'disabled' : undefined}>
|
||||
<Table.Cell column="name" {root}
|
||||
><Typography.Text truncate>{project.name}</Typography.Text
|
||||
></Table.Cell>
|
||||
<Table.Cell column="created" {root}>
|
||||
{toLocaleDateTime(project.$createdAt)}
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
</div>
|
||||
</Layout.Stack>
|
||||
|
||||
{#if selectedProjectsToDelete.length >= requiredToDelete}
|
||||
<Layout.Stack gap="xs">
|
||||
<InputText
|
||||
required
|
||||
id="confirmation"
|
||||
placeholder="I understand"
|
||||
disabled={isDeletingProjects}
|
||||
bind:value={confirmationInput}
|
||||
label={`Type "I understand" to confirm permanent deletion of the selected projects`} />
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Button secondary on:click={() => (showSelectProject = false)}>Cancel</Button>
|
||||
<Button submit disabled={selectedProjects.length !== allowedProjectsToKeep}
|
||||
>Save</Button>
|
||||
<Button
|
||||
submit
|
||||
danger
|
||||
submissionLoader
|
||||
forceShowLoader={isDeletingProjects}
|
||||
disabled={selectedProjectsToDelete.length < requiredToDelete ||
|
||||
!isConfirmationValid}>Delete projects</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
{/if}
|
||||
@@ -352,4 +405,14 @@
|
||||
min-width: 96px;
|
||||
}
|
||||
}
|
||||
|
||||
.controlled-selection :global([role='rowheader']) {
|
||||
pointer-events: none;
|
||||
|
||||
:global([role='cell']) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
color: var(--fgcolor-neutral-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
import { Uniform, MeshBasicMaterial, Vector2 } from 'three';
|
||||
import noise from './threlte/shaders/noise.glsl?raw';
|
||||
import { onMount } from 'svelte';
|
||||
import { useViewport } from '@threlte/extras';
|
||||
|
||||
const { invalidate } = useThrelte();
|
||||
const viewport = useViewport();
|
||||
const { invalidate, size } = useThrelte();
|
||||
|
||||
let uAspect: Uniform;
|
||||
let uOpacity: Uniform;
|
||||
@@ -14,8 +11,8 @@
|
||||
let uViewportSize: Uniform;
|
||||
|
||||
$effect(() => {
|
||||
const width = $viewport.width;
|
||||
const height = $viewport.height;
|
||||
const width = $size.width || 1;
|
||||
const height = $size.height || 1;
|
||||
if (uAspect) {
|
||||
uAspect.value = width / height;
|
||||
invalidate();
|
||||
@@ -30,10 +27,13 @@
|
||||
let material = $state<MeshBasicMaterial>(new MeshBasicMaterial());
|
||||
|
||||
onMount(() => {
|
||||
uAspect = new Uniform($viewport.width / $viewport.height);
|
||||
const width = $size.width || 1;
|
||||
const height = $size.height || 1;
|
||||
|
||||
uAspect = new Uniform(width / height);
|
||||
uOpacity = new Uniform(0);
|
||||
uTime = new Uniform(0);
|
||||
uViewportSize = new Uniform(new Vector2($viewport.width, $viewport.height));
|
||||
uViewportSize = new Uniform(new Vector2(width, height));
|
||||
|
||||
material.onBeforeCompile = (shader) => {
|
||||
shader.uniforms.uTime = uTime;
|
||||
@@ -104,6 +104,6 @@
|
||||
</script>
|
||||
|
||||
<T.OrthographicCamera position={[0, 0, 10]} fov={10} near={0.1} far={1000} makeDefault />
|
||||
<T.Mesh scale={[$viewport.width, $viewport.height, 1]} {material}>
|
||||
<T.Mesh scale={[$size.width || 1, $size.height || 1, 1]} {material}>
|
||||
<T.PlaneGeometry args={[1, 1]} />
|
||||
</T.Mesh>
|
||||
|
||||
+27
-12
@@ -9,12 +9,29 @@
|
||||
import { page } from '$app/state';
|
||||
import { IconPlus, IconX } from '@appwrite.io/pink-icons-svelte';
|
||||
|
||||
export let show = false;
|
||||
export let variables: Partial<Models.Variable>[];
|
||||
export type ProductLabel = 'site' | 'function';
|
||||
|
||||
let newVariables: Partial<Models.Variable>[] = [{ key: '', value: '' }];
|
||||
let secret = false;
|
||||
let error = '';
|
||||
let {
|
||||
show = $bindable(false),
|
||||
variables = $bindable(),
|
||||
productLabel = 'site'
|
||||
}: {
|
||||
show: boolean;
|
||||
variables: Partial<Models.Variable>[];
|
||||
productLabel?: ProductLabel;
|
||||
} = $props();
|
||||
|
||||
let newVariables = $state<Partial<Models.Variable>[]>([{ key: '', value: '' }]);
|
||||
let secret = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (!show) {
|
||||
newVariables = [{ key: '', value: '' }];
|
||||
secret = false;
|
||||
error = '';
|
||||
}
|
||||
});
|
||||
|
||||
function handleVariable() {
|
||||
try {
|
||||
@@ -54,19 +71,17 @@
|
||||
|
||||
function removeVariable(index: number) {
|
||||
if (newVariables.length === 1) {
|
||||
newVariables[0].key = '';
|
||||
newVariables[0].value = '';
|
||||
newVariables = [{ key: '', value: '' }];
|
||||
} else {
|
||||
newVariables.splice(index, 1);
|
||||
newVariables = [...newVariables];
|
||||
newVariables = newVariables.filter((_, i) => i !== index);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:show onSubmit={handleVariable} title="Create variables" bind:error>
|
||||
<span slot="description">
|
||||
Set the environment variables or secret that will be passed to your site. Global variables
|
||||
can be set in <Link
|
||||
Set the environment variables or secret that will be passed to your {productLabel}. Global
|
||||
variables can be set in <Link
|
||||
variant="muted"
|
||||
href={`${base}/project-${page.params.region}-${page.params.project}/settings`}
|
||||
>project settings</Link
|
||||
@@ -95,7 +110,7 @@
|
||||
type="button"
|
||||
size="s"
|
||||
disabled={newVariables.length === 1 && !pair.key && !pair.value}
|
||||
on:click={() => removeVariable(i)}>
|
||||
onclick={() => removeVariable(i)}>
|
||||
<Icon icon={IconX} />
|
||||
</PinkButton.Button>
|
||||
</Layout.Stack>
|
||||
@@ -0,0 +1,271 @@
|
||||
<script lang="ts">
|
||||
import { Empty, Paginator } from '$lib/components';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import {
|
||||
ActionMenu,
|
||||
Accordion,
|
||||
Badge,
|
||||
InteractiveText,
|
||||
Icon,
|
||||
Layout,
|
||||
Popover,
|
||||
Skeleton,
|
||||
Table,
|
||||
Tooltip,
|
||||
Button as PinkButton
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import {
|
||||
IconDotsHorizontal,
|
||||
IconCode,
|
||||
IconUpload,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
IconEyeOff,
|
||||
IconPencil
|
||||
} from '@appwrite.io/pink-icons-svelte';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import VariableEditorModal from './variableEditorModal.svelte';
|
||||
import SecretVariableModal from './secretVariableModal.svelte';
|
||||
import ImportVariablesModal from './importVariablesModal.svelte';
|
||||
import CreateVariableModal, { type ProductLabel } from './createVariableModal.svelte';
|
||||
import DeleteVariableModal from './deleteVariableModal.svelte';
|
||||
import UpdateVariableModal from './updateVariableModal.svelte';
|
||||
import { Click, trackEvent } from '$lib/actions/analytics';
|
||||
|
||||
const DOCS_LINKS: Record<ProductLabel, string> = {
|
||||
site: 'https://appwrite.io/docs/products/sites/develop#accessing-environment-variables',
|
||||
function: 'https://appwrite.io/docs/products/functions/develop#environment-variables'
|
||||
};
|
||||
|
||||
let {
|
||||
variables = $bindable([]),
|
||||
productLabel = 'site',
|
||||
analyticsSource = 'site_configuration',
|
||||
analyticsCreateSource = 'site_settings',
|
||||
isLoading = false
|
||||
}: {
|
||||
variables: Partial<Models.Variable>[];
|
||||
productLabel?: ProductLabel;
|
||||
analyticsSource?: string;
|
||||
analyticsCreateSource?: string;
|
||||
isLoading?: boolean;
|
||||
} = $props();
|
||||
|
||||
let showEditorModal = $state(false);
|
||||
let showImportModal = $state(false);
|
||||
let showSecretModal = $state(false);
|
||||
let showCreate = $state(false);
|
||||
let showUpdate = $state(false);
|
||||
let showDelete = $state(false);
|
||||
let currentVariable = $state<Partial<Models.Variable>>(undefined);
|
||||
|
||||
const createSource = $derived(analyticsCreateSource || analyticsSource);
|
||||
const docsLink = $derived(DOCS_LINKS[productLabel]);
|
||||
|
||||
const tableColumns = [
|
||||
{ id: 'key', width: { min: 300 } },
|
||||
{ id: 'value', width: { min: 280 } },
|
||||
{ id: 'actions', width: 40 }
|
||||
];
|
||||
</script>
|
||||
|
||||
<Accordion title="Environment variables" badge="Optional" hideDivider>
|
||||
<Layout.Stack gap="xl">
|
||||
Set up environment variables to securely manage keys and settings for your project.
|
||||
<Layout.Stack gap="l">
|
||||
<Layout.Stack direction="row">
|
||||
<Layout.Stack direction="row" gap="s">
|
||||
<Button
|
||||
secondary
|
||||
size="s"
|
||||
on:click={() => {
|
||||
showEditorModal = true;
|
||||
trackEvent(Click.VariablesUpdateClick, {
|
||||
source: analyticsSource
|
||||
});
|
||||
}}>
|
||||
<Icon slot="start" icon={IconCode} /> Editor
|
||||
</Button>
|
||||
<Button
|
||||
secondary
|
||||
size="s"
|
||||
on:click={() => {
|
||||
showImportModal = true;
|
||||
trackEvent(Click.VariablesImportClick, {
|
||||
source: analyticsSource
|
||||
});
|
||||
}}>
|
||||
<Icon slot="start" icon={IconUpload} /> Import .env
|
||||
</Button>
|
||||
</Layout.Stack>
|
||||
{#if variables?.length}
|
||||
<Button
|
||||
secondary
|
||||
size="s"
|
||||
on:click={() => {
|
||||
showCreate = true;
|
||||
trackEvent(Click.VariablesCreateClick, {
|
||||
source: createSource
|
||||
});
|
||||
}}>
|
||||
<Icon slot="start" icon={IconPlus} /> Create variable
|
||||
</Button>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
|
||||
{#if isLoading && !variables?.length}
|
||||
<Table.Root class="responsive-table" let:root columns={tableColumns}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="key" {root}>Key</Table.Header.Cell>
|
||||
<Table.Header.Cell column="value" {root}>Value</Table.Header.Cell>
|
||||
<Table.Header.Cell column="actions" {root}></Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
{#each Array(3) as _}
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell column="key" {root}>
|
||||
<Skeleton variant="line" width={120} height={14} />
|
||||
</Table.Cell>
|
||||
<Table.Cell column="value" {root}>
|
||||
<Skeleton variant="line" width="100%" height={14} />
|
||||
</Table.Cell>
|
||||
<Table.Cell column="actions" {root}>
|
||||
<Skeleton variant="line" width={24} height={14} />
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
{:else if variables?.length}
|
||||
<Paginator items={variables} limit={6} hideFooter={variables.length <= 6}>
|
||||
{#snippet children(paginatedItems)}
|
||||
<Table.Root let:root columns={tableColumns}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="key" {root}>Key</Table.Header.Cell>
|
||||
<Table.Header.Cell column="value" {root}>Value</Table.Header.Cell>
|
||||
<Table.Header.Cell column="actions" {root}></Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
{#each paginatedItems as variable}
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell column="key" {root}>{variable.key}</Table.Cell>
|
||||
<Table.Cell column="value" {root}>
|
||||
<!-- TODO: fix max width -->
|
||||
<div style="max-width: 100%">
|
||||
{#if variable.secret}
|
||||
<Tooltip maxWidth="26rem">
|
||||
<Badge
|
||||
content="Secret"
|
||||
variant="secondary"
|
||||
size="s" />
|
||||
<svelte:fragment slot="tooltip">
|
||||
This value is secret, you cannot see its
|
||||
value.
|
||||
</svelte:fragment>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
<InteractiveText
|
||||
variant="secret"
|
||||
isVisible={true}
|
||||
text={variable.value} />
|
||||
{/if}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="actions" {root}>
|
||||
<div style="margin-inline-start: auto">
|
||||
<Popover
|
||||
padding="none"
|
||||
placement="bottom-end"
|
||||
let:toggle>
|
||||
<PinkButton.Button
|
||||
icon
|
||||
variant="text"
|
||||
size="s"
|
||||
aria-label="More options"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
toggle(e);
|
||||
}}>
|
||||
<Icon icon={IconDotsHorizontal} size="s" />
|
||||
</PinkButton.Button>
|
||||
|
||||
<svelte:fragment slot="tooltip" let:toggle>
|
||||
<ActionMenu.Root>
|
||||
{#if !variable?.secret}
|
||||
<ActionMenu.Item.Button
|
||||
leadingIcon={IconPencil}
|
||||
onclick={(e) => {
|
||||
toggle(e);
|
||||
currentVariable = variable;
|
||||
showUpdate = true;
|
||||
}}>
|
||||
Update
|
||||
</ActionMenu.Item.Button>
|
||||
{/if}
|
||||
{#if !variable?.secret}
|
||||
<ActionMenu.Item.Button
|
||||
leadingIcon={IconEyeOff}
|
||||
onclick={(e) => {
|
||||
toggle(e);
|
||||
currentVariable = variable;
|
||||
showSecretModal = true;
|
||||
}}>
|
||||
Secret
|
||||
</ActionMenu.Item.Button>
|
||||
{/if}
|
||||
<ActionMenu.Item.Button
|
||||
status="danger"
|
||||
leadingIcon={IconTrash}
|
||||
onclick={(e) => {
|
||||
toggle(e);
|
||||
currentVariable = variable;
|
||||
showDelete = true;
|
||||
}}>
|
||||
Delete
|
||||
</ActionMenu.Item.Button>
|
||||
</ActionMenu.Root>
|
||||
</svelte:fragment>
|
||||
</Popover>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
{/snippet}
|
||||
</Paginator>
|
||||
{:else}
|
||||
<Empty
|
||||
on:click={() => {
|
||||
showCreate = true;
|
||||
trackEvent(Click.VariablesCreateClick, {
|
||||
source: createSource
|
||||
});
|
||||
}}>Create variables to get started</Empty>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
</Accordion>
|
||||
|
||||
{#if showEditorModal}
|
||||
<VariableEditorModal bind:variables bind:showEditor={showEditorModal} {docsLink} />
|
||||
{/if}
|
||||
|
||||
{#if showSecretModal}
|
||||
<SecretVariableModal bind:show={showSecretModal} bind:currentVariable bind:variables />
|
||||
{/if}
|
||||
|
||||
{#if showImportModal}
|
||||
<ImportVariablesModal bind:show={showImportModal} bind:variables />
|
||||
{/if}
|
||||
|
||||
{#if showCreate}
|
||||
<CreateVariableModal bind:show={showCreate} bind:variables {productLabel} />
|
||||
{/if}
|
||||
{#if showUpdate}
|
||||
<UpdateVariableModal
|
||||
bind:show={showUpdate}
|
||||
bind:variables
|
||||
bind:selectedVar={currentVariable}
|
||||
{productLabel} />
|
||||
{/if}
|
||||
|
||||
{#if showDelete}
|
||||
<DeleteVariableModal bind:show={showDelete} bind:variables bind:currentVariable />
|
||||
{/if}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as CreateVariableModal, type ProductLabel } from './createVariableModal.svelte';
|
||||
export { default as DeleteVariableModal } from './deleteVariableModal.svelte';
|
||||
export { default as EnvironmentVariables } from './environmentVariables.svelte';
|
||||
export { default as ImportVariablesModal } from './importVariablesModal.svelte';
|
||||
export { default as SecretVariableModal } from './secretVariableModal.svelte';
|
||||
export { default as UpdateVariableModal } from './updateVariableModal.svelte';
|
||||
export { default as VariableEditorModal } from './variableEditorModal.svelte';
|
||||
+3
-1
@@ -11,6 +11,7 @@
|
||||
export let show = false;
|
||||
export let selectedVar: Partial<Models.Variable>;
|
||||
export let variables: Partial<Models.Variable>[];
|
||||
export let productLabel = 'site';
|
||||
|
||||
let pair = {
|
||||
$id: selectedVar?.$id,
|
||||
@@ -40,7 +41,8 @@
|
||||
|
||||
<Modal bind:show onSubmit={handleVariable} title="Update variable">
|
||||
<span slot="description">
|
||||
Update the environment variable for your site. Global variables can be set in <Link
|
||||
Update the environment variable for your {productLabel}. Global variables can be set in
|
||||
<Link
|
||||
variant="muted"
|
||||
href={`${base}/project-${page.params.region}-${page.params.project}/settings`}
|
||||
>project settings</Link
|
||||
+3
-5
@@ -13,6 +13,8 @@
|
||||
|
||||
export let showEditor = false;
|
||||
export let variables: Partial<Models.Variable>[];
|
||||
export let docsLink =
|
||||
'https://appwrite.io/docs/products/sites/develop#accessing-environment-variables';
|
||||
|
||||
const editableVariables = variables.filter((variable) => !variable.secret);
|
||||
const secretVariables = variables.filter((variable) => variable.secret);
|
||||
@@ -122,11 +124,7 @@
|
||||
{#if secretVariables?.length > 0}
|
||||
<Alert.Inline status="info">
|
||||
{secretVariables.length} secret variables are hidden from the editor. Their values will
|
||||
remain unchanged. <Link
|
||||
href="https://appwrite.io/docs/products/sites/develop#accessing-environment-variables"
|
||||
external
|
||||
variant="muted">Learn more</Link
|
||||
>.
|
||||
remain unchanged. <Link href={docsLink} external variant="muted">Learn more</Link>.
|
||||
</Alert.Inline>
|
||||
{/if}
|
||||
<Layout.Stack gap="s">
|
||||
@@ -19,7 +19,17 @@
|
||||
export let leadingIcon: ComponentType | undefined = undefined;
|
||||
|
||||
let error: string;
|
||||
|
||||
// TODO: Remove this once Pink Svelte is fixed
|
||||
$: if (value !== null && typeof value === 'object' && 'target' in (value as object)) {
|
||||
const event = value as Event;
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target?.value !== undefined) {
|
||||
const parsedValue = target.value === '' ? null : Number(target.value);
|
||||
value = Number.isNaN(parsedValue) ? null : parsedValue;
|
||||
} else {
|
||||
value = null;
|
||||
}
|
||||
}
|
||||
const handleInvalid = (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
|
||||
event.preventDefault();
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
const SECRET = env.PUBLIC_CONSOLE_FINGERPRINT_KEY ?? '';
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
async function sha256(message: string): Promise<string> {
|
||||
if (!crypto?.subtle) {
|
||||
console.warn('crypto.subtle unavailable, fingerprinting disabled');
|
||||
return '';
|
||||
}
|
||||
const msgBuffer = new TextEncoder().encode(message);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function hmacSha256(message: string, secret: string): Promise<string> {
|
||||
if (!crypto?.subtle) return '';
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
|
||||
return Array.from(new Uint8Array(sig))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function getCanvasFingerprint(): string {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return '';
|
||||
|
||||
canvas.width = 200;
|
||||
canvas.height = 50;
|
||||
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.fillStyle = '#f60';
|
||||
ctx.fillRect(125, 1, 62, 20);
|
||||
ctx.fillStyle = '#069';
|
||||
ctx.fillText('Appwrite Console', 2, 15);
|
||||
ctx.fillStyle = 'rgba(102, 204, 0, 0.7)';
|
||||
ctx.fillText('Appwrite Console', 4, 17);
|
||||
|
||||
return canvas.toDataURL();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getWebGLFingerprint(): string {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl =
|
||||
canvas.getContext('webgl') ||
|
||||
(canvas.getContext('experimental-webgl') as WebGLRenderingContext | null);
|
||||
if (!gl) return '';
|
||||
|
||||
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
if (!debugInfo) return 'webgl-no-debug';
|
||||
|
||||
const vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) || '';
|
||||
const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) || '';
|
||||
|
||||
return `${vendor}~${renderer}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function getAudioFingerprint(): Promise<string> {
|
||||
try {
|
||||
const OfflineCtx =
|
||||
window.OfflineAudioContext ||
|
||||
(window as unknown as { webkitOfflineAudioContext: typeof OfflineAudioContext })
|
||||
.webkitOfflineAudioContext;
|
||||
if (!OfflineCtx) return '';
|
||||
|
||||
const sampleRate = 44100;
|
||||
const length = 4096;
|
||||
const context = new OfflineCtx(1, length, sampleRate);
|
||||
|
||||
const oscillator = context.createOscillator();
|
||||
oscillator.type = 'triangle';
|
||||
oscillator.frequency.value = 10000;
|
||||
|
||||
const compressor = context.createDynamicsCompressor();
|
||||
compressor.threshold.value = -50;
|
||||
compressor.knee.value = 40;
|
||||
compressor.ratio.value = 12;
|
||||
compressor.attack.value = 0;
|
||||
compressor.release.value = 0.25;
|
||||
|
||||
oscillator.connect(compressor);
|
||||
compressor.connect(context.destination);
|
||||
|
||||
oscillator.start(0);
|
||||
|
||||
const buffer = await context.startRendering();
|
||||
const samples = buffer.getChannelData(0);
|
||||
|
||||
let sum = 0;
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
sum += Math.abs(samples[i]);
|
||||
}
|
||||
|
||||
return sum.toString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
interface StaticSignals {
|
||||
userAgent: string;
|
||||
language: string;
|
||||
languages: string[];
|
||||
platform: string;
|
||||
hardwareConcurrency: number;
|
||||
deviceMemory: number | undefined;
|
||||
maxTouchPoints: number;
|
||||
screenWidth: number;
|
||||
screenHeight: number;
|
||||
screenColorDepth: number;
|
||||
screenPixelDepth: number;
|
||||
devicePixelRatio: number;
|
||||
timezoneOffset: number;
|
||||
timezone: string;
|
||||
canvas: string;
|
||||
webgl: string;
|
||||
audio: string;
|
||||
}
|
||||
|
||||
interface BrowserSignals extends StaticSignals {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface SignalsCache {
|
||||
signals: StaticSignals;
|
||||
collectedAt: number;
|
||||
}
|
||||
|
||||
let cache: SignalsCache | null = null;
|
||||
let cachePromise: Promise<StaticSignals> | null = null;
|
||||
|
||||
async function collectStaticSignals(): Promise<StaticSignals> {
|
||||
const [canvasRaw, webgl, audio] = await Promise.all([
|
||||
Promise.resolve(getCanvasFingerprint()),
|
||||
Promise.resolve(getWebGLFingerprint()),
|
||||
getAudioFingerprint()
|
||||
]);
|
||||
|
||||
const canvas = canvasRaw ? await sha256(canvasRaw) : '';
|
||||
|
||||
return {
|
||||
userAgent: navigator.userAgent,
|
||||
language: navigator.language,
|
||||
languages: [...(navigator.languages || [])],
|
||||
platform: navigator.platform,
|
||||
hardwareConcurrency: navigator.hardwareConcurrency || 0,
|
||||
deviceMemory: (navigator as Navigator & { deviceMemory?: number }).deviceMemory,
|
||||
maxTouchPoints: navigator.maxTouchPoints || 0,
|
||||
screenWidth: screen.width,
|
||||
screenHeight: screen.height,
|
||||
screenColorDepth: screen.colorDepth,
|
||||
screenPixelDepth: screen.pixelDepth,
|
||||
devicePixelRatio: window.devicePixelRatio || 1,
|
||||
timezoneOffset: new Date().getTimezoneOffset(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
canvas,
|
||||
webgl,
|
||||
audio
|
||||
};
|
||||
}
|
||||
|
||||
async function getCachedSignals(): Promise<StaticSignals> {
|
||||
const now = Date.now();
|
||||
|
||||
if (cache && now - cache.collectedAt < CACHE_TTL_MS) {
|
||||
return cache.signals;
|
||||
}
|
||||
|
||||
if (cachePromise) {
|
||||
return cachePromise;
|
||||
}
|
||||
|
||||
cachePromise = collectStaticSignals();
|
||||
|
||||
try {
|
||||
const signals = await cachePromise;
|
||||
cache = { signals, collectedAt: now };
|
||||
return signals;
|
||||
} finally {
|
||||
cachePromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateFingerprintToken(): Promise<string> {
|
||||
const staticSignals = await getCachedSignals();
|
||||
|
||||
const signals: BrowserSignals = {
|
||||
...staticSignals,
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
|
||||
const payload = JSON.stringify(signals);
|
||||
const bytes = new TextEncoder().encode(payload);
|
||||
let binary = '';
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
const encoded = btoa(binary);
|
||||
|
||||
if (!SECRET) {
|
||||
return encoded;
|
||||
}
|
||||
|
||||
const signature = await hmacSha256(encoded, SECRET);
|
||||
|
||||
return `${encoded}.${signature}`;
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
export type PrefRow = { key: string; value: string };
|
||||
|
||||
export function normalizePrefs(entries: [string, string][] | PrefRow[]): [string, string][] {
|
||||
function stringTrim(s: unknown): string {
|
||||
return String(s ?? '').trim();
|
||||
}
|
||||
|
||||
export function normalizePrefs(
|
||||
entries: [string, unknown][] | PrefRow[] | { key: unknown; value: unknown }[]
|
||||
): [string, string][] {
|
||||
return entries
|
||||
.map((item): [string, string] =>
|
||||
Array.isArray(item) ? [item[0], item[1]] : [item.key, item.value]
|
||||
Array.isArray(item)
|
||||
? [String(item[0] ?? ''), String(item[1] ?? '')]
|
||||
: [String(item.key ?? ''), String(item.value ?? '')]
|
||||
)
|
||||
|
||||
.filter(([k, v]) => k.trim() && v.trim())
|
||||
.filter(([k, v]) => stringTrim(k).length > 0 && stringTrim(v).length > 0)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
@@ -23,5 +30,5 @@ export function isAddDisabled(prefs: PrefRow[] | null): boolean {
|
||||
}
|
||||
|
||||
export function sanitizePrefs(prefs: PrefRow[]) {
|
||||
return prefs.filter((p) => p.key.trim() && p.value.trim());
|
||||
return prefs.filter((p) => stringTrim(p.key).length > 0 && stringTrim(p.value).length > 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
|
||||
export function normalizeDetectedVariables(
|
||||
detected: Models.DetectionVariable[] = []
|
||||
): Partial<Models.Variable>[] {
|
||||
const normalized: Partial<Models.Variable>[] = [];
|
||||
for (const variable of detected) {
|
||||
const key = variable.name?.trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
normalized.push({
|
||||
key,
|
||||
value: variable.value ?? '',
|
||||
secret: false
|
||||
});
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function mergeVariables(
|
||||
existing: Partial<Models.Variable>[],
|
||||
detected: Partial<Models.Variable>[]
|
||||
) {
|
||||
const map = new Map(existing.map((variable) => [variable.key, variable]));
|
||||
detected.forEach((variable) => {
|
||||
if (!variable.key) {
|
||||
return;
|
||||
}
|
||||
if (!map.has(variable.key)) {
|
||||
map.set(variable.key, variable);
|
||||
}
|
||||
});
|
||||
return Array.from(map.values());
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import { user } from './user';
|
||||
|
||||
import BudgetLimitAlert from '$routes/(console)/organization-[organization]/budgetLimitAlert.svelte';
|
||||
import TeamReadonlyAlert from '$routes/(console)/organization-[organization]/teamReadonlyAlert.svelte';
|
||||
import ProjectsLimit from '$lib/components/billing/alerts/projectsLimit.svelte';
|
||||
import EnterpriseTrial from '$routes/(console)/organization-[organization]/enterpriseTrial.svelte';
|
||||
|
||||
export const roles = [
|
||||
@@ -368,32 +367,6 @@ export function calculateTrialDay(org: Models.Organization) {
|
||||
return days;
|
||||
}
|
||||
|
||||
export async function checkForProjectsLimit(org: Models.Organization, orgProjectCount?: number) {
|
||||
if (!isCloud) return;
|
||||
if (!org) return;
|
||||
|
||||
const plan = await sdk.forConsole.organizations.getPlan({
|
||||
organizationId: org.$id
|
||||
});
|
||||
if (!plan) return;
|
||||
|
||||
if (!org.projects) return;
|
||||
if (org.projects.length > 0) return;
|
||||
|
||||
const projectCount = orgProjectCount;
|
||||
if (projectCount === undefined) return;
|
||||
|
||||
// not unlimited and current exceeds plan limits!
|
||||
if (plan.projects > 0 && projectCount > plan.projects) {
|
||||
headerAlert.add({
|
||||
id: 'projectsLimitReached',
|
||||
component: ProjectsLimit,
|
||||
show: true,
|
||||
importance: 12
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkForUsageLimit(organization: Models.Organization) {
|
||||
if (
|
||||
organization?.status === teamStatusReadonly &&
|
||||
|
||||
@@ -105,8 +105,6 @@ function createFeedbackStore() {
|
||||
return feedback;
|
||||
});
|
||||
},
|
||||
// TODO: update growth server to accept `billingPlan`.
|
||||
// TODO: update growth server to accept `source` key to know the feedback source area.
|
||||
submitFeedback: async (
|
||||
subject: string,
|
||||
message: string,
|
||||
@@ -122,18 +120,6 @@ function createFeedbackStore() {
|
||||
if (!VARS.GROWTH_ENDPOINT) return;
|
||||
trackEvent(Submit.FeedbackSubmit);
|
||||
|
||||
const customFields: Array<{ id: string; value: string | number }> = [
|
||||
{ id: '47364', value: currentPage }
|
||||
];
|
||||
|
||||
if (value) {
|
||||
customFields.push({ id: '40655', value });
|
||||
}
|
||||
|
||||
if (billingPlan) {
|
||||
customFields.push({ id: '56109', value: billingPlan });
|
||||
}
|
||||
|
||||
const response = await fetch(`${VARS.GROWTH_ENDPOINT}/feedback`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -143,9 +129,11 @@ function createFeedbackStore() {
|
||||
subject,
|
||||
message,
|
||||
email,
|
||||
customFields,
|
||||
firstname: (name || 'Unknown').slice(0, 40),
|
||||
metaFields: {
|
||||
currentPage,
|
||||
npsScore: value,
|
||||
billingPlan,
|
||||
source: get(feedback).source,
|
||||
orgId,
|
||||
projectId,
|
||||
|
||||
+46
-49
@@ -1,5 +1,10 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { Resources } from '@appwrite.io/console';
|
||||
import {
|
||||
AppwriteMigrationResource,
|
||||
FirebaseMigrationResource,
|
||||
NHostMigrationResource,
|
||||
SupabaseMigrationResource
|
||||
} from '@appwrite.io/console';
|
||||
import { includesAll } from '$lib/helpers/array';
|
||||
|
||||
const initialFormData = {
|
||||
@@ -51,66 +56,47 @@ export const ResourcesFriendly = {
|
||||
row: { singular: 'Row', plural: 'Rows' }
|
||||
};
|
||||
|
||||
// @todo: @itznotabug - check if other resources are correct and work fine!
|
||||
export const providerResources: Record<Provider, Resources[]> = {
|
||||
appwrite: Object.values(Resources),
|
||||
supabase: [
|
||||
Resources.User,
|
||||
Resources.Database,
|
||||
Resources.Collection,
|
||||
Resources.Attribute,
|
||||
Resources.Index,
|
||||
Resources.Document,
|
||||
Resources.Bucket,
|
||||
Resources.File
|
||||
],
|
||||
nhost: [
|
||||
Resources.User,
|
||||
Resources.Database,
|
||||
Resources.Collection,
|
||||
Resources.Attribute,
|
||||
Resources.Index,
|
||||
Resources.Document,
|
||||
Resources.Bucket,
|
||||
Resources.File
|
||||
],
|
||||
firebase: [
|
||||
Resources.User,
|
||||
Resources.Database,
|
||||
Resources.Collection,
|
||||
Resources.Attribute,
|
||||
Resources.Document,
|
||||
Resources.Bucket,
|
||||
Resources.File
|
||||
]
|
||||
export const MigrationResources = AppwriteMigrationResource;
|
||||
|
||||
export type MigrationResource =
|
||||
| AppwriteMigrationResource
|
||||
| FirebaseMigrationResource
|
||||
| SupabaseMigrationResource
|
||||
| NHostMigrationResource;
|
||||
|
||||
export const providerResources: Record<Provider, MigrationResource[]> = {
|
||||
appwrite: Object.values(AppwriteMigrationResource),
|
||||
supabase: Object.values(SupabaseMigrationResource),
|
||||
nhost: Object.values(NHostMigrationResource),
|
||||
firebase: Object.values(FirebaseMigrationResource)
|
||||
};
|
||||
|
||||
export const migrationFormToResources = (
|
||||
formData: MigrationFormData,
|
||||
provider: Provider
|
||||
): Resources[] => {
|
||||
const resources: Resources[] = [];
|
||||
const addResource = (resource: Resources) => {
|
||||
): MigrationResource[] => {
|
||||
const resources: MigrationResource[] = [];
|
||||
const addResource = (resource: MigrationResource) => {
|
||||
if (providerResources[provider].includes(resource)) {
|
||||
resources.push(resource);
|
||||
}
|
||||
};
|
||||
|
||||
if (formData.users.root) {
|
||||
addResource(Resources.User);
|
||||
addResource(MigrationResources.User);
|
||||
}
|
||||
if (formData.databases.root) {
|
||||
addResource(Resources.Database);
|
||||
addResource(Resources.Table);
|
||||
addResource(Resources.Column);
|
||||
addResource(Resources.Index);
|
||||
addResource(MigrationResources.Database);
|
||||
addResource(MigrationResources.Table);
|
||||
addResource(MigrationResources.Column);
|
||||
addResource(MigrationResources.Index);
|
||||
}
|
||||
if (formData.databases.rows) {
|
||||
addResource(Resources.Row);
|
||||
addResource(MigrationResources.Row);
|
||||
}
|
||||
if (formData.storage.root) {
|
||||
addResource(Resources.Bucket);
|
||||
addResource(Resources.File);
|
||||
addResource(MigrationResources.Bucket);
|
||||
addResource(MigrationResources.File);
|
||||
}
|
||||
|
||||
return resources;
|
||||
@@ -137,18 +123,29 @@ export const isVersionAtLeast = (version: string, atLeast: string) => {
|
||||
return compareVersions(version, atLeast) >= 0;
|
||||
};
|
||||
|
||||
export const resourcesToMigrationForm = (resources: Resources[]): MigrationFormData => {
|
||||
export const resourcesToMigrationForm = (resources: MigrationResource[]): MigrationFormData => {
|
||||
const formData = { ...initialFormData };
|
||||
if (resources.includes(Resources.User)) {
|
||||
if (resources.includes(MigrationResources.User)) {
|
||||
formData.users.root = true;
|
||||
}
|
||||
if (resources.includes(Resources.Database)) {
|
||||
if (resources.includes(MigrationResources.Database)) {
|
||||
formData.databases.root = true;
|
||||
}
|
||||
if (includesAll(resources, [Resources.Table, Resources.Column, Resources.Row] as Resources[])) {
|
||||
if (
|
||||
includesAll(resources, [
|
||||
MigrationResources.Table,
|
||||
MigrationResources.Column,
|
||||
MigrationResources.Row
|
||||
] as MigrationResource[])
|
||||
) {
|
||||
formData.databases.rows = true;
|
||||
}
|
||||
if (includesAll(resources, [Resources.Bucket, Resources.File] as Resources[])) {
|
||||
if (
|
||||
includesAll(resources, [
|
||||
MigrationResources.Bucket,
|
||||
MigrationResources.File
|
||||
] as MigrationResource[])
|
||||
) {
|
||||
formData.storage.root = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,5 +53,5 @@
|
||||
The deployment for pull request #{data.providerPullRequestId}
|
||||
is awaiting approval. When authorized, deployments will be started.
|
||||
</Typography.Title>
|
||||
<Button on:click={approveDeployment} secondary>Approve Deployment</Button>
|
||||
<Button on:click={approveDeployment} secondary disabled={loading}>Approve Deployment</Button>
|
||||
</Layout.Stack>
|
||||
|
||||
@@ -7,11 +7,18 @@
|
||||
createMigrationProviderStore,
|
||||
type MigrationFormData,
|
||||
providerResources,
|
||||
resourcesToMigrationForm
|
||||
resourcesToMigrationForm,
|
||||
MigrationResources
|
||||
} from '$lib/stores/migration';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
import { Resources, type Models } from '@appwrite.io/console';
|
||||
import {
|
||||
type Models,
|
||||
AppwriteMigrationResource,
|
||||
FirebaseMigrationResource,
|
||||
NHostMigrationResource,
|
||||
SupabaseMigrationResource
|
||||
} from '@appwrite.io/console';
|
||||
import type { sdk } from '$lib/stores/sdk';
|
||||
import ImportReport from '$routes/(console)/project-[region]-[project]/settings/migrations/(import)/importReport.svelte';
|
||||
|
||||
@@ -46,7 +53,7 @@
|
||||
switch ($provider.provider) {
|
||||
case 'appwrite':
|
||||
report = await projectSdk.migrations.getAppwriteReport({
|
||||
resources: providerResources.appwrite,
|
||||
resources: providerResources.appwrite as AppwriteMigrationResource[],
|
||||
endpoint: $provider.endpoint,
|
||||
projectID: $provider.projectID,
|
||||
key: $provider.apiKey
|
||||
@@ -54,7 +61,7 @@
|
||||
break;
|
||||
case 'supabase':
|
||||
report = await projectSdk.migrations.getSupabaseReport({
|
||||
resources: providerResources.supabase,
|
||||
resources: providerResources.supabase as SupabaseMigrationResource[],
|
||||
endpoint: $provider.endpoint,
|
||||
apiKey: $provider.apiKey,
|
||||
databaseHost: $provider.host,
|
||||
@@ -65,13 +72,13 @@
|
||||
break;
|
||||
case 'firebase':
|
||||
report = await projectSdk.migrations.getFirebaseReport({
|
||||
resources: providerResources.firebase,
|
||||
resources: providerResources.firebase as FirebaseMigrationResource[],
|
||||
serviceAccount: $provider.serviceAccount
|
||||
});
|
||||
break;
|
||||
case 'nhost':
|
||||
report = await projectSdk.migrations.getNHostReport({
|
||||
resources: providerResources.nhost,
|
||||
resources: providerResources.nhost as NHostMigrationResource[],
|
||||
subdomain: $provider.subdomain,
|
||||
region: $provider.region,
|
||||
adminSecret: $provider.adminSecret,
|
||||
@@ -100,13 +107,19 @@
|
||||
}
|
||||
|
||||
if (groupKey === 'storage') {
|
||||
return resources.includes(Resources.Bucket) && resources.includes(Resources.File);
|
||||
return (
|
||||
resources.includes(MigrationResources.Bucket) &&
|
||||
resources.includes(MigrationResources.File)
|
||||
);
|
||||
}
|
||||
|
||||
// Map groupKey to Resources enum
|
||||
const groupToResource: Record<string, Resources> = {
|
||||
users: Resources.User,
|
||||
databases: Resources.Database
|
||||
// Map groupKey to MigrationResources enum
|
||||
const groupToResource: Record<
|
||||
string,
|
||||
(typeof MigrationResources)[keyof typeof MigrationResources]
|
||||
> = {
|
||||
users: MigrationResources.User,
|
||||
databases: MigrationResources.Database
|
||||
};
|
||||
const resource = groupToResource[groupKey];
|
||||
return resource ? resources.includes(resource) : false;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import ResourceForm from './resource-form.svelte';
|
||||
import { requestedMigration } from '$routes/store';
|
||||
import { formData, provider, selectedProject, selectedRegion } from '.';
|
||||
import { ID, type Models, Query } from '@appwrite.io/console';
|
||||
import { ID, type Models, Query, AppwriteMigrationResource } from '@appwrite.io/console';
|
||||
import { InputSelect, InputText } from '$lib/elements/forms';
|
||||
import {
|
||||
Button,
|
||||
@@ -114,7 +114,7 @@
|
||||
|
||||
try {
|
||||
await projectSdkInstance.migrations.createAppwriteMigration({
|
||||
resources,
|
||||
resources: resources as AppwriteMigrationResource[],
|
||||
endpoint: $provider.endpoint,
|
||||
projectId: $provider.projectID,
|
||||
apiKey: $provider.apiKey
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
checkForMarkedForDeletion,
|
||||
checkForMissingPaymentMethod,
|
||||
checkForNewDevUpgradePro,
|
||||
checkForProjectsLimit,
|
||||
checkForUsageLimit,
|
||||
checkPaymentAuthorizationRequired,
|
||||
paymentExpired,
|
||||
@@ -39,7 +38,7 @@
|
||||
import { showSupportModal } from './wizard/support/store';
|
||||
import { activeHeaderAlert, consoleVariables } from './store';
|
||||
|
||||
import { base } from '$app/paths';
|
||||
import { base, resolve } from '$app/paths';
|
||||
import { headerAlert } from '$lib/stores/headerAlert';
|
||||
import { UsageRates } from '$lib/components/billing';
|
||||
import { canSeeProjects } from '$lib/stores/roles';
|
||||
@@ -54,11 +53,8 @@
|
||||
IconSparkles,
|
||||
IconSwitchHorizontal
|
||||
} from '@appwrite.io/pink-icons-svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
|
||||
export let data: LayoutData;
|
||||
|
||||
function kebabToSentenceCase(str: string) {
|
||||
return str
|
||||
.split('-')
|
||||
@@ -75,9 +71,7 @@
|
||||
$: $registerCommands([
|
||||
{
|
||||
label: 'Go to Projects',
|
||||
callback: () => {
|
||||
goto(base);
|
||||
},
|
||||
callback: () => goto(resolve('/')),
|
||||
keys: ['g', 'p'],
|
||||
group: 'navigation',
|
||||
disabled:
|
||||
@@ -296,9 +290,6 @@
|
||||
if (currentOrganizationId === org.$id) return;
|
||||
if (isCloud) {
|
||||
currentOrganizationId = org.$id;
|
||||
const orgProjectCount =
|
||||
data.currentOrgId === org.$id ? data.allProjectsCount : undefined;
|
||||
await checkForProjectsLimit(org, orgProjectCount);
|
||||
checkForEnterpriseTrial(org);
|
||||
await checkForUsageLimit(org);
|
||||
checkForMarkedForDeletion(org);
|
||||
|
||||
@@ -316,7 +316,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- show invite members -->
|
||||
{#if selectedOrgId && !selectedOrg?.billingPlanDetails.addons.seats.supported}
|
||||
{#if selectedOrgId && !(selectedOrg?.billingPlanDetails?.addons?.seats?.supported ?? true)}
|
||||
{#if selectedOrgId === newOrgId}
|
||||
<InputText
|
||||
label="Organization name"
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import { GRACE_PERIOD_OVERRIDE, isCloud } from '$lib/system';
|
||||
import { page } from '$app/state';
|
||||
import { registerCommands } from '$lib/commandCenter';
|
||||
import { formatName as formatNameHelper } from '$lib/helpers/string';
|
||||
import {
|
||||
CardContainer,
|
||||
Empty,
|
||||
@@ -24,11 +23,11 @@
|
||||
import { canWriteProjects } from '$lib/stores/roles';
|
||||
import { checkPricingRefAndRedirect } from '$lib/helpers/pricingRedirect';
|
||||
import { Alert, Badge, Icon, Layout, Tag, Tooltip, Typography } from '@appwrite.io/pink-svelte';
|
||||
import { isSmallViewport } from '$lib/stores/viewport';
|
||||
import {
|
||||
IconAndroid,
|
||||
IconApple,
|
||||
IconCode,
|
||||
IconExclamationCircle,
|
||||
IconFlutter,
|
||||
IconPlus,
|
||||
IconReact,
|
||||
@@ -38,14 +37,11 @@
|
||||
import { getPlatformInfo } from '$lib/helpers/platform';
|
||||
import CreateProjectCloud from './createProjectCloud.svelte';
|
||||
import { regions as regionsStore } from '$lib/stores/organization';
|
||||
import SelectProjectCloud from '$lib/components/billing/alerts/selectProjectCloud.svelte';
|
||||
import ArchiveProject from '$lib/components/archiveProject.svelte';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
let showCreate = $state(false);
|
||||
let addOrganization = $state(false);
|
||||
let showSelectProject = $state(false);
|
||||
let showCreateProjectCloud = $state(false);
|
||||
let freePlanAlertDismissed = $state(false);
|
||||
|
||||
@@ -123,24 +119,7 @@
|
||||
return $regionsStore.regions.find((region) => region.$id === project.region);
|
||||
}
|
||||
|
||||
function isSetToArchive(project: Models.Project): boolean {
|
||||
if (!isCloud) return false;
|
||||
if (!project || !project.$id) return false;
|
||||
return project.status === 'archived';
|
||||
}
|
||||
|
||||
const projectsToArchive = $derived(
|
||||
(data.archivedProjectsPage ?? data.projects.projects).filter(
|
||||
(project) => project.status === 'archived'
|
||||
)
|
||||
);
|
||||
|
||||
const activeTotalOverall = $derived(
|
||||
data?.activeTotalOverall ??
|
||||
data?.organization?.projects?.length ??
|
||||
data?.projects?.total ??
|
||||
0
|
||||
);
|
||||
const activeProjectsTotal = $derived(data?.projects.total);
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery?.clearInput();
|
||||
@@ -162,11 +141,6 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<SelectProjectCloud
|
||||
bind:showSelectProject
|
||||
organizationId={page.params.organization}
|
||||
selectedProjects={data.organization.projects || []} />
|
||||
|
||||
<Container>
|
||||
<Layout.Stack direction="row" justifyContent="space-between" class="common-section">
|
||||
<SearchQuery bind:this={searchQuery} placeholder="Search by name, label, or ID" />
|
||||
@@ -196,34 +170,11 @@
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
|
||||
{#if isCloud && data.currentPlan?.projects && data.currentPlan?.projects > 0 && data.organization.projects.length > 0 && $canWriteProjects && (projectsToArchive.length > 0 || data.projects.total > data.currentPlan.projects)}
|
||||
{@const difference = projectsToArchive.length}
|
||||
{@const messagePrefix =
|
||||
difference !== 1 ? `${difference} projects are` : `${difference} project is`}
|
||||
<Alert.Inline title={`${messagePrefix} archived`}>
|
||||
<Typography.Text>Upgrade your plan to restore archived projects</Typography.Text>
|
||||
<svelte:fragment slot="actions">
|
||||
<Button
|
||||
compact
|
||||
size="s"
|
||||
href={getChangePlanUrl(data.organization.$id)}
|
||||
on:click={() => {
|
||||
trackEvent(Click.OrganizationClickUpgrade, {
|
||||
from: 'button',
|
||||
source: 'projects_archive_alert'
|
||||
});
|
||||
}}>
|
||||
Upgrade to Pro
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</Alert.Inline>
|
||||
{/if}
|
||||
|
||||
{#if isCloud && data.currentPlan?.projects !== 0 && projectsToArchive.length === 0 && !freePlanAlertDismissed}
|
||||
{#if isCloud && !data.program && data.currentPlan?.projects && activeProjectsTotal <= data.currentPlan.projects && !freePlanAlertDismissed}
|
||||
<Alert.Inline dismissible on:dismiss={dismissFreePlanAlert}>
|
||||
<Typography.Text
|
||||
>Your Free plan includes up to 2 projects and limited resources. Upgrade to unlock
|
||||
more capacity and features.</Typography.Text>
|
||||
>Your Free plan includes up to {data.currentPlan?.projects} projects and limited resources.
|
||||
Upgrade to unlock more capacity and features.</Typography.Text>
|
||||
<svelte:fragment slot="actions">
|
||||
<Button
|
||||
compact
|
||||
@@ -244,42 +195,28 @@
|
||||
{#if data.projects.total > 0}
|
||||
<CardContainer
|
||||
disableEmpty={!$canWriteProjects}
|
||||
total={activeTotalOverall}
|
||||
total={activeProjectsTotal}
|
||||
offset={data.offset}
|
||||
on:click={handleCreateProject}>
|
||||
{#each data.projects.projects as project}
|
||||
{@const platforms = filterPlatforms(
|
||||
project.platforms.map((platform) => getPlatformInfo(platform.type))
|
||||
)}
|
||||
{@const formatted = isSetToArchive(project)
|
||||
? formatNameHelper(project.name, isSmallViewport ? 19 : 25)
|
||||
: project.name}
|
||||
<GridItem1
|
||||
href={`${base}/project-${project.region}-${project.$id}/overview/platforms`}>
|
||||
<svelte:fragment slot="eyebrow">
|
||||
{project?.platforms?.length ? project?.platforms?.length : 'No'} apps
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="title">
|
||||
<Tooltip
|
||||
maxWidth={project.name.length.toString()}
|
||||
placement="top"
|
||||
disabled={!isSetToArchive(project)}>
|
||||
{formatted}
|
||||
<span slot="tooltip">
|
||||
{project.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{project.name}
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="status">
|
||||
{#if isSetToArchive(project)}
|
||||
<Tag
|
||||
size="s"
|
||||
style="white-space: nowrap;"
|
||||
on:click={(event) => {
|
||||
event.preventDefault();
|
||||
showSelectProject = true;
|
||||
}}>Set to archive</Tag>
|
||||
{#if project.status === 'paused'}
|
||||
<Tag size="s" style="white-space: nowrap;">
|
||||
<Icon icon={IconExclamationCircle} size="s" slot="start" />
|
||||
Paused
|
||||
</Tag>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
@@ -329,16 +266,7 @@
|
||||
name="Projects"
|
||||
limit={data.limit}
|
||||
offset={data.offset}
|
||||
total={activeTotalOverall} />
|
||||
|
||||
<!-- Archived Projects Section -->
|
||||
<ArchiveProject
|
||||
{projectsToArchive}
|
||||
organization={data.organization}
|
||||
currentPlan={data.currentPlan}
|
||||
archivedTotalOverall={data.archivedTotalOverall}
|
||||
archivedOffset={data.archivedOffset}
|
||||
limit={data.limit} />
|
||||
total={activeProjectsTotal} />
|
||||
</Container>
|
||||
<CreateOrganization bind:show={addOrganization} />
|
||||
<CreateProject bind:show={showCreate} teamId={page.params.organization} />
|
||||
|
||||
@@ -5,12 +5,17 @@ import { getLimit, getPage, getSearch, pageToOffset } from '$lib/helpers/load';
|
||||
import { CARD_LIMIT, Dependencies } from '$lib/constants';
|
||||
import type { PageLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { base } from '$app/paths';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
export const load: PageLoad = async ({ params, url, route, depends, parent }) => {
|
||||
const { scopes } = await parent();
|
||||
if (!scopes.includes('projects.read') && scopes.includes('billing.read')) {
|
||||
return redirect(301, `${base}/organization-${params.organization}/billing`);
|
||||
return redirect(
|
||||
301,
|
||||
resolve('/(console)/organization-[organization]/billing', {
|
||||
organization: params.organization
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
depends(Dependencies.ORGANIZATION);
|
||||
@@ -20,76 +25,33 @@ export const load: PageLoad = async ({ params, url, route, depends, parent }) =>
|
||||
const offset = pageToOffset(page, limit);
|
||||
const search = getSearch(url);
|
||||
|
||||
const archivedPageRaw = parseInt(url.searchParams.get('archivedPage') || '1', 10);
|
||||
const archivedPage =
|
||||
Number.isFinite(archivedPageRaw) && archivedPageRaw > 0 ? archivedPageRaw : 1;
|
||||
const archivedOffset = pageToOffset(archivedPage, limit);
|
||||
|
||||
const searchQueries = search
|
||||
? [Query.or([Query.search('search', search), Query.contains('labels', search)])]
|
||||
: [];
|
||||
const commonQueries = [Query.equal('teamId', params.organization)];
|
||||
const activeQueries = isCloud
|
||||
? [Query.or([Query.equal('status', 'active'), Query.isNull('status')])]
|
||||
? [Query.or([Query.equal('status', ['active', 'paused']), Query.isNull('status')])]
|
||||
: [];
|
||||
|
||||
const [activeProjects, archivedProjects, activeTotal, archivedTotal] = await Promise.all([
|
||||
sdk.forConsole.projects.list({
|
||||
queries: [
|
||||
Query.offset(offset),
|
||||
Query.limit(limit),
|
||||
Query.orderDesc(''),
|
||||
...commonQueries,
|
||||
...searchQueries,
|
||||
...activeQueries
|
||||
]
|
||||
}),
|
||||
isCloud
|
||||
? sdk.forConsole.projects.list({
|
||||
queries: [
|
||||
Query.offset(archivedOffset),
|
||||
Query.limit(limit),
|
||||
Query.orderDesc(''),
|
||||
...commonQueries,
|
||||
...searchQueries,
|
||||
Query.equal('status', 'archived')
|
||||
]
|
||||
})
|
||||
: Promise.resolve({ projects: [], total: 0 }),
|
||||
sdk.forConsole.projects.list({
|
||||
queries: [...commonQueries, ...activeQueries, ...searchQueries]
|
||||
}),
|
||||
isCloud
|
||||
? sdk.forConsole.projects.list({
|
||||
queries: [...commonQueries, ...searchQueries, Query.equal('status', 'archived')]
|
||||
})
|
||||
: Promise.resolve({ projects: [], total: 0 })
|
||||
]);
|
||||
const activeProjects = await sdk.forConsole.projects.list({
|
||||
queries: [
|
||||
...searchQueries,
|
||||
...activeQueries,
|
||||
Query.offset(offset),
|
||||
Query.limit(limit),
|
||||
Query.orderDesc(''),
|
||||
Query.equal('teamId', params.organization)
|
||||
]
|
||||
});
|
||||
|
||||
// set `default` if no region!
|
||||
for (const project of activeProjects.projects) {
|
||||
project.region ??= 'default';
|
||||
}
|
||||
if (isCloud) {
|
||||
for (const project of archivedProjects.projects) {
|
||||
project.region ??= 'default';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
offset,
|
||||
limit,
|
||||
projects: {
|
||||
...activeProjects,
|
||||
projects: activeProjects.projects,
|
||||
total: activeTotal.total
|
||||
},
|
||||
activeProjectsPage: activeProjects.projects,
|
||||
archivedProjectsPage: archivedProjects.projects,
|
||||
activeTotalOverall: activeTotal.total,
|
||||
archivedTotalOverall: archivedTotal.total,
|
||||
archivedOffset,
|
||||
archivedPage,
|
||||
search
|
||||
offset,
|
||||
search,
|
||||
projects: activeProjects
|
||||
};
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
let showUpdateState = $state(false);
|
||||
let isSelectedBackup = $state(false);
|
||||
let paymentMethodNeedingState: Models.PaymentMethod | null = $state(null);
|
||||
let dismissedPaymentMethodIds = $state<string[]>([]);
|
||||
|
||||
const hasPaymentError = $derived.by(() => {
|
||||
return (
|
||||
@@ -117,7 +118,8 @@
|
||||
(method: Models.PaymentMethod) =>
|
||||
method?.country?.toLowerCase() === 'us' &&
|
||||
(!method.state || method.state.trim() === '') &&
|
||||
!!method.last4
|
||||
!!method.last4 &&
|
||||
!dismissedPaymentMethodIds.includes(method.$id)
|
||||
);
|
||||
|
||||
if (usMethodWithoutState) {
|
||||
@@ -129,6 +131,10 @@
|
||||
|
||||
$effect(() => {
|
||||
if (!showUpdateState && paymentMethodNeedingState) {
|
||||
dismissedPaymentMethodIds = [
|
||||
...dismissedPaymentMethodIds,
|
||||
paymentMethodNeedingState.$id
|
||||
];
|
||||
paymentMethodNeedingState = null;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
Icon,
|
||||
Layout,
|
||||
Divider,
|
||||
Badge
|
||||
Badge,
|
||||
Tooltip
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import { humanFileSize } from '$lib/helpers/sizeConvertion';
|
||||
import { formatNum } from '$lib/helpers/string';
|
||||
@@ -72,6 +73,7 @@
|
||||
const baseAmount = $derived(currentAggregation?.amount ?? currentPlan?.price ?? 0);
|
||||
const creditsApplied = $derived(Math.min(baseAmount, availableCredit ?? 0));
|
||||
const totalAmount = $derived(Math.max(baseAmount - creditsApplied, 0));
|
||||
const isUpgrading = $derived($organization?.status === 'upgrading');
|
||||
|
||||
function formatHumanSize(bytes: number): string {
|
||||
const size = humanFileSize(bytes || 0);
|
||||
@@ -588,62 +590,61 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="actions-container">
|
||||
{#if !currentPlan.requiresPaymentMethod}
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="flex-end"
|
||||
gap="s"
|
||||
wrap="wrap"
|
||||
class="u-width-full-line actions-mobile">
|
||||
{#if !currentPlan?.usagePerProject}
|
||||
<Button text href={`${base}/organization-${$organization?.$id}/usage`}>
|
||||
View estimated usage
|
||||
</Button>
|
||||
{/if}
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="flex-end"
|
||||
gap="s"
|
||||
wrap="wrap"
|
||||
class="u-width-full-line actions-mobile">
|
||||
<Tooltip disabled={!isUpgrading}>
|
||||
<div>
|
||||
{#if !currentPlan.requiresPaymentMethod}
|
||||
<Button
|
||||
text
|
||||
disabled={$organization?.markedForDeletion || isUpgrading}
|
||||
href={getChangePlanUrl($organization?.$id)}
|
||||
on:click={() =>
|
||||
trackEvent(Click.OrganizationClickUpgrade, {
|
||||
from: 'button',
|
||||
source: 'billing_tab'
|
||||
})}>
|
||||
Upgrade
|
||||
</Button>
|
||||
{:else if $organization?.billingPlanDowngrade !== null}
|
||||
<Button
|
||||
text
|
||||
disabled={isUpgrading}
|
||||
on:click={() => (showCancel = true)}>
|
||||
Cancel change
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
text
|
||||
disabled={$organization?.markedForDeletion || isUpgrading}
|
||||
href={getChangePlanUrl($organization?.$id)}
|
||||
on:click={() =>
|
||||
trackEvent(Click.OrganizationClickUpgrade, {
|
||||
from: 'button',
|
||||
source: 'billing_tab'
|
||||
})}>
|
||||
Change plan
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<svelte:fragment slot="tooltip">
|
||||
Your payment is still being processed, check with your payment provider.
|
||||
</svelte:fragment>
|
||||
</Tooltip>
|
||||
{#if !currentPlan?.usagePerProject}
|
||||
<Button
|
||||
disabled={$organization?.markedForDeletion}
|
||||
href={getChangePlanUrl($organization?.$id)}
|
||||
on:click={() =>
|
||||
trackEvent(Click.OrganizationClickUpgrade, {
|
||||
from: 'button',
|
||||
source: 'billing_tab'
|
||||
})}>
|
||||
Upgrade
|
||||
text={!currentPlan.requiresPaymentMethod}
|
||||
secondary={currentPlan.requiresPaymentMethod}
|
||||
href={`${base}/organization-${$organization?.$id}/usage`}>
|
||||
View estimated usage
|
||||
</Button>
|
||||
</Layout.Stack>
|
||||
{:else}
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="flex-end"
|
||||
gap="s"
|
||||
wrap="wrap"
|
||||
class="u-width-full-line actions-mobile">
|
||||
{#if $organization?.billingPlanDowngrade !== null}
|
||||
<Button text on:click={() => (showCancel = true)}>Cancel change</Button>
|
||||
{:else}
|
||||
<Button
|
||||
text
|
||||
disabled={$organization?.markedForDeletion}
|
||||
href={getChangePlanUrl($organization?.$id)}
|
||||
on:click={() =>
|
||||
trackEvent(Click.OrganizationClickUpgrade, {
|
||||
from: 'button',
|
||||
source: 'billing_tab'
|
||||
})}>
|
||||
Change plan
|
||||
</Button>
|
||||
{/if}
|
||||
{#if !currentPlan?.usagePerProject}
|
||||
<Button
|
||||
secondary
|
||||
href={`${base}/organization-${$organization?.$id}/usage`}>
|
||||
View estimated usage
|
||||
</Button>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</div>
|
||||
</EstimatedCard>
|
||||
{/key}
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
const isTrial =
|
||||
new Date($organization?.billingStartDate).getTime() - today.getTime() > 0 &&
|
||||
$organization?.billingTrialDays; /* number of trial days. */
|
||||
$: isUpgrading = $organization?.status === 'upgrading';
|
||||
|
||||
const extraUsage = currentInvoice ? currentInvoice.amount - currentPlan?.price : 0;
|
||||
</script>
|
||||
@@ -41,9 +42,11 @@
|
||||
A breakdown of your estimated upcoming payment for the current billing period. Totals displayed
|
||||
exclude accumulated credits and applicable taxes.
|
||||
<svelte:fragment slot="aside">
|
||||
<p class="text u-bold">
|
||||
Due at: {toLocaleDate($organization?.billingNextInvoiceDate)}
|
||||
</p>
|
||||
<Layout.Stack gap="xs">
|
||||
<p class="text u-bold">
|
||||
Due at: {toLocaleDate($organization?.billingNextInvoiceDate)}
|
||||
</p>
|
||||
</Layout.Stack>
|
||||
<Card.Base variant="secondary" padding="s">
|
||||
<Layout.Stack>
|
||||
<Layout.Stack direction="row" justifyContent="space-between">
|
||||
@@ -165,50 +168,55 @@
|
||||
</Card.Base>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="actions">
|
||||
{#if !currentPlan.requiresPaymentMethod}
|
||||
<div
|
||||
class="u-flex u-flex-vertical-mobile u-cross-center u-gap-16 u-flex-wrap u-width-full-line u-main-end">
|
||||
{#if !currentPlan?.usagePerProject}
|
||||
<Button text href={`${base}/organization-${$organization?.$id}/usage`}>
|
||||
View estimated usage
|
||||
</Button>
|
||||
{/if}
|
||||
<div
|
||||
class="u-flex u-flex-vertical-mobile u-cross-center u-gap-16 u-flex-wrap u-width-full-line u-main-end">
|
||||
<Tooltip disabled={!isUpgrading}>
|
||||
<div>
|
||||
{#if !currentPlan.requiresPaymentMethod}
|
||||
<Button
|
||||
disabled={$organization?.markedForDeletion || isUpgrading}
|
||||
href={getChangePlanUrl($organization?.$id)}
|
||||
on:click={() =>
|
||||
trackEvent(Click.OrganizationClickUpgrade, {
|
||||
from: 'button',
|
||||
source: 'billing_tab'
|
||||
})}>
|
||||
Upgrade
|
||||
</Button>
|
||||
{:else if $organization?.billingPlanDowngrade !== null}
|
||||
<Button
|
||||
text
|
||||
disabled={isUpgrading}
|
||||
on:click={() => (showCancel = true)}>
|
||||
Cancel change
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
text
|
||||
disabled={$organization?.markedForDeletion || isUpgrading}
|
||||
href={getChangePlanUrl($organization?.$id)}
|
||||
on:click={() =>
|
||||
trackEvent(Click.OrganizationClickUpgrade, {
|
||||
from: 'button',
|
||||
source: 'billing_tab'
|
||||
})}>
|
||||
Change plan
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<svelte:fragment slot="tooltip">
|
||||
Your payment is still being processed, check with your payment provider.
|
||||
</svelte:fragment>
|
||||
</Tooltip>
|
||||
{#if !currentPlan?.usagePerProject}
|
||||
<Button
|
||||
disabled={$organization?.markedForDeletion}
|
||||
href={getChangePlanUrl($organization?.$id)}
|
||||
on:click={() =>
|
||||
trackEvent(Click.OrganizationClickUpgrade, {
|
||||
from: 'button',
|
||||
source: 'billing_tab'
|
||||
})}>
|
||||
Upgrade
|
||||
text={!currentPlan.requiresPaymentMethod}
|
||||
secondary={currentPlan.requiresPaymentMethod}
|
||||
href={`${base}/organization-${$organization?.$id}/usage`}>
|
||||
View estimated usage
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="u-flex u-flex-vertical-mobile u-cross-center u-gap-16 u-flex-wrap u-width-full-line u-main-end">
|
||||
{#if $organization?.billingPlanDowngrade !== null}
|
||||
<Button text on:click={() => (showCancel = true)}>Cancel change</Button>
|
||||
{:else}
|
||||
<Button
|
||||
text
|
||||
disabled={$organization?.markedForDeletion}
|
||||
href={getChangePlanUrl($organization?.$id)}
|
||||
on:click={() =>
|
||||
trackEvent(Click.OrganizationClickUpgrade, {
|
||||
from: 'button',
|
||||
source: 'billing_tab'
|
||||
})}>
|
||||
Change plan
|
||||
</Button>
|
||||
{/if}
|
||||
{#if !currentPlan?.usagePerProject}
|
||||
<Button secondary href={`${base}/organization-${$organization?.$id}/usage`}>
|
||||
View estimated usage
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</CardGrid>
|
||||
{/if}
|
||||
|
||||
@@ -46,9 +46,6 @@
|
||||
let previousPage: string = resolve('/');
|
||||
let showExitModal = false;
|
||||
let formComponent: Form;
|
||||
let usageLimitsComponent:
|
||||
| { validateOrAlert: () => boolean; getSelectedProjects: () => string[] }
|
||||
| undefined;
|
||||
let isSubmitting = writable(false);
|
||||
let collaborators: string[] =
|
||||
data?.members?.memberships
|
||||
@@ -62,7 +59,7 @@
|
||||
let feedbackDowngradeReason: string;
|
||||
let feedbackMessage: string;
|
||||
let orgUsage: Models.UsageOrganization;
|
||||
let allProjects: { projects: Models.Project[] } | undefined;
|
||||
let allProjects: { projects: Models.Project[] } = { projects: [] };
|
||||
|
||||
$: paymentMethods = null;
|
||||
|
||||
@@ -136,18 +133,15 @@
|
||||
return paymentMethods;
|
||||
}
|
||||
|
||||
function hasExcessProjectsForFreePlan(): boolean {
|
||||
const freeBasePlan = getBasePlanFromGroup(BillingPlanGroup.Starter);
|
||||
const freePlanProjectLimit = freeBasePlan?.projects ?? 2;
|
||||
const currentProjectCount = allProjects.projects.length;
|
||||
return currentProjectCount > freePlanProjectLimit;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (isDowngrade) {
|
||||
// If target plan has a non-zero project limit, ensure selection made
|
||||
const targetProjectsLimit = selectedPlan?.projects ?? 0;
|
||||
const shouldShowProjectSelector =
|
||||
targetProjectsLimit > 0 && allProjects.projects.length > targetProjectsLimit;
|
||||
|
||||
if (shouldShowProjectSelector && usageLimitsComponent?.validateOrAlert) {
|
||||
const ok = usageLimitsComponent.validateOrAlert();
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
await downgrade();
|
||||
} else if (isUpgrade) {
|
||||
await upgrade();
|
||||
@@ -173,6 +167,18 @@
|
||||
}
|
||||
|
||||
async function downgrade() {
|
||||
if (selectedPlan.group === BillingPlanGroup.Starter && hasExcessProjectsForFreePlan()) {
|
||||
const freeBasePlan = getBasePlanFromGroup(BillingPlanGroup.Starter);
|
||||
const freePlanProjectLimit = freeBasePlan?.projects ?? 2;
|
||||
const currentProjectCount = allProjects?.projects?.length ?? 0;
|
||||
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: `Please delete ${currentProjectCount - freePlanProjectLimit} project${currentProjectCount - freePlanProjectLimit !== 1 ? 's' : ''} before downgrading`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1) update the plan first
|
||||
await sdk.forConsole.organizations.updatePlan({
|
||||
@@ -181,22 +187,6 @@
|
||||
paymentMethodId
|
||||
});
|
||||
|
||||
// 2) If the target plan has a project limit, apply selected projects now
|
||||
const targetProjectsLimit = selectedPlan?.projects ?? 0;
|
||||
if (targetProjectsLimit > 0 && usageLimitsComponent) {
|
||||
const selected = usageLimitsComponent.getSelectedProjects();
|
||||
if (selected?.length) {
|
||||
try {
|
||||
await sdk.forConsole.organizations.updateProjects({
|
||||
organizationId: data.organization.$id,
|
||||
projects: selected
|
||||
});
|
||||
} catch (projectError) {
|
||||
console.warn('Project selection failed after plan update:', projectError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([trackDowngradeFeedback(), invalidate(Dependencies.ORGANIZATION)]);
|
||||
|
||||
await goto(previousPage);
|
||||
@@ -323,9 +313,19 @@
|
||||
|
||||
$: isUpgrade = selectedPlan.order > $currentPlan?.order;
|
||||
$: isDowngrade = selectedPlan.order < $currentPlan?.order;
|
||||
$: isButtonDisabled =
|
||||
$organization?.billingPlanId === selectedPlan.$id ||
|
||||
(isDowngrade && selectedPlan.group === BillingPlanGroup.Starter && data.hasFreeOrgs);
|
||||
|
||||
// Check if projects exceed Free plan limit when downgrading
|
||||
$: isButtonDisabled = (() => {
|
||||
const freeBasePlan = getBasePlanFromGroup(BillingPlanGroup.Starter);
|
||||
const freePlanProjectLimit = freeBasePlan?.projects ?? 2;
|
||||
const hasExcessProjects = allProjects.projects.length > freePlanProjectLimit;
|
||||
|
||||
if ($organization?.billingPlanId === selectedPlan.$id) return true;
|
||||
if (isDowngrade && selectedPlan.group === BillingPlanGroup.Starter && data.hasFreeOrgs)
|
||||
return true;
|
||||
|
||||
return isDowngrade && selectedPlan.group === BillingPlanGroup.Starter && hasExcessProjects;
|
||||
})();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -405,9 +405,8 @@
|
||||
{/if}
|
||||
|
||||
<OrganizationUsageLimits
|
||||
bind:this={usageLimitsComponent}
|
||||
organization={data.organization}
|
||||
projects={allProjects?.projects || []}
|
||||
bind:projects={allProjects.projects}
|
||||
members={data.members?.memberships || []}
|
||||
storageUsage={orgUsage?.storageTotal ?? 0} />
|
||||
{/if}
|
||||
|
||||
@@ -178,7 +178,7 @@
|
||||
limit={data.limit}
|
||||
offset={data.offset}
|
||||
total={data.domains.total} />
|
||||
{:else if data?.query}
|
||||
{:else if data?.query || data?.search}
|
||||
<EmptySearch hidePages bind:search={data.search} target="domains">
|
||||
<svelte:fragment slot="actions">
|
||||
<Button
|
||||
|
||||
@@ -29,7 +29,8 @@ export const load = async ({ depends, url, route, parent }) => {
|
||||
Query.orderDesc(''),
|
||||
Query.equal('teamId', organization.$id),
|
||||
...parsedQueries.values()
|
||||
]
|
||||
],
|
||||
search: search || undefined
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
@@ -151,7 +151,10 @@
|
||||
</Button>
|
||||
</div>
|
||||
<div slot="tooltip">
|
||||
{!organization?.billingPlanDetails.addons.seats.supported
|
||||
{!(
|
||||
organization?.billingPlanDetails?.addons?.seats?.supported ??
|
||||
true
|
||||
)
|
||||
? 'Upgrade to add more members'
|
||||
: `You've reached the members limit for the ${
|
||||
organization?.billingPlanDetails.name
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { base } from '$app/paths';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import Upgrade from '$lib/components/roles/upgrade.svelte';
|
||||
import { getRoleLabel } from '$lib/stores/billing';
|
||||
import { getServiceLimit, readOnly, getRoleLabel } from '$lib/stores/billing';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { currentPlan, newMemberModal, organization } from '$lib/stores/organization';
|
||||
import { isOwner } from '$lib/stores/roles';
|
||||
@@ -14,7 +14,7 @@
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import Delete from '../deleteMember.svelte';
|
||||
import Edit from './edit.svelte';
|
||||
import { isCloud } from '$lib/system';
|
||||
import { isCloud, GRACE_PERIOD_OVERRIDE } from '$lib/system';
|
||||
import {
|
||||
IconDotsHorizontal,
|
||||
IconInfo,
|
||||
@@ -45,8 +45,10 @@
|
||||
// Calculate if button should be disabled and tooltip should show
|
||||
$: memberCount = data.organizationMembers?.total ?? 0;
|
||||
$: supportsMembers = $organization?.billingPlanDetails?.addons?.seats;
|
||||
$: isFreeWithMembers = !supportsMembers && memberCount >= 1;
|
||||
$: isButtonDisabled = isCloud ? isFreeWithMembers : false;
|
||||
$: limit = getServiceLimit('members', null, $currentPlan) || Infinity;
|
||||
$: isLimited = limit !== 0 && limit < Infinity;
|
||||
$: isButtonDisabled =
|
||||
isCloud && (($readOnly && !GRACE_PERIOD_OVERRIDE) || (isLimited && memberCount >= limit));
|
||||
|
||||
const resend = async (member: Models.Membership) => {
|
||||
try {
|
||||
|
||||
@@ -67,13 +67,11 @@
|
||||
firstName: ($user?.name ?? '').slice(0, 40),
|
||||
message: `BAA request for ${$organization?.name ?? ''} (${$organization?.$id ?? ''})`,
|
||||
tags: ['cloud'],
|
||||
customFields: [
|
||||
{ id: '41612', value: 'BAA' },
|
||||
{ id: '48493', value: $user?.name ?? '' },
|
||||
{ id: '48492', value: $organization?.$id ?? '' },
|
||||
{ id: '48490', value: $user?.$id ?? '' }
|
||||
],
|
||||
metaFields: {
|
||||
category: 'BAA',
|
||||
userName: $user?.name ?? '',
|
||||
orgId: $organization?.$id ?? '',
|
||||
userId: $user?.$id ?? '',
|
||||
employees: employees,
|
||||
country: country,
|
||||
role: role,
|
||||
|
||||
@@ -68,13 +68,11 @@
|
||||
firstName: ($user?.name ?? '').slice(0, 40),
|
||||
message: `SOC-2 request for ${$organization?.name ?? ''} (${$organization?.$id ?? ''})`,
|
||||
tags: ['cloud'],
|
||||
customFields: [
|
||||
{ id: '41612', value: 'SOC-2' },
|
||||
{ id: '48493', value: $user?.name ?? '' },
|
||||
{ id: '48492', value: $organization?.$id ?? '' },
|
||||
{ id: '48490', value: $user?.$id ?? '' }
|
||||
],
|
||||
metaFields: {
|
||||
category: 'SOC-2',
|
||||
userName: $user?.name ?? '',
|
||||
orgId: $organization?.$id ?? '',
|
||||
userId: $user?.$id ?? '',
|
||||
employees: employees,
|
||||
country: country,
|
||||
role: role,
|
||||
|
||||
+3
-2
@@ -12,14 +12,15 @@
|
||||
|
||||
$: total = members?.total ?? 0;
|
||||
$: organizationMembersSupported =
|
||||
!$organization?.billingPlanDetails.addons.seats.supported; /* false on free */
|
||||
$organization?.billingPlanDetails?.addons?.seats?.supported ??
|
||||
true; /* true on paid plans */
|
||||
</script>
|
||||
|
||||
<CardGrid>
|
||||
<svelte:fragment slot="title">Members</svelte:fragment>
|
||||
The number of members in your organization.
|
||||
<svelte:fragment slot="aside">
|
||||
{#if !organizationMembersSupported}
|
||||
{#if organizationMembersSupported}
|
||||
<div class="u-flex u-flex-vertical">
|
||||
<Layout.Stack direction="row" justifyContent="space-between">
|
||||
<Layout.Stack gap="s" direction="row" alignItems="center">
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
canWriteSites
|
||||
} from '$lib/stores/roles';
|
||||
import CsvImportBox from '$lib/components/csvImportBox.svelte';
|
||||
import { isCloud } from '$lib/system';
|
||||
import PausedProjectModal from './pausedProjectModal.svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
export let data: LayoutData;
|
||||
|
||||
onMount(() => {
|
||||
return realtime.forProject(page.params.region, ['project', 'console'], (response) => {
|
||||
@@ -114,6 +119,10 @@
|
||||
|
||||
<slot />
|
||||
|
||||
{#if isCloud && data.project?.status === 'paused'}
|
||||
<PausedProjectModal show={true} projectId={data.project.$id} teamId={data.project.teamId} />
|
||||
{/if}
|
||||
|
||||
<div class="layout-level-progress-bars">
|
||||
<UploadBox />
|
||||
<MigrationBox />
|
||||
|
||||
@@ -11,13 +11,25 @@ import { loadAvailableRegions } from '$routes/(console)/regions';
|
||||
import { type Models, Platform } from '@appwrite.io/console';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { resolve } from '$app/paths';
|
||||
import { generateFingerprintToken } from '$lib/helpers/fingerprint';
|
||||
import { normalizeConsoleVariables } from '$lib/helpers/domains';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export const load: LayoutLoad = async ({ params, depends, parent }) => {
|
||||
const { plansInfo, organizations, preferences: prefs } = await parent();
|
||||
depends(Dependencies.PROJECT);
|
||||
|
||||
const project = await sdk.forConsole.projects.get({ projectId: params.project });
|
||||
if (project.status !== 'active' && project.status !== 'paused') {
|
||||
// project isn't active, redirect back to organizations page
|
||||
redirect(
|
||||
303,
|
||||
resolve('/(console)/organization-[organization]', {
|
||||
organization: project.teamId
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
project.region ??= 'default';
|
||||
|
||||
// fast path without a network call!
|
||||
@@ -92,6 +104,22 @@ export const load: LayoutLoad = async ({ params, depends, parent }) => {
|
||||
plansInfo.set(organization.billingPlanId, organizationPlan);
|
||||
}
|
||||
|
||||
// Track console access for cloud projects (fire-and-forget, backend has 6-day cooldown).
|
||||
// Skip if paused — user must explicitly resume via the paused project modal.
|
||||
if (isCloud && browser && project.status !== 'paused') {
|
||||
generateFingerprintToken()
|
||||
.then((fingerprint) => {
|
||||
sdk.forConsole.client.headers['X-Appwrite-Console-Fingerprint'] = fingerprint;
|
||||
return sdk.forConsole.projects.updateConsoleAccess({
|
||||
projectId: params.project
|
||||
});
|
||||
})
|
||||
.catch((e) => console.error('Failed to update console access:', e))
|
||||
.finally(() => {
|
||||
delete sdk.forConsole.client.headers['X-Appwrite-Console-Fingerprint'];
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
project,
|
||||
organization,
|
||||
|
||||
+5
-5
@@ -22,9 +22,7 @@
|
||||
|
||||
$: if (prefs) {
|
||||
const currentNormalized = normalizePrefs(prefs);
|
||||
const originalNormalized = normalizePrefs(
|
||||
Object.entries(($team?.prefs ?? {}) as Record<string, string>)
|
||||
);
|
||||
const originalNormalized = normalizePrefs(Object.entries($team?.prefs ?? {}));
|
||||
|
||||
arePrefsDisabled = deepEqual(currentNormalized, originalNormalized);
|
||||
}
|
||||
@@ -33,10 +31,12 @@
|
||||
let arePrefsDisabled = true;
|
||||
|
||||
onMount(async () => {
|
||||
const entries = Object.entries(($team?.prefs ?? {}) as Record<string, string>);
|
||||
const entries = Object.entries($team?.prefs ?? {});
|
||||
prefs =
|
||||
entries.length > 0
|
||||
? entries.map(([key, value]) => createPrefRow(key, value))
|
||||
? entries.map(([key, value]) =>
|
||||
createPrefRow(String(key ?? ''), String(value ?? ''))
|
||||
)
|
||||
: [createPrefRow()];
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -23,12 +23,12 @@
|
||||
try {
|
||||
const template = await loadEmailTemplate(
|
||||
project.$id,
|
||||
EmailTemplateType.Mfachallenge,
|
||||
EmailTemplateType.MfaChallenge,
|
||||
locale
|
||||
);
|
||||
emailTemplate.set(template);
|
||||
$baseEmailTemplate = { ...$emailTemplate };
|
||||
trackEvent(Submit.EmailChangeLocale, { locale, type: EmailTemplateType.Mfachallenge });
|
||||
trackEvent(Submit.EmailChangeLocale, { locale, type: EmailTemplateType.MfaChallenge });
|
||||
} catch (error) {
|
||||
trackError(error, Submit.EmailChangeLocale);
|
||||
addNotification({
|
||||
|
||||
+2
-2
@@ -23,12 +23,12 @@
|
||||
try {
|
||||
const template = await loadEmailTemplate(
|
||||
project.$id,
|
||||
EmailTemplateType.Magicsession,
|
||||
EmailTemplateType.MagicSession,
|
||||
locale
|
||||
);
|
||||
emailTemplate.set(template);
|
||||
$baseEmailTemplate = { ...$emailTemplate };
|
||||
trackEvent(Submit.EmailChangeLocale, { locale, type: EmailTemplateType.Magicsession });
|
||||
trackEvent(Submit.EmailChangeLocale, { locale, type: EmailTemplateType.MagicSession });
|
||||
} catch (error) {
|
||||
trackError(error, Submit.EmailChangeLocale);
|
||||
addNotification({
|
||||
|
||||
+2
-2
@@ -23,12 +23,12 @@
|
||||
try {
|
||||
const template = await loadEmailTemplate(
|
||||
project.$id,
|
||||
EmailTemplateType.Otpsession,
|
||||
EmailTemplateType.OtpSession,
|
||||
locale
|
||||
);
|
||||
emailTemplate.set(template);
|
||||
$baseEmailTemplate = { ...$emailTemplate };
|
||||
trackEvent(Submit.EmailChangeLocale, { locale, type: EmailTemplateType.Otpsession });
|
||||
trackEvent(Submit.EmailChangeLocale, { locale, type: EmailTemplateType.OtpSession });
|
||||
} catch (error) {
|
||||
trackError(error, Submit.EmailChangeLocale);
|
||||
addNotification({
|
||||
|
||||
+2
-2
@@ -23,12 +23,12 @@
|
||||
try {
|
||||
const template = await loadEmailTemplate(
|
||||
project.$id,
|
||||
EmailTemplateType.Sessionalert,
|
||||
EmailTemplateType.SessionAlert,
|
||||
locale
|
||||
);
|
||||
emailTemplate.set(template);
|
||||
$baseEmailTemplate = { ...$emailTemplate };
|
||||
trackEvent(Submit.EmailChangeLocale, { locale, type: EmailTemplateType.Sessionalert });
|
||||
trackEvent(Submit.EmailChangeLocale, { locale, type: EmailTemplateType.SessionAlert });
|
||||
} catch (error) {
|
||||
trackError(error, Submit.EmailChangeLocale);
|
||||
addNotification({
|
||||
|
||||
@@ -51,13 +51,13 @@ export const templates = [
|
||||
component: EmailVerificationTemplate
|
||||
},
|
||||
{
|
||||
key: EmailTemplateType.Magicsession,
|
||||
key: EmailTemplateType.MagicSession,
|
||||
title: 'Magic URL',
|
||||
description: 'Send an email to users that sign in with a magic URL.',
|
||||
component: EmailMagicUrlTemplate
|
||||
},
|
||||
{
|
||||
key: EmailTemplateType.Otpsession,
|
||||
key: EmailTemplateType.OtpSession,
|
||||
title: 'OTP session',
|
||||
description: 'Send an email to users that sign in with a email OTP.',
|
||||
component: EmailOtpSessionTemplate
|
||||
@@ -75,13 +75,13 @@ export const templates = [
|
||||
component: EmailInviteTemplate
|
||||
},
|
||||
{
|
||||
key: EmailTemplateType.Mfachallenge,
|
||||
key: EmailTemplateType.MfaChallenge,
|
||||
title: '2FA verification',
|
||||
description: 'Send a two-factor authentication email to a user.',
|
||||
component: Email2FaTemplate
|
||||
},
|
||||
{
|
||||
key: EmailTemplateType.Sessionalert,
|
||||
key: EmailTemplateType.SessionAlert,
|
||||
title: 'Session alert',
|
||||
description: 'Send an email to users when a new session is created.',
|
||||
component: EmailSessionAlertTemplate,
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
const entries = Object.entries($user?.prefs ?? {});
|
||||
prefs =
|
||||
entries.length > 0
|
||||
? entries.map(([key, value]) => createPrefRow(key, value))
|
||||
? entries.map(([key, value]) =>
|
||||
createPrefRow(String(key ?? ''), String(value ?? ''))
|
||||
)
|
||||
: [createPrefRow()];
|
||||
});
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
<ResponsiveContainerHeader
|
||||
hasSearch
|
||||
{columns}
|
||||
bind:view={data.view}
|
||||
view={data.view}
|
||||
searchPlaceholder="Search by name or ID">
|
||||
{#if $canWriteDatabases}
|
||||
<Tooltip disabled={!isLimited}>
|
||||
@@ -108,7 +108,9 @@
|
||||
</Button>
|
||||
</div>
|
||||
<svelte:fragment slot="tooltip">
|
||||
You have reached the maximum number of databases for your plan.
|
||||
<div style="white-space: pre-line;">
|
||||
You have reached the maximum number of databases for your plan.
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ export type DatabaseSdkResult = {
|
||||
deleteRecord: (params: {
|
||||
databaseId: string;
|
||||
entityId: string;
|
||||
recordId?: string;
|
||||
recordId: string;
|
||||
databaseType?: DatabaseType;
|
||||
}) => Promise<Record>;
|
||||
deleteRecords: (params: {
|
||||
|
||||
+1
@@ -212,6 +212,7 @@
|
||||
<Button text on:click={() => resetSampleFieldsConfig()}>Cancel</Button>
|
||||
<Button
|
||||
on:click={async () => {
|
||||
$randomDataModalState.show = false;
|
||||
await $randomDataModalState.onSubmit?.();
|
||||
resetSampleFieldsConfig();
|
||||
}}>Create</Button>
|
||||
|
||||
+9
-6
@@ -226,8 +226,11 @@
|
||||
size="s"
|
||||
secondary
|
||||
class="small-button-dimensions"
|
||||
disabled={!(hasColumns && hasValidColumns && data.rows.total) ||
|
||||
disableButton}
|
||||
disabled={!(
|
||||
hasColumns &&
|
||||
hasValidColumns &&
|
||||
data.rows?.total
|
||||
) || disableButton}
|
||||
on:click={() => {
|
||||
trackEvent(Click.DatabaseExportCsv);
|
||||
goto(getTableExportUrl());
|
||||
@@ -238,13 +241,13 @@
|
||||
<svelte:fragment slot="tooltip">Export CSV</svelte:fragment>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip disabled={isRefreshing || !data.rows.total} placement="top">
|
||||
<Tooltip disabled={isRefreshing || !data.rows?.total} placement="top">
|
||||
<Button
|
||||
icon
|
||||
size="s"
|
||||
secondary
|
||||
disabled={isRefreshing ||
|
||||
!data.rows.total ||
|
||||
!data.rows?.total ||
|
||||
!(hasColumns && hasValidColumns) ||
|
||||
disableButton}
|
||||
class="small-button-dimensions"
|
||||
@@ -298,7 +301,7 @@
|
||||
|
||||
<div class="databases-spreadsheet">
|
||||
{#if hasColumns && hasValidColumns && $entityColumnSuggestions.force !== true}
|
||||
{#if data.rows.total}
|
||||
{#if data.rows?.total}
|
||||
<Divider />
|
||||
<SpreadSheet {data} bind:showRowCreateSheet={$showRowCreateSheet} />
|
||||
{:else if $hasPageQueries}
|
||||
@@ -350,7 +353,7 @@
|
||||
</EmptySheet>
|
||||
{/if}
|
||||
{:else if isCloud && canShowSuggestionsSheet}
|
||||
<SuggestionsEmptySheet userColumns={$tableColumns} userDataRows={data.rows.rows} />
|
||||
<SuggestionsEmptySheet userColumns={$tableColumns} userDataRows={data.rows?.rows} />
|
||||
{:else}
|
||||
<EmptySheet
|
||||
mode="records"
|
||||
|
||||
+9
-4
@@ -15,7 +15,7 @@
|
||||
Tooltip,
|
||||
Typography
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import { isRelationship, isSpatialType, isString } from '../rows/store';
|
||||
import { isRelationship, isSpatialType, isTextType } from '../rows/store';
|
||||
import {
|
||||
columns,
|
||||
type ColumnsWidth,
|
||||
@@ -392,7 +392,12 @@
|
||||
|
||||
{#each updatedColumnsForSheet as column, index (column.key)}
|
||||
{@const isId = column.key === '$id'}
|
||||
{@const option = columnOptions.find((option) => option.type === column.type)}
|
||||
{@const option = columnOptions.find(
|
||||
(option) =>
|
||||
option.type === column.type &&
|
||||
option.format ===
|
||||
('format' in column && column.format ? column.format : undefined)
|
||||
)}
|
||||
{@const isSelectable =
|
||||
column['system'] || column.type === 'relationship' ? 'disabled' : true}
|
||||
<Spreadsheet.Row.Base {root} select={isSelectable} id={column.key}>
|
||||
@@ -420,7 +425,7 @@
|
||||
{:else if column.key === '$id'}
|
||||
<Icon icon={IconFingerPrint} size="s" />
|
||||
{:else}
|
||||
<Icon icon={option.icon} size="s" />
|
||||
<Icon icon={option?.icon ?? IconViewList} size="s" />
|
||||
{/if}
|
||||
|
||||
<Layout.Stack
|
||||
@@ -437,7 +442,7 @@
|
||||
{/if}
|
||||
</Typography.Text>
|
||||
|
||||
{#if isString(column) && column.encrypt}
|
||||
{#if isTextType(column) && 'encrypt' in column && column.encrypt}
|
||||
<Tooltip portal>
|
||||
<Icon
|
||||
size="s"
|
||||
|
||||
+15
-8
@@ -9,7 +9,7 @@
|
||||
import deepEqual from 'deep-equal';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { columnsOrder, databaseColumnSheetOptions } from '../store';
|
||||
import { columnOptions, type Option } from './store';
|
||||
import { columnOptions, STRING_COLUMN_NAME, type Option } from './store';
|
||||
import { onMount } from 'svelte';
|
||||
import { Layout } from '@appwrite.io/pink-svelte';
|
||||
import { preferences } from '$lib/stores/preferences';
|
||||
@@ -35,14 +35,21 @@
|
||||
}
|
||||
});
|
||||
|
||||
$: option = columnOptions.find((option) => {
|
||||
if (selectedColumn) {
|
||||
if ('format' in selectedColumn && selectedColumn.format) {
|
||||
return option?.format === selectedColumn?.format;
|
||||
} else {
|
||||
return option?.type === selectedColumn?.type;
|
||||
}
|
||||
$: option = columnOptions.find((opt) => {
|
||||
if (!selectedColumn) return false;
|
||||
|
||||
// format match when present
|
||||
if ('format' in selectedColumn && selectedColumn.format) {
|
||||
return opt?.format === selectedColumn.format;
|
||||
}
|
||||
|
||||
// Legacy string columns (no format)
|
||||
if (selectedColumn.type === 'string') {
|
||||
return opt.name === STRING_COLUMN_NAME;
|
||||
}
|
||||
|
||||
// Fallback: match by type
|
||||
return opt.type === selectedColumn.type;
|
||||
}) as Option;
|
||||
|
||||
export async function submit() {
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { isCloud } from '$lib/system';
|
||||
import { getChangePlanUrl } from '$lib/stores/billing';
|
||||
import { currentPlan } from '$lib/stores/organization';
|
||||
import {
|
||||
ActionMenu,
|
||||
Popover,
|
||||
Layout,
|
||||
Selector,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Link
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
|
||||
let {
|
||||
encrypt = $bindable(false),
|
||||
editing = false,
|
||||
disabled = false,
|
||||
id = 'encrypt'
|
||||
}: {
|
||||
encrypt?: boolean;
|
||||
editing?: boolean;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
} = $props();
|
||||
|
||||
const organizationId = page.data?.organization?.$id ?? page.data?.project?.teamId;
|
||||
const supportsEncryption = $derived(isCloud ? $currentPlan?.databasesAllowEncrypt : true);
|
||||
</script>
|
||||
|
||||
<Tooltip disabled={!(editing || disabled)} maxWidth="275px" placement="bottom-start">
|
||||
<div
|
||||
class="popover-holder"
|
||||
class:cursor-not-allowed={editing || disabled}
|
||||
class:disabled-checkbox={!supportsEncryption || editing || disabled}>
|
||||
<Layout.Stack inline gap="s" alignItems="flex-start" direction="row">
|
||||
<Popover let:toggle placement="bottom-start">
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
{id}
|
||||
bind:checked={encrypt}
|
||||
disabled={!supportsEncryption || editing || disabled} />
|
||||
|
||||
<Layout.Stack gap="xxs" direction="column">
|
||||
<button
|
||||
type="button"
|
||||
disabled={editing || disabled}
|
||||
class:cursor-pointer={!(editing || disabled)}
|
||||
class:cursor-not-allowed={editing || disabled}
|
||||
onclick={(e) => {
|
||||
if (!supportsEncryption) {
|
||||
toggle(e);
|
||||
} else {
|
||||
encrypt = !encrypt;
|
||||
}
|
||||
}}>
|
||||
<Layout.Stack inline gap="xxs" direction="row" alignItems="center">
|
||||
<Typography.Text variant="m-500">Encrypted</Typography.Text>
|
||||
{#if !supportsEncryption}
|
||||
<Tag variant="default" size="xs">Pro</Tag>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</button>
|
||||
<Typography.Text color="--fgcolor-neutral-tertiary">
|
||||
Protect column against data leaks for best privacy compliance. Encrypted
|
||||
columns cannot be queried.
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
|
||||
<ActionMenu.Root width="180px" slot="tooltip">
|
||||
<Typography.Text variant="m-500">
|
||||
Available on Pro plan. <Link.Anchor href={getChangePlanUrl(organizationId)}
|
||||
>Upgrade</Link.Anchor>
|
||||
to enable encrypted columns.
|
||||
</Typography.Text>
|
||||
</ActionMenu.Root>
|
||||
</Popover>
|
||||
</Layout.Stack>
|
||||
</div>
|
||||
|
||||
<svelte:fragment slot="tooltip">
|
||||
Encryption can only be set when creating the column.
|
||||
</svelte:fragment>
|
||||
</Tooltip>
|
||||
|
||||
<style lang="scss">
|
||||
.popover-holder {
|
||||
& :global([role='tooltip']) {
|
||||
margin-top: 4px;
|
||||
left: 8rem !important;
|
||||
}
|
||||
|
||||
&.disabled-checkbox :global(button) {
|
||||
cursor: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.cursor-not-allowed {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
+11
-2
@@ -17,7 +17,8 @@
|
||||
key,
|
||||
required: data.required,
|
||||
xdefault: data.default,
|
||||
array: data.array
|
||||
array: data.array,
|
||||
encrypt: data.encrypt
|
||||
});
|
||||
}
|
||||
export async function updateLongtext(
|
||||
@@ -42,17 +43,21 @@
|
||||
<script lang="ts">
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
|
||||
import EncryptCheckbox from './encryptCheckbox.svelte';
|
||||
import { InputTextarea } from '$lib/elements/forms';
|
||||
import { Layout, Typography } from '@appwrite.io/pink-svelte';
|
||||
|
||||
export let data: Partial<Models.ColumnLongtext> = {
|
||||
required: false,
|
||||
array: false
|
||||
array: false,
|
||||
encrypt: false
|
||||
};
|
||||
|
||||
export let editing = false;
|
||||
export let disabled = false;
|
||||
|
||||
if (data && (data.encrypt === undefined || data.encrypt === null)) data.encrypt = false;
|
||||
|
||||
let savedDefault = data.default;
|
||||
|
||||
function handleDefaultState(hideDefault: boolean) {
|
||||
@@ -97,3 +102,7 @@
|
||||
{disabled}
|
||||
bind:array={data.array}
|
||||
bind:required={data.required} />
|
||||
|
||||
<Layout.Stack gap="xs" direction="column">
|
||||
<EncryptCheckbox id="encrypt-longtext" bind:encrypt={data.encrypt} {editing} {disabled} />
|
||||
</Layout.Stack>
|
||||
|
||||
+11
-2
@@ -17,7 +17,8 @@
|
||||
key,
|
||||
required: data.required,
|
||||
xdefault: data.default,
|
||||
array: data.array
|
||||
array: data.array,
|
||||
encrypt: data.encrypt
|
||||
});
|
||||
}
|
||||
export async function updateMediumtext(
|
||||
@@ -42,17 +43,21 @@
|
||||
<script lang="ts">
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
|
||||
import EncryptCheckbox from './encryptCheckbox.svelte';
|
||||
import { InputTextarea } from '$lib/elements/forms';
|
||||
import { Layout, Typography } from '@appwrite.io/pink-svelte';
|
||||
|
||||
export let data: Partial<Models.ColumnMediumtext> = {
|
||||
required: false,
|
||||
array: false
|
||||
array: false,
|
||||
encrypt: false
|
||||
};
|
||||
|
||||
export let editing = false;
|
||||
export let disabled = false;
|
||||
|
||||
if (data && (data.encrypt === undefined || data.encrypt === null)) data.encrypt = false;
|
||||
|
||||
let savedDefault = data.default;
|
||||
|
||||
function handleDefaultState(hideDefault: boolean) {
|
||||
@@ -97,3 +102,7 @@
|
||||
{disabled}
|
||||
bind:array={data.array}
|
||||
bind:required={data.required} />
|
||||
|
||||
<Layout.Stack gap="xs" direction="column">
|
||||
<EncryptCheckbox id="encrypt-mediumtext" bind:encrypt={data.encrypt} {editing} {disabled} />
|
||||
</Layout.Stack>
|
||||
|
||||
+28
-16
@@ -229,23 +229,35 @@
|
||||
</div>
|
||||
</Box>
|
||||
{#if data.relationType}
|
||||
{@const isManySide = data.side === 'child' && data.relationType === 'oneToMany'}
|
||||
<div>
|
||||
<p class="u-text-center">
|
||||
<b data-private>{camelize(currentTable.name)}</b> can contain {[
|
||||
'oneToOne',
|
||||
'manyToOne'
|
||||
].includes(data.relationType)
|
||||
? 'one'
|
||||
: 'many'}
|
||||
<b data-private>{camelize(data.key)}</b>
|
||||
</p>
|
||||
<p class="u-text-center">
|
||||
<b data-private>{camelize(data.key)}</b>
|
||||
can belong to {['oneToOne', 'oneToMany'].includes(data.relationType)
|
||||
? 'one'
|
||||
: 'many'}
|
||||
<b data-private>{camelize(currentTable.name)}</b>
|
||||
</p>
|
||||
{#if isManySide}
|
||||
<p class="u-text-center">
|
||||
<b data-private>{camelize(currentTable.name)}</b> can belong to one
|
||||
<b data-private>{camelize(data.key)}</b>
|
||||
</p>
|
||||
<p class="u-text-center">
|
||||
<b data-private>{camelize(data.key)}</b> can have many
|
||||
<b data-private>{camelize(currentTable.name)}</b>
|
||||
</p>
|
||||
{:else}
|
||||
<p class="u-text-center">
|
||||
<b data-private>{camelize(currentTable.name)}</b> can contain {[
|
||||
'oneToOne',
|
||||
'manyToOne'
|
||||
].includes(data.relationType)
|
||||
? 'one'
|
||||
: 'many'}
|
||||
<b data-private>{camelize(data.key)}</b>
|
||||
</p>
|
||||
<p class="u-text-center">
|
||||
<b data-private>{camelize(data.key)}</b>
|
||||
can belong to {['oneToOne', 'oneToMany'].includes(data.relationType)
|
||||
? 'one'
|
||||
: 'many'}
|
||||
<b data-private>{camelize(currentTable.name)}</b>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
+3
-1
@@ -85,6 +85,8 @@ export type Option = {
|
||||
icon: ComponentType;
|
||||
};
|
||||
|
||||
export const STRING_COLUMN_NAME = 'String (deprecated)';
|
||||
|
||||
export const columnOptions: Option[] = [
|
||||
{
|
||||
name: 'Text',
|
||||
@@ -235,7 +237,7 @@ export const columnOptions: Option[] = [
|
||||
icon: IconRelationship
|
||||
},
|
||||
{
|
||||
name: 'String (deprecated)',
|
||||
name: STRING_COLUMN_NAME,
|
||||
sentenceName: 'string',
|
||||
component: String,
|
||||
type: 'string',
|
||||
|
||||
+11
-2
@@ -15,7 +15,8 @@
|
||||
key,
|
||||
required: data.required,
|
||||
xdefault: data.default,
|
||||
array: data.array
|
||||
array: data.array,
|
||||
encrypt: data.encrypt
|
||||
});
|
||||
}
|
||||
export async function updateText(
|
||||
@@ -38,17 +39,21 @@
|
||||
<script lang="ts">
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
|
||||
import EncryptCheckbox from './encryptCheckbox.svelte';
|
||||
import { InputTextarea } from '$lib/elements/forms';
|
||||
import { Layout, Typography } from '@appwrite.io/pink-svelte';
|
||||
|
||||
export let data: Partial<Models.ColumnText> = {
|
||||
required: false,
|
||||
array: false
|
||||
array: false,
|
||||
encrypt: false
|
||||
};
|
||||
|
||||
export let editing = false;
|
||||
export let disabled = false;
|
||||
|
||||
if (data && (data.encrypt === undefined || data.encrypt === null)) data.encrypt = false;
|
||||
|
||||
let savedDefault = data.default;
|
||||
|
||||
function handleDefaultState(hideDefault: boolean) {
|
||||
@@ -93,3 +98,7 @@
|
||||
{disabled}
|
||||
bind:array={data.array}
|
||||
bind:required={data.required} />
|
||||
|
||||
<Layout.Stack gap="xs" direction="column">
|
||||
<EncryptCheckbox id="encrypt-text" bind:encrypt={data.encrypt} {editing} {disabled} />
|
||||
</Layout.Stack>
|
||||
|
||||
+20
-4
@@ -16,7 +16,8 @@
|
||||
size: data.size,
|
||||
required: data.required,
|
||||
xdefault: data.default,
|
||||
array: data.array
|
||||
array: data.array,
|
||||
encrypt: data.encrypt
|
||||
});
|
||||
}
|
||||
export async function updateVarchar(
|
||||
@@ -45,16 +46,20 @@
|
||||
import { ProgressBar } from '$lib/components';
|
||||
import { Layout, Typography, Tooltip, Icon } from '@appwrite.io/pink-svelte';
|
||||
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
|
||||
import EncryptCheckbox from './encryptCheckbox.svelte';
|
||||
|
||||
export let data: Partial<Models.ColumnVarchar> = {
|
||||
required: false,
|
||||
size: 255,
|
||||
array: false
|
||||
array: false,
|
||||
encrypt: false
|
||||
};
|
||||
|
||||
export let editing = false;
|
||||
export let disabled = false;
|
||||
|
||||
if (data && (data.encrypt === undefined || data.encrypt === null)) data.encrypt = false;
|
||||
|
||||
// Local size for reactivity
|
||||
let size = data.size ?? 255;
|
||||
$: data.size = size;
|
||||
@@ -118,6 +123,10 @@
|
||||
$: listen(data);
|
||||
|
||||
$: handleDefaultState($required || $array);
|
||||
|
||||
$: if (data.encrypt && size < 150) {
|
||||
size = 150;
|
||||
}
|
||||
</script>
|
||||
|
||||
<InputNumber
|
||||
@@ -127,8 +136,11 @@
|
||||
{disabled}
|
||||
placeholder="Enter size"
|
||||
bind:value={size}
|
||||
min={1}
|
||||
max={16383} />
|
||||
min={data.encrypt ? 150 : 1}
|
||||
max={16383}
|
||||
helper={data.encrypt
|
||||
? 'Encrypted varchar columns require a minimum size of 150.'
|
||||
: undefined} />
|
||||
|
||||
{#if !editing}
|
||||
<Layout.Stack gap="xs">
|
||||
@@ -173,3 +185,7 @@
|
||||
{disabled}
|
||||
bind:array={data.array}
|
||||
bind:required={data.required} />
|
||||
|
||||
<Layout.Stack gap="xs" direction="column">
|
||||
<EncryptCheckbox id="encrypt-varchar" bind:encrypt={data.encrypt} {editing} {disabled} />
|
||||
</Layout.Stack>
|
||||
|
||||
+3
-1
@@ -48,6 +48,7 @@
|
||||
required: column?.required ?? false,
|
||||
array: column?.array ?? false,
|
||||
default: column?.default ?? null,
|
||||
encrypt: (column as { encrypt?: boolean })?.encrypt ?? false,
|
||||
...column
|
||||
} as Partial<Columns>);
|
||||
|
||||
@@ -61,7 +62,8 @@
|
||||
data = {
|
||||
required: false,
|
||||
array: false,
|
||||
default: null
|
||||
default: null,
|
||||
encrypt: false
|
||||
};
|
||||
|
||||
/* default to text */
|
||||
|
||||
+4
-2
@@ -15,7 +15,7 @@
|
||||
isRelationship,
|
||||
isRelationshipToMany,
|
||||
isSpatialType,
|
||||
isString
|
||||
isTextType
|
||||
} from '$database/table-[table]/rows/store';
|
||||
import {
|
||||
columns,
|
||||
@@ -1101,7 +1101,9 @@
|
||||
{@const isEmptyArray = formatted === 'Empty'}
|
||||
{@const isDatetimeAttribute = rowColumn.type === 'datetime'}
|
||||
{@const isEncryptedAttribute =
|
||||
isString(rowColumn) && rowColumn.encrypt}
|
||||
isTextType(rowColumn) &&
|
||||
'encrypt' in rowColumn &&
|
||||
rowColumn.encrypt}
|
||||
{#if isDatetimeAttribute}
|
||||
<DualTimeView time={value}>
|
||||
<span slot="title">Timestamp</span>
|
||||
|
||||
@@ -110,7 +110,9 @@
|
||||
</Button>
|
||||
</div>
|
||||
<svelte:fragment slot="tooltip">
|
||||
You have reached the maximum number of functions for your plan.
|
||||
<div style="white-space: pre-line;">
|
||||
You have reached the maximum number of functions for your plan.
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Tooltip>
|
||||
</Layout.Stack>
|
||||
|
||||
+16
-1
@@ -22,6 +22,7 @@
|
||||
import RepoCard from './repoCard.svelte';
|
||||
import { getIconFromRuntime } from '$lib/stores/runtimes';
|
||||
import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store';
|
||||
import { normalizeDetectedVariables, mergeVariables } from '$lib/helpers/variables';
|
||||
|
||||
export let data;
|
||||
|
||||
@@ -59,6 +60,12 @@
|
||||
|
||||
let detectingRuntime = true;
|
||||
|
||||
let prevRootDir = rootDir;
|
||||
$: if (rootDir !== prevRootDir) {
|
||||
prevRootDir = rootDir;
|
||||
detectRuntime();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
installation.set(data.installation);
|
||||
repository.set(data.repository);
|
||||
@@ -82,6 +89,10 @@
|
||||
entrypoint = detections.entrypoint;
|
||||
buildCommand = detections.commands;
|
||||
runtime = detections.runtime as Runtime;
|
||||
const detectedVariables = normalizeDetectedVariables(detections?.variables);
|
||||
if (detectedVariables.length) {
|
||||
variables = mergeVariables(variables, detectedVariables);
|
||||
}
|
||||
|
||||
trackEvent(Submit.FrameworkDetect, { runtime, source: 'repository' });
|
||||
} catch (error) {
|
||||
@@ -189,7 +200,11 @@
|
||||
installationId={data.installation.$id}
|
||||
repositoryId={data.repository.id} />
|
||||
|
||||
<Configuration bind:buildCommand bind:roles />
|
||||
<Configuration
|
||||
bind:buildCommand
|
||||
bind:roles
|
||||
bind:variables
|
||||
isLoading={detectingRuntime} />
|
||||
</Layout.Stack>
|
||||
</Form>
|
||||
<svelte:fragment slot="aside">
|
||||
|
||||
+10
@@ -3,9 +3,13 @@
|
||||
import { Link } from '$lib/elements';
|
||||
import { InputText } from '$lib/elements/forms';
|
||||
import { Accordion, Fieldset, Layout } from '@appwrite.io/pink-svelte';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { EnvironmentVariables } from '$lib/components/variables';
|
||||
|
||||
export let buildCommand = '';
|
||||
export let roles: string[] = [];
|
||||
export let variables: Partial<Models.Variable>[] = [];
|
||||
export let isLoading = false;
|
||||
</script>
|
||||
|
||||
<Fieldset legend="Settings">
|
||||
@@ -31,5 +35,11 @@
|
||||
<Roles bind:roles />
|
||||
</Layout.Stack>
|
||||
</Accordion>
|
||||
<EnvironmentVariables
|
||||
bind:variables
|
||||
productLabel="function"
|
||||
analyticsSource="function_configuration"
|
||||
analyticsCreateSource="function_configuration"
|
||||
{isLoading} />
|
||||
</Layout.Stack>
|
||||
</Fieldset>
|
||||
|
||||
+2
-2
@@ -29,8 +29,8 @@
|
||||
const ruleId = page.url.searchParams.get('rule');
|
||||
|
||||
const showCNAMETab = $derived(
|
||||
Boolean($regionalConsoleVariables._APP_DOMAIN_FUNCTIONS) &&
|
||||
$regionalConsoleVariables._APP_DOMAIN_FUNCTIONS !== 'localhost'
|
||||
Boolean($regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME) &&
|
||||
$regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME !== 'localhost'
|
||||
);
|
||||
const showATab = $derived(
|
||||
!isCloud &&
|
||||
|
||||
+2
-2
@@ -26,8 +26,8 @@
|
||||
} = $props();
|
||||
|
||||
const showCNAMETab = $derived(
|
||||
Boolean($regionalConsoleVariables._APP_DOMAIN_FUNCTIONS) &&
|
||||
$regionalConsoleVariables._APP_DOMAIN_FUNCTIONS !== 'localhost'
|
||||
Boolean($regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME) &&
|
||||
$regionalConsoleVariables._APP_DOMAIN_TARGET_CNAME !== 'localhost'
|
||||
);
|
||||
const showATab = $derived(
|
||||
!isCloud &&
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
{#if $canWritePlatforms}
|
||||
{#if isLimited}
|
||||
<Tooltip maxWidth="200px">
|
||||
<Tooltip>
|
||||
<div>
|
||||
<Button disabled>
|
||||
<Icon icon={IconPlus} slot="start" />
|
||||
@@ -29,7 +29,9 @@
|
||||
</div>
|
||||
|
||||
<svelte:fragment slot="tooltip">
|
||||
You have reached the maximum number of platforms for your plan in a project.
|
||||
<div style="white-space: pre-line;">
|
||||
You have reached the maximum number of platforms for your plan in a project.
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { goto, invalidate } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { Submit, trackError } from '$lib/actions/analytics';
|
||||
import { generateFingerprintToken } from '$lib/helpers/fingerprint';
|
||||
import { Alert, Layout, Modal, Typography } from '@appwrite.io/pink-svelte';
|
||||
import { Status } from '@appwrite.io/console';
|
||||
|
||||
let {
|
||||
show = $bindable(false),
|
||||
projectId,
|
||||
teamId
|
||||
}: {
|
||||
show: boolean;
|
||||
projectId: string;
|
||||
teamId: string;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
async function handleResume() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const fingerprint = await generateFingerprintToken();
|
||||
sdk.forConsole.client.headers['X-Appwrite-Console-Fingerprint'] = fingerprint;
|
||||
|
||||
try {
|
||||
await sdk.forConsole.projects.updateStatus({ projectId, status: Status.Active });
|
||||
} finally {
|
||||
delete sdk.forConsole.client.headers['X-Appwrite-Console-Fingerprint'];
|
||||
}
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Project resumed successfully'
|
||||
});
|
||||
|
||||
// Reload project data to get updated consoleAccessedAt
|
||||
await invalidate(Dependencies.PROJECT);
|
||||
|
||||
show = false;
|
||||
} catch (e) {
|
||||
const message =
|
||||
e && typeof e === 'object' && 'message' in e
|
||||
? String((e as { message: string }).message)
|
||||
: 'Failed to resume project. Please try again.';
|
||||
error = message;
|
||||
trackError(e, Submit.ProjectResume);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal title="Project paused" bind:open={show} size="s" dismissible={false}>
|
||||
<Layout.Stack gap="m">
|
||||
<Typography.Text>This project has been paused due to inactivity.</Typography.Text>
|
||||
<Typography.Text>
|
||||
Your data is safe and will remain intact. Resume the project to continue using it.
|
||||
</Typography.Text>
|
||||
|
||||
{#if error}
|
||||
<Alert.Inline status="error" dismissible on:dismiss={() => (error = null)}>
|
||||
{error}
|
||||
</Alert.Inline>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Layout.Stack direction="row" justifyContent="flex-end">
|
||||
<Button
|
||||
text
|
||||
disabled={loading}
|
||||
on:click={() =>
|
||||
goto(
|
||||
resolve('/(console)/organization-[organization]', {
|
||||
organization: teamId
|
||||
})
|
||||
)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={loading} on:click={handleResume}>
|
||||
{#if loading}
|
||||
Resuming...
|
||||
{:else}
|
||||
Resume project
|
||||
{/if}
|
||||
</Button>
|
||||
</Layout.Stack>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
+19
-8
@@ -7,6 +7,12 @@
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { migrationFormToResources, type Provider } from '$lib/stores/migration';
|
||||
import {
|
||||
AppwriteMigrationResource,
|
||||
FirebaseMigrationResource,
|
||||
NHostMigrationResource,
|
||||
SupabaseMigrationResource
|
||||
} from '@appwrite.io/console';
|
||||
import { started } from '../stores';
|
||||
import { showMigrationBox } from '$lib/components/migrationBox.svelte';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
@@ -45,7 +51,7 @@
|
||||
await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.migrations.createAppwriteMigration({
|
||||
resources,
|
||||
resources: resources as AppwriteMigrationResource[],
|
||||
endpoint: $provider.endpoint,
|
||||
projectId: $provider.projectID,
|
||||
apiKey: $provider.apiKey
|
||||
@@ -58,7 +64,7 @@
|
||||
await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.migrations.createSupabaseMigration({
|
||||
resources,
|
||||
resources: resources as SupabaseMigrationResource[],
|
||||
endpoint: $provider.endpoint,
|
||||
apiKey: $provider.apiKey,
|
||||
databaseHost: $provider.host,
|
||||
@@ -73,7 +79,7 @@
|
||||
await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.migrations.createFirebaseMigration({
|
||||
resources,
|
||||
resources: resources as FirebaseMigrationResource[],
|
||||
serviceAccount: $provider.serviceAccount
|
||||
});
|
||||
await invalidate(Dependencies.MIGRATIONS);
|
||||
@@ -83,7 +89,7 @@
|
||||
await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.migrations.createNHostMigration({
|
||||
resources,
|
||||
resources: resources as NHostMigrationResource[],
|
||||
subdomain: $provider.subdomain,
|
||||
region: $provider.region,
|
||||
adminSecret: $provider.adminSecret,
|
||||
@@ -206,7 +212,8 @@
|
||||
</span>
|
||||
<Layout.Stack gap="none">
|
||||
<Typography.Text variant="m-600"
|
||||
>Project settings are not imported</Typography.Text>
|
||||
>Project settings are not imported
|
||||
</Typography.Text>
|
||||
You will need to set service and project settings manually.
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
@@ -218,7 +225,8 @@
|
||||
</span>
|
||||
<Layout.Stack gap="none">
|
||||
<Typography.Text variant="m-600"
|
||||
>Keep your organization plan's limits in mind</Typography.Text>
|
||||
>Keep your organization plan's limits in mind
|
||||
</Typography.Text>
|
||||
Make sure to have enough storage in your organization plan when importing files.
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
@@ -256,9 +264,12 @@
|
||||
variant="secondary"
|
||||
on:click={() => {
|
||||
showExitModal = true;
|
||||
}}>Cancel</Button.Button>
|
||||
}}
|
||||
>Cancel
|
||||
</Button.Button>
|
||||
|
||||
<Button.Button variant="primary" disabled={!isFinalsButtonEnabled} on:click={onFinish}
|
||||
>Create</Button.Button>
|
||||
>Create
|
||||
</Button.Button>
|
||||
</svelte:fragment>
|
||||
</Wizard>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
|
||||
<ViewSelector ui="new" {columns} view={View.Table} hideView />
|
||||
{#if $canWriteWebhooks}
|
||||
<Tooltip disabled={!isLimited} maxWidth="200px">
|
||||
<Tooltip disabled={!isLimited}>
|
||||
<div>
|
||||
<Button disabled={isLimited} event="create_webhook" href={webhooksCreateUrl}>
|
||||
<Icon icon={IconPlus} slot="start" size="s" />
|
||||
@@ -47,7 +47,9 @@
|
||||
</Button>
|
||||
</div>
|
||||
<svelte:fragment slot="tooltip">
|
||||
You have reached the maximum number of webhooks for your plan.
|
||||
<div style="white-space: pre-line;">
|
||||
You have reached the maximum number of webhooks for your plan.
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
+4
-230
@@ -1,38 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Empty, Paginator } from '$lib/components';
|
||||
import { Button, InputSelect, InputText } from '$lib/elements/forms';
|
||||
import {
|
||||
Fieldset,
|
||||
Layout,
|
||||
Popover,
|
||||
Icon,
|
||||
Table,
|
||||
Badge,
|
||||
InteractiveText,
|
||||
ActionMenu,
|
||||
Accordion,
|
||||
Tooltip,
|
||||
Button as PinkButton
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import {
|
||||
IconDotsHorizontal,
|
||||
IconCode,
|
||||
IconUpload,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
IconEyeOff,
|
||||
IconPencil
|
||||
} from '@appwrite.io/pink-icons-svelte';
|
||||
import { Fieldset, Layout, Accordion } from '@appwrite.io/pink-svelte';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { iconPath } from '$lib/stores/app';
|
||||
import VariableEditorModal from './variableEditorModal.svelte';
|
||||
import SecretVariableModal from './secretVariableModal.svelte';
|
||||
import ImportSiteVariablesModal from './importSiteVariablesModal.svelte';
|
||||
import CreateVariableModal from './createVariableModal.svelte';
|
||||
import DeleteVariableModal from './deleteVariableModal.svelte';
|
||||
import UpdateVariableModal from './updateVariableModal.svelte';
|
||||
import { Click, trackEvent } from '$lib/actions/analytics';
|
||||
import { getFrameworkIcon } from '$lib/stores/sites';
|
||||
import { EnvironmentVariables } from '$lib/components/variables';
|
||||
|
||||
export let frameworks: Models.Framework[];
|
||||
export let selectedFramework: Models.Framework;
|
||||
@@ -42,18 +14,11 @@
|
||||
frameworkData?.adapters.find((adapter) => adapter.key === 'static');
|
||||
|
||||
export let variables: Partial<Models.Variable>[] = [];
|
||||
export let isLoading = false;
|
||||
export let installCommand = '';
|
||||
export let buildCommand = '';
|
||||
export let outputDirectory = '';
|
||||
|
||||
let showEditorModal = false;
|
||||
let showImportModal = false;
|
||||
let showSecretModal = false;
|
||||
let showCreate = false;
|
||||
let showUpdate = false;
|
||||
let showDelete = false;
|
||||
|
||||
let currentVariable: Partial<Models.Variable>;
|
||||
let frameworkId = selectedFramework.key;
|
||||
|
||||
$: if (!installCommand || !buildCommand || !outputDirectory) {
|
||||
@@ -138,198 +103,7 @@
|
||||
</Layout.Stack>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Environment variables" badge="Optional" hideDivider>
|
||||
<Layout.Stack gap="xl">
|
||||
Set up environment variables to securely manage keys and settings for your
|
||||
project.
|
||||
<Layout.Stack gap="l">
|
||||
<Layout.Stack direction="row">
|
||||
<Layout.Stack direction="row" gap="s">
|
||||
<Button
|
||||
secondary
|
||||
size="s"
|
||||
on:mousedown={() => {
|
||||
showEditorModal = true;
|
||||
trackEvent(Click.VariablesUpdateClick, {
|
||||
source: 'site_configuration'
|
||||
});
|
||||
}}>
|
||||
<Icon slot="start" icon={IconCode} /> Editor
|
||||
</Button>
|
||||
<Button
|
||||
secondary
|
||||
size="s"
|
||||
on:mousedown={() => {
|
||||
showImportModal = true;
|
||||
trackEvent(Click.VariablesImportClick, {
|
||||
source: 'site_configuration'
|
||||
});
|
||||
}}>
|
||||
<Icon slot="start" icon={IconUpload} /> Import .env
|
||||
</Button>
|
||||
</Layout.Stack>
|
||||
{#if variables?.length}
|
||||
<Button
|
||||
secondary
|
||||
size="s"
|
||||
on:mousedown={() => {
|
||||
showCreate = true;
|
||||
trackEvent(Click.VariablesCreateClick, {
|
||||
source: 'site_settings'
|
||||
});
|
||||
}}>
|
||||
<Icon slot="start" icon={IconPlus} /> Create variable
|
||||
</Button>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
|
||||
{#if variables?.length}
|
||||
<Paginator
|
||||
items={variables}
|
||||
limit={6}
|
||||
hideFooter={variables.length <= 6}>
|
||||
{#snippet children(paginatedItems)}
|
||||
<Table.Root
|
||||
let:root
|
||||
columns={[
|
||||
{ id: 'key', width: 200 },
|
||||
{ id: 'value' },
|
||||
{ id: 'actions', width: 40 }
|
||||
]}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="key" {root}
|
||||
>Key</Table.Header.Cell>
|
||||
<Table.Header.Cell column="value" {root}
|
||||
>Value</Table.Header.Cell>
|
||||
<Table.Header.Cell column="actions" {root}
|
||||
></Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
{#each paginatedItems as variable}
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell column="key" {root}
|
||||
>{variable.key}</Table.Cell>
|
||||
<Table.Cell column="value" {root}>
|
||||
<!-- TODO: fix max width -->
|
||||
<div style="max-width: 20rem">
|
||||
{#if variable.secret}
|
||||
<Tooltip maxWidth="26rem">
|
||||
<Badge
|
||||
content="Secret"
|
||||
variant="secondary"
|
||||
size="s" />
|
||||
<svelte:fragment slot="tooltip">
|
||||
This value is secret, you cannot
|
||||
see its value.
|
||||
</svelte:fragment>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
<InteractiveText
|
||||
variant="secret"
|
||||
isVisible={false}
|
||||
text={variable.value} />
|
||||
{/if}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="actions" {root}>
|
||||
<div style="margin-inline-start: auto">
|
||||
<Popover
|
||||
padding="none"
|
||||
placement="bottom-end"
|
||||
let:toggle>
|
||||
<PinkButton.Button
|
||||
icon
|
||||
variant="text"
|
||||
size="s"
|
||||
aria-label="More options"
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
toggle(e);
|
||||
}}>
|
||||
<Icon
|
||||
icon={IconDotsHorizontal}
|
||||
size="s" />
|
||||
</PinkButton.Button>
|
||||
|
||||
<svelte:fragment
|
||||
slot="tooltip"
|
||||
let:toggle>
|
||||
<ActionMenu.Root>
|
||||
{#if !variable?.secret}
|
||||
<ActionMenu.Item.Button
|
||||
leadingIcon={IconPencil}
|
||||
on:click={(e) => {
|
||||
toggle(e);
|
||||
currentVariable =
|
||||
variable;
|
||||
showUpdate = true;
|
||||
}}>
|
||||
Update
|
||||
</ActionMenu.Item.Button>
|
||||
{/if}
|
||||
{#if !variable?.secret}
|
||||
<ActionMenu.Item.Button
|
||||
leadingIcon={IconEyeOff}
|
||||
on:click={(e) => {
|
||||
toggle(e);
|
||||
|
||||
currentVariable =
|
||||
variable;
|
||||
showSecretModal = true;
|
||||
}}>
|
||||
Secret
|
||||
</ActionMenu.Item.Button>
|
||||
{/if}
|
||||
<ActionMenu.Item.Button
|
||||
status="danger"
|
||||
leadingIcon={IconTrash}
|
||||
on:click={(e) => {
|
||||
toggle(e);
|
||||
currentVariable =
|
||||
variable;
|
||||
showDelete = true;
|
||||
}}>
|
||||
Delete
|
||||
</ActionMenu.Item.Button>
|
||||
</ActionMenu.Root>
|
||||
</svelte:fragment>
|
||||
</Popover>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
{/snippet}
|
||||
</Paginator>
|
||||
{:else}
|
||||
<Empty on:click={() => (showCreate = true)}
|
||||
>Create variables to get started</Empty>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
</Accordion>
|
||||
<EnvironmentVariables bind:variables {isLoading} />
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
</Fieldset>
|
||||
|
||||
{#if showEditorModal}
|
||||
<VariableEditorModal bind:variables bind:showEditor={showEditorModal} />
|
||||
{/if}
|
||||
|
||||
{#if showSecretModal}
|
||||
<SecretVariableModal bind:show={showSecretModal} bind:currentVariable bind:variables />
|
||||
{/if}
|
||||
|
||||
{#if showImportModal}
|
||||
<ImportSiteVariablesModal bind:show={showImportModal} bind:variables />
|
||||
{/if}
|
||||
|
||||
{#if showCreate}
|
||||
<CreateVariableModal bind:show={showCreate} bind:variables />
|
||||
{/if}
|
||||
{#if showUpdate}
|
||||
<UpdateVariableModal bind:show={showUpdate} bind:variables bind:selectedVar={currentVariable} />
|
||||
{/if}
|
||||
|
||||
{#if showDelete}
|
||||
<DeleteVariableModal bind:show={showDelete} bind:variables bind:currentVariable />
|
||||
{/if}
|
||||
|
||||
+17
-2
@@ -138,7 +138,22 @@
|
||||
}
|
||||
|
||||
function handleInvalid(e: CustomEvent) {
|
||||
const reason = e.detail?.reason ?? '';
|
||||
let reason = e.detail?.reason ?? '';
|
||||
|
||||
if (!reason) {
|
||||
const nativeEvent = e.detail as Event | undefined;
|
||||
const input = (nativeEvent?.currentTarget ?? nativeEvent?.target) as
|
||||
| HTMLInputElement
|
||||
| undefined;
|
||||
const pickedFiles = Array.from(input?.files ?? []);
|
||||
|
||||
if (pickedFiles.some((file) => file.size > maxSize)) {
|
||||
reason = InvalidFileType.SIZE;
|
||||
} else if (pickedFiles.some((file) => !file.name.toLowerCase().endsWith('.tar.gz'))) {
|
||||
reason = InvalidFileType.EXTENSION;
|
||||
}
|
||||
}
|
||||
|
||||
if (reason === InvalidFileType.EXTENSION) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
@@ -147,7 +162,7 @@
|
||||
} else if (reason === InvalidFileType.SIZE) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: 'File size exceeds 10MB'
|
||||
message: `File size exceeds ${readableMaxSize.value}${readableMaxSize.unit}`
|
||||
});
|
||||
} else {
|
||||
addNotification({
|
||||
|
||||
+16
@@ -27,6 +27,7 @@
|
||||
import Configuration from '../../configuration.svelte';
|
||||
import Domain from '../../domain.svelte';
|
||||
import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store';
|
||||
import { normalizeDetectedVariables, mergeVariables } from '$lib/helpers/variables';
|
||||
|
||||
export let data;
|
||||
let showExitModal = false;
|
||||
@@ -47,6 +48,13 @@
|
||||
let silentMode = false;
|
||||
let domain = data.domain;
|
||||
let domainIsValid = true;
|
||||
let isVariablesLoading = true;
|
||||
|
||||
let prevRootDir = rootDir;
|
||||
$: if (rootDir !== prevRootDir) {
|
||||
prevRootDir = rootDir;
|
||||
detectFramework();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
installation.set(data.installation);
|
||||
@@ -58,6 +66,7 @@
|
||||
|
||||
async function detectFramework() {
|
||||
try {
|
||||
isVariablesLoading = true;
|
||||
const response = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.vcs.createRepositoryDetection({
|
||||
@@ -71,6 +80,10 @@
|
||||
installCommand = adapter?.installCommand;
|
||||
buildCommand = adapter?.buildCommand;
|
||||
outputDirectory = adapter?.outputDirectory;
|
||||
const detectedVariables = normalizeDetectedVariables(response?.variables);
|
||||
if (detectedVariables.length) {
|
||||
variables = mergeVariables(variables, detectedVariables);
|
||||
}
|
||||
trackEvent(Submit.FrameworkDetect, {
|
||||
source: 'repository',
|
||||
framework: framework.key
|
||||
@@ -78,6 +91,8 @@
|
||||
} catch (error) {
|
||||
framework = data.frameworks.frameworks.find((f) => f.key === 'other');
|
||||
trackError(error, Submit.FrameworkDetect);
|
||||
} finally {
|
||||
isVariablesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +216,7 @@
|
||||
bind:outputDirectory
|
||||
bind:selectedFramework={framework}
|
||||
bind:variables
|
||||
isLoading={isVariablesLoading}
|
||||
frameworks={data.frameworks.frameworks} />
|
||||
{/key}
|
||||
|
||||
|
||||
+37
-19
@@ -14,6 +14,7 @@
|
||||
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { realtime, sdk } from '$lib/stores/sdk';
|
||||
import { sortBranches } from '$lib/stores/vcs';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import CreateCliModal from './createCliModal.svelte';
|
||||
@@ -40,28 +41,45 @@
|
||||
});
|
||||
|
||||
async function connect(selectedInstallationId: string, selectedRepository: string) {
|
||||
let nextBranch = data.site?.providerBranch ?? 'main';
|
||||
try {
|
||||
await sdk.forProject(page.params.region, page.params.project).sites.update({
|
||||
siteId: data.site.$id,
|
||||
name: data.site.name,
|
||||
framework: data.site.framework as Framework,
|
||||
enabled: data.site.enabled,
|
||||
logging: data.site.logging || undefined,
|
||||
timeout: data.site.timeout,
|
||||
installCommand: data.site.installCommand,
|
||||
buildCommand: data.site.buildCommand,
|
||||
outputDirectory: data.site.outputDirectory,
|
||||
buildRuntime: data.site.buildRuntime as BuildRuntime,
|
||||
adapter: data.site.adapter as Adapter,
|
||||
fallbackFile: data.site.fallbackFile,
|
||||
installationId: selectedInstallationId,
|
||||
providerRepositoryId: selectedRepository,
|
||||
providerBranch: 'main'
|
||||
});
|
||||
invalidate(Dependencies.SITE);
|
||||
const branchList = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.vcs.listRepositoryBranches({
|
||||
installationId: selectedInstallationId,
|
||||
providerRepositoryId: selectedRepository
|
||||
});
|
||||
const sorted = sortBranches(branchList.branches);
|
||||
nextBranch =
|
||||
sorted.find((branch) => branch.name === data.site?.providerBranch)?.name ??
|
||||
sorted.find((branch) => branch.name === 'main' || branch.name === 'master')?.name ??
|
||||
sorted[0]?.name ??
|
||||
nextBranch;
|
||||
} catch {
|
||||
return;
|
||||
// Ignore branch lookup failures; fallback to default.
|
||||
}
|
||||
|
||||
await sdk.forProject(page.params.region, page.params.project).sites.update({
|
||||
siteId: data.site.$id,
|
||||
name: data.site.name,
|
||||
framework: data.site.framework as Framework,
|
||||
enabled: data.site.enabled,
|
||||
logging: data.site.logging || undefined,
|
||||
timeout: data.site.timeout,
|
||||
installCommand: data.site.installCommand,
|
||||
buildCommand: data.site.buildCommand,
|
||||
outputDirectory: data.site.outputDirectory,
|
||||
buildRuntime: data.site.buildRuntime as BuildRuntime,
|
||||
adapter: data.site.adapter as Adapter,
|
||||
fallbackFile: data.site.fallbackFile,
|
||||
installationId: selectedInstallationId,
|
||||
providerRepositoryId: selectedRepository,
|
||||
providerBranch: nextBranch,
|
||||
providerSilentMode: data.site?.providerSilentMode ?? undefined,
|
||||
providerRootDirectory: data.site?.providerRootDirectory ?? undefined,
|
||||
specification: data.site?.specification || undefined
|
||||
});
|
||||
invalidate(Dependencies.SITE);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
+37
-21
@@ -116,30 +116,46 @@
|
||||
}
|
||||
|
||||
async function connect(selectedInstallationId: string, selectedRepository: string) {
|
||||
let nextBranch = site?.providerBranch ?? 'main';
|
||||
try {
|
||||
await sdk.forProject(page.params.region, page.params.project).sites.update({
|
||||
siteId: site.$id,
|
||||
name: site.name,
|
||||
framework: site.framework as Framework,
|
||||
enabled: site?.enabled,
|
||||
logging: site?.logging || undefined,
|
||||
timeout: site?.timeout,
|
||||
installCommand: site?.installCommand,
|
||||
buildCommand: site?.buildCommand,
|
||||
outputDirectory: site?.outputDirectory,
|
||||
buildRuntime: site?.buildRuntime as BuildRuntime,
|
||||
adapter: site.adapter as Adapter,
|
||||
fallbackFile: site?.fallbackFile,
|
||||
installationId: selectedInstallationId,
|
||||
providerRepositoryId: selectedRepository,
|
||||
providerBranch: 'main',
|
||||
specification: site?.specification || undefined
|
||||
});
|
||||
|
||||
invalidate(Dependencies.SITE);
|
||||
const branchList = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.vcs.listRepositoryBranches({
|
||||
installationId: selectedInstallationId,
|
||||
providerRepositoryId: selectedRepository
|
||||
});
|
||||
const sorted = sortBranches(branchList.branches);
|
||||
nextBranch =
|
||||
sorted.find((branch) => branch.name === site?.providerBranch)?.name ??
|
||||
sorted.find((branch) => branch.name === 'main' || branch.name === 'master')?.name ??
|
||||
sorted[0]?.name ??
|
||||
nextBranch;
|
||||
} catch {
|
||||
return;
|
||||
// Ignore branch lookup failures; fallback to default.
|
||||
}
|
||||
|
||||
await sdk.forProject(page.params.region, page.params.project).sites.update({
|
||||
siteId: site.$id,
|
||||
name: site.name,
|
||||
framework: site.framework as Framework,
|
||||
enabled: site?.enabled,
|
||||
logging: site?.logging || undefined,
|
||||
timeout: site?.timeout,
|
||||
installCommand: site?.installCommand,
|
||||
buildCommand: site?.buildCommand,
|
||||
outputDirectory: site?.outputDirectory,
|
||||
buildRuntime: site?.buildRuntime as BuildRuntime,
|
||||
adapter: site.adapter as Adapter,
|
||||
fallbackFile: site?.fallbackFile,
|
||||
installationId: selectedInstallationId,
|
||||
providerRepositoryId: selectedRepository,
|
||||
providerBranch: nextBranch,
|
||||
providerSilentMode: site?.providerSilentMode ?? undefined,
|
||||
providerRootDirectory: site?.providerRootDirectory ?? undefined,
|
||||
specification: site?.specification || undefined
|
||||
});
|
||||
|
||||
invalidate(Dependencies.SITE);
|
||||
}
|
||||
|
||||
$: if (site?.installationId && site?.providerRepositoryId) {
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
view={data.view}
|
||||
searchPlaceholder="Search by name or ID">
|
||||
{#if $canWriteBuckets}
|
||||
<Tooltip disabled={!isLimited} maxWidth="200px">
|
||||
<Tooltip disabled={!isLimited}>
|
||||
<div>
|
||||
<Button
|
||||
size="s"
|
||||
@@ -56,7 +56,9 @@
|
||||
</Button>
|
||||
</div>
|
||||
<svelte:fragment slot="tooltip">
|
||||
You have reached the maximum number of buckets for your plan.
|
||||
<div style="white-space: pre-line;">
|
||||
You have reached the maximum number of buckets for your plan.
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
@@ -134,16 +134,17 @@
|
||||
formData.append('subject', $supportData.subject);
|
||||
formData.append('firstName', ($user?.name || 'Unknown').slice(0, 40));
|
||||
formData.append('message', $supportData.message);
|
||||
formData.append('tags[]', 'cloud');
|
||||
formData.append('tags[]', categoryTopicTag);
|
||||
formData.append(
|
||||
'customFields',
|
||||
JSON.stringify([
|
||||
{ id: '41612', value: $supportData.category },
|
||||
{ id: '48492', value: $organization?.$id ?? '' },
|
||||
{ id: '48491', value: $supportData?.project ?? '' },
|
||||
{ id: '56023', value: $supportData?.severity ?? '' },
|
||||
{ id: '56024', value: $organization?.billingPlanId ?? '' }
|
||||
])
|
||||
'metaFields',
|
||||
JSON.stringify({
|
||||
category: $supportData.category,
|
||||
orgId: $organization?.$id ?? '',
|
||||
projectId: $supportData?.project ?? '',
|
||||
severity: $supportData?.severity ?? '',
|
||||
billingPlan: $organization?.billingPlanId ?? ''
|
||||
})
|
||||
);
|
||||
if (files && files.length > 0) {
|
||||
formData.append('attachment', files[0]);
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { sentrySvelteKit } from '@sentry/sveltekit';
|
||||
import { svelteTesting } from '@testing-library/svelte/vite';
|
||||
|
||||
Reference in New Issue
Block a user