Merge branch 'feat-documentsdb' into feat-vectordb

This commit is contained in:
Prem Palanisamy
2026-03-16 09:12:58 +00:00
94 changed files with 2458 additions and 1698 deletions
+3
View File
@@ -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 }}"
+2
View File
@@ -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
+27 -37
View File
@@ -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
View File
@@ -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"
}
}
+1
View File
@@ -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',
+2 -2
View File
@@ -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(
+3 -1
View File
@@ -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 {
-379
View File
@@ -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;
}
+247
View File
@@ -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>
+23 -1
View File
@@ -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>
+14 -2
View File
@@ -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>
+351 -113
View File
@@ -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>
+14
View File
@@ -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;
};
+166 -103
View File
@@ -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>
+9 -9
View File
@@ -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>
@@ -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}
+7
View File
@@ -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';
@@ -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
@@ -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">
+11 -1
View File
@@ -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();
+225
View File
@@ -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}`;
}
+12 -5
View File
@@ -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);
}
+35
View File
@@ -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());
}
-27
View File
@@ -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 &&
+3 -15
View File
@@ -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
View File
@@ -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
+2 -11
View File
@@ -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,
@@ -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,
@@ -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()];
});
@@ -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({
@@ -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({
@@ -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({
@@ -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}
@@ -75,7 +75,7 @@ export type DatabaseSdkResult = {
deleteRecord: (params: {
databaseId: string;
entityId: string;
recordId?: string;
recordId: string;
databaseType?: DatabaseType;
}) => Promise<Record>;
deleteRecords: (params: {
@@ -212,6 +212,7 @@
<Button text on:click={() => resetSampleFieldsConfig()}>Cancel</Button>
<Button
on:click={async () => {
$randomDataModalState.show = false;
await $randomDataModalState.onSubmit?.();
resetSampleFieldsConfig();
}}>Create</Button>
@@ -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"
@@ -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"
@@ -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() {
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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',
@@ -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>
@@ -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>
@@ -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 */
@@ -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>
@@ -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">
@@ -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>
@@ -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 &&
@@ -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>
@@ -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}
@@ -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}
@@ -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({
@@ -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}
@@ -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>
@@ -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}
+9 -8
View File
@@ -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
View File
@@ -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';