Merge branch 'main' into feat-group
@@ -12,7 +12,7 @@
|
||||
"download-contributors": "node ./scripts/download-contributor-data.js",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"generate:icons": "pnpx svgo -rf src/icons/svg -o src/icons/optimized",
|
||||
"generate:icons": "node ./src/icons/optimize.js",
|
||||
"icons:build": "node ./src/icons/build.js",
|
||||
"icons:generate": "node ./src/icons/optimize.js && node ./src/icons/build.js",
|
||||
"icons:optimize": "node ./src/icons/optimize.js",
|
||||
@@ -24,11 +24,10 @@
|
||||
"optimize": "node ./scripts/optimize-assets.js",
|
||||
"optimize:all": "node ./scripts/optimize-all.js"
|
||||
},
|
||||
"packageManager": "pnpm@10.6.2",
|
||||
"packageManager": "pnpm@10.8.1",
|
||||
"dependencies": {
|
||||
"@number-flow/svelte": "^0.3.3",
|
||||
"h3": "^1.14.0",
|
||||
"melt": "^0.28.2",
|
||||
"posthog-js": "^1.210.2",
|
||||
"sharp": "^0.33.5"
|
||||
},
|
||||
@@ -54,6 +53,7 @@
|
||||
"@types/markdown-it": "^13.0.9",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"analytics": "^0.8.16",
|
||||
"bits-ui": "^1.3.19",
|
||||
"clsx": "^2.1.1",
|
||||
"cva": "npm:class-variance-authority@^0.7.1",
|
||||
"date-fns": "^3.6.0",
|
||||
@@ -70,6 +70,7 @@
|
||||
"highlight.js": "^11.11.1",
|
||||
"markdown-it": "^14.1.0",
|
||||
"meilisearch": "^0.37.0",
|
||||
"melt": "^0.29.2",
|
||||
"motion": "^10.18.0",
|
||||
"node-html-parser": "^6.1.13",
|
||||
"openapi-types": "^12.1.3",
|
||||
|
||||
65
pnpm-lock.yaml
generated
@@ -14,9 +14,6 @@ importers:
|
||||
h3:
|
||||
specifier: ^1.14.0
|
||||
version: 1.15.1
|
||||
melt:
|
||||
specifier: ^0.28.2
|
||||
version: 0.28.2(@floating-ui/dom@1.6.13)(svelte@5.25.6)
|
||||
posthog-js:
|
||||
specifier: ^1.210.2
|
||||
version: 1.230.4
|
||||
@@ -87,6 +84,9 @@ importers:
|
||||
analytics:
|
||||
specifier: ^0.8.16
|
||||
version: 0.8.16(@types/dlv@1.1.5)
|
||||
bits-ui:
|
||||
specifier: ^1.3.19
|
||||
version: 1.3.19(svelte@5.25.6)
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
@@ -135,6 +135,9 @@ importers:
|
||||
meilisearch:
|
||||
specifier: ^0.37.0
|
||||
version: 0.37.0(encoding@0.1.13)
|
||||
melt:
|
||||
specifier: ^0.29.2
|
||||
version: 0.29.2(@floating-ui/dom@1.6.13)(svelte@5.25.6)
|
||||
motion:
|
||||
specifier: ^10.18.0
|
||||
version: 10.18.0
|
||||
@@ -611,6 +614,9 @@ packages:
|
||||
'@internationalized/date@3.5.0':
|
||||
resolution: {integrity: sha512-nw0Q+oRkizBWMioseI8+2TeUPEyopJVz5YxoYVzR0W1v+2YytiYah7s/ot35F149q/xAg4F1gT/6eTd+tsUpFQ==}
|
||||
|
||||
'@internationalized/date@3.8.0':
|
||||
resolution: {integrity: sha512-J51AJ0fEL68hE4CwGPa6E0PO6JDaVLd8aln48xFCSy7CZkZc96dGEGmLs2OEEbBxcsVZtfrqkXJwI2/MSG8yKw==}
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1694,6 +1700,12 @@ packages:
|
||||
bindings@1.5.0:
|
||||
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
|
||||
|
||||
bits-ui@1.3.19:
|
||||
resolution: {integrity: sha512-2blb6dkgedHUsDXqCjvmtUi4Advgd9MhaJDT8r7bEWDzHI8HGsOoYsLeh8CxpEWWEYPrlGN+7k+kpxRhIDdFrQ==}
|
||||
engines: {node: '>=18', pnpm: '>=8.7.0'}
|
||||
peerDependencies:
|
||||
svelte: ^5.11.0
|
||||
|
||||
bmp-js@0.1.0:
|
||||
resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==}
|
||||
|
||||
@@ -2451,6 +2463,9 @@ packages:
|
||||
resolution: {integrity: sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==}
|
||||
engines: {node: ^18.17.0 || >=20.5.0}
|
||||
|
||||
inline-style-parser@0.2.4:
|
||||
resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==}
|
||||
|
||||
ip-address@9.0.5:
|
||||
resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==}
|
||||
engines: {node: '>= 12'}
|
||||
@@ -2726,8 +2741,8 @@ packages:
|
||||
meilisearch@0.37.0:
|
||||
resolution: {integrity: sha512-LdbK6JmRghCawrmWKJSEQF0OiE82md+YqJGE/U2JcCD8ROwlhTx0KM6NX4rQt0u0VpV0QZVG9umYiu3CSSIJAQ==}
|
||||
|
||||
melt@0.28.2:
|
||||
resolution: {integrity: sha512-55DGQ4B3bHKnDnK1ECJ46D+xythNKvuil60k0RXWJ3eEY5XsGjT6WZPVpRooYLfMlAki2J7JCWglCMYzvXwxVw==}
|
||||
melt@0.29.2:
|
||||
resolution: {integrity: sha512-x0qR8yE8+x2Bu6s1DRJNAxPBN295ANfTVJ/8UcWsNm/hb7M14ws9G64OFpRExZcI45kdh2KZb1LwHFmNsLwUbQ==}
|
||||
peerDependencies:
|
||||
'@floating-ui/dom': ^1.6.0
|
||||
svelte: ^5.0.0
|
||||
@@ -3431,6 +3446,9 @@ packages:
|
||||
resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
style-to-object@1.0.8:
|
||||
resolution: {integrity: sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==}
|
||||
|
||||
sucrase@3.35.0:
|
||||
resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
@@ -3474,6 +3492,12 @@ packages:
|
||||
peerDependencies:
|
||||
svelte: ^3.0.0 || ^4.0.0 || ^5.0.0-next.1
|
||||
|
||||
svelte-toolbelt@0.7.1:
|
||||
resolution: {integrity: sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==}
|
||||
engines: {node: '>=18', pnpm: '>=8.7.0'}
|
||||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
|
||||
svelte@5.25.6:
|
||||
resolution: {integrity: sha512-RGkaeAXDuJdvhA1fdSM5GgD++vYfJYijZL0uN6kM2s/TRJ663jktBhZlF0qjzAJGR/34PtaeT3G8MKJY1EKeqg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4174,6 +4198,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.15
|
||||
|
||||
'@internationalized/date@3.8.0':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.15
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
dependencies:
|
||||
string-width: 5.1.2
|
||||
@@ -5298,6 +5326,17 @@ snapshots:
|
||||
dependencies:
|
||||
file-uri-to-path: 1.0.0
|
||||
|
||||
bits-ui@1.3.19(svelte@5.25.6):
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.6.9
|
||||
'@floating-ui/dom': 1.6.13
|
||||
'@internationalized/date': 3.8.0
|
||||
esm-env: 1.2.2
|
||||
runed: 0.23.4(svelte@5.25.6)
|
||||
svelte: 5.25.6
|
||||
svelte-toolbelt: 0.7.1(svelte@5.25.6)
|
||||
tabbable: 6.2.0
|
||||
|
||||
bmp-js@0.1.0: {}
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
@@ -6128,6 +6167,8 @@ snapshots:
|
||||
|
||||
ini@5.0.0: {}
|
||||
|
||||
inline-style-parser@0.2.4: {}
|
||||
|
||||
ip-address@9.0.5:
|
||||
dependencies:
|
||||
jsbn: 1.1.0
|
||||
@@ -6407,9 +6448,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
melt@0.28.2(@floating-ui/dom@1.6.13)(svelte@5.25.6):
|
||||
melt@0.29.2(@floating-ui/dom@1.6.13)(svelte@5.25.6):
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.6.13
|
||||
dequal: 2.0.3
|
||||
jest-axe: 9.0.0
|
||||
nanoid: 5.1.3
|
||||
runed: 0.23.4(svelte@5.25.6)
|
||||
@@ -7065,6 +7107,10 @@ snapshots:
|
||||
'@tokenizer/token': 0.3.0
|
||||
peek-readable: 4.1.0
|
||||
|
||||
style-to-object@1.0.8:
|
||||
dependencies:
|
||||
inline-style-parser: 0.2.4
|
||||
|
||||
sucrase@3.35.0:
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.8
|
||||
@@ -7126,6 +7172,13 @@ snapshots:
|
||||
dependencies:
|
||||
svelte: 5.25.6
|
||||
|
||||
svelte-toolbelt@0.7.1(svelte@5.25.6):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
runed: 0.23.4(svelte@5.25.6)
|
||||
style-to-object: 1.0.8
|
||||
svelte: 5.25.6
|
||||
|
||||
svelte@5.25.6:
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
|
||||
32
src/app.css
@@ -84,12 +84,14 @@
|
||||
/* Animations */
|
||||
--animate-scale-in: scale-in 200ms ease-out forwards;
|
||||
--animate-caret-blink: caret-blink 1s ease-in-out infinite;
|
||||
--animate-text:
|
||||
fade 0.75s ease-in-out both, blur 0.75s ease-in-out both, up 0.75s ease-in-out both;
|
||||
--animate-enter:
|
||||
fade-in 0.75s ease-in-out both, blur 0.75s ease-in-out both, up 0.75s ease-in-out both;
|
||||
--animate-scroll: scroll 60s linear infinite;
|
||||
--animate-fade-in: fade-in 0.5s ease-in-out both;
|
||||
--animate-marquee: marquee var(--speed, 30s) linear infinite var(--direction, forwards);
|
||||
--animate-lighting: lighting 1.25s ease-out forwards;
|
||||
--animate-menu-in: menu-in 0.25s ease-out forwards;
|
||||
--animate-menu-out: menu-out 0.25s ease-out forwards;
|
||||
|
||||
/* Keyframes */
|
||||
@keyframes scale-in {
|
||||
@@ -168,6 +170,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes menu-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
filter: blur(2px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes menu-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
transform: translateY(0px);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
filter: blur(2px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fonts */
|
||||
--font-sans: 'Inter', arial, sans-serif;
|
||||
--font-mono: 'Fira Code', monospace;
|
||||
|
||||
1
src/icons/optimized/edge.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9.925 4.028 C 9.521 4.066,8.877 4.204,8.524 4.328 C 8.123 4.469,7.392 4.849,7.035 5.101 C 6.608 5.403,5.892 6.119,5.591 6.545 C 5.321 6.929,4.984 7.584,4.835 8.018 C 4.401 9.277,4.401 10.708,4.835 11.967 C 5.340 13.435,6.497 14.723,7.918 15.400 C 10.018 16.401,12.497 16.102,14.298 14.630 C 14.650 14.342,15.135 13.821,15.408 13.438 C 15.675 13.063,16.010 12.412,16.165 11.967 C 16.599 10.715,16.599 9.270,16.165 8.018 C 15.445 5.946,13.523 4.360,11.374 4.065 C 10.964 4.009,10.306 3.992,9.925 4.028 M11.305 5.269 C 12.589 5.477,13.793 6.266,14.514 7.372 C 15.950 9.575,15.343 12.526,13.153 13.986 C 12.377 14.505,11.454 14.781,10.500 14.781 C 9.605 14.781,8.772 14.552,8.033 14.104 C 6.863 13.395,6.055 12.239,5.786 10.891 C 5.702 10.471,5.702 9.514,5.786 9.094 C 6.040 7.821,6.785 6.700,7.840 6.005 C 8.366 5.658,9.088 5.368,9.663 5.271 C 10.068 5.204,10.894 5.203,11.305 5.269 " fill="currentColor" stroke="none" fill-rule="evenodd"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
src/icons/optimized/pop-locations.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9.167 4.438 C 7.692 4.591,6.489 5.403,5.845 6.679 C 5.665 7.034,5.490 7.548,5.417 7.933 C 5.388 8.089,5.357 8.248,5.349 8.287 C 5.336 8.348,5.303 8.360,5.098 8.379 C 4.540 8.431,3.888 8.622,3.350 8.890 C 2.026 9.551,1.337 10.768,1.411 12.317 C 1.497 14.133,2.739 15.301,4.866 15.565 C 5.255 15.613,14.745 15.613,15.134 15.565 C 16.701 15.370,17.807 14.676,18.307 13.574 C 18.646 12.826,18.689 11.790,18.416 10.950 C 17.980 9.610,16.649 8.644,14.885 8.388 L 14.621 8.349 14.529 8.031 C 14.275 7.147,13.888 6.448,13.316 5.840 C 12.290 4.750,10.850 4.264,9.167 4.438 M10.305 5.635 C 11.168 5.723,11.851 6.047,12.419 6.638 C 13.064 7.309,13.425 8.176,13.517 9.275 L 13.539 9.533 13.995 9.533 C 15.366 9.534,16.567 10.061,17.063 10.881 C 17.464 11.544,17.505 12.528,17.158 13.178 C 16.817 13.817,16.071 14.239,15.057 14.366 C 14.685 14.412,5.315 14.412,4.943 14.366 C 3.718 14.212,2.914 13.644,2.677 12.763 C 2.593 12.452,2.602 11.738,2.694 11.395 C 2.917 10.563,3.522 10.006,4.519 9.717 C 4.960 9.589,5.406 9.533,5.983 9.533 L 6.500 9.533 6.501 9.275 C 6.501 8.850,6.561 8.267,6.631 7.994 C 7.068 6.287,8.387 5.440,10.305 5.635 " stroke="none" fill-rule="evenodd" fill="black"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
src/icons/optimized/regions.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9.468 2.484 C 8.358 2.664,7.422 3.046,6.510 3.690 C 6.111 3.973,5.323 4.766,5.030 5.180 C 4.233 6.306,3.806 7.818,3.917 9.119 C 4.093 11.169,5.147 13.050,7.302 15.155 C 8.143 15.977,9.253 16.888,10.023 17.390 C 10.274 17.554,10.317 17.570,10.500 17.570 C 10.683 17.570,10.726 17.554,10.977 17.390 C 12.355 16.491,14.197 14.796,15.194 13.510 C 17.213 10.907,17.631 8.361,16.431 5.968 C 16.114 5.334,15.817 4.930,15.261 4.374 C 14.350 3.464,13.397 2.924,12.145 2.609 C 11.339 2.406,10.262 2.356,9.468 2.484 M11.242 3.657 C 13.450 3.949,15.235 5.489,15.746 7.543 C 15.985 8.501,15.920 9.509,15.557 10.483 C 14.974 12.050,13.662 13.713,11.765 15.290 C 11.209 15.752,10.560 16.240,10.500 16.240 C 10.438 16.240,9.772 15.737,9.196 15.256 C 6.995 13.419,5.618 11.502,5.214 9.713 C 5.070 9.073,5.086 8.182,5.255 7.528 C 5.915 4.961,8.494 3.294,11.242 3.657 M9.929 6.459 C 9.106 6.623,8.309 7.338,8.034 8.158 C 7.955 8.395,7.933 8.535,7.920 8.873 C 7.899 9.410,7.958 9.706,8.177 10.150 C 8.317 10.433,8.391 10.533,8.677 10.819 C 8.968 11.110,9.059 11.177,9.346 11.316 C 10.097 11.678,10.903 11.678,11.654 11.316 C 11.941 11.177,12.032 11.110,12.323 10.819 C 12.609 10.532,12.683 10.433,12.823 10.150 C 13.018 9.755,13.089 9.445,13.089 8.995 C 13.089 8.131,12.734 7.431,12.023 6.892 C 11.475 6.477,10.672 6.311,9.929 6.459 M11.085 7.733 C 11.957 8.142,12.162 9.297,11.482 9.976 C 11.296 10.163,11.095 10.277,10.833 10.347 C 10.371 10.471,9.871 10.329,9.518 9.976 C 8.977 9.435,8.975 8.556,9.514 8.018 C 9.701 7.830,9.970 7.678,10.197 7.629 C 10.456 7.574,10.841 7.619,11.085 7.733 " stroke="none" fill-rule="evenodd" fill="black"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -11,7 +11,7 @@ const outputPath = resolve(process.cwd(), 'src/lib/components/ui/icon');
|
||||
|
||||
const generateIconSprite = () => {
|
||||
const files = readdirSync(optimized);
|
||||
const outputDir = resolve(`${outputPath}`);
|
||||
const outputDir = resolve(`${outputPath}/sprite`);
|
||||
const spriteOutputPath = resolve(outputDir, 'sprite.svelte');
|
||||
|
||||
if (!existsSync(outputDir)) {
|
||||
@@ -31,7 +31,9 @@ const generateIconSprite = () => {
|
||||
const svgMatch = svgContent.match(/<svg[^>]*>([\s\S]*?)<\/svg>/i);
|
||||
|
||||
if (svgMatch && svgMatch[1]) {
|
||||
const innerContent = svgMatch[1].trim();
|
||||
const innerContent = svgMatch[1]
|
||||
.trim()
|
||||
.replace(/fill=['"]([^'"]*)['"]/g, 'fill="currentColor"');
|
||||
const viewBoxMatch = svgContent.match(/viewBox=['"]([^'"]*)['"]/i);
|
||||
const viewBox = viewBoxMatch ? viewBoxMatch[1] : '0 0 24 24';
|
||||
|
||||
@@ -74,7 +76,10 @@ export const optimizeSVG = async () => {
|
||||
showProgressBar: true
|
||||
});
|
||||
|
||||
await fixer.fix();
|
||||
await fixer
|
||||
.fix()
|
||||
.then(() => generateIconSprite())
|
||||
.then(() => generateIconType());
|
||||
};
|
||||
|
||||
export const generateIcons = async () => {
|
||||
@@ -98,7 +103,5 @@ export const generateIcons = async () => {
|
||||
},
|
||||
emptyDist: true,
|
||||
generateInfoData: true
|
||||
})
|
||||
.then(() => generateIconSprite())
|
||||
.then(() => generateIconType());
|
||||
});
|
||||
};
|
||||
|
||||
14
src/icons/svg/edge.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
width="21"
|
||||
height="20"
|
||||
viewBox="0 0 21 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M10.5 14.8C13.151 14.8 15.3 12.651 15.3 10C15.3 7.34903 13.151 5.2 10.5 5.2C7.84903 5.2 5.7 7.34903 5.7 10C5.7 12.651 7.84903 14.8 10.5 14.8ZM10.5 16C13.8137 16 16.5 13.3137 16.5 10C16.5 6.68629 13.8137 4 10.5 4C7.18629 4 4.5 6.68629 4.5 10C4.5 13.3137 7.18629 16 10.5 16Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 509 B |
13
src/icons/svg/pop-locations.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9.82222 5C6.69333 5 5.91111 7.49542 5.91111 8.92137C5.55556 8.92137 2 8.92137 2 12.1298C2 14.6965 4.60741 15.1005 5.91111 14.9817H14.0889C15.3926 15.1005 18 14.6965 18 12.1298C18 9.56305 15.3926 8.92137 14.0889 8.92137C13.9704 7.61425 12.9511 5 9.82222 5Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 466 B |
8
src/icons/svg/regions.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M16.5 8.72727C16.5 13.1818 10.5 17 10.5 17C10.5 17 4.5 13.1818 4.5 8.72727C4.5 7.20831 5.13214 5.75155 6.25736 4.67748C7.38258 3.60341 8.9087 3 10.5 3C12.0913 3 13.6174 3.60341 14.7426 4.67748C15.8679 5.75155 16.5 7.20831 16.5 8.72727Z"
|
||||
stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path
|
||||
d="M10.5 11C11.6046 11 12.5 10.1046 12.5 9C12.5 7.89543 11.6046 7 10.5 7C9.39543 7 8.5 7.89543 8.5 9C8.5 10.1046 9.39543 11 10.5 11Z"
|
||||
stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 710 B |
21
src/lib/actions/animate-in-view.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { inView, type InViewOptions } from 'motion';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const useAnimateInView = ({ options }: { options?: InViewOptions }) => {
|
||||
let animate = writable<boolean>(false);
|
||||
|
||||
const action = (node: HTMLElement) => {
|
||||
inView(
|
||||
node,
|
||||
() => {
|
||||
animate.set(true);
|
||||
},
|
||||
{ ...options }
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
animate,
|
||||
action
|
||||
};
|
||||
};
|
||||
34
src/lib/actions/mouse-position.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { inView } from 'motion';
|
||||
import { type Writable, writable } from 'svelte/store';
|
||||
|
||||
export const useMousePosition = () => {
|
||||
let position = writable<{ x: number; y: number }>({
|
||||
x: 0,
|
||||
y: 0
|
||||
});
|
||||
|
||||
const action = (node: HTMLElement) => {
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
position.set({
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
});
|
||||
};
|
||||
|
||||
inView(
|
||||
node,
|
||||
() => {
|
||||
node.addEventListener('mousemove', handleMouseMove);
|
||||
},
|
||||
{ amount: 'any' }
|
||||
);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('mousemove', handleMouseMove);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return { action, position };
|
||||
};
|
||||
@@ -37,7 +37,8 @@
|
||||
{ label: 'Functions', href: '/products/functions' },
|
||||
{ label: 'Messaging', href: '/products/messaging' },
|
||||
{ label: 'Storage', href: '/products/storage' },
|
||||
{ label: 'Realtime', href: '/docs/apis/realtime' }
|
||||
{ label: 'Realtime', href: '/docs/apis/realtime' },
|
||||
{ label: 'The Appwrite Network', href: '/docs/products/network' }
|
||||
],
|
||||
Learn: [
|
||||
{ label: 'Docs', href: '/docs' },
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<span class="relative">
|
||||
{#each words as word, i}
|
||||
<span
|
||||
class="animate-text mr-2 inline-block"
|
||||
class="animate-enter mr-2 inline-block"
|
||||
style:animation-delay="{i * 75}ms
|
||||
">{word}</span
|
||||
>
|
||||
612
src/lib/components/appwrite-network/data/pins.ts
Normal file
@@ -0,0 +1,612 @@
|
||||
export const pins = {
|
||||
'pop-locations': [
|
||||
{
|
||||
lat: 39.04,
|
||||
lng: -77.49,
|
||||
city: 'Ashburn',
|
||||
code: 'ASH',
|
||||
available: true,
|
||||
offsetX: 10,
|
||||
offsetY: -10
|
||||
},
|
||||
{
|
||||
lat: 33.75,
|
||||
lng: -84.39,
|
||||
city: 'Atlanta',
|
||||
code: 'ATL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 42.36,
|
||||
lng: -71.06,
|
||||
city: 'Boston',
|
||||
code: 'BOS',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 51.05,
|
||||
lng: -114.07,
|
||||
city: 'Calgary',
|
||||
code: 'CAL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 41.88,
|
||||
lng: -87.63,
|
||||
city: 'Chicago',
|
||||
code: 'CHI',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 39.96,
|
||||
lng: -82.99,
|
||||
city: 'Columbus',
|
||||
code: 'COL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 32.78,
|
||||
lng: -96.8,
|
||||
city: 'Dallas',
|
||||
code: 'DAL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 39.74,
|
||||
lng: -104.99,
|
||||
city: 'Denver',
|
||||
code: 'DEN',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 42.33,
|
||||
lng: -83.05,
|
||||
city: 'Detroit',
|
||||
code: 'DET',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 28.31,
|
||||
lng: -125.86,
|
||||
city: 'Honolulu',
|
||||
code: 'HNL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 29.76,
|
||||
lng: -95.37,
|
||||
city: 'Houston',
|
||||
code: 'HOU',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 30.33,
|
||||
lng: -81.66,
|
||||
city: 'Jacksonville',
|
||||
code: 'JAX',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 39.1,
|
||||
lng: -94.58,
|
||||
city: 'Kansas City',
|
||||
code: 'KC',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 34.05,
|
||||
lng: -118.24,
|
||||
city: 'Los Angeles',
|
||||
code: 'LA',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 25.77,
|
||||
lng: -80.19,
|
||||
city: 'Miami',
|
||||
code: 'MIA',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 44.98,
|
||||
lng: -93.27,
|
||||
city: 'Minneapolis',
|
||||
code: 'MIN',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 45.5,
|
||||
lng: -73.57,
|
||||
city: 'Montreal',
|
||||
code: 'MTL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 40.71,
|
||||
lng: -74.01,
|
||||
city: 'New York',
|
||||
code: 'NYC',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 33.45,
|
||||
lng: -112.07,
|
||||
city: 'Phoenix',
|
||||
code: 'PHX',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 45.52,
|
||||
lng: -122.68,
|
||||
city: 'Portland',
|
||||
code: 'PDX',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 37.34,
|
||||
lng: -121.89,
|
||||
city: 'San Jose',
|
||||
code: 'SJ',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 47.61,
|
||||
lng: -122.33,
|
||||
city: 'Seattle',
|
||||
code: 'SEA',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 38.63,
|
||||
lng: -90.2,
|
||||
city: 'St Louis',
|
||||
code: 'STL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 43.65,
|
||||
lng: -79.38,
|
||||
city: 'Toronto',
|
||||
code: 'TOR',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 49.28,
|
||||
lng: -123.12,
|
||||
city: 'Vancouver',
|
||||
code: 'VAN',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 4.71,
|
||||
lng: -74.07,
|
||||
city: 'Bogota',
|
||||
code: 'BOG',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: -34.61,
|
||||
lng: -58.38,
|
||||
city: 'Buenos Aires',
|
||||
code: 'BUE',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: -25.43,
|
||||
lng: -49.27,
|
||||
city: 'Curitiba',
|
||||
code: 'CUR',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: -8.73,
|
||||
lng: -38.52,
|
||||
city: 'Fortaleza',
|
||||
code: 'FOR',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: -12.05,
|
||||
lng: -77.04,
|
||||
city: 'Lima',
|
||||
code: 'LIM',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: -23.55,
|
||||
lng: -46.63,
|
||||
city: 'São Paulo',
|
||||
code: 'SAO',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: -33.45,
|
||||
lng: -70.67,
|
||||
city: 'Santiago',
|
||||
code: 'SCL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: -22.91,
|
||||
lng: -43.17,
|
||||
city: 'Rio de Janeiro',
|
||||
code: 'RIO',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 52.37,
|
||||
lng: 4.89,
|
||||
city: 'Amsterdam',
|
||||
code: 'AMS',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 55.68,
|
||||
lng: 12.57,
|
||||
city: 'Copenhagen',
|
||||
code: 'CPH',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 50.85,
|
||||
lng: 4.35,
|
||||
city: 'Brussels',
|
||||
code: 'BRU',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 55.35,
|
||||
lng: -10.26,
|
||||
city: 'Dublin',
|
||||
code: 'DUB',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 50.11,
|
||||
lng: 8.68,
|
||||
city: 'Frankfurt',
|
||||
code: 'FRA',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 60.17,
|
||||
lng: 24.94,
|
||||
city: 'Helsinki',
|
||||
code: 'HEL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 40.72,
|
||||
lng: -12.14,
|
||||
city: 'Lisbon',
|
||||
code: 'LIS',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 51.51,
|
||||
lng: -0.13,
|
||||
city: 'London',
|
||||
code: 'LON',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 46.42,
|
||||
lng: -3.7,
|
||||
city: 'Madrid',
|
||||
code: 'MAD',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 53.48,
|
||||
lng: -2.24,
|
||||
city: 'Manchester',
|
||||
code: 'MAN',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 43.3,
|
||||
lng: 5.37,
|
||||
city: 'Marseille',
|
||||
code: 'MRS',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 45.46,
|
||||
lng: 9.19,
|
||||
city: 'Milan',
|
||||
code: 'MIL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 48.14,
|
||||
lng: 11.58,
|
||||
city: 'Munich',
|
||||
code: 'MUN',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 62.91,
|
||||
lng: 8.75,
|
||||
city: 'Oslo',
|
||||
code: 'OSL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 38.12,
|
||||
lng: 13.36,
|
||||
city: 'Palermo',
|
||||
code: 'PAL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 48.86,
|
||||
lng: 2.35,
|
||||
city: 'Paris',
|
||||
code: 'PAR',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 41.9,
|
||||
lng: 12.5,
|
||||
city: 'Rome',
|
||||
code: 'ROM',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 42.7,
|
||||
lng: 23.32,
|
||||
city: 'Sofia',
|
||||
code: 'SOF',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 59.33,
|
||||
lng: 18.07,
|
||||
city: 'Stockholm',
|
||||
code: 'STO',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 48.21,
|
||||
lng: 16.37,
|
||||
city: 'Vienna',
|
||||
code: 'VIE',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 5.56,
|
||||
lng: -0.2,
|
||||
city: 'Accra',
|
||||
code: 'ACC',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: -33.93,
|
||||
lng: 18.42,
|
||||
city: 'Cape Town',
|
||||
code: 'CPT',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: -26.2,
|
||||
lng: 28.05,
|
||||
city: 'Johannesburg',
|
||||
code: 'JHB',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 13.75,
|
||||
lng: 100.5,
|
||||
city: 'Bangkok',
|
||||
code: 'BKK',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 13.08,
|
||||
lng: 80.28,
|
||||
city: 'Chennai',
|
||||
code: 'CHE',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 25.27,
|
||||
lng: 55.3,
|
||||
city: 'Dubai',
|
||||
code: 'DXB',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 25.12,
|
||||
lng: 56.33,
|
||||
city: 'Fujairah',
|
||||
code: 'FUJ',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 22.32,
|
||||
lng: 114.17,
|
||||
city: 'Hong Kong',
|
||||
code: 'HK',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 17.38,
|
||||
lng: 78.48,
|
||||
city: 'Hyderabad',
|
||||
code: 'HYD',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 22.57,
|
||||
lng: 88.36,
|
||||
city: 'Kolkata',
|
||||
code: 'KOL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 3.14,
|
||||
lng: 101.69,
|
||||
city: 'Kuala Lumpur',
|
||||
code: 'KL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 14.6,
|
||||
lng: 120.98,
|
||||
city: 'Manila',
|
||||
code: 'MNL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 19.08,
|
||||
lng: 72.88,
|
||||
city: 'Mumbai',
|
||||
code: 'MUM',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 28.61,
|
||||
lng: 77.21,
|
||||
city: 'New Delhi',
|
||||
code: 'DEL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 34.69,
|
||||
lng: 135.5,
|
||||
city: 'Osaka',
|
||||
code: 'OSA',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 37.57,
|
||||
lng: 126.98,
|
||||
city: 'Seoul',
|
||||
code: 'SEL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 1.35,
|
||||
lng: 103.82,
|
||||
city: 'Singapore',
|
||||
code: 'SIN',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: 35.69,
|
||||
lng: 139.69,
|
||||
city: 'Tokyo',
|
||||
code: 'TYO',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: -34.93,
|
||||
lng: 138.6,
|
||||
city: 'Adelaide',
|
||||
code: 'ADL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: -39.85,
|
||||
lng: 174.76,
|
||||
city: 'Auckland',
|
||||
code: 'AKL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: -27.47,
|
||||
lng: 153.03,
|
||||
city: 'Brisbane',
|
||||
code: 'BNE',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: -48.53,
|
||||
lng: 150.64,
|
||||
city: 'Christchurch',
|
||||
code: 'CHC',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: -37.81,
|
||||
lng: 144.96,
|
||||
city: 'Melbourne',
|
||||
code: 'MEL',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: -31.95,
|
||||
lng: 115.85,
|
||||
city: 'Perth',
|
||||
code: 'PER',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: -33.87,
|
||||
lng: 151.21,
|
||||
city: 'Sydney',
|
||||
code: 'SYD',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: -45.29,
|
||||
lng: 158.78,
|
||||
city: 'Wellington',
|
||||
code: 'WLG',
|
||||
available: true
|
||||
}
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
lat: 40.71,
|
||||
lng: -74.01,
|
||||
city: 'New York',
|
||||
code: 'NYC',
|
||||
available: true,
|
||||
offsetX: 10,
|
||||
offsetY: -10
|
||||
},
|
||||
{
|
||||
lat: 50.11,
|
||||
lng: 8.68,
|
||||
city: 'Frankfurt',
|
||||
code: 'FRA',
|
||||
available: true
|
||||
},
|
||||
{
|
||||
lat: -33.87,
|
||||
lng: 151.21,
|
||||
city: 'Sydney',
|
||||
code: 'AUS',
|
||||
available: true
|
||||
}
|
||||
],
|
||||
regions: [
|
||||
{
|
||||
lat: 40.71,
|
||||
lng: -74.01,
|
||||
city: 'New York',
|
||||
code: 'NYC',
|
||||
available: true,
|
||||
offsetX: 10,
|
||||
offsetY: -10
|
||||
},
|
||||
{
|
||||
lat: 50.11,
|
||||
lng: 8.68,
|
||||
city: 'Frankfurt',
|
||||
code: 'FRA',
|
||||
available: true
|
||||
},
|
||||
|
||||
{
|
||||
lat: -33.87,
|
||||
lng: 151.21,
|
||||
city: 'Sydney',
|
||||
code: 'AUS',
|
||||
available: true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export type PinSegment = keyof typeof pins;
|
||||
64
src/lib/components/appwrite-network/map-marker.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { classNames } from '$lib/utils/classnames';
|
||||
import { slugify } from '$lib/utils/slugify';
|
||||
import { latLongToSvgPosition } from './utils/projections';
|
||||
import { tooltipData } from './map-tooltip.svelte';
|
||||
|
||||
interface Props {
|
||||
city: string;
|
||||
code: string;
|
||||
index: number;
|
||||
lat: number;
|
||||
lng: number;
|
||||
bounds: {
|
||||
north: number;
|
||||
south: number;
|
||||
west: number;
|
||||
east: number;
|
||||
};
|
||||
available: boolean;
|
||||
class?: string;
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
const { city, code, index = 0, lat, lng, available, animate = false }: Props = $props();
|
||||
|
||||
const position = $derived(latLongToSvgPosition({ latitude: lat, longitude: lng }));
|
||||
|
||||
const handleSetActiveMarker = () => {
|
||||
tooltipData.set({
|
||||
city,
|
||||
code,
|
||||
available
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetActiveMarker = () => {
|
||||
tooltipData.set({
|
||||
city: null,
|
||||
code: null,
|
||||
available: null
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<button
|
||||
class={classNames(
|
||||
'group absolute z-10 flex size-2 cursor-pointer items-center justify-center opacity-0 [animation-delay:var(--delay)]',
|
||||
{ 'animate-fade-in': animate }
|
||||
)}
|
||||
style="left: {position.x}%; top: {position.y}%;--delay: {index * 10}ms;"
|
||||
data-region={slugify(city)}
|
||||
onmouseenter={handleSetActiveMarker}
|
||||
onfocus={handleSetActiveMarker}
|
||||
onmouseleave={handleResetActiveMarker}
|
||||
onblur={handleResetActiveMarker}
|
||||
aria-label={city}
|
||||
>
|
||||
<span
|
||||
class="from-accent/20 to-accent/10 border-gradient ease-spring pointer-events-none absolute inline-flex h-5 w-5 rounded-full bg-gradient-to-b opacity-0 transition-opacity group-hover:animate-ping group-hover:opacity-75 before:rounded-full"
|
||||
style:animation-duration="1.5s"
|
||||
></span>
|
||||
<span class="bg-accent absolute inline-flex h-full w-full rounded-full"></span>
|
||||
<span class="absolute size-1/2 rounded-full bg-white/80 transition-all"></span>
|
||||
</button>
|
||||
55
src/lib/components/appwrite-network/map-nav.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { classNames } from '$lib/utils/classnames';
|
||||
import { Tabs } from 'bits-ui';
|
||||
|
||||
import type { IconType } from '../ui';
|
||||
import Icon from '../ui/icon';
|
||||
|
||||
type Props = {
|
||||
onValueChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const { onValueChange }: Props = $props();
|
||||
|
||||
const navItems: Array<{ label: string; value: string; icon: IconType }> = [
|
||||
{
|
||||
label: 'PoP Locations',
|
||||
value: 'pop-locations',
|
||||
icon: 'pop-locations'
|
||||
},
|
||||
{
|
||||
label: 'Edges',
|
||||
value: 'edges',
|
||||
icon: 'edge'
|
||||
},
|
||||
{
|
||||
label: 'Regions',
|
||||
value: 'regions',
|
||||
icon: 'regions'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<Tabs.Root
|
||||
value={navItems[0].value}
|
||||
{onValueChange}
|
||||
class="flex flex-col items-center justify-center gap-12 md:-mt-8"
|
||||
>
|
||||
<Tabs.List
|
||||
class="border-smooth animate-fade-in bg-card relative grid w-full max-w-xl grid-cols-1 place-content-center gap-3 p-1 px-8 drop-shadow-md md:grid-cols-3 md:rounded-full md:border md:px-1"
|
||||
>
|
||||
{#each navItems as { label, icon, value }, index}
|
||||
<Tabs.Trigger
|
||||
{value}
|
||||
class={classNames(
|
||||
'text-caption animate-enter text-primary bg-smooth border-smooth flex h-8 cursor-pointer items-center justify-center gap-2 rounded-full border font-medium outline-0 transition-colors hover:border-white/12',
|
||||
'group data-[state="active"]:bg-accent/4 data-[state="active"]:border-accent/36 data-[state="active"]:text-white'
|
||||
)}
|
||||
style="animation-delay:{index * 75}ms;"
|
||||
>
|
||||
<Icon name={icon} class="group-data-[state='active']:text-accent -ml-2" />
|
||||
{label}</Tabs.Trigger
|
||||
>
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
58
src/lib/components/appwrite-network/map-tooltip.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts" module>
|
||||
import { classNames } from '$lib/utils/classnames';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const tooltipData = writable<{
|
||||
city: string | null;
|
||||
code: string | null;
|
||||
available: boolean | null;
|
||||
}>({
|
||||
city: null,
|
||||
code: null,
|
||||
available: null
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
coords: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
};
|
||||
|
||||
const { coords }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if $tooltipData.city}
|
||||
<div
|
||||
class="pointer-events-none absolute"
|
||||
style:left="{coords.x - 50}px"
|
||||
style:top="{coords.y - 50}px"
|
||||
>
|
||||
<div
|
||||
class={classNames(
|
||||
'bg-card/90 border-gradient relative z-100 flex w-[190px] flex-col gap-2 rounded-[10px] p-2 backdrop-blur-lg before:rounded-[10px] after:rounded-[10px]',
|
||||
'data-[state="closed"]:animate-menu-out data-[state="instant-open"]:animate-menu-in data-[state="delayed-open"]:animate-menu-in'
|
||||
)}
|
||||
>
|
||||
<span class="text-primary text-caption w-fit">
|
||||
{$tooltipData.city}
|
||||
({$tooltipData.code})
|
||||
</span>
|
||||
{#if $tooltipData.available}
|
||||
<div
|
||||
class="text-caption flex h-5 items-center justify-center place-self-start rounded-md bg-[#10B981]/24 p-1 text-center text-[#B4F8E2]"
|
||||
>
|
||||
<span class="text-micro -tracking-tight">Available now</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="text-caption flex h-5 items-center justify-center place-self-start rounded-md bg-white/6 p-1 text-center text-white/60"
|
||||
>
|
||||
<span class="text-micro -tracking-tight">Planned</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
125
src/lib/components/appwrite-network/map.svelte
Normal file
@@ -0,0 +1,125 @@
|
||||
<script lang="ts" module>
|
||||
export const MAP_BOUNDS = $state({
|
||||
west: -138,
|
||||
east: 167,
|
||||
north: 74,
|
||||
south: -62
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import MapMarker from './map-marker.svelte';
|
||||
import { slugify } from '$lib/utils/slugify';
|
||||
import { classNames } from '$lib/utils/classnames';
|
||||
import MapNav from './map-nav.svelte';
|
||||
import { useMousePosition } from '$lib/actions/mouse-position';
|
||||
import { useAnimateInView } from '$lib/actions/animate-in-view';
|
||||
import { pins, type PinSegment } from './data/pins';
|
||||
import MapTooltip from './map-tooltip.svelte';
|
||||
import { dev } from '$app/environment';
|
||||
|
||||
let dimensions = $state({
|
||||
width: 0,
|
||||
height: 0
|
||||
});
|
||||
|
||||
let activeRegion = $state<string | null>(null);
|
||||
let activeMarker: HTMLElement | null = null;
|
||||
let activeSegment = $state<string>('pop-locations');
|
||||
|
||||
const { action: mousePosition, position } = useMousePosition();
|
||||
const { action: inView, animate } = useAnimateInView({});
|
||||
|
||||
const scrollMarkerIntoView = (marker: HTMLElement) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
marker.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'center'
|
||||
});
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].intersectionRatio > 0.5) {
|
||||
observer.disconnect();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
{ threshold: [0, 0.25, 0.5, 0.75, 1] }
|
||||
);
|
||||
|
||||
observer.observe(marker);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetActiveMarker = async (city: string) => {
|
||||
const citySlug = slugify(city);
|
||||
|
||||
if (activeRegion === citySlug) {
|
||||
activeMarker = null;
|
||||
activeRegion = null;
|
||||
return;
|
||||
}
|
||||
|
||||
activeMarker = document.querySelector(`[data-region="${citySlug}"]`);
|
||||
|
||||
if (activeMarker) {
|
||||
await scrollMarkerIntoView(activeMarker);
|
||||
activeRegion = citySlug;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="-mt-8 w-full overflow-x-scroll [scrollbar-width:none] md:overflow-x-hidden">
|
||||
<div
|
||||
class="sticky left-0 mx-auto block max-w-[calc(100vw_-_calc(var(--spacing)_*-2))] md:hidden"
|
||||
>
|
||||
<select
|
||||
class="web-input-text mx-auto appearance-none"
|
||||
onchange={(e) => handleSetActiveMarker(e.currentTarget.value)}
|
||||
>
|
||||
{#each pins[activeSegment as PinSegment] as pin}
|
||||
<option value={pin.city}>{pin.city}-({pin.code})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative mx-auto h-full w-[250vw] [scrollbar-width:none] md:w-fit"
|
||||
use:inView
|
||||
use:mousePosition
|
||||
>
|
||||
<div
|
||||
class="relative w-full origin-bottom transform-[perspective(25px)_rotateX(1deg)_scale3d(1.4,_1.4,_1)] transition-all [scrollbar-width:none]"
|
||||
bind:clientWidth={dimensions.width}
|
||||
bind:clientHeight={dimensions.height}
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 mask-[image:url('/images/appwrite-network/map.svg')] mask-contain mask-no-repeat"
|
||||
>
|
||||
<div
|
||||
class={classNames(
|
||||
'relative block aspect-square size-40 rounded-full blur-3xl transition-opacity',
|
||||
'from-accent bg-radial-[circle_at_center] via-white/70 to-white/70',
|
||||
'transform-[translate3d(calc(var(--mouse-x,_-100%)_*_1_-_16rem),_calc(var(--mouse-y,_-100%)_*_1_-_28rem),0)]'
|
||||
)}
|
||||
style:--mouse-x="{$position.x}px"
|
||||
style:--mouse-y="{$position.y}px"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<img
|
||||
src="/images/appwrite-network/map.svg"
|
||||
class="pointer-events-none relative -z-10 w-full opacity-10 md:max-h-[525px]"
|
||||
draggable="false"
|
||||
alt="Map of the world"
|
||||
/>
|
||||
|
||||
{#each pins[activeSegment as PinSegment] as pin, index}
|
||||
<MapMarker {...pin} animate={$animate} {index} bounds={MAP_BOUNDS} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MapTooltip coords={$position} />
|
||||
<MapNav onValueChange={(value) => (activeSegment = value)} />
|
||||
24
src/lib/components/appwrite-network/utils/projections.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { MAP_BOUNDS } from '../map.svelte';
|
||||
|
||||
const MAP_WIDTH = 1048.25;
|
||||
const MAP_HEIGHT = 525;
|
||||
|
||||
type Coordinates = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
export const latLongToSvgPosition = ({ latitude, longitude }: Coordinates) => {
|
||||
const { west, east, north, south } = MAP_BOUNDS;
|
||||
|
||||
const lngRatio = (longitude - west) / (east - west);
|
||||
const latRatio = (latitude - south) / (north - south);
|
||||
|
||||
const clampedLngRatio = Math.max(0, Math.min(1, lngRatio));
|
||||
const clampedLatRatio = Math.max(0, Math.min(1, latRatio));
|
||||
|
||||
const x = clampedLngRatio * 100;
|
||||
const y = (1 - clampedLatRatio) * 100;
|
||||
|
||||
return { x, y }; // percentages, e.g., { x: 42.3, y: 71.8 }
|
||||
};
|
||||
@@ -1,9 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { classNames } from '$lib/utils/classnames';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { SvelteHTMLElements } from 'svelte/elements';
|
||||
|
||||
let className = '';
|
||||
type Props = {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
} & SvelteHTMLElements['span'];
|
||||
|
||||
export { className as class };
|
||||
let { class: className = '', children, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
@@ -11,6 +16,7 @@
|
||||
'block bg-[linear-gradient(6deg,_#f8a1ba,_#fff_35%)] bg-clip-text text-transparent',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<slot />
|
||||
{@render children?.()}
|
||||
</span>
|
||||
|
||||
@@ -3,12 +3,22 @@
|
||||
import type { SvelteHTMLElements } from 'svelte/elements';
|
||||
import type { IconType } from './types';
|
||||
|
||||
type Props = SvelteHTMLElements['span'] & {
|
||||
type Props = SvelteHTMLElements['svg'] & {
|
||||
class?: string;
|
||||
name?: IconType;
|
||||
};
|
||||
|
||||
const { class: className = '', name = 'arrow-right', ...rest }: Props = $props();
|
||||
const {
|
||||
xmlns = 'http://www.w3.org/2000/svg',
|
||||
viewBox = '0 0 24 24',
|
||||
height = 20,
|
||||
width = 20,
|
||||
class: className = '',
|
||||
name = 'arrow-right',
|
||||
...rest
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<span class={classNames(`web-icon-${name}`, className)} {...rest}></span>
|
||||
<svg class={className} {xmlns} {height} {width} {viewBox} {...rest}>
|
||||
<use xlink:href="#{name}" />
|
||||
</svg>
|
||||
|
||||
1
src/lib/components/ui/icon/sprite/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Sprite } from './sprite.svelte';
|
||||
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 88 KiB |
@@ -21,6 +21,7 @@ export type IconType =
|
||||
| 'discord'
|
||||
| 'divider-vertical'
|
||||
| 'download'
|
||||
| 'edge'
|
||||
| 'ext-link'
|
||||
| 'firebase'
|
||||
| 'github'
|
||||
@@ -41,8 +42,10 @@ export type IconType =
|
||||
| 'platform'
|
||||
| 'play'
|
||||
| 'plus'
|
||||
| 'pop-locations'
|
||||
| 'product-hunt'
|
||||
| 'refine'
|
||||
| 'regions'
|
||||
| 'rest'
|
||||
| 'search'
|
||||
| 'sendgrid'
|
||||
|
||||
@@ -20,4 +20,5 @@
|
||||
export { default as Youtube } from './Youtube.svelte';
|
||||
export { default as Call_To_Action } from './Call_To_Action.svelte';
|
||||
export { default as Storage_Image } from './Storage_Image.svelte';
|
||||
export { default as Appwrite_Network_Map } from '../../lib/components/appwrite-network/map.svelte';
|
||||
</script>
|
||||
|
||||
@@ -15,25 +15,25 @@
|
||||
},
|
||||
{
|
||||
number: 0,
|
||||
suffix: 'TB',
|
||||
suffix: '+ TB',
|
||||
description: 'of data served',
|
||||
top: 78.25
|
||||
},
|
||||
{
|
||||
number: 0,
|
||||
suffix: 'M',
|
||||
description: 'end users',
|
||||
suffix: 'B',
|
||||
description: 'requests',
|
||||
top: 62.5
|
||||
},
|
||||
{
|
||||
number: 0,
|
||||
suffix: '+',
|
||||
description: 'total compute time',
|
||||
suffix: 'K',
|
||||
description: 'projects',
|
||||
top: 46.75
|
||||
}
|
||||
];
|
||||
|
||||
const numbers = [12, 900, 1, 999];
|
||||
const numbers = [12, 1000, 50, 300];
|
||||
|
||||
let animate: boolean = false;
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { trackEvent } from '$lib/actions/analytics';
|
||||
import { saveReferrerAndUtmSource } from '$lib/utils/utm';
|
||||
import { Sprite } from '$lib/components/ui/icon/sprite';
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
||||
@@ -205,6 +206,7 @@
|
||||
>
|
||||
|
||||
<slot />
|
||||
<Sprite />
|
||||
|
||||
<style lang="scss">
|
||||
:global(html) {
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
<div class="my-12 lg:my-[7.5rem]">
|
||||
<section class="container pb-0">
|
||||
<a
|
||||
href="/blog/post/what-is-mcp"
|
||||
href="/blog/post/the-appwrite-network"
|
||||
class="web-hero-banner-button mb-4"
|
||||
on:click={() => trackEvent({ plausible: { name: 'Banner button click' } })}
|
||||
>
|
||||
@@ -142,7 +142,7 @@
|
||||
<span class="text-caption shrink-0 font-medium">New</span>
|
||||
<div class="web-hero-banner-button-sep"></div>
|
||||
<span class="text-caption web-u-trim-1"
|
||||
>Announcing new Appwrite MCP server</span
|
||||
>Announcing The Appwrite Network</span
|
||||
>
|
||||
<span class="web-icon-arrow-right shrink-0" aria-hidden="true"></span>
|
||||
</a>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
layout: author
|
||||
slug: christy-jacob
|
||||
name: Christy Jacob
|
||||
role: Engineering lead
|
||||
role: Head of Engineering
|
||||
bio: Leading Appwrite's Cloud development.
|
||||
avatar: /images/avatars/christy.png
|
||||
twitter: https://twitter.com/christyjacob4
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
layout: post
|
||||
title: How to change regions in Appwrite Cloud using migrations
|
||||
description: Learn how to migrate your Appwrite Cloud project from one region to another.
|
||||
date: 2025-04-15
|
||||
date: 2025-04-16
|
||||
cover: /images/blog/change-regions-with-migrations/cover.png
|
||||
timeToRead: 10
|
||||
author: ebenezer-don
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
layout: post
|
||||
title: How to reduce cloud latency
|
||||
description: Learn how to reduce cloud latency and improve the performance of your apps and services.
|
||||
date: 2025-04-17
|
||||
cover: /images/blog/how-to-reduce-cloud-latency/cover.png
|
||||
timeToRead: 9
|
||||
author: christy-jacob
|
||||
category: Tutorial
|
||||
callToAction: true
|
||||
---
|
||||
|
||||
Whether users are streaming a video, loading a webpage, or interacting with an app, they expect things to work fast. One key factor that affects the speed of cloud services is *cloud latency —* the delay between a user's request and the cloud's response. But why does this matter, and how can reducing latency make your apps and services faster and more efficient?
|
||||
|
||||
In this blog, we'll break down the importance of reducing latency, explore practical ways to achieve it, and explain why faster web experiences are vital for both users and businesses.
|
||||
|
||||
# What is cloud latency?
|
||||
|
||||
Cloud latency refers to the delay or time it takes for data to travel between a user's device and the cloud server. This includes the time it takes for a user request to reach the server and the time it takes for the server to respond. High latency can cause slow load times, buffering, and a poor user experience, especially for applications like online gaming, video streaming, or real-time data processing.
|
||||
|
||||
# Why you need to make your app faster
|
||||
|
||||
Speed isn't just a nice-to-have feature — it's a necessity. If your app is slow, it's likely losing users, conversions, and productivity. Here's why making your app faster should be a top priority:
|
||||
|
||||
1. **Users won't wait**: People expect instant responses from apps and websites. If your app is slow, users will quickly get frustrated and may leave for faster alternatives. Reducing latency ensures a smooth, satisfying experience that keeps users engaged.
|
||||
2. **Boost your revenue**: In e-commerce, speed directly affects sales. Faster apps mean higher conversion rates, allowing users to browse, make decisions, and complete transactions faster. Every second of delay could be costing you customers and revenue.
|
||||
3. **Wider accessibility**: Not everyone has access to fast internet or high-end devices. By reducing latency, you make your app more accessible to users in varying conditions, creating a better experience for everyone.
|
||||
4. **Improved productivity**: In a world dominated by cloud services, faster apps mean quicker collaboration, faster access to resources, and higher overall productivity. Whether your users are sharing files or managing data, speed is essential for efficiency.
|
||||
|
||||
# The real impact of reduced latency
|
||||
|
||||
Lowering latency has real, tangible benefits for people using your services every day. Let's explore how reduced latency affects your users in practical ways:
|
||||
|
||||
1. **Smoother UX**: When latency is low, apps load quickly, videos stream smoothly, and interactions feel immediate. For customers, this translates into a better overall experience. Whether you're checking your bank balance or browsing products in an online store, responsiveness makes all the difference.
|
||||
2. **Better engagement**: Research shows that even slight delays can cause users to abandon websites. For example, if a page takes more than 3 seconds to load, nearly half of the users will leave. Reduced latency helps keep users engaged, minimizing drop-off and improving retention rates.
|
||||
3. **More reliable real-time applications**: For everyday tools like video calls, online gaming, and real-time collaboration software, low latency is essential. High latency can cause lag, resulting in dropped frames, awkward delays, or even miscommunication during video chats. By lowering latency, these real-time applications become much more dependable and enjoyable to use.
|
||||
4. **Better search engine rankings**: Search engines like Google consider page load times when ranking websites. Faster websites with lower latency tend to rank higher, increasing visibility and attracting more visitors.
|
||||
|
||||
# How to reduce cloud latency
|
||||
|
||||
Reducing cloud latency requires a combination of infrastructure improvements and strategic decisions about how and where your data is stored. Here are two effective methods for minimizing latency:
|
||||
|
||||
## 1. Use a Content Delivery Network
|
||||
|
||||
A **Content Delivery Network (CDN)** is a system of distributed servers that deliver content to users based on their geographic location. CDNs reduce latency by caching data closer to the user, which shortens the distance data must travel. This is especially effective for media-heavy websites or applications that serve users across different regions.
|
||||
|
||||
- **How it works**: When a user requests data (like loading a webpage or a video), instead of pulling it from a distant server, the CDN retrieves it from the closest available location, speeding up the process.
|
||||
- **Why it matters for customers**: By shortening the distance data has to travel, CDNs drastically reduce loading times. For users, this means faster access to websites, streaming without interruptions, and quicker interactions with apps.
|
||||
|
||||
## 2. Choose the right cloud region
|
||||
|
||||
Choosing the right **cloud region** is another important step in reducing latency. Most cloud providers, like AWS, Google Cloud, and Microsoft Azure, offer multiple regions across the globe. By selecting a cloud region that is physically closer to your users, you can significantly reduce the time it takes for data to travel between your server and end-users.
|
||||
|
||||
- **How to do it**: Analyze where your users are located and select cloud regions close to them. For example, if most of your users are in the US, hosting your data in a European data center will lower latency compared to hosting it in a European-based server. The Appwrite Network has recently expanded to include regions in New York and Sydney so you can cover majority of the globe and choose the server closest to your userbase.
|
||||
- **Why it matters for customers:** Hosting in the right cloud region reduces wait times, ensuring quicker response. This localized approach improves the user experience, making it seamless regardless of where customers are located.
|
||||
|
||||
# Benefits of increased distribution with global cloud regions
|
||||
|
||||
The more globally distributed your cloud infrastructure is, the better performance you can offer your users. Global cloud regions allow you to place your services closer to where your users are, improving speed, reliability, and user satisfaction.
|
||||
|
||||
- **Faster access for global users**: The internet is global, and your users could be anywhere in the world. Increased distribution across cloud regions allows each user to connect to the server nearest to them. This means that whether a customer is in New York, London, or Tokyo, they'll experience the same fast, reliable service.
|
||||
- **Better uptime and reliability**: Distributing your services across different regions also adds a layer of resilience. If one server or region experiences downtime, another region can take over without users even noticing. For day-to-day users, this ensures fewer interruptions and greater reliability, even during peak times.
|
||||
- **Scalability and resilience**: Global cloud regions enable better scaling of services. If your platform experiences increased demand in a certain region, you can easily add more servers in that location without overloading your infrastructure.
|
||||
|
||||
# Conclusion
|
||||
|
||||
Cloud latency plays a huge role in the performance of modern web applications and services. By reducing latency through CDNs, choosing the right cloud regions, and increasing distribution across global cloud regions, you can create faster, more reliable services that enhance user experience and engagement.
|
||||
|
||||
Ultimately, prioritizing speed ensures that your app not only meets but exceeds user expectations.
|
||||
|
||||
# Further reading
|
||||
|
||||
- [Why multi-cloud is taking over](/blog/why-multi-cloud-is-taking-over?doFollow=true)
|
||||
- [How to optimize your Appwrite project for cost and performance](/blog/post/how-to-optimize-your-appwrite-project?doFollow=true)
|
||||
- [How to stop unexpected cloud bills before they happen](/blog/post/budget-caps-stop-unexpected-cloud-bills?doFollow=true)
|
||||
- [The Appwrite Network docs](/docs/products/network?doFollow=true)
|
||||
878
src/routes/blog/post/offline-first-journal/+page.markdoc
Normal file
@@ -0,0 +1,878 @@
|
||||
---
|
||||
layout: post
|
||||
title: Build an offline-first journal app with RxDB and Appwrite
|
||||
description: Learn how you can use RxDB to setup data replication and enable offline synchronization with Appwrite Databases in a JavaScript app.
|
||||
date: 2025-04-17
|
||||
cover: /images/blog/offline-first-journal/cover.png
|
||||
timeToRead: 14
|
||||
author: aditya-oberai
|
||||
category: integrations
|
||||
callToAction: true
|
||||
---
|
||||
|
||||
Ever found yourself staring at a loading spinner in a no-network zone, wishing your app could just work offline? Whether you're building a journal, a field notes app, or anything in between, offline data synchronization is no longer a luxury but a necessity. There is some good news, though. If you’re building with JavaScript, Appwrite Databases now features a direct integration with RxDB, making it easier than ever to build real-world apps that stay in sync online and offline.
|
||||
|
||||
# What is RxDB?
|
||||
|
||||
RxDB (Reactive Database) is a local-first NoSQL database designed for JavaScript-based web and mobile applications. What sets RxDB apart is its reactive, offline-first architecture, making it ideal for apps that need to store and sync data locally, especially when internet connectivity is spotty or intermittent.
|
||||
|
||||
## Key features of RxDB
|
||||
|
||||
- **Local-first storage**: RxDB stores data locally using browser-compatible storage engines like IndexedDB, SQLite, and even filesystem-based storage on mobile platforms. This makes it perfect for apps that need to function completely offline.
|
||||
- **Reactive data streams**: Built on top of RxJS, RxDB turns queries into live data streams. That means when the underlying data changes (locally or remotely), your UI updates automatically in real-time. There is no polling, no refreshing, just smooth reactivity.
|
||||
- **Seamless replication**: RxDB supports push-pull replication with various remote databases, including Appwrite Databases. This allows two-way syncing: it pushes local changes to a backend and pulls new changes down.
|
||||
- **Security and extensibility**: RxDB comes with optional encryption, schema validation, conflict resolution strategies, and plugins for custom behaviors like attachments, migrations, and leader election in multi-tab apps.
|
||||
- **Cross-platform support**: It runs smoothly in browsers, PWAs, React Native, Electron, and other environments, making it a versatile choice for building cross-platform apps with consistent offline sync logic.
|
||||
|
||||
## Synchronize data between RxDB and Appwrite Databases
|
||||
|
||||
RxDB has recently introduced a plugin that allows developers to replicate data to Appwrite Databases, meaning all your client app’s locally stored data can be synchronized. Since RxDB stores all data locally, your app can continue to function with zero internet, and information is synced to an external Appwrite database as soon as connectivity returns. Additionally, Appwrite’s Realtime API and RxDB’s live replication allow data to be instantaneously updated across multiple clients.
|
||||
|
||||
# Building an offline-first journal app
|
||||
|
||||
To understand how to build an offline-first application with RxDB and Appwrite, let’s build a journal app. Our app will be a simplified version of the [demo app](https://offline-journal.vercel.app/) shown below.
|
||||
|
||||

|
||||
|
||||
Our tech stack for this app will be:
|
||||
|
||||
- **SvelteKit**, configured as a Progressive Web App (PWA)
|
||||
- **IndexedDB**, to store local data
|
||||
- **Appwrite Cloud**, for external replication
|
||||
|
||||
## Configure your Appwrite project
|
||||
|
||||
First, [create an Appwrite Cloud account](https://cloud.appwrite.io/) if you haven’t already. Once your project is ready, go to the **Settings** page and copy your project ID and API endpoint for further usage. Next, go to the **Databases** page from the left sidebar, create a new database with the ID `journals`, and then a collection with the ID `entries` (save both IDs for further usage).
|
||||
|
||||
Next, head to the **Attributes** tab and add the following:
|
||||
|
||||
| Key | Type | Size | Required |
|
||||
| --- | --- | --- | --- |
|
||||
| `title` | String | 100 | Yes |
|
||||
| `content` | String | 20000 | Yes |
|
||||
| `createdAt` | Integer | | Yes |
|
||||
| `updatedAt` | Integer | | Yes |
|
||||
| `deleted` | Boolean | | Yes |
|
||||
|
||||
> **Note:** The `deleted` attribute is necessary to add because RxDB does not hard delete any data, only soft deletes to prevent data loss in offline scenarios.
|
||||
|
||||
Then, head to the **Settings** tab of your collection, scroll down to the **Permissions** section, and add the following:
|
||||
|
||||
| Role | Create | Read | Update | Delete |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Any | Yes | Yes | Yes | Yes |
|
||||
|
||||
## Prepare the app logic
|
||||
|
||||
Once our Appwrite project is set up, let’s start building our app.
|
||||
|
||||
### Create a SvelteKit app
|
||||
|
||||
To create the SvelteKit app, open up your terminal and run the following command:
|
||||
|
||||
```sh
|
||||
npx sv create
|
||||
```
|
||||
|
||||
This will load the Svelte CLI, where you can enter the following inputs to create a minimal app:
|
||||
|
||||
- Where would you like your project to be created? **>** `offline-journal`
|
||||
- Which template would you like? **>** `SvelteKit minimal`
|
||||
- Add type checking with TypeScript? **>** `No`
|
||||
- What would you like to add to your project? **>** `prettier, eslint`
|
||||
- Which package manager do you want to install dependencies with? **>** `npm`
|
||||
|
||||
Once that is done, enter the app’s working directory and install all dependencies by running the following commands:
|
||||
|
||||
```sh
|
||||
cd offline-journal
|
||||
npm install
|
||||
```
|
||||
|
||||
### Install the Appwrite Web SDK
|
||||
|
||||
Now, that the SvelteKit app is created, install the Appwrite Web SDK by running the following command:
|
||||
|
||||
```sh
|
||||
npm install appwrite
|
||||
```
|
||||
|
||||
In the root directory of your app, create a `.env` file and add the information you saved from your Appwrite project:
|
||||
|
||||
```env
|
||||
PUBLIC_APPWRITE_ENDPOINT=your-appwrite-cloud-endpoint
|
||||
PUBLIC_APPWRITE_PROJECT_ID=your-project-id
|
||||
PUBLIC_APPWRITE_DATABASE_ID=your-database-id
|
||||
PUBLIC_APPWRITE_COLLECTION_ID=your-collection-id
|
||||
```
|
||||
|
||||
Next, in the `src/lib` subdirectory, create a file `appwrite.js` and add the following code:
|
||||
|
||||
```js
|
||||
import { Client } from 'appwrite';
|
||||
import {
|
||||
PUBLIC_APPWRITE_ENDPOINT,
|
||||
PUBLIC_APPWRITE_PROJECT_ID,
|
||||
PUBLIC_APPWRITE_DATABASE_ID,
|
||||
PUBLIC_APPWRITE_COLLECTION_ID
|
||||
} from '$env/static/public';
|
||||
|
||||
export const appwriteConfig = {
|
||||
endpoint: PUBLIC_APPWRITE_ENDPOINT,
|
||||
projectId: PUBLIC_APPWRITE_PROJECT_ID,
|
||||
databaseId: PUBLIC_APPWRITE_DATABASE_ID,
|
||||
collectionId: PUBLIC_APPWRITE_COLLECTION_ID
|
||||
};
|
||||
|
||||
export const client = new Client()
|
||||
.setEndpoint(appwriteConfig.endpoint)
|
||||
.setEndpointRealtime(appwriteConfig.endpoint)
|
||||
.setProject(appwriteConfig.projectId);
|
||||
```
|
||||
|
||||
### Setup RxDB
|
||||
|
||||
To set up RxDB, first install the RxDB library in your app by running the following command:
|
||||
|
||||
```sh
|
||||
npm install rxdb
|
||||
```
|
||||
|
||||
Next, in the `src/lib` directory, create a files `databases.js` and add the following imports:
|
||||
|
||||
```js
|
||||
// RxDB imports
|
||||
import { createRxDatabase, addRxPlugin, RxCollectionBase } from 'rxdb/plugins/core';
|
||||
import { RxDBQueryBuilderPlugin } from 'rxdb/plugins/query-builder';
|
||||
import { RxDBUpdatePlugin } from 'rxdb/plugins/update';
|
||||
import { getRxStorageDexie } from 'rxdb/plugins/storage-dexie';
|
||||
import { replicateAppwrite } from 'rxdb/plugins/replication-appwrite';
|
||||
|
||||
// Appwrite imports
|
||||
import { ID } from 'appwrite';
|
||||
import { client, appwriteConfig } from './appwrite.js';
|
||||
|
||||
addRxPlugin(RxDBQueryBuilderPlugin);
|
||||
addRxPlugin(RxDBUpdatePlugin);
|
||||
```
|
||||
|
||||
The RxDB imports include core RxDB functionalities to create databases and collections and to add plugins, the query builder plugin for complex read queries, the update plugin for updating data, the Dexie.js storage plugin to use IndexedDB as the local database, and the Appwrite replication plugin to manage data replication in the external Appwrite database.
|
||||
|
||||
### Create a local database
|
||||
|
||||
To create a local database, first, we must prepare the database schema. To do so, add the following code in the `databases.js` file:
|
||||
|
||||
```js
|
||||
const journalSchema = {
|
||||
title: 'journal entry schema',
|
||||
version: 0,
|
||||
primaryKey: 'id',
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
maxLength: 100
|
||||
},
|
||||
title: {
|
||||
type: 'string'
|
||||
},
|
||||
content: {
|
||||
type: 'string'
|
||||
},
|
||||
createdAt: {
|
||||
type: 'number'
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'number'
|
||||
}
|
||||
},
|
||||
required: ['id', 'title', 'content', 'createdAt', 'updatedAt']
|
||||
};
|
||||
```
|
||||
|
||||
Then, we create the database and collection using the Dexie.js plugin by adding the following code just after the schema:
|
||||
|
||||
```js
|
||||
let dbPromise = null;
|
||||
|
||||
export const getDB = async () => {
|
||||
if (dbPromise) return dbPromise;
|
||||
|
||||
try {
|
||||
// Create the database
|
||||
dbPromise = createRxDatabase({
|
||||
name: 'journals', // Name must match the database ID from Appwrite
|
||||
storage: getRxStorageDexie()
|
||||
});
|
||||
|
||||
const db = await dbPromise;
|
||||
|
||||
// Add collections
|
||||
await db.addCollections({
|
||||
entries: { // Name must match the collection ID from Appwrite
|
||||
schema: journalSchema
|
||||
}
|
||||
});
|
||||
|
||||
// Set up replication
|
||||
setupReplication(db);
|
||||
|
||||
return db;
|
||||
} catch (error) {
|
||||
console.error('Database creation error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Setup data replication
|
||||
|
||||
To setup replication in the Appwrite database, add the following code to the `databases.js` file:
|
||||
|
||||
```js
|
||||
const setupReplication = async (db) => {
|
||||
try {
|
||||
// Start replication
|
||||
replicationState = replicateAppwrite({
|
||||
replicationIdentifier: 'journals-replication',
|
||||
client,
|
||||
databaseId: appwriteConfig.databaseId,
|
||||
collectionId: appwriteConfig.collectionId,
|
||||
deletedField: 'deleted',
|
||||
collection: db.entries,
|
||||
pull: {
|
||||
batchSize: 25 // Can be updated
|
||||
},
|
||||
push: {
|
||||
batchSize: 25 // Can be updated
|
||||
}
|
||||
});
|
||||
|
||||
// Handle replication events
|
||||
replicationState.error$.subscribe((error) => {
|
||||
console.error('Replication error:', error);
|
||||
});
|
||||
|
||||
replicationState.active$.subscribe((active) => {
|
||||
});
|
||||
|
||||
return replicationState;
|
||||
} catch (error) {
|
||||
console.error('Replication setup error:', error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Add database operations
|
||||
|
||||
Lastly, add the following helper functions for different database operations in the `databases.js` file:
|
||||
|
||||
```js
|
||||
export const getJournals = async () => {
|
||||
const db = await getDB();
|
||||
return db.entries.find().sort({ updatedAt: 'desc' }).exec();
|
||||
};
|
||||
|
||||
export const getJournal = async (id) => {
|
||||
const db = await getDB();
|
||||
return db.entries.findOne({ selector: { id } }).exec();
|
||||
};
|
||||
|
||||
export const createJournal = async (journalData) => {
|
||||
const db = await getDB();
|
||||
const timestamp = Date.now();
|
||||
return db.entries.insert({
|
||||
id: ID.unique(),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
...journalData
|
||||
});
|
||||
};
|
||||
|
||||
export const updateJournal = async (id, journalData) => {
|
||||
const db = await getDB();
|
||||
const journal = await getJournal(id);
|
||||
|
||||
if (!journal) throw new Error('Journal entry not found');
|
||||
return journal.update({
|
||||
$set: {
|
||||
...journalData,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteJournal = async (id) => {
|
||||
const db = await getDB();
|
||||
const journal = await getJournal(id);
|
||||
|
||||
if (!journal) throw new Error('Journal entry not found');
|
||||
|
||||
return journal.remove();
|
||||
};
|
||||
```
|
||||
|
||||
## Develop the UI
|
||||
|
||||
Now that our database library functions are set up, let’s create all journal-related pages.
|
||||
|
||||
> **Note:** To maintain conciseness, we will skip all styling-related CSS. You can find examples of the same in our [demo app’s GitHub repo](https://github.com/appwrite-community/offline-journal/tree/main/src).
|
||||
|
||||
### List all journal entries
|
||||
|
||||
We will list all journal entries on the index page of the app. Head to the `src/routes` directory, create a `+page.js` file and add the following code:
|
||||
|
||||
```js
|
||||
import { getJournals } from '$lib/database';
|
||||
|
||||
/** @type {import('./$types').PageLoad} */
|
||||
export async function load({ url }) {
|
||||
let journals = null;
|
||||
|
||||
try {
|
||||
journals = await getJournals();
|
||||
} catch (err) {
|
||||
console.error('Error fetching journals:', err);
|
||||
journals = [];
|
||||
}
|
||||
|
||||
return {
|
||||
journals
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This will pre-load all journal entries before the page renders. Then, open the `+page.svelte` file and edit it to the following code:
|
||||
|
||||
```html
|
||||
<script>
|
||||
import { getJournals, deleteJournal } from '$lib/database.js';
|
||||
|
||||
let { data } = $props();
|
||||
let journals = $state(data.journals);
|
||||
let error = $state(null);
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (confirm('Are you sure you want to delete this journal entry?')) {
|
||||
try {
|
||||
await deleteJournal(id);
|
||||
await loadJournals();
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Journal App</title>
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<header>
|
||||
<h1>My Journal</h1>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">
|
||||
<p>{error}</p>
|
||||
<button onclick={() => (error = null)}>Dismiss</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<a href="/journal/new" class="new-entry-btn">New Journal Entry</a>
|
||||
</div>
|
||||
|
||||
{#if journals.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>You don't have any journal entries yet.</p>
|
||||
<a href="/journal/new">Create your first entry</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="journal-entries">
|
||||
{#each journals as journal (journal.id)}
|
||||
<div class="journal-card">
|
||||
<div class="journal-header">
|
||||
<h2>{journal.title}</h2>
|
||||
<div class="journal-actions">
|
||||
<a href={`/journal/${journal.id}`} class="view-btn">View</a>
|
||||
<a href={`/journal/${journal.id}/edit`} class="edit-btn">Edit</a>
|
||||
<button class="delete-btn" onclick={() => handleDelete(journal.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="journal-preview">
|
||||
{#if journal.content.length > 150}
|
||||
<p>{journal.content.substring(0, 150)}...</p>
|
||||
{:else}
|
||||
<p>{journal.content}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="journal-footer">
|
||||
<div class="timestamp">
|
||||
<span>Created: {formatDate(journal.createdAt)}</span>
|
||||
<span>Updated: {formatDate(journal.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
```
|
||||
|
||||
This page will trigger the creation of a local database the first time it is launched and set up replication with Appwrite. All existing journal entries will then be loaded from IndexedDB and rendered as cards on the page. Each journal entry card allows you to access pages, view, edit, or delete an entry from the database. The page will also allow you to create a new journal entry.
|
||||
|
||||
### View a journal entry
|
||||
|
||||
In the `src/routes` directory, create a subdirectory `journal`, within which you must create another subdirectory `[id]`, and add a `+page.svelte` file with the following code:
|
||||
|
||||
```html
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { deleteJournal, getJournal } from '$lib/database.js';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let journal = $state(null);
|
||||
let error = $state(null);
|
||||
let loading = $state(true);
|
||||
|
||||
async function handleDelete() {
|
||||
if (confirm('Are you sure you want to delete this journal entry?')) {
|
||||
try {
|
||||
await deleteJournal(journal.id);
|
||||
goto('/');
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
loading = true;
|
||||
journal = await getJournal(page.params.id);
|
||||
if (!journal) {
|
||||
error = 'Journal entry not found';
|
||||
}
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{journal ? journal.title : 'Loading...'} | Journal App</title>
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<header>
|
||||
<a href="/" class="back-btn">← Back to Journal</a>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">
|
||||
<p>{error}</p>
|
||||
<button onclick={() => (error = null)}>Dismiss</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if loading}
|
||||
<div class="loading">Loading...</div>
|
||||
{:else if journal}
|
||||
<article class="journal-entry">
|
||||
<div class="journal-header">
|
||||
<h1>{journal.title}</h1>
|
||||
<div class="journal-actions">
|
||||
<a href={`/journal/${journal.id}/edit`} class="edit-btn">Edit</a>
|
||||
<button class="delete-btn" onclick={handleDelete}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="journal-meta">
|
||||
<div class="timestamp">
|
||||
<span>Created: {formatDate(journal.createdAt)}</span>
|
||||
<span>Updated: {formatDate(journal.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="journal-content">
|
||||
<p>{journal.content}</p>
|
||||
</div>
|
||||
</article>
|
||||
{:else}
|
||||
<div class="not-found">
|
||||
<p>Journal entry not found</p>
|
||||
<a href="/">Return to Journal</a>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
```
|
||||
|
||||
When accessing this page, the `[id]` in the URL acts as a slug for fetching data pertaining to a specific journal entry and rendering it on the page.
|
||||
|
||||
### Edit a journal entry
|
||||
|
||||
In the `src/routes/journal/[id]` directory, create a subdirectory `edit`, and add a `+page.svelte` file with the following code:
|
||||
|
||||
```html
|
||||
<script>
|
||||
import { preventDefault } from 'svelte/legacy';
|
||||
import { page } from '$app/state';
|
||||
import { updateJournal, getJournal } from '$lib/database.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let journal = $state(null);
|
||||
let title = $state('');
|
||||
let content = $state('');
|
||||
let saving = $state(false);
|
||||
let error = $state(null);
|
||||
let loading = $state(true);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!title || !content) {
|
||||
error = 'Title and content are required.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
saving = true;
|
||||
await updateJournal(journal.id, {
|
||||
title,
|
||||
content
|
||||
});
|
||||
|
||||
// Navigate to the journal entry view
|
||||
goto(`/journal/${journal.id}`);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
loading = true;
|
||||
journal = await getJournal(page.params.id);
|
||||
if (journal) {
|
||||
title = journal.title;
|
||||
content = journal.content;
|
||||
} else {
|
||||
error = 'Journal entry not found';
|
||||
}
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Edit Journal Entry | Journal App</title>
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<header>
|
||||
<h1>Edit Journal Entry</h1>
|
||||
<a href={`/journal/${page.params.id}`} class="back-btn">← Back to Entry</a>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">
|
||||
<p>{error}</p>
|
||||
<button onclick={() => (error = null)}>Dismiss</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if loading}
|
||||
<p>Loading...</p>
|
||||
{:else if journal}
|
||||
<form onsubmit={preventDefault(handleSubmit)}>
|
||||
<div class="form-group">
|
||||
<label for="title">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
bind:value={title}
|
||||
placeholder="Enter a title for your journal entry"
|
||||
disabled={saving}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="content">Content</label>
|
||||
<textarea
|
||||
id="content"
|
||||
bind:value={content}
|
||||
placeholder="Write your thoughts here..."
|
||||
rows="15"
|
||||
disabled={saving}
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="cancel-btn"
|
||||
onclick={() => goto(`/journal/${journal.id}`)}
|
||||
disabled={saving}>Cancel</button
|
||||
>
|
||||
<button type="submit" class="save-btn" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="not-found">
|
||||
<p>Journal entry not found</p>
|
||||
<a href="/">Return to Journal</a>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
```
|
||||
|
||||
This page will load the data for a specific journal entry and allow the user to edit its title and content. Saving the edited content will also update the entry's “updated at” time.
|
||||
|
||||
### Add a new journal entry
|
||||
|
||||
In the `src/routes/journal` directory, create a subdirectory `new`, and add a `+page.svelte` file with the following code:
|
||||
|
||||
```html
|
||||
<script>
|
||||
import { preventDefault } from 'svelte/legacy';
|
||||
import { createJournal } from '$lib/database.js';
|
||||
import { goto } from '$app/navigation';
|
||||
let title = $state('');
|
||||
let content = $state('');
|
||||
let loading = $state(false);
|
||||
let error = $state(null);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!title || !content) {
|
||||
error = 'Title and content are required.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
loading = true;
|
||||
const journal = await createJournal({
|
||||
title,
|
||||
content
|
||||
});
|
||||
|
||||
// Navigate back to the main page
|
||||
goto('/');
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>New Journal Entry</title>
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<header>
|
||||
<h1>New Journal Entry</h1>
|
||||
<a href="/" class="back-btn">← Back to Journal</a>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">
|
||||
<p>{error}</p>
|
||||
<button onclick={() => (error = null)}>Dismiss</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={preventDefault(handleSubmit)}>
|
||||
<div class="form-group">
|
||||
<label for="title">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
bind:value={title}
|
||||
placeholder="Enter a title for your journal entry"
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="content">Content</label>
|
||||
<textarea
|
||||
id="content"
|
||||
bind:value={content}
|
||||
placeholder="Write your thoughts here..."
|
||||
rows="15"
|
||||
disabled={loading}
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="cancel-btn" onclick={() => goto('/')} disabled={loading}
|
||||
>Cancel</button
|
||||
>
|
||||
<button type="submit" class="save-btn" disabled={loading}>
|
||||
{loading ? 'Saving...' : 'Save Entry'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
```
|
||||
|
||||
This page features a form that would allow the user to add a new journal entry to the database.
|
||||
|
||||
## Configure app as a PWA
|
||||
|
||||
For easier offline usage, let’s configure the web app to work as a PWA to offer an offline-first experience. For those who aren’t aware, a PWA or Progress Web App is a type of web application that can be installed on a device as a standalone app, offering a native-like experience.
|
||||
|
||||
To configure our web app as a PWA, you must follow four steps.
|
||||
|
||||
### Create a manifest.json file
|
||||
|
||||
In the `static/` directory, create a new `manifest.json` file and add the following code:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Offline Journal",
|
||||
"short_name": "Journal",
|
||||
"description": "A private offline-first journaling application",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#4a76a8",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-192-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Add app icons
|
||||
|
||||
In the `static/` directory, create a subdirectory `icons/`and add two icons files of the sizes `192px x 192px` and `512px x 512px`. You can [download our demo app’s icons](https://github.com/appwrite-community/offline-journal/tree/main/static/icons) from our GitHub repo as a placeholder. Ensure that the file names for both images comply with those in the `manifest.json` file.
|
||||
|
||||
### Link manifest file in the app.html file
|
||||
|
||||
In the `src/` directory, open the `app.html` file and add the following code within the `<head>` tags:
|
||||
|
||||
```html
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
```
|
||||
|
||||
### Create a service worker
|
||||
|
||||
In the `src/` directory, create a subdirectory `service-worker`, and add a file `index.js` with the following code:
|
||||
|
||||
```js
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
|
||||
// @ts-nocheck
|
||||
import { build, files, version } from '$service-worker';
|
||||
|
||||
// Create a unique cache name for this deployment
|
||||
const CACHE = `cache-${version}`;
|
||||
|
||||
const ASSETS = [
|
||||
...build, // the app itself
|
||||
...files // everything in `static`
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
// Create a new cache and add all files to it
|
||||
async function addFilesToCache() {
|
||||
const cache = await caches.open(CACHE);
|
||||
await cache.addAll(ASSETS);
|
||||
}
|
||||
|
||||
event.waitUntil(addFilesToCache());
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
// Remove previous cached data from disk
|
||||
async function deleteOldCaches() {
|
||||
for (const key of await caches.keys()) {
|
||||
if (key !== CACHE) await caches.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
event.waitUntil(deleteOldCaches());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// ignore POST requests etc
|
||||
if (event.request.method !== 'GET') return;
|
||||
|
||||
async function respond() {
|
||||
const url = new URL(event.request.url);
|
||||
const cache = await caches.open(CACHE);
|
||||
|
||||
// `build`/`files` can always be served from the cache
|
||||
if (ASSETS.includes(url.pathname)) {
|
||||
return cache.match(url.pathname);
|
||||
}
|
||||
|
||||
// for everything else, try the network first, but
|
||||
// fall back to the cache if we're offline
|
||||
try {
|
||||
const response = await fetch(event.request);
|
||||
|
||||
if (response.status === 200) {
|
||||
cache.put(event.request, response.clone());
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch {
|
||||
return cache.match(event.request);
|
||||
}
|
||||
}
|
||||
|
||||
event.respondWith(respond());
|
||||
});
|
||||
```
|
||||
|
||||
## Test the app
|
||||
|
||||
To locally deploy and test the app, run the following command in your terminal:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
You can then visit `https://localhost:5173` in your browser and try out the app.
|
||||
|
||||
# Next steps
|
||||
|
||||
And with that, our offline-first journal app built with RxJS and SvelteKit is ready!
|
||||
|
||||
We developed a more complex version of this app, featuring an authentication implementation and better styling, and deployed it publicly to try out: https://offline-journal.vercel.app/
|
||||
|
||||
You can find the source code for this application in our [GitHub repo](https://github.com/appwrite-community/offline-journal).
|
||||
|
||||
Learn more about RxDB and Appwrite:
|
||||
|
||||
- [RxDB docs for Appwrite](https://rxdb.info/replication-appwrite.html#do-other-things-with-the-replication-state)
|
||||
- [Appwrite offline sync docs](/docs/products/databases/offline)
|
||||
- [RxDB in the Integrations catalog](/integrations/replication-rxdb)
|
||||
99
src/routes/blog/post/the-appwrite-network/+page.markdoc
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
layout: post
|
||||
title: "Announcing the Appwrite Network: Appwrite’s vision for a global cloud infrastructure"
|
||||
description: Announcing more regions for Cloud, and many more to come with the introduction of the Appwrite Network.
|
||||
date: 2025-04-16
|
||||
cover: /images/blog/the-appwrite-network/network-cover-image.png
|
||||
timeToRead: 05
|
||||
author: christy-jacob
|
||||
category: product
|
||||
featured: true
|
||||
callToAction: true
|
||||
---
|
||||
|
||||
We are happy to announce the launch of the Appwrite Network, a network of cloud regions and edge locations (edges) to improve Appwrite Cloud availability, performance, and compliance with local regulations. This will provide Appwrite developers and teams with the best tools and infrastructure to build, deploy, and scale your applications. This brings us closer to the Appwrite mission of making software development more accessible and enjoyable for all developers.
|
||||
|
||||
Starting today, all Pro users have access to three regions of choice, including our two new regions in **New York City** ('NYC'), and **Sydney** ('SYD') that are joining our first region in **Frankfurt** ('FRA'). In the next few weeks, this will be available to all Cloud users, with more regions to come.
|
||||
|
||||

|
||||
|
||||
# The plan
|
||||
|
||||
The Appwrite Cloud beta journey began with a single region in Frankfurt, which allowed us to focus on delivering the highest quality and performance on a smaller scale. As we approach the General Availability (GA) of Appwrite Cloud, and with a growing number of developers, projects, and organizations on the platform, we are excited to share the plans to expand the network to include additional regions and edges across the globe.
|
||||
|
||||
| Location | Status | Region | Edge | Timeline |
|
||||
|---------------------|-----------------------|--------|------|---------------|
|
||||
| Frankfurt (`FRA`) | Ready and operational | Yes | Yes | Available Now |
|
||||
| Sydney (`SYD`) | Ready and operational | Yes | Yes | Available Now |
|
||||
| New York City (`NYC`) | Ready and operational | Yes | Yes | Available Now |
|
||||
| Singapore (`SGP`) | In work | Yes | Yes | Q4 2025 |
|
||||
| San Francisco (`SFO`) | In work | Yes | Yes | Q4 2025 |
|
||||
| Bangalore (`BLR`) | In plans | Yes | Yes | TBD |
|
||||
| Amsterdam (`AMS`) | In plans | Yes | Yes | TBD |
|
||||
| London (`LON`) | In plans | Yes | Yes | TBD |
|
||||
| Toronto (`TOR`) | In plans | Yes | Yes | TBD |
|
||||
|
||||
|
||||
# Addressing the latency problem
|
||||
|
||||
Latency is a critical factor in the performance of both web and mobile applications. High latency can lead to slow response times, poor user experience, and ultimately, loss of revenue. There are several primary causes of latency:
|
||||
|
||||
- **Physical distance:** The further the data has to travel, the longer it takes. This is known as propagation delay.
|
||||
- **Network congestion:** Overloaded networks can slow down data transmission.
|
||||
- **Routing and switching:** Each hop a packet takes through the network can add delays.
|
||||
- **Server processing time:** The time it takes for a server to process a request and send a response.
|
||||
|
||||
Establishing a global network of regions and edges can significantly reduce latency. For example, without a global network, a request from a user in Sydney to a server in New York could experience latency upwards of 250 milliseconds. With a global network, routing that same request through an edge in Singapore and a region in Frankfurt can reduce latency to under 100 milliseconds.
|
||||
|
||||
A global network allows us to distribute data and processing power closer to the end-users, minimizing the distance that data needs to travel and thus reducing the time it takes for requests and responses to be processed. This results in faster load times, improved performance, and a better overall user experience.
|
||||
|
||||
# Why build the Appwrite Network?
|
||||
|
||||
Building a fully fledged network from scratch is not a decision we take lightly. Here are the key reasons behind this approach:
|
||||
|
||||
**1. Innovation and quality**: By owning the network, we can innovate and optimize in ways that would be impossible with off-the-shelf solutions. This control allows us to deliver a superior developer experience explicitly tailored to your needs.
|
||||
|
||||
**2. Security and trust**: Maintaining control over the infrastructure to ensure the highest standards of security and privacy for Appwrite developers. This helps us ensure you always own and control your data.
|
||||
|
||||
**3. Flexibility and customization**: Having a network allows us to be more agile and responsive to the evolving needs of Appwrite developers. We can quickly adapt and introduce new features and improvements without being constrained by third-party limitations.
|
||||
|
||||
**4. Cost efficiency**: By building and managing the network, we can reduce costs and pass those savings on to Appwrite developers. By maintaining independence, we can ensure predictable costs for compute, and this helps us make Appwrite more affordable for teams of all sizes.
|
||||
|
||||
**5. It’s fun**: At the core of Appwrite, we are geeks who love technology and building stuff from scratch, so you don’t have to. Building a network is not just a business decision but also a passion. It's an opportunity to push the boundaries of what's possible and share that excitement with the Appwrite community of developers.
|
||||
|
||||
# Regions vs Edges vs PoPs
|
||||
|
||||

|
||||
|
||||
In Appwrite, Regions are where all your core data and services live. This includes your databases, auth, functions, messaging, and storage. Regions are the source of truth, handling heavy workloads and ensuring your application runs reliably while keeping your data compliant with local regulations.
|
||||
|
||||
Edges are about speed. They process requests closer to your users using smart geo-routing, reducing latency by handling compute tasks at the nearest edge location. Edges are perfect for serving cached content, executing lightweight computations, and optimizing user interactions.
|
||||
|
||||
Our global CDN leverages strategically positioned Points of Presence (PoPs) to cache and deliver your content from locations nearest to your users, ensuring rapid load times and enhanced performance.
|
||||
|
||||
In short, a region hosts your data, and the edge executes your functions close to your users. Both regions and edges take advantage of the Appwrite CDN to optimize delivery and security.
|
||||
|
||||
# Data storage and global availability
|
||||
|
||||
One of the key features of the Appwrite Network is the ability to choose where your project's data is stored. You can select your preferred region, ensuring compliance with local data regulations and optimizing performance for your primary user base. However, thanks to the global network of edges, your data will be accessible worldwide on the Appwrite network, ensuring fast and reliable access for users regardless of their location.
|
||||
|
||||
Each region will also function as an edge for other regions, enhancing network coverage and reducing latency. This interconnected approach ensures that we can deliver the best possible performance and reliability for all Appwrite developers and your end-users.
|
||||
|
||||
# Global CDN with integrated DDoS protection
|
||||
|
||||
All Appwrite’s Cloud projects are served by Appwrite’s built-in CDN. Our new global CDN is designed to serve your content rapidly and reliably worldwide. By leveraging a network of strategically located points of presence (PoPs), the CDN ensures that every user request is delivered from the closest possible location, drastically reducing latency and enhancing the overall user experience.
|
||||
|
||||
Moreover, the CDN includes integrated DDoS protection that combines standard traffic analysis, rate limiting, and filtering techniques to help mitigate potential threats in real time. This balanced approach helps maintain solid performance while providing a reliable level of security for your applications.
|
||||
|
||||
# Web application firewall (WAF)
|
||||
|
||||
Our [Web Application Firewall](/docs/products/network/waf) (WAF) is now available exclusively to enterprise customers, providing a crucial protective layer for your applications. Operating at OSI Layer 7, the WAF inspects and filters all HTTP/HTTPS traffic in real time, effectively blocking common web vulnerabilities such as SQL injection, XSS, and CSRF, while also mitigating application-level DDoS attacks.
|
||||
|
||||
Customizable rulesets allow you to tailor the WAF to your application's specific needs, with detailed analysis of request headers, payloads, and query strings to identify and neutralize threats before they reach your infrastructure. Setup and configuration are managed through your dedicated Appwrite success manager, ensuring that the WAF adapts to evolving security challenges and compliance requirements. To learn more about our enterprise plan, you can [contact us](/contact-us/enterprise).
|
||||
|
||||
# The road ahead
|
||||
|
||||
We welcome you to visit the docs to learn more about the [Appwrite Network](/docs/products/network) and how Appwrite handle's other topics like [secure transportation](/docs/products/network/tls), [compression](/docs/products/network/compression), [caching](/docs/products/network/caching) and more.
|
||||
|
||||
We are excited about the future of the Appwrite network and the endless possibilities it will unlock for developers worldwide. Stay tuned for more updates as we continue to build and expand the network. Thank you for being part of the Appwrite community and for your ongoing support. Together, we are building the future of cloud platforms designed for developers.
|
||||
|
||||
@@ -7,7 +7,8 @@ cover: /images/blog/what-is-mcp/cover.png
|
||||
timeToRead: 7
|
||||
author: ebenezer-don
|
||||
category: tutorial
|
||||
featured: true
|
||||
featured: false
|
||||
callToAction: true
|
||||
---
|
||||
|
||||
If you've ever tried using an AI assistant for something practical, like pulling real data from your work files, checking a database, or sending a message, then you've probably hit a frustrating wall.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
layout: post
|
||||
title: Why multi-cloud is taking over
|
||||
description: Multi-cloud is becoming the go-to solution for organizations of all sizes. Learn how it offers better flexibility, control, and performance.
|
||||
date: 2025-04-17
|
||||
cover: /images/blog/why-multi-cloud-is-taking-over/cover.png
|
||||
timeToRead: 4
|
||||
author: aditya-oberai
|
||||
category: product
|
||||
callToAction: true
|
||||
---
|
||||
|
||||
Choosing the right cloud provider can be challenging, which is why more businesses are turning to **multi-cloud,** using services from multiple cloud providers instead of relying on just one.
|
||||
|
||||
The newly announced Appwrite Network is a prime example, with its multi-cloud, global, and vendor-agnostic approach. Why multi-cloud? It's simple: it offers better flexibility, control, and performance.
|
||||
|
||||
Let's break down why multi-cloud is becoming the go-to solution for organizations of all sizes.
|
||||
|
||||
# What is multi-cloud?
|
||||
|
||||
Multicloud means using more than one cloud provider, like AWS, Google Cloud, or Microsoft Azure, for different services. Instead of putting all your eggs in one basket, you spread them out, using the strengths of each provider.
|
||||
|
||||
This approach lets you select the best features from different vendors to meet your business needs, reducing the risk of vendor lock-in.
|
||||
|
||||
More organizations are adopting multi-cloud strategies because they allow applications to run where needed without adding unnecessary complexity.
|
||||
|
||||
# Benefits of going multi-cloud
|
||||
|
||||
Appwrite Network uses a multi-cloud, agnostic approach for several key reasons. By leveraging multiple clouds, Appwrite can distribute its services globally, reducing latency and ensuring a more reliable and faster user experience across different regions.
|
||||
|
||||
## 1. **No vendor lock-In**
|
||||
|
||||
Relying on one cloud provider can be risky. What if their prices go up? Or their services start to slow down? Multicloud keeps your options open. You're free to switch things up whenever you want without being tied to one vendor's rules or pricing.
|
||||
|
||||
## 2. **Better performance and cost savings**
|
||||
|
||||
Every cloud platform has its strengths. Some are great at handling data, while others excel at running applications. With multi-cloud, you can pick the best tool for each job. This means you get better performance and can even save money by optimizing your cloud use.
|
||||
|
||||
For instance, you could use Google Cloud for your AI projects while letting AWS handle your data storage—getting the best of both worlds without overspending.
|
||||
|
||||
## 3. **Global reach**
|
||||
|
||||
With multi-cloud, your business isn't limited to the geographical reach of a single provider. You can leverage multiple cloud providers to have a truly **global presence**. Need a data center in Asia, Europe, and North America? No problem. By using different cloud services in different regions, you can reach users wherever they are—faster and more reliably.
|
||||
|
||||
This also comes in handy when dealing with data sovereignty laws, which often require data to be stored in specific countries. With multi-cloud, you can easily store data in compliance with local regulations while still maintaining a global footprint.
|
||||
|
||||
## 4. **Reduced latency**
|
||||
|
||||
One of the biggest challenges in cloud computing is **latency**—the delay between a user making a request and the system responding. When your cloud provider's data center is far from your user, that lag can be noticeable. With multi-cloud, you can strategically place your services on data centers that are closest to your users, reducing latency and speeding up performance.
|
||||
|
||||
For example, if your app has users in Europe and Asia, you can deploy servers in both regions through different providers. This reduces the time it takes for data to travel, resulting in a smoother, faster experience for your users.
|
||||
|
||||
## 5. Data compliance and security
|
||||
|
||||
If your business operates in multiple regions, you probably have to deal with different rules about where data is stored. With multi-cloud, you can store data in different locations using multiple providers, ensuring you meet all those pesky legal requirements.
|
||||
|
||||
For example, you might store European data on a cloud provider that complies with GDPR while using another provider for data in the US.
|
||||
|
||||
## 6. Flexibility
|
||||
|
||||
Multi-cloud gives you access to a wider range of tools and technologies. Need a feature that one cloud offers but another doesn't? No problem. You can combine them. This flexibility allows you to stay agile and adapt quickly to changes in your business or technology needs.
|
||||
|
||||
No single cloud provider is perfect at everything. Some excel at certain things, like AWS for scalability or Google Cloud for AI tools. By using multi-cloud, you get access to the best features each platform offers, helping you stay ahead of the competition.
|
||||
|
||||
# Cons of multi-cloud
|
||||
|
||||
While multi-cloud offers plenty of benefits, it also comes with some challenges that businesses need to consider:
|
||||
|
||||
1. **Increased complexity**: Managing multiple cloud platforms can quickly become complicated. Each provider has its own set of tools, services, and interfaces, which require more time and expertise to manage efficiently.
|
||||
2. **Higher costs**: Multicloud setups can lead to higher costs, as you may need to pay for additional services, data transfers, or even specialized management tools to keep everything running smoothly across providers.
|
||||
3. **Security risks**: As more platforms are used, the attack surface expands, making security management more difficult. Businesses need to ensure strong security practices across all providers to avoid vulnerabilities.
|
||||
4. **Data integration challenges**: Moving data between different cloud providers can be tricky. Ensuring seamless integration and preventing data silos is a common challenge in a multi-cloud environment.
|
||||
|
||||
# Multicloud is the future
|
||||
|
||||
Multicloud isn't just a trend. It's a smart strategy for businesses that want flexibility, reliability, and the best tools at their disposal. It frees you from vendor lock-in, optimizes performance, and gives you peace of mind with backup options.
|
||||
|
||||
Multicloud is taking over because it puts the power back in the hands of businesses, letting them choose the right tools, minimize risks, and deliver a better user experience.
|
||||
|
||||
If you'd like to learn more about how the Appwrite Network makes best use of the multi-cloud approach, take a look at these resources:
|
||||
|
||||
- [Appwrite Network announcement](/blog/post/appwrite-network-announcement?doFollow=true)
|
||||
- [How to reduce cloud latency](/blog/post/how-to-reduce-cloud-latency?doFollow=true)
|
||||
- [Introducing Database Backups](/blog/post/introducing-database-backups?doFollow=true)
|
||||
22
src/routes/changelog/(entries)/2025-04-16.markdoc
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
layout: changelog
|
||||
title: "Announcing the Appwrite Network: Appwrite’s vision for a global cloud infrastructure"
|
||||
date: 2025-04-16
|
||||
cover: /images/changelog/2025-04-15.png
|
||||
---
|
||||
|
||||
We’re excited to launch the **Appwrite Network**, a global network of cloud regions and edge locations designed to enhance Appwrite Cloud’s availability, performance, and regulatory compliance.
|
||||
|
||||
Starting today, all Pro users can access three regions: **Frankfurt (FRA)**, our first region, and two new regions in **New York City (NYC)** and **Sydney (SYD)**. These new regions will be available to all Cloud users in the coming weeks, with more regions to follow.
|
||||
|
||||
# Key features of the Appwrite Network
|
||||
|
||||
- **Reduced latency with global coverage:** By distributing regions and edge locations worldwide, we minimize physical distance and routing delays, ensuring faster response times and better user experiences.
|
||||
|
||||
- **Regional data control with global access:** Choose where your data resides to meet local regulations and optimize for performance, while still ensuring global availability through Appwrite’s interconnected network.
|
||||
|
||||
- **Global CDN with built-in DDoS protection:** Deliver content faster with our CDN powered by strategically placed PoPs, and stay secure with integrated DDoS mitigation that filters threats in real-time.
|
||||
|
||||
We’re just getting started with the Appwrite Network and can’t wait to share what’s next. Thanks for being part of the journey, together, we’re shaping the future of cloud platforms for developers.
|
||||
|
||||
[Read the announcement to learn more](/blog/post/the-appwrite-network).
|
||||
@@ -40,6 +40,10 @@
|
||||
label: 'Permissions',
|
||||
href: '/docs/products/databases/permissions'
|
||||
},
|
||||
{
|
||||
label: 'Offline Sync',
|
||||
href: '/docs/products/databases/offline'
|
||||
},
|
||||
{
|
||||
label: 'Relationships',
|
||||
href: '/docs/products/databases/relationships'
|
||||
|
||||
51
src/routes/docs/products/databases/offline/+page.markdoc
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
layout: article
|
||||
title: Offline Sync
|
||||
description: Enable offline synchronization of data between your apps and Appwrite Databases.
|
||||
---
|
||||
|
||||
Offline synchronization (or offline sync) is a mechanism that allows apps to store and update data locally when a user is offline (i.e., loses internet connectivity), and then synchronize that data with an Appwrite database once the user is back online.
|
||||
|
||||
This capability is crucial for building resilient and responsive applications, especially in environments with unreliable or intermittent internet connectivity. Suppose you are driving from one city to another and lose internet connectivitity while passing through a rural area, locally-downloaded maps in your GPS app would ensure that you do not get lost. Another example could be that you are waiting in queue at a supermarket and there is a network outage; an offline-synchronized databases with inventory data would prevent the point-of-sale (POS) systems from failing, ensuring you and your fellow customers can buy groceries.
|
||||
|
||||
Some real-world scenarios where offline sync is useful are:
|
||||
|
||||
- Journaling and note-taking apps
|
||||
- Warehouse inventory management systems
|
||||
- Medical data entry tools
|
||||
- Airline check-in management apps
|
||||
- GPS navigation software
|
||||
|
||||
# Integrate offline sync in your apps
|
||||
|
||||
{% only_light %}
|
||||
{% cards %}
|
||||
|
||||
{% cards_item href="/integrations/replication-rxdb" title="RxDB" image="/images/docs/databases/offline/logos/rxdb.svg" %}
|
||||
{% /cards_item %}
|
||||
|
||||
{% /cards %}
|
||||
{% /only_light %}
|
||||
|
||||
{% only_dark %}
|
||||
{% cards %}
|
||||
|
||||
{% cards_item href="/integrations/replication-rxdb" title="RxDB" image="/images/docs/databases/offline/logos/dark/rxdb.svg" %}
|
||||
{% /cards_item %}
|
||||
|
||||
{% /cards %}
|
||||
{% /only_dark %}
|
||||
|
||||
# How does offline sync work?
|
||||
|
||||
The process of implementing offline sync in Appwrite-powered apps (and in general) is as follows:
|
||||
|
||||
1. **Local data storage:** When a user opens your app, the app downloads relevant data from the server and saves it locally on their device via local-first data stores like IndexedDB, LocalStorage, SQLite, or RxDB.
|
||||
|
||||
2. **Working offline**: While offline, users can either read previously synced data or make changes (create, update, or delete data) in the local data store.
|
||||
|
||||
3. **Detecting connectivity**: The app monitors network status. As soon as connectivity is restored, a sync operation is triggered between the local data store and the Appwrite database.
|
||||
|
||||
5. **Two-way synchronization**: Local changes are *"pushed"* to the Appwrite database and new changes from the database are *"pulled"* into the local store. This process is called **push-pull replication**.
|
||||
|
||||
6. **Conflict resolution**: If the same data was changed both locally and on the server, the system must prioritise one of the two operations. Various strategies can be implemented to mitigate this issue, such as *last write wins* or *manual user conflict resolution*.
|
||||
@@ -5,7 +5,7 @@ description: Understand and label the contents of images
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/ai-hugging-face-image-classification/cover.png
|
||||
category: ai
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Translate text between languages
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/ai-hugging-face-language-translation/cover.png
|
||||
category: ai
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Process speech audio into text
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/ai-hugging-face-speech-recognition/cover.png
|
||||
category: ai
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Send text prompts to OpenAI GPT-3.5 and receive text generations
|
||||
date: 2024-07-30
|
||||
featured: true
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/ai-openai/cover.png
|
||||
category: ai
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Send text prompts to Perplexity and receive text generations
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/ai-perplexity/cover.png
|
||||
category: ai
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Manage versions and deploy Appwrite Functions
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/deployments-github/cover.png
|
||||
category: deployments
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Send customized emails to your users
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/email-sendgrid/cover.png
|
||||
category: messaging
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Connect Lemon Squeezy to Appwrite to handle payments, manage orders
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/lemon-squeezy-payments/cover.png
|
||||
category: payments
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Connect Lemon Squeezy subscriptions service to Appwrite to receive
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/lemon-squeezy-subscriptions/cover.png
|
||||
category: payments
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Monitor application health in real time, store logs and track issue
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/logging-appsignal/cover.png
|
||||
category: logging
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Integrate Raygun, an application performane monitoring (APM) tool w
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/logging-raygun/cover.png
|
||||
category: logging
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Connect Sentry to your self-hosted Appwrite instance for improved e
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/logging-sentry/cover.png
|
||||
category: logging
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Authenticate users with an existing Amazon account
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/oauth-amazon/cover.png
|
||||
category: auth
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Authenticate users with an existing Apple account
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/oauth-apple/cover.png
|
||||
category: auth
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Authenticate users with an existing Discord account
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/oauth-discord/cover.png
|
||||
category: auth
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Authenticate users with an existing Google account
|
||||
date: 2024-07-30
|
||||
featured: true
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/oauth-google/cover.png
|
||||
category: auth
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Authenticate users with an existing Notion account
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/oauth-notion/cover.png
|
||||
category: auth
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Authenticate users via OTPs sent through SMS using Twilio
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/phone-auth-twilio/cover.png
|
||||
category: auth
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Integrate Apple Push Notification Service (APNs) with Appwrite Mess
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/push-apns/cover.png
|
||||
category: messaging
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Integrate Firebase Cloud Messaging (FCM) with Appwrite to send push
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/push-fcm/cover.png
|
||||
category: messaging
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Save a document in your MongoDB cluster and list all stored documen
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/query-mongodb/cover.png
|
||||
category: databases
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Store data in your Upstash vector database and query it for similar
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/query-upstash/cover.png
|
||||
category: databases
|
||||
product:
|
||||
|
||||
177
src/routes/integrations/replication-rxdb/+page.markdoc
Normal file
@@ -0,0 +1,177 @@
|
||||
---
|
||||
layout: integration
|
||||
title: Data replication with RxDB
|
||||
description: Setup push-pull replication from a local database and enable offline sync capabilities using RxDB
|
||||
date: 2025-04-17
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
cover: /images/integrations/replication-rxdb/cover.png
|
||||
category: databases
|
||||
product:
|
||||
avatar: '/images/integrations/avatars/rxdb.png'
|
||||
vendor: Upstash
|
||||
description: 'RxDB (Reactive Database) is a local-first, NoSQL database for JavaScript applications.'
|
||||
platform:
|
||||
- 'Self-hosted'
|
||||
- 'Cloud'
|
||||
images:
|
||||
- /images/integrations/replication-rxdb/cover.png
|
||||
---
|
||||
|
||||
RxDB (Reactive Database) is a client-side, NoSQL database designed for JavaScript applications. It emphasizes real-time data synchronization, offline-first capabilities, and reactive programming paradigms. RxDB is particularly suited for applications that require seamless user experiences across various platforms, including web browsers, mobile devices, and desktop environments.
|
||||
|
||||
# How does the integration work?
|
||||
|
||||
RxDB integrates with Appwrite through a replication plugin that enables two-way data synchronization between a client-side RxDB instance and an Appwrite Database. This setup allows apps to store data locally (using data stores such as LocalStorage, SQLite, or IndexedDB) and push/pull changes from the Appwrite backend. When offline, the app continues to function smoothly using RxDB's local-first model, and once connectivity is restored, it syncs changes with Appwrite. Conflict handling, soft deletion, and live replication are handled seamlessly using RxDB’s built-in tools and Appwrite’s Realtime API.
|
||||
|
||||
# How to implement
|
||||
|
||||
To implement the RxDB integration, there are several steps you must complete:
|
||||
|
||||
## Step 1: Configure Appwrite project
|
||||
|
||||
For this step, you must [create an account on Appwrite Cloud](https://cloud.appwrite.io/register) or [self-host Appwrite](https://appwrite.io/docs/advanced/self-hosting) if you haven’t already. Head over to the Appwrite console, go to the **Settings** page, and copy your project ID and API endpoint for further usage. Next, go to the **Databases** page from the left sidebar, create a new database with the ID `mydb`, and then a collection with the ID `humans` (save both IDs for further usage).
|
||||
|
||||
Click on the **Attributes** tab and add the following attributes (schema used for demo purposes):
|
||||
|
||||
| Key | Type | Size | Required |
|
||||
| --- | --- | --- | --- |
|
||||
| `name` | String | 100 | Yes |
|
||||
| `age` | Integer | | Yes |
|
||||
| `homeAddress` | String | 2000 | Yes |
|
||||
| `deleted` | Boolean | | Yes |
|
||||
|
||||
> **Note:** The `deleted` attribute is necessary to add because RxDB only soft deletes to prevent data loss in offline scenarios (no hard deletion of data occurs).
|
||||
|
||||
Then, head to the **Settings** tab of your collection, scroll down to the **Permissions** section, and the following:
|
||||
|
||||
| Role | Create | Read | Update | Delete |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Any | Yes | Yes | Yes | Yes |
|
||||
|
||||
> **Note:** While RxDB does not allow you to manually configure permissions for each document, if your app uses Appwrite Auth and document-level permissions are enabled for your collection, the Appwrite SDK will automatically assign read and write permissions to the logged-in user for each document created.
|
||||
|
||||
## Step 2: Install Appwrite Web SDK and RxDB library
|
||||
|
||||
Open the terminal in your app’s working directory and run the following command:
|
||||
|
||||
```bash
|
||||
npm install appwrite rxdb
|
||||
```
|
||||
|
||||
In your app’s `.env` file, add the following:
|
||||
|
||||
```bash
|
||||
APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
||||
APPWRITE_PROJECT_ID=your-project-id
|
||||
APPWRITE_DATABASE_ID=your-database-id
|
||||
APPWRITE_COLLECTION_ID=your-collection-id
|
||||
```
|
||||
|
||||
Then, import all necessary libraries in your code:
|
||||
|
||||
```js
|
||||
import { replicateAppwrite } from 'rxdb/plugins/replication-appwrite';
|
||||
import { createRxDatabase, addRxPlugin, RxCollection } from 'rxdb/plugins/core';
|
||||
import { getRxStorageLocalstorage } from 'rxdb/plugins/storage-localstorage';
|
||||
import { Client } from 'appwrite';
|
||||
```
|
||||
|
||||
## Step 3: Setup Appwrite client
|
||||
|
||||
To create an Appwrite client, add the following code:
|
||||
|
||||
```js
|
||||
export const client = new Client()
|
||||
.setEndpoint(process.env.APPWRITE_ENDPOINT)
|
||||
.setEndpointRealtime(process.env.APPWRITE_ENDPOINT)
|
||||
.setProject(process.env.APPWRITE_PROJECT_ID);
|
||||
```
|
||||
|
||||
## Step 4: Create an RxDB database and collection
|
||||
|
||||
To create an RxDB database and collection, first, you must prepare a database schema via the following code:
|
||||
|
||||
```js
|
||||
const mySchema = {
|
||||
title: 'my schema',
|
||||
version: 0,
|
||||
primaryKey: 'id',
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
maxLength: 100
|
||||
},
|
||||
name: {
|
||||
type: 'string'
|
||||
},
|
||||
age: {
|
||||
type: 'number'
|
||||
},
|
||||
homeAddress: {
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
required: ['id', 'name', 'age', 'homeAddress']
|
||||
};
|
||||
```
|
||||
|
||||
Using this schema, you can create a database and collection via the following code:
|
||||
|
||||
```js
|
||||
const db = await createRxDatabase({
|
||||
name: 'mydb',
|
||||
storage: getRxStorageLocalstorage()
|
||||
});
|
||||
|
||||
await db.addCollections({
|
||||
humans: {
|
||||
schema: mySchema
|
||||
}
|
||||
});
|
||||
|
||||
const collection = db.humans;
|
||||
```
|
||||
|
||||
> **Note:** Please ensure that the names of your RxDB database and collection match the IDs of your Appwrite database and collection.
|
||||
>
|
||||
|
||||
## Step 5: Start replication
|
||||
|
||||
To start data replication, add the following code:
|
||||
|
||||
```js
|
||||
const replicationState = replicateAppwrite({
|
||||
replicationIdentifier: 'my-appwrite-replication',
|
||||
client,
|
||||
databaseId: process.env.APPWRITE_DATABASE_ID,
|
||||
collectionId: process.env.APPWRITE_COLLECTION_ID,
|
||||
deletedField: 'deleted', // Field that represents deletion in Appwrite
|
||||
collection,
|
||||
pull: {
|
||||
batchSize: 10,
|
||||
},
|
||||
push: {
|
||||
batchSize: 10
|
||||
},
|
||||
/*
|
||||
* ...
|
||||
* You can set all other options for RxDB replication states
|
||||
* like 'live' or 'retryTime'
|
||||
* ...
|
||||
*/
|
||||
});
|
||||
```
|
||||
|
||||
With that, your RxDB integration is configured and you can use other relevant RxDB functionalities and database operations in your application.
|
||||
|
||||
# Further resources
|
||||
|
||||
If you would like to learn more about RxDB and Appwrite Databases, we have some resources that you should visit:
|
||||
|
||||
- [RxDB docs for Appwrite](https://rxdb.info/replication-appwrite.html)
|
||||
- [Appwrite offline sync docs](/docs/products/databases/offline)
|
||||
- [Build an offline-first journal app with RxDB and Appwrite](/blog/post/offline-first-journal)
|
||||
- [Offline-first journal demo app on GitHub](https://github.com/appwrite-community/offline-journal)
|
||||
@@ -5,7 +5,7 @@ description: Integrate Algolia search into your Appwrite database
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/search-algolia/cover.png
|
||||
category: search
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Send customized SMS messages to your users
|
||||
date: 2024-07-30
|
||||
featured: true
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/sms-twilio/cover.png
|
||||
category: messaging
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Store files in Amazon S3 instead of your local storage device
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/storage-s3/cover.png
|
||||
category: storage
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Integrate Stripe with Appwrite to accept payments, store orders, ma
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/stripe-payments/cover.png
|
||||
category: payments
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Integrate Stripe subscriptions with Appwrite to accept and manage r
|
||||
date: 2024-07-30
|
||||
featured: true
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/stripe-subscriptions/cover.png
|
||||
category: payments
|
||||
product:
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Send automated replies to messages on WhatsApp
|
||||
date: 2024-07-30
|
||||
featured: false
|
||||
isPartner: true
|
||||
isNew: true
|
||||
isNew: false
|
||||
cover: /images/integrations/whatsapp-vonage/cover.png
|
||||
category: messaging
|
||||
product:
|
||||
|
||||
78
src/routes/the-appwrite-network/+page.svelte
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import Map from '$lib/components/appwrite-network/map.svelte';
|
||||
import { InlineTag, Icon } from '$lib/components/ui';
|
||||
import GradientText from '$lib/components/ui/gradient-text.svelte';
|
||||
import { Main } from '$lib/layouts';
|
||||
import { classNames } from '$lib/utils/classnames';
|
||||
import {
|
||||
DEFAULT_DESCRIPTION,
|
||||
DEFAULT_HOST,
|
||||
getInlinedScriptTag,
|
||||
organizationJsonSchema,
|
||||
softwareAppSchema
|
||||
} from '$lib/utils/metadata';
|
||||
|
||||
const title = 'The Appwrite Network';
|
||||
const description = DEFAULT_DESCRIPTION;
|
||||
const ogImage = `${DEFAULT_HOST}/images/open-graph/website.png`;
|
||||
|
||||
const heading = 'The Appwrite Network';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<!-- Titles -->
|
||||
<title>{title}</title>
|
||||
<meta property="og:title" content={title} />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<!-- Description -->
|
||||
<meta name="description" content={description} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<!-- Image -->
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
|
||||
{@html getInlinedScriptTag(softwareAppSchema())}
|
||||
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
|
||||
{@html getInlinedScriptTag(organizationJsonSchema())}
|
||||
</svelte:head>
|
||||
|
||||
<Main>
|
||||
<div class="relative max-w-screen overflow-hidden">
|
||||
<div class="container mt-20 flex flex-col items-center">
|
||||
<a
|
||||
href="/blog/post/the-appwrite-network"
|
||||
class="bg-accent/4 animate-enter group border-accent/36 text-primary relative -mb-8 flex items-center gap-2 rounded-full border px-4 py-1 text-sm"
|
||||
style:animation-delay="250ms"
|
||||
>Read the announcement <Icon
|
||||
name="arrow-right"
|
||||
class="transition group-hover:translate-x-0.5"
|
||||
/></a
|
||||
>
|
||||
<h1 class="text-display font-aeonik-pro mx-auto inline-block py-12 text-center">
|
||||
{#each heading.split(' ') as word, i}
|
||||
<GradientText
|
||||
class="animate-enter mr-2 inline-block"
|
||||
style="animation-delay:{i * 75}ms">{word}</GradientText
|
||||
>
|
||||
{/each}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={classNames(
|
||||
'animate-lighting absolute top-0 left-0 -z-10 h-screen w-[200vw] -translate-x-[25%] translate-y-8 rotate-25 overflow-hidden blur-3xl md:w-full',
|
||||
'bg-[image:radial-gradient(ellipse_390px_50px_at_10%_30%,_rgba(254,_149,_103,_0.2)_0%,_rgba(254,_149,_103,_0)_70%),_radial-gradient(ellipse_1100px_170px_at_15%_40%,rgba(253,_54,_110,_0.08)_0%,_rgba(253,_54,_110,_0)_70%),_radial-gradient(ellipse_1200px_180px_at_30%_30%,_rgba(253,_54,_110,_0.08)_0%,_rgba(253,_54,_110,_0)_70%)]',
|
||||
'bg-position-[0%_0%]'
|
||||
)}
|
||||
></div>
|
||||
<div class="mb-20">
|
||||
<Map />
|
||||
</div>
|
||||
</div>
|
||||
</Main>
|
||||
@@ -1,4 +1,4 @@
|
||||
.prose {
|
||||
/* .prose {
|
||||
--prose-color: var(--color-gray-700);
|
||||
--prose-heading-color: var(--color-gray-950);
|
||||
--prose-strong-color: var(--color-gray-950);
|
||||
@@ -301,4 +301,4 @@
|
||||
max-width: calc(100% + calc(var(--spacing) * 8));
|
||||
}
|
||||
}
|
||||
}
|
||||
} */
|
||||
|
||||
7
static/images/appwrite-network/map.svg
Normal file
|
After Width: | Height: | Size: 523 KiB |
BIN
static/images/blog/how-to-reduce-cloud-latency/cover.png
Normal file
|
After Width: | Height: | Size: 740 KiB |
BIN
static/images/blog/offline-first-journal/cover.png
Normal file
|
After Width: | Height: | Size: 600 KiB |
BIN
static/images/blog/offline-first-journal/demo.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
static/images/blog/the-appwrite-network/cloud-regions.png
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
static/images/blog/the-appwrite-network/network-cover-image.png
Normal file
|
After Width: | Height: | Size: 756 KiB |
BIN
static/images/blog/the-appwrite-network/regions-edges-pops.png
Normal file
|
After Width: | Height: | Size: 273 KiB |
BIN
static/images/blog/why-multi-cloud-is-taking-over/cover.png
Normal file
|
After Width: | Height: | Size: 824 KiB |
BIN
static/images/changelog/2025-04-15.png
Normal file
|
After Width: | Height: | Size: 756 KiB |
5
static/images/docs/databases/offline/logos/dark/rxdb.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.31069 1.66669H7.07024V4.01568H7.07027V2.4497H7.82982V4.01568H8.58934V2.4497H9.34889V4.01568H10.1085V3.23267H10.868V4.01568H15.2083V7.48324H4.79163V5.91726V4.01568V2.4497H5.55117V4.01568H5.5512V3.23267H6.31069V1.66669Z" fill="#EDEDF0"/>
|
||||
<rect x="4.79163" y="8.26624" width="10.4167" height="3.46756" fill="#EDEDF0"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.6892 18.3334H12.9296V15.9844H12.9296V17.5504H12.1701V15.9844H11.4105V17.5504H10.651V15.9844H9.89138V16.7674H9.13183V15.9844H4.79158V12.5168H15.2083V14.0828V15.9844V17.5504H14.4487V15.9844H14.4487V16.7674H13.6892V18.3334Z" fill="#EDEDF0"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 763 B |
5
static/images/docs/databases/offline/logos/rxdb.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.31069 1.66669H7.07024V4.01568H7.07027V2.4497H7.82982V4.01568H8.58934V2.4497H9.34889V4.01568H10.1085V3.23267H10.868V4.01568H15.2083V7.48324H4.79163V5.91726V4.01568V2.4497H5.55117V4.01568H5.5512V3.23267H6.31069V1.66669Z" fill="#2D2D31"/>
|
||||
<rect x="4.79163" y="8.26624" width="10.4167" height="3.46756" fill="#2D2D31"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.6892 18.3334H12.9296V15.9844H12.9296V17.5504H12.1701V15.9844H11.4105V17.5504H10.651V15.9844H9.89138V16.7674H9.13183V15.9844H4.79158V12.5168H15.2083V14.0828V15.9844V17.5504H14.4487V15.9844H14.4487V16.7674H13.6892V18.3334Z" fill="#2D2D31"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 763 B |
BIN
static/images/integrations/avatars/rxdb.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
static/images/integrations/replication-rxdb/cover.png
Normal file
|
After Width: | Height: | Size: 998 KiB |