Compare commits
356 Commits
jazz-bette
...
jazz-auth-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceaa555e83 | ||
|
|
03229b2ea9 | ||
|
|
e2737d44b6 | ||
|
|
4b73834883 | ||
|
|
1b3d43d5f4 | ||
|
|
9c9a689879 | ||
|
|
2fd88b938c | ||
|
|
d1f955006f | ||
|
|
bb3d5f1f87 | ||
|
|
26ce61ab78 | ||
|
|
1f300114d5 | ||
|
|
da69f812f8 | ||
|
|
0bcbf551ca | ||
|
|
6b3d5b5560 | ||
|
|
d1bdbf5d49 | ||
|
|
621e809fad | ||
|
|
d6600d9322 | ||
|
|
2b08bd77c1 | ||
|
|
c1c6e31711 | ||
|
|
0b16085f3c | ||
|
|
e53db2e96a | ||
|
|
384f0e23c0 | ||
|
|
daaf1789d9 | ||
|
|
1f9e20e753 | ||
|
|
ce9ca54f5c | ||
|
|
67e0968809 | ||
|
|
96a922cceb | ||
|
|
9b22fc74cd | ||
|
|
1bebe3c6c8 | ||
|
|
0a98b826f1 | ||
|
|
e1bd16d08b | ||
|
|
0967c2ee5a | ||
|
|
62a3854c41 | ||
|
|
f22ef4e646 | ||
|
|
6c35d0031d | ||
|
|
7bdb6f4279 | ||
|
|
93f3fb231b | ||
|
|
01d13d5df2 | ||
|
|
944e725b95 | ||
|
|
16024fec8e | ||
|
|
f90414ab95 | ||
|
|
492eecb46a | ||
|
|
51144ec832 | ||
|
|
fcaf4b9c30 | ||
|
|
afae2649f5 | ||
|
|
b5b0284c61 | ||
|
|
bf1475a143 | ||
|
|
e82cb80ca4 | ||
|
|
32c2a617d6 | ||
|
|
d3c2a41c81 | ||
|
|
4b99ff1fe3 | ||
|
|
3ebf8258a0 | ||
|
|
4809d14f6d | ||
|
|
5ae1f33127 | ||
|
|
ca5d84f6a9 | ||
|
|
6e6acc3404 | ||
|
|
b17b7b6481 | ||
|
|
5341646301 | ||
|
|
5416165d28 | ||
|
|
b5a9f681c5 | ||
|
|
7dffc006eb | ||
|
|
cd3cc5b0ab | ||
|
|
ceab75eb4d | ||
|
|
103d1b41f7 | ||
|
|
b87cc6973e | ||
|
|
3d541ca241 | ||
|
|
e72bfec884 | ||
|
|
19c7ad27d9 | ||
|
|
0bc7bfc5cc | ||
|
|
2c8120d46f | ||
|
|
c936c8c611 | ||
|
|
58c6013770 | ||
|
|
3eb3291a97 | ||
|
|
6b659f2df3 | ||
|
|
dcc9c9a5ec | ||
|
|
fe9a244363 | ||
|
|
9440bbc058 | ||
|
|
1c92cc2997 | ||
|
|
33ebbf0bdd | ||
|
|
d630b5bde5 | ||
|
|
1c6ae12cd9 | ||
|
|
ac5d20d159 | ||
|
|
21bcaabd5a | ||
|
|
17b4d5b668 | ||
|
|
3cd15862d5 | ||
|
|
b3d1ad7201 | ||
|
|
d87df11795 | ||
|
|
82c2a62b2a | ||
|
|
0a9112506e | ||
|
|
fbc29f2f17 | ||
|
|
3915bbbf3c | ||
|
|
0b471c4e89 | ||
|
|
09077d37ef | ||
|
|
afe06b4fa6 | ||
|
|
d89b6e488a | ||
|
|
f6361ee43b | ||
|
|
726dbfb6df | ||
|
|
267f689f10 | ||
|
|
893ad3ae23 | ||
|
|
f5590b1be8 | ||
|
|
17a01f57e8 | ||
|
|
7318d86f52 | ||
|
|
1c8403e87a | ||
|
|
dd747c068a | ||
|
|
1f0f230fe2 | ||
|
|
da655cbff5 | ||
|
|
02f6c6220e | ||
|
|
0755cd198e | ||
|
|
c4a8227b66 | ||
|
|
86f0302233 | ||
|
|
72b5542130 | ||
|
|
5fd9225a54 | ||
|
|
9138d30208 | ||
|
|
a5ece15797 | ||
|
|
9f8877202e | ||
|
|
d190097ed9 | ||
|
|
9841617c66 | ||
|
|
165a6170cd | ||
|
|
5148419df9 | ||
|
|
fc0ecb0968 | ||
|
|
802b5a3060 | ||
|
|
e47af262b3 | ||
|
|
688a4850a4 | ||
|
|
e87fef751e | ||
|
|
8f714440f8 | ||
|
|
70cd09170e | ||
|
|
e98b610fd0 | ||
|
|
b554983558 | ||
|
|
4c63334299 | ||
|
|
4aef7cdac5 | ||
|
|
76adeb0d53 | ||
|
|
d95dcbe7db | ||
|
|
f9d538f049 | ||
|
|
40c7336c09 | ||
|
|
e0d2723615 | ||
|
|
93e68c62f5 | ||
|
|
dadee9dcc5 | ||
|
|
6724c4bd83 | ||
|
|
1942bd5de4 | ||
|
|
16764f6365 | ||
|
|
b56cfc2e1f | ||
|
|
7091bcf9c0 | ||
|
|
436cbfa095 | ||
|
|
c19a25f928 | ||
|
|
104e664bbb | ||
|
|
f199b451eb | ||
|
|
70bc48458e | ||
|
|
f28b2a6135 | ||
|
|
55b770b7c9 | ||
|
|
e6838dfb98 | ||
|
|
5e34061fdc | ||
|
|
6d9b77195a | ||
|
|
9bf7946ee6 | ||
|
|
acecffaeb2 | ||
|
|
0a98d6aaf2 | ||
|
|
4ea1a63a0a | ||
|
|
41a4c3bc95 | ||
|
|
60d0027f9d | ||
|
|
748c2ff751 | ||
|
|
70938b0ab3 | ||
|
|
f2f5b55dbf | ||
|
|
3c3acae803 | ||
|
|
896ee3460f | ||
|
|
9b9bf44e2b | ||
|
|
392aa88d95 | ||
|
|
7ce82cd934 | ||
|
|
0c8158b91c | ||
|
|
5a48c9c44c | ||
|
|
25c56146f5 | ||
|
|
c564fbb02e | ||
|
|
12481e14c2 | ||
|
|
fd2d247ff5 | ||
|
|
9e9ea029b2 | ||
|
|
a0da272dcd | ||
|
|
72fbcc3262 | ||
|
|
f4c8cc858b | ||
|
|
0ab4d7a20d | ||
|
|
5c98ff4e4f | ||
|
|
4cbda689c4 | ||
|
|
771b0ed914 | ||
|
|
79913c3136 | ||
|
|
43d3511d15 | ||
|
|
928ef14086 | ||
|
|
048dd7def0 | ||
|
|
51fcb8a44b | ||
|
|
c5888c39f5 | ||
|
|
2defcfae67 | ||
|
|
873b146d15 | ||
|
|
213de11c3b | ||
|
|
2f24d35471 | ||
|
|
42667c81bb | ||
|
|
1b881cc89f | ||
|
|
af295d816a | ||
|
|
fe8d3497c0 | ||
|
|
c2899e94ca | ||
|
|
f4be67e9b6 | ||
|
|
ba9ad295b6 | ||
|
|
9ed5a96ef8 | ||
|
|
4272ea9019 | ||
|
|
9509307ed1 | ||
|
|
be08921bc5 | ||
|
|
77e3c21cbd | ||
|
|
ab1798c7bd | ||
|
|
26ae69a242 | ||
|
|
25be055a51 | ||
|
|
21ad3767b9 | ||
|
|
a9383516c1 | ||
|
|
bffc516c68 | ||
|
|
9e7c0d9887 | ||
|
|
99b44d5780 | ||
|
|
02db5f3b1d | ||
|
|
1949a5fcd9 | ||
|
|
dcd3b022cc | ||
|
|
a7b837c7e1 | ||
|
|
88ebcf58ab | ||
|
|
b173e0884a | ||
|
|
f379a168be | ||
|
|
bde6ac7d45 | ||
|
|
231947c97a | ||
|
|
d1609cdd55 | ||
|
|
d5b57ad1fc | ||
|
|
b71ab3168a | ||
|
|
0c8f6e5039 | ||
|
|
0bf5c53bec | ||
|
|
e7b1550003 | ||
|
|
6a93a1b8a3 | ||
|
|
9f654a2603 | ||
|
|
dbf735d9e1 | ||
|
|
c62abefb66 | ||
|
|
1453869a46 | ||
|
|
f5039cefc1 | ||
|
|
239da90c9f | ||
|
|
972791e7a8 | ||
|
|
6540893caf | ||
|
|
bfc85c4573 | ||
|
|
e9076313ab | ||
|
|
c6afd8ae36 | ||
|
|
370f20d13d | ||
|
|
0c0178764e | ||
|
|
928350b821 | ||
|
|
be3fd9c696 | ||
|
|
269c028df0 | ||
|
|
e4df837138 | ||
|
|
54fe6d93ba | ||
|
|
979689c6d8 | ||
|
|
859a37868f | ||
|
|
57bd32d77e | ||
|
|
f9b3116deb | ||
|
|
352d34979f | ||
|
|
7ff736ace4 | ||
|
|
5bab466fd0 | ||
|
|
329b8c3d6a | ||
|
|
e21cbccd4b | ||
|
|
a66ab7d174 | ||
|
|
78e91f4030 | ||
|
|
7a915c198e | ||
|
|
c9b0420746 | ||
|
|
2303f3e70a | ||
|
|
a7bc9569a3 | ||
|
|
f351ba0fcd | ||
|
|
d3e554f491 | ||
|
|
b5e31456ad | ||
|
|
c0aeb7baf9 | ||
|
|
8a14de10d7 | ||
|
|
b585b39a86 | ||
|
|
42d07ba7b4 | ||
|
|
b81b6ba69b | ||
|
|
1bc1759bb4 | ||
|
|
512aacdbc2 | ||
|
|
7ad843aa3e | ||
|
|
fc027a56db | ||
|
|
959a7a3927 | ||
|
|
2548085b59 | ||
|
|
b27bb3e65b | ||
|
|
d1efde468f | ||
|
|
2b61e853a7 | ||
|
|
6f79b45544 | ||
|
|
2e1ff99579 | ||
|
|
e9b2860e74 | ||
|
|
6327d74f68 | ||
|
|
ac782674de | ||
|
|
bedbabdcb4 | ||
|
|
5eb406d54d | ||
|
|
a3be832414 | ||
|
|
7ca8dd960e | ||
|
|
62c8aff73f | ||
|
|
7731109a28 | ||
|
|
dfc4286694 | ||
|
|
970ff0d813 | ||
|
|
eee221f563 | ||
|
|
2283d375ef | ||
|
|
202e763380 | ||
|
|
52bbdb37a9 | ||
|
|
f5c47feeb6 | ||
|
|
b8b0851433 | ||
|
|
2bbb07b0bf | ||
|
|
d3053955d8 | ||
|
|
f40484eca9 | ||
|
|
d581a59aa1 | ||
|
|
0ca09f75c1 | ||
|
|
e8fcd101f2 | ||
|
|
cf43fa7529 | ||
|
|
df1cdda4e8 | ||
|
|
7a60d7bb76 | ||
|
|
f8263a8358 | ||
|
|
f6da966922 | ||
|
|
8a2ab51543 | ||
|
|
466e6c44ee | ||
|
|
5bd8277161 | ||
|
|
0ec917e453 | ||
|
|
6326d0fc45 | ||
|
|
d746b1279a | ||
|
|
c09dcdfc76 | ||
|
|
4402c553b6 | ||
|
|
e76fe343da | ||
|
|
a2626a0f38 | ||
|
|
ec579bcaf7 | ||
|
|
6b662b0efe | ||
|
|
e9af90c841 | ||
|
|
2b7c6f5aa7 | ||
|
|
d73a3d9d46 | ||
|
|
8af39077a3 | ||
|
|
54bd487818 | ||
|
|
a8b3ec7bb0 | ||
|
|
a420b43029 | ||
|
|
a57268de32 | ||
|
|
6b2c4ed280 | ||
|
|
8d4e0027be | ||
|
|
a4141da1b7 | ||
|
|
c9ca5202f9 | ||
|
|
7b50a2e06d | ||
|
|
43dabccb57 | ||
|
|
b6d04f56ef | ||
|
|
628195b678 | ||
|
|
9a5d769717 | ||
|
|
e30a3f66bf | ||
|
|
6327fce933 | ||
|
|
a650da4184 | ||
|
|
6e4a94f6ce | ||
|
|
b73bec64bc | ||
|
|
50ae2f47c2 | ||
|
|
724d8e7f30 | ||
|
|
7b285ab110 | ||
|
|
01ac9b8c4c | ||
|
|
4e2e1ac73e | ||
|
|
94960c1f65 | ||
|
|
b5af58347b | ||
|
|
46a84558c5 | ||
|
|
f93566c045 | ||
|
|
d97ed603a3 | ||
|
|
8d33103182 | ||
|
|
aaa1ff978b | ||
|
|
82655ea7a7 | ||
|
|
8afe3a2e02 | ||
|
|
ae2adcbd15 | ||
|
|
eb0460d330 |
2
.github/workflows/code-quality.yml
vendored
2
.github/workflows/code-quality.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
- name: Setup Biome
|
||||
uses: biomejs/setup-biome@v2
|
||||
with:
|
||||
version: 1.9.4
|
||||
version: 2.1.3
|
||||
- name: Run Biome
|
||||
run: biome ci .
|
||||
|
||||
|
||||
47
biome.json
47
biome.json
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.1.3/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
@@ -7,39 +7,35 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"ignore": [
|
||||
"jazz-tools.json",
|
||||
"**/ios/**",
|
||||
"**/android/**",
|
||||
"tests/jazz-svelte/src/**",
|
||||
"examples/*svelte*/**",
|
||||
"starters/*svelte*/**",
|
||||
"examples/server-worker-inbox/src/routeTree.gen.ts",
|
||||
"homepage/homepage/**",
|
||||
"**/package.json"
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/jazz-tools.json",
|
||||
"!**/ios/**",
|
||||
"!**/android/**",
|
||||
"!**/tests/jazz-svelte/src/**",
|
||||
"!**/examples/**/*svelte*/**",
|
||||
"!**/starters/**/*svelte*/**",
|
||||
"!**/examples/server-worker-inbox/src/routeTree.gen.ts",
|
||||
"!**/homepage/homepage/**",
|
||||
"!**/package.json",
|
||||
"!**/*svelte*/**"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "off" } } },
|
||||
"linter": {
|
||||
"enabled": false,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off",
|
||||
"useImportExtensions": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"suggestedExtensions": {
|
||||
"ts": {
|
||||
"module": "js",
|
||||
"component": "jsx"
|
||||
}
|
||||
}
|
||||
"forceJsExtensions": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,7 +43,7 @@
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"include": ["packages/**/src/**"],
|
||||
"includes": ["**/packages/**/src/**"],
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
@@ -56,7 +52,10 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"include": ["packages/cojson/src/storage/*/**", "cojson-transport-ws/**"],
|
||||
"includes": [
|
||||
"**/packages/cojson/src/storage/**/*/**",
|
||||
"**/cojson-transport-ws/**"
|
||||
],
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
@@ -65,7 +64,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"include": ["**/tests/**"],
|
||||
"includes": ["**/tests/**"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
@@ -75,7 +74,7 @@
|
||||
"noNonNullAssertion": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "info"
|
||||
"noExplicitAny": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,9 @@ export function ChatScreen({ navigation }: { navigation: any }) {
|
||||
|
||||
const renderMessageItem = ({
|
||||
item,
|
||||
}: { item: Loaded<typeof Message, { text: true }> }) => {
|
||||
}: {
|
||||
item: Loaded<typeof Message, { text: true }>;
|
||||
}) => {
|
||||
const isMe = item._edits?.text?.by?.isMe;
|
||||
return (
|
||||
<View
|
||||
|
||||
@@ -3,11 +3,7 @@ import React from "react";
|
||||
import { Text } from "react-native";
|
||||
import { Chat } from "./schema";
|
||||
|
||||
export function HandleInviteScreen({
|
||||
navigation,
|
||||
}: {
|
||||
navigation: any;
|
||||
}) {
|
||||
export function HandleInviteScreen({ navigation }: { navigation: any }) {
|
||||
useAcceptInviteNative({
|
||||
invitedObjectSchema: Chat,
|
||||
onAccept: async (chatId) => {
|
||||
|
||||
@@ -1,5 +1,81 @@
|
||||
# passkey-svelte
|
||||
|
||||
## 0.0.114
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [0bcbf55]
|
||||
- Updated dependencies [d1bdbf5]
|
||||
- Updated dependencies [4b73834]
|
||||
- jazz-tools@0.17.1
|
||||
|
||||
## 0.0.113
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [fcaf4b9]
|
||||
- jazz-tools@0.17.0
|
||||
|
||||
## 0.0.112
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [67e0968]
|
||||
- Updated dependencies [2c8120d]
|
||||
- jazz-tools@0.16.6
|
||||
|
||||
## 0.0.111
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [33ebbf0]
|
||||
- jazz-tools@0.16.5
|
||||
|
||||
## 0.0.110
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [16764f6]
|
||||
- jazz-tools@0.16.4
|
||||
|
||||
## 0.0.109
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [43d3511]
|
||||
- jazz-tools@0.16.3
|
||||
|
||||
## 0.0.108
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-tools@0.16.2
|
||||
|
||||
## 0.0.107
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [c62abef]
|
||||
- jazz-tools@0.16.1
|
||||
|
||||
## 0.0.106
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2bbb07b: Introduce a cleaner separation between Zod and CoValue schemas:
|
||||
- Zod schemas and CoValue schemas are fully separated. Zod schemas can only be composed with other Zod schemas. CoValue schemas can be composed with either Zod or other CoValue schemas.
|
||||
- `z.optional()` and `z.discriminatedUnion()` no longer work with CoValue schemas. Use `co.optional()` and `co.discriminatedUnion()` instead.
|
||||
- Internal schema access is now simpler. You no longer need to use Zod’s `.def` to access internals. Use properties like `CoMapSchema.shape`, `CoListSchema.element`, and `CoOptionalSchema.innerType` directly.
|
||||
- CoValue schema types are now namespaced under `co.`. Non-namespaced exports have been removed
|
||||
- CoMap schemas no longer incorrectly inherit from Zod. Previously, methods like `.extend()` and `.partial()` appeared available but could cause unexpected behavior. These methods are now disabled. In their place, `.optional()` has been added, and more Zod-like methods will be introduced in future releases.
|
||||
- Upgraded Zod from `3.25.28` to `3.25.76`.
|
||||
- Removed deprecated `withHelpers` method from CoValue schemas
|
||||
- Removed deprecated `createCoValueObservable` function
|
||||
- Updated dependencies [c09dcdf]
|
||||
- Updated dependencies [2bbb07b]
|
||||
- jazz-tools@0.16.0
|
||||
|
||||
## 0.0.105
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chat-svelte",
|
||||
"version": "0.0.105",
|
||||
"version": "0.0.114",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { ImageDefinition, type Loaded } from 'jazz-tools';
|
||||
import { useProgressiveImg } from '$lib/utils/useProgressiveImage.svelte';
|
||||
import { Image } from 'jazz-tools/svelte';
|
||||
let { image }: { image: Loaded<typeof ImageDefinition> } = $props();
|
||||
const { src } = $derived(
|
||||
useProgressiveImg({
|
||||
image
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<img class="h-auto max-h-[20rem] max-w-full rounded-t-xl mb-1" {src} alt="" />
|
||||
<Image
|
||||
imageId={image.id}
|
||||
alt=""
|
||||
class="h-auto max-h-[20rem] max-w-full rounded-t-xl mb-1"
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { co, z } from 'jazz-tools';
|
||||
import { co } from 'jazz-tools';
|
||||
|
||||
export const Message = co.map({
|
||||
text: co.plainText(),
|
||||
image: z.optional(co.image())
|
||||
image: co.optional(co.image())
|
||||
});
|
||||
|
||||
export const Chat = co.list(Message);
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { ImageDefinition, type Loaded } from 'jazz-tools';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
export function useProgressiveImg({
|
||||
image,
|
||||
maxWidth,
|
||||
targetWidth
|
||||
}: {
|
||||
image: Loaded<typeof ImageDefinition> | null | undefined;
|
||||
maxWidth?: number;
|
||||
targetWidth?: number;
|
||||
}) {
|
||||
let current = $state<{
|
||||
src?: string;
|
||||
res?: `${number}x${number}` | 'placeholder';
|
||||
}>();
|
||||
const originalSize = $state(image?.originalSize);
|
||||
|
||||
const unsubscribe = image?.subscribe({}, (update: Loaded<typeof ImageDefinition>) => {
|
||||
const highestRes = ImageDefinition.highestResAvailable(update, { maxWidth, targetWidth });
|
||||
if (highestRes) {
|
||||
if (highestRes.res !== current?.res) {
|
||||
const blob = highestRes.stream.toBlob();
|
||||
if (blob) {
|
||||
const blobURI = URL.createObjectURL(blob);
|
||||
current = { src: blobURI, res: highestRes.res };
|
||||
|
||||
setTimeout(() => URL.revokeObjectURL(blobURI), 200);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
current = {
|
||||
src: update?.placeholderDataURL,
|
||||
res: 'placeholder'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => () => {
|
||||
unsubscribe?.();
|
||||
});
|
||||
|
||||
return {
|
||||
get src() {
|
||||
return current?.src;
|
||||
},
|
||||
get res() {
|
||||
return current?.res;
|
||||
},
|
||||
|
||||
originalSize
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { createImage } from 'jazz-tools/browser-media-images';
|
||||
import { createImage } from 'jazz-tools/media';
|
||||
import { AccountCoState, CoState } from 'jazz-tools/svelte';
|
||||
import { Account, CoPlainText, type ID } from 'jazz-tools';
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
"clsx": "^2.0.0",
|
||||
"hash-slash": "workspace:*",
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.274.0",
|
||||
"lucide-react": "^0.536.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"zod": "3.25.28"
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Account, co } from "jazz-tools";
|
||||
import { createImage, useAccount, useCoState } from "jazz-tools/react";
|
||||
import { useState } from "react";
|
||||
import { Account } from "jazz-tools";
|
||||
import { createImage } from "jazz-tools/media";
|
||||
import { useAccount, useCoState } from "jazz-tools/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Chat, Message } from "./schema.ts";
|
||||
import {
|
||||
BubbleBody,
|
||||
@@ -15,14 +16,17 @@ import {
|
||||
TextInput,
|
||||
} from "./ui.tsx";
|
||||
|
||||
export function ChatScreen(props: { chatID: string }) {
|
||||
const chat = useCoState(Chat, props.chatID, {
|
||||
resolve: { $each: { text: true } },
|
||||
});
|
||||
const { me } = useAccount();
|
||||
const [showNLastMessages, setShowNLastMessages] = useState(30);
|
||||
const INITIAL_MESSAGES_TO_SHOW = 30;
|
||||
|
||||
if (!chat)
|
||||
export function ChatScreen(props: { chatID: string }) {
|
||||
const chat = useCoState(Chat, props.chatID);
|
||||
const { me } = useAccount();
|
||||
const [showNLastMessages, setShowNLastMessages] = useState(
|
||||
INITIAL_MESSAGES_TO_SHOW,
|
||||
);
|
||||
const isLoading = useMessagesPreload(props.chatID);
|
||||
|
||||
if (!chat || isLoading)
|
||||
return (
|
||||
<div className="flex-1 flex justify-center items-center">Loading...</div>
|
||||
);
|
||||
@@ -37,11 +41,15 @@ export function ChatScreen(props: { chatID: string }) {
|
||||
return;
|
||||
}
|
||||
|
||||
createImage(file, { owner: chat._owner }).then((image) => {
|
||||
createImage(file, {
|
||||
owner: chat._owner,
|
||||
progressive: true,
|
||||
placeholder: "blur",
|
||||
}).then((image) => {
|
||||
chat.push(
|
||||
Message.create(
|
||||
{
|
||||
text: co.plainText().create(file.name, chat._owner),
|
||||
text: file.name,
|
||||
image: image,
|
||||
},
|
||||
chat._owner,
|
||||
@@ -59,9 +67,14 @@ export function ChatScreen(props: { chatID: string }) {
|
||||
<ChatBody>
|
||||
{chat.length > 0 ? (
|
||||
chat
|
||||
// We call slice before reverse to avoid mutating the original array
|
||||
.slice(-showNLastMessages)
|
||||
.reverse() // this plus flex-col-reverse on ChatBody gives us scroll-to-bottom behavior
|
||||
.map((msg) => <ChatBubble me={me} msg={msg} key={msg.id} />)
|
||||
// Reverse plus flex-col-reverse on ChatBody gives us scroll-to-bottom behavior
|
||||
.reverse()
|
||||
.map(
|
||||
(msg) =>
|
||||
msg?.text && <ChatBubble me={me} msg={msg} key={msg.id} />,
|
||||
)
|
||||
) : (
|
||||
<EmptyChatMessage />
|
||||
)}
|
||||
@@ -80,12 +93,7 @@ export function ChatScreen(props: { chatID: string }) {
|
||||
|
||||
<TextInput
|
||||
onSubmit={(text) => {
|
||||
chat.push(
|
||||
Message.create(
|
||||
{ text: co.plainText().create(text, chat._owner) },
|
||||
chat._owner,
|
||||
),
|
||||
);
|
||||
chat.push(Message.create({ text }, chat._owner));
|
||||
}}
|
||||
/>
|
||||
</InputBar>
|
||||
@@ -93,10 +101,7 @@ export function ChatScreen(props: { chatID: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ChatBubble(props: {
|
||||
me: Account;
|
||||
msg: co.loaded<typeof Message, { text: true }>;
|
||||
}) {
|
||||
function ChatBubble(props: { me: Account; msg: Message }) {
|
||||
if (!props.me.canRead(props.msg) || !props.msg.text?.toString()) {
|
||||
return (
|
||||
<BubbleContainer fromMe={false}>
|
||||
@@ -126,3 +131,35 @@ function ChatBubble(props: {
|
||||
</BubbleContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warms the local cache with the initial messages to load only the initial messages
|
||||
* and avoid flickering
|
||||
*/
|
||||
function useMessagesPreload(chatID: string) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
preloadChatMessages(chatID).finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [chatID]);
|
||||
|
||||
return isLoading;
|
||||
}
|
||||
|
||||
async function preloadChatMessages(chatID: string) {
|
||||
const chat = await Chat.load(chatID);
|
||||
|
||||
if (!chat?._refs) return;
|
||||
|
||||
const promises = [];
|
||||
|
||||
for (const msg of Array.from(chat._refs)
|
||||
.reverse()
|
||||
.slice(0, INITIAL_MESSAGES_TO_SHOW)) {
|
||||
promises.push(Message.load(msg.id, { resolve: { text: true } }));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import clsx from "clsx";
|
||||
import { CoPlainText, ImageDefinition } from "jazz-tools";
|
||||
import { ProgressiveImg } from "jazz-tools/react";
|
||||
import { Image } from "jazz-tools/react";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { useId, useRef } from "react";
|
||||
|
||||
@@ -83,14 +83,12 @@ export function BubbleText(props: {
|
||||
|
||||
export function BubbleImage(props: { image: ImageDefinition }) {
|
||||
return (
|
||||
<ProgressiveImg image={props.image}>
|
||||
{({ src }) => (
|
||||
<img
|
||||
className="h-auto max-h-80 max-w-full rounded-t-xl mb-1"
|
||||
src={src}
|
||||
/>
|
||||
)}
|
||||
</ProgressiveImg>
|
||||
<Image
|
||||
imageId={props.image.id}
|
||||
className="h-auto max-h-80 max-w-full rounded-t-xl mb-1"
|
||||
height="original"
|
||||
width="original"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,7 +110,9 @@ export function InputBar(props: { children: React.ReactNode }) {
|
||||
|
||||
export function ImageInput({
|
||||
onImageChange,
|
||||
}: { onImageChange?: (event: React.ChangeEvent<HTMLInputElement>) => void }) {
|
||||
}: {
|
||||
onImageChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onUploadClick = () => {
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
|
||||
export function SignInScreen({
|
||||
setPage,
|
||||
}: { setPage: (page: "sign-in" | "sign-up") => void }) {
|
||||
}: {
|
||||
setPage: (page: "sign-in" | "sign-up") => void;
|
||||
}) {
|
||||
const { signIn, setActive, isLoaded } = useSignIn();
|
||||
|
||||
const [emailAddress, setEmailAddress] = useState("");
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
|
||||
export function SignUpScreen({
|
||||
setPage,
|
||||
}: { setPage: (page: "sign-in" | "sign-up") => void }) {
|
||||
}: {
|
||||
setPage: (page: "sign-in" | "sign-up") => void;
|
||||
}) {
|
||||
const { isLoaded, signUp, setActive } = useSignUp();
|
||||
|
||||
const [emailAddress, setEmailAddress] = React.useState("");
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useIframeHashRouter } from "hash-slash";
|
||||
import { Loaded } from "jazz-tools";
|
||||
import { useAccount, useCoState } from "jazz-tools/react";
|
||||
import { useState } from "react";
|
||||
import { Errors } from "./Errors.tsx";
|
||||
@@ -9,7 +8,7 @@ import {
|
||||
BubbleTeaOrder,
|
||||
DraftBubbleTeaOrder,
|
||||
JazzAccount,
|
||||
ListOfBubbleTeaAddOns,
|
||||
validateDraftOrder,
|
||||
} from "./schema.ts";
|
||||
|
||||
export function CreateOrder() {
|
||||
@@ -21,20 +20,19 @@ export function CreateOrder() {
|
||||
|
||||
if (!me?.root) return;
|
||||
|
||||
const onSave = (draft: Loaded<typeof DraftBubbleTeaOrder>) => {
|
||||
// validate if the draft is a valid order
|
||||
const validation = DraftBubbleTeaOrder.validate(draft);
|
||||
const onSave = (draft: DraftBubbleTeaOrder) => {
|
||||
const validation = validateDraftOrder(draft);
|
||||
setErrors(validation.errors);
|
||||
if (validation.errors.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// turn the draft into a real order
|
||||
me.root.orders.push(draft as Loaded<typeof BubbleTeaOrder>);
|
||||
me.root.orders.push(draft as BubbleTeaOrder);
|
||||
|
||||
// reset the draft
|
||||
me.root.draft = DraftBubbleTeaOrder.create({
|
||||
addOns: ListOfBubbleTeaAddOns.create([]),
|
||||
addOns: [],
|
||||
});
|
||||
|
||||
router.navigate("/");
|
||||
@@ -60,7 +58,7 @@ function CreateOrderForm({
|
||||
onSave,
|
||||
}: {
|
||||
id: string;
|
||||
onSave: (draft: Loaded<typeof DraftBubbleTeaOrder>) => void;
|
||||
onSave: (draft: DraftBubbleTeaOrder) => void;
|
||||
}) {
|
||||
const draft = useCoState(DraftBubbleTeaOrder, id, {
|
||||
resolve: { addOns: true, instructions: true },
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useAccount } from "jazz-tools/react";
|
||||
import { DraftBubbleTeaOrder, JazzAccount } from "./schema";
|
||||
import { JazzAccount, hasChanges } from "./schema";
|
||||
export function DraftIndicator() {
|
||||
const { me } = useAccount(JazzAccount, {
|
||||
resolve: { root: { draft: true } },
|
||||
});
|
||||
|
||||
if (DraftBubbleTeaOrder.hasChanges(me?.root.draft)) {
|
||||
if (hasChanges(me?.root.draft)) {
|
||||
return (
|
||||
<div className="absolute -top-1 -right-1 bg-blue-500 border-2 border-white w-3 h-3 rounded-full dark:border-stone-925">
|
||||
<span className="sr-only">You have a draft</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CoPlainText, Loaded } from "jazz-tools";
|
||||
import { CoPlainText } from "jazz-tools";
|
||||
import {
|
||||
BubbleTeaAddOnTypes,
|
||||
BubbleTeaBaseTeaTypes,
|
||||
@@ -10,7 +10,7 @@ export function OrderForm({
|
||||
order,
|
||||
onSave,
|
||||
}: {
|
||||
order: Loaded<typeof BubbleTeaOrder> | Loaded<typeof DraftBubbleTeaOrder>;
|
||||
order: BubbleTeaOrder | DraftBubbleTeaOrder;
|
||||
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
// Handles updates to the instructions field of the order.
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { Loaded } from "jazz-tools";
|
||||
import { BubbleTeaOrder } from "./schema.ts";
|
||||
|
||||
export function OrderThumbnail({
|
||||
order,
|
||||
}: {
|
||||
order: Loaded<typeof BubbleTeaOrder>;
|
||||
}) {
|
||||
export function OrderThumbnail({ order }: { order: BubbleTeaOrder }) {
|
||||
const { id, baseTea, addOns, instructions, deliveryDate, withMilk } = order;
|
||||
const date = deliveryDate.toLocaleDateString();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Loaded, co, z } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
export const BubbleTeaAddOnTypes = [
|
||||
"Pearl",
|
||||
@@ -15,13 +15,14 @@ export const BubbleTeaBaseTeaTypes = [
|
||||
"Thai",
|
||||
] as const;
|
||||
|
||||
export const ListOfBubbleTeaAddOns = co
|
||||
.list(z.literal([...BubbleTeaAddOnTypes]))
|
||||
.withHelpers((Self) => ({
|
||||
hasChanges(list?: Loaded<typeof Self> | null) {
|
||||
return list && Object.entries(list._raw.insertions).length > 0;
|
||||
},
|
||||
}));
|
||||
export const ListOfBubbleTeaAddOns = co.list(
|
||||
z.literal([...BubbleTeaAddOnTypes]),
|
||||
);
|
||||
export type ListOfBubbleTeaAddOns = co.loaded<typeof ListOfBubbleTeaAddOns>;
|
||||
|
||||
function hasAddOnsChanges(list?: ListOfBubbleTeaAddOns | null) {
|
||||
return list && Object.entries(list._raw.insertions).length > 0;
|
||||
}
|
||||
|
||||
export const BubbleTeaOrder = co.map({
|
||||
baseTea: z.literal([...BubbleTeaBaseTeaTypes]),
|
||||
@@ -30,37 +31,30 @@ export const BubbleTeaOrder = co.map({
|
||||
withMilk: z.boolean(),
|
||||
instructions: co.optional(co.plainText()),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co
|
||||
.map({
|
||||
baseTea: z.optional(z.literal([...BubbleTeaBaseTeaTypes])),
|
||||
addOns: co.optional(ListOfBubbleTeaAddOns),
|
||||
deliveryDate: z.optional(z.date()),
|
||||
withMilk: z.optional(z.boolean()),
|
||||
instructions: co.optional(co.plainText()),
|
||||
})
|
||||
.withHelpers((Self) => ({
|
||||
hasChanges(order: Loaded<typeof Self> | undefined) {
|
||||
return (
|
||||
!!order &&
|
||||
(Object.keys(order._edits).length > 1 ||
|
||||
ListOfBubbleTeaAddOns.hasChanges(order.addOns))
|
||||
);
|
||||
},
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
validate(order: Loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
export function validateDraftOrder(order: DraftBubbleTeaOrder) {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!order.baseTea) {
|
||||
errors.push("Please select your preferred base tea.");
|
||||
}
|
||||
if (!order.deliveryDate) {
|
||||
errors.push("Plese select a delivery date.");
|
||||
}
|
||||
if (!order.baseTea) {
|
||||
errors.push("Please select your preferred base tea.");
|
||||
}
|
||||
if (!order.deliveryDate) {
|
||||
errors.push("Plese select a delivery date.");
|
||||
}
|
||||
|
||||
return { errors };
|
||||
},
|
||||
}));
|
||||
return { errors };
|
||||
}
|
||||
|
||||
export function hasChanges(order?: DraftBubbleTeaOrder | null) {
|
||||
return (
|
||||
!!order &&
|
||||
(Object.keys(order._edits).length > 1 || hasAddOnsChanges(order.addOns))
|
||||
);
|
||||
}
|
||||
|
||||
/** The root is an app-specific per-user private `CoMap`
|
||||
* where you can store top-level objects for that user */
|
||||
@@ -76,15 +70,9 @@ export const JazzAccount = co
|
||||
})
|
||||
.withMigration((account) => {
|
||||
if (!account.root) {
|
||||
const orders = co.list(BubbleTeaOrder).create([], account);
|
||||
const draft = DraftBubbleTeaOrder.create(
|
||||
{
|
||||
addOns: ListOfBubbleTeaAddOns.create([], account),
|
||||
instructions: co.plainText().create("", account),
|
||||
},
|
||||
account.root = AccountRoot.create(
|
||||
{ draft: { addOns: [], instructions: "" }, orders: [] },
|
||||
account,
|
||||
);
|
||||
|
||||
account.root = AccountRoot.create({ draft, orders }, account);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import ImageUpload from "./ImageUpload.tsx";
|
||||
import ProfileImageComponent from "./ProfileImageComponent.tsx";
|
||||
import ProfileImageImperative from "./ProfileImageImperative.tsx";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<main className="max-w-3xl mx-auto px-3 py-16">
|
||||
<ImageUpload />
|
||||
<main className="max-w-6xl mx-auto px-3 py-16">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Upload Image</h2>
|
||||
<ImageUpload />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Profile Image - imperative way</h2>
|
||||
<ProfileImageImperative />
|
||||
<hr />
|
||||
<h2>Profile Image - component</h2>
|
||||
<ProfileImageComponent />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ProgressiveImg, createImage, useAccount } from "jazz-tools/react";
|
||||
import { createImage } from "jazz-tools/media";
|
||||
import { useAccount } from "jazz-tools/react";
|
||||
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||
import { JazzAccount } from "./schema";
|
||||
|
||||
@@ -35,9 +36,14 @@ export default function ImageUpload() {
|
||||
setImagePreviewUrl(objectUrl);
|
||||
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
me.profile.image = await createImage(file, {
|
||||
owner: me.profile._owner,
|
||||
progressive: true,
|
||||
placeholder: "blur",
|
||||
});
|
||||
const endTime = performance.now();
|
||||
console.log(`Image upload took ${endTime - startTime} milliseconds`);
|
||||
} catch (error) {
|
||||
console.error("Error uploading image:", error);
|
||||
} finally {
|
||||
@@ -47,29 +53,6 @@ export default function ImageUpload() {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteImage = () => {
|
||||
if (!me?.profile) return;
|
||||
me.profile.image = undefined;
|
||||
};
|
||||
|
||||
if (me?.profile?.image) {
|
||||
return (
|
||||
<>
|
||||
<ProgressiveImg image={me.profile.image as any /* TODO: fix this */}>
|
||||
{({ src }) => <img alt="" src={src} className="w-full h-auto" />}
|
||||
</ProgressiveImg>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={deleteImage}
|
||||
className="mt-5 bg-blue-600 text-white py-2 px-3 rounded"
|
||||
>
|
||||
Delete image
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (imagePreviewUrl) {
|
||||
return (
|
||||
<div className="relative">
|
||||
|
||||
35
examples/image-upload/src/ProfileImageComponent.tsx
Normal file
35
examples/image-upload/src/ProfileImageComponent.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Image, useAccount } from "jazz-tools/react";
|
||||
import { JazzAccount } from "./schema";
|
||||
|
||||
export default function ProfileImage() {
|
||||
const { me } = useAccount(JazzAccount, { resolve: { profile: true } });
|
||||
|
||||
const deleteImage = () => {
|
||||
if (!me?.profile) return;
|
||||
me.profile.image = undefined;
|
||||
};
|
||||
|
||||
if (!me?.profile?.image) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 bg-gray-100 rounded-lg">
|
||||
<p className="text-gray-500">No profile image</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Profile Image</h2>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Image imageId={me.profile.image.id} alt="Profile" width={600} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={deleteImage}
|
||||
className="bg-red-600 text-white py-2 px-3 rounded hover:bg-red-700"
|
||||
>
|
||||
Delete image
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
examples/image-upload/src/ProfileImageImperative.tsx
Normal file
80
examples/image-upload/src/ProfileImageImperative.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
highestResAvailable,
|
||||
// loadImage,
|
||||
// loadImageBySize,
|
||||
} from "jazz-tools/media";
|
||||
import { useAccount } from "jazz-tools/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { JazzAccount } from "./schema";
|
||||
|
||||
export default function ProfileImageImperative() {
|
||||
const [image, setImage] = useState<string | undefined>(undefined);
|
||||
const { me } = useAccount(JazzAccount, { resolve: { profile: true } });
|
||||
|
||||
useEffect(() => {
|
||||
if (!me?.profile?.image) return;
|
||||
|
||||
// `loadImage` returns always the original image
|
||||
// loadImage(me.profile.image).then((image) => {
|
||||
// if(image === null) {
|
||||
// console.error('Unable to load image');
|
||||
// return;
|
||||
// }
|
||||
// console.log('loadImage', {w: image.width, h: image.height, ready: image.image.getChunks() ? 'ready' : 'not ready'});
|
||||
// });
|
||||
|
||||
// `loadImageBySize` returns the best available image for the given size
|
||||
// loadImageBySize(me.profile.image.id, 1024, 1024).then((image) => {
|
||||
// if(image === null) {
|
||||
// console.error('Unable to load image');
|
||||
// return;
|
||||
// }
|
||||
// console.log('loadImageBySize', {w: image.width, h: image.height, ready: image.image.getChunks() ? 'ready' : 'not ready'});
|
||||
// });
|
||||
|
||||
// keep it synced and return the best _loaded_ image for the given size
|
||||
const unsub = me.profile.image.subscribe({}, (image) => {
|
||||
const bestImage = highestResAvailable(image, 1024, 1024);
|
||||
console.info(bestImage ? "Blob is ready" : "Blob is not ready");
|
||||
if (bestImage) {
|
||||
const blob = bestImage.image.toBlob();
|
||||
if (blob) {
|
||||
setImage(URL.createObjectURL(blob));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
};
|
||||
}, [me?.profile?.image]);
|
||||
|
||||
const deleteImage = () => {
|
||||
if (!me?.profile) return;
|
||||
me.profile.image = undefined;
|
||||
};
|
||||
|
||||
if (!me?.profile?.image) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 bg-gray-100 rounded-lg">
|
||||
<p className="text-gray-500">No profile image</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Profile Image</h2>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<img alt="Profile" src={image} className="w-full h-auto" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={deleteImage}
|
||||
className="bg-red-600 text-white py-2 px-3 rounded hover:bg-red-700"
|
||||
>
|
||||
Delete image
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,9 +11,9 @@ export const Issue = co.map({
|
||||
status: z.enum(["open", "closed"]),
|
||||
labels: co.list(z.string()),
|
||||
reactions: ReactionsList,
|
||||
file: z.optional(co.fileStream()),
|
||||
image: z.optional(co.image()),
|
||||
lead: z.optional(co.account()),
|
||||
file: co.optional(co.fileStream()),
|
||||
image: co.optional(co.image()),
|
||||
lead: co.optional(co.account()),
|
||||
});
|
||||
|
||||
export const Project = co.map({
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"jazz-tools": "workspace:*",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"zod": "3.25.28"
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -31,4 +31,4 @@
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "3.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,11 @@ function Avatar({
|
||||
name,
|
||||
color,
|
||||
active,
|
||||
}: { name: string; color: string; active: boolean }) {
|
||||
}: {
|
||||
name: string;
|
||||
color: string;
|
||||
active: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
title={name}
|
||||
|
||||
@@ -47,7 +47,9 @@ button {
|
||||
font-family: inherit;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.05s ease, border-color 0.1s ease;
|
||||
transition:
|
||||
all 0.05s ease,
|
||||
border-color 0.1s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
button:hover {
|
||||
@@ -93,8 +95,9 @@ button:active {
|
||||
background-color: white;
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px
|
||||
rgba(0, 0, 0, 0.06);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
width: 28rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.274.0",
|
||||
"lucide-react": "^0.536.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-router": "^6.16.0",
|
||||
@@ -38,6 +38,7 @@
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"typescript": "5.6.2",
|
||||
"vite": "^6.3.5"
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-pwa": "^1.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,15 +84,14 @@ export const MusicaAccount = co
|
||||
* You can use it to set up the account root and any other initial CoValues you need.
|
||||
*/
|
||||
if (account.root === undefined) {
|
||||
const tracks = co.list(MusicTrack).create([]);
|
||||
const rootPlaylist = Playlist.create({
|
||||
tracks,
|
||||
tracks: [],
|
||||
title: "",
|
||||
});
|
||||
|
||||
account.root = MusicaAccountRoot.create({
|
||||
rootPlaylist,
|
||||
playlists: co.list(Playlist).create([]),
|
||||
playlists: [],
|
||||
activeTrack: undefined,
|
||||
activePlaylist: rootPlaylist,
|
||||
exampleDataLoaded: false,
|
||||
|
||||
@@ -15,7 +15,7 @@ import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { JazzReactProvider } from "jazz-tools/react";
|
||||
import { onAnonymousAccountDiscarded } from "./4_actions";
|
||||
import { KeyboardListener } from "./components/PlayerControls";
|
||||
import { useUploadExampleData } from "./lib/useUploadExampleData";
|
||||
import { usePrepareAppState } from "./lib/usePrepareAppState";
|
||||
|
||||
/**
|
||||
* Walkthrough: The top-level provider `<JazzReactProvider/>`
|
||||
@@ -31,7 +31,7 @@ import { useUploadExampleData } from "./lib/useUploadExampleData";
|
||||
function Main() {
|
||||
const mediaPlayer = useMediaPlayer();
|
||||
|
||||
useUploadExampleData();
|
||||
const isReady = usePrepareAppState(mediaPlayer);
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
@@ -48,6 +48,8 @@ function Main() {
|
||||
},
|
||||
]);
|
||||
|
||||
if (!isReady) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
|
||||
@@ -11,6 +11,7 @@ import { uploadMusicTracks } from "./4_actions";
|
||||
import { MediaPlayer } from "./5_useMediaPlayer";
|
||||
import { FileUploadButton } from "./components/FileUploadButton";
|
||||
import { MusicTrackRow } from "./components/MusicTrackRow";
|
||||
import { PlayerControls } from "./components/PlayerControls";
|
||||
import { PlaylistTitleInput } from "./components/PlaylistTitleInput";
|
||||
import { SidePanel } from "./components/SidePanel";
|
||||
import { Button } from "./components/ui/button";
|
||||
@@ -42,7 +43,11 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
const playlistId = params.playlistId ?? me?.root._refs.rootPlaylist.id;
|
||||
|
||||
const playlist = useCoState(Playlist, playlistId, {
|
||||
resolve: { tracks: true },
|
||||
resolve: {
|
||||
tracks: {
|
||||
$each: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const isRootPlaylist = !params.playlistId;
|
||||
@@ -64,16 +69,16 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
|
||||
return (
|
||||
<SidebarInset className="flex flex-col h-screen text-gray-800 bg-blue-50">
|
||||
<SidebarInset className="flex flex-col h-screen text-gray-800">
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<SidePanel mediaPlayer={mediaPlayer} />
|
||||
<main className="flex-1 p-6 overflow-y-auto overflow-x-hidden">
|
||||
<SidebarTrigger />
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<SidePanel />
|
||||
<main className="flex-1 px-2 py-4 md:px-6 overflow-y-auto overflow-x-hidden relative sm:h-[calc(100vh-80px)] bg-white h-[calc(100vh-165px)]">
|
||||
<SidebarTrigger className="md:hidden" />
|
||||
<div className="flex flex-row items-center justify-between mb-4 pl-1 md:pl-10 pr-2 md:pr-0 mt-2 md:mt-0 w-full">
|
||||
{isRootPlaylist ? (
|
||||
<h1 className="text-2xl font-bold text-blue-800">All tracks</h1>
|
||||
) : (
|
||||
<PlaylistTitleInput playlistId={playlistId} />
|
||||
<PlaylistTitleInput className="w-full" playlistId={playlistId} />
|
||||
)}
|
||||
<div className="flex items-center space-x-4">
|
||||
{isRootPlaylist && (
|
||||
@@ -90,14 +95,14 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ul className="flex flex-col max-w-full">
|
||||
<ul className="flex flex-col max-w-full sm:gap-1">
|
||||
{playlist?.tracks?.map(
|
||||
(track) =>
|
||||
(track, index) =>
|
||||
track && (
|
||||
<MusicTrackRow
|
||||
trackId={track.id}
|
||||
key={track.id}
|
||||
isLoading={mediaPlayer.loading === track.id}
|
||||
index={index}
|
||||
isPlaying={
|
||||
mediaPlayer.activeTrackId === track.id &&
|
||||
isActivePlaylist &&
|
||||
@@ -106,12 +111,12 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
onClick={() => {
|
||||
mediaPlayer.setActiveTrack(track, playlist);
|
||||
}}
|
||||
showAddToPlaylist={isRootPlaylist}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</main>
|
||||
<PlayerControls mediaPlayer={mediaPlayer} />
|
||||
</div>
|
||||
</SidebarInset>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { getAudioFileData } from "@/lib/audio/getAudioFileData";
|
||||
import { FileStream, Group, co } from "jazz-tools";
|
||||
import {
|
||||
MusicTrack,
|
||||
MusicTrackWaveform,
|
||||
MusicaAccount,
|
||||
Playlist,
|
||||
} from "./1_schema";
|
||||
import { FileStream, Group } from "jazz-tools";
|
||||
import { MusicTrack, MusicaAccount, Playlist } from "./1_schema";
|
||||
|
||||
/**
|
||||
* Walkthrough: Actions
|
||||
@@ -51,7 +46,7 @@ export async function uploadMusicTracks(
|
||||
{
|
||||
file: fileStream,
|
||||
duration: data.duration,
|
||||
waveform: MusicTrackWaveform.create({ data: data.waveform }, group),
|
||||
waveform: { data: data.waveform },
|
||||
title: file.name,
|
||||
isExampleTrack,
|
||||
},
|
||||
@@ -73,18 +68,10 @@ export async function createNewPlaylist() {
|
||||
},
|
||||
});
|
||||
|
||||
// Since playlists are meant to be shared we associate them
|
||||
// to a group which will contain the keys required to get
|
||||
// access to the "owned" values
|
||||
const playlistGroup = Group.create();
|
||||
|
||||
const playlist = Playlist.create(
|
||||
{
|
||||
title: "New Playlist",
|
||||
tracks: co.list(MusicTrack).create([], playlistGroup),
|
||||
},
|
||||
playlistGroup,
|
||||
);
|
||||
const playlist = Playlist.create({
|
||||
title: "New Playlist",
|
||||
tracks: [],
|
||||
});
|
||||
|
||||
// Again, we associate the new playlist to the
|
||||
// user by pushing it into the playlists CoList
|
||||
@@ -129,7 +116,7 @@ export async function removeTrackFromPlaylist(
|
||||
|
||||
if (track._owner._type === "Group" && playlist._owner._type === "Group") {
|
||||
const trackGroup = track._owner;
|
||||
await trackGroup.removeMember(playlist._owner);
|
||||
trackGroup.removeMember(playlist._owner);
|
||||
|
||||
const index =
|
||||
playlist.tracks?.findIndex(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FileStream } from "jazz-tools";
|
||||
import { useAccount } from "jazz-tools/react";
|
||||
import { useRef, useState } from "react";
|
||||
import { updateActivePlaylist, updateActiveTrack } from "./4_actions";
|
||||
import { useAudioManager } from "./lib/audio/AudioManager";
|
||||
import { getNextTrack, getPrevTrack } from "./lib/getters";
|
||||
|
||||
export function useMediaPlayer() {
|
||||
@@ -12,6 +13,7 @@ export function useMediaPlayer() {
|
||||
resolve: { root: true },
|
||||
});
|
||||
|
||||
const audioManager = useAudioManager();
|
||||
const playState = usePlayState();
|
||||
const playMedia = usePlayMedia();
|
||||
|
||||
@@ -24,8 +26,10 @@ export function useMediaPlayer() {
|
||||
|
||||
async function loadTrack(track: MusicTrack) {
|
||||
lastLoadedTrackId.current = track.id;
|
||||
audioManager.unloadCurrentAudio();
|
||||
|
||||
setLoading(track.id);
|
||||
updateActiveTrack(track);
|
||||
|
||||
const file = await FileStream.loadAsBlob(track._refs.file!.id); // TODO: see if we can avoid !
|
||||
|
||||
@@ -40,8 +44,6 @@ export function useMediaPlayer() {
|
||||
return;
|
||||
}
|
||||
|
||||
updateActiveTrack(track);
|
||||
|
||||
await playMedia(file);
|
||||
|
||||
setLoading(null);
|
||||
@@ -85,6 +87,7 @@ export function useMediaPlayer() {
|
||||
playNextTrack,
|
||||
playPrevTrack,
|
||||
loading,
|
||||
loadTrack,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
59
examples/music-player/src/components/ConfirmDialog.tsx
Normal file
59
examples/music-player/src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm: () => void;
|
||||
variant?: "default" | "destructive";
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel",
|
||||
onConfirm,
|
||||
variant = "destructive",
|
||||
}: ConfirmDialogProps) {
|
||||
function handleConfirm() {
|
||||
onConfirm();
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button variant={variant} onClick={handleConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -9,31 +9,41 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loaded } from "jazz-tools";
|
||||
import { useAccount, useCoState } from "jazz-tools/react";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { Fragment } from "react/jsx-runtime";
|
||||
import { MusicTrackTitleInput } from "./MusicTrackTitleInput";
|
||||
import { MoreHorizontal, Pause, Play } from "lucide-react";
|
||||
import { Fragment, useCallback, useState } from "react";
|
||||
import { EditTrackDialog } from "./RenameTrackDialog";
|
||||
import { Waveform } from "./Waveform";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
function isPartOfThePlaylist(
|
||||
trackId: string,
|
||||
playlist: Loaded<typeof Playlist, { tracks: true }>,
|
||||
) {
|
||||
return Array.from(playlist.tracks._refs).some((t) => t.id === trackId);
|
||||
}
|
||||
|
||||
export function MusicTrackRow({
|
||||
trackId,
|
||||
isLoading,
|
||||
isPlaying,
|
||||
onClick,
|
||||
showAddToPlaylist,
|
||||
index,
|
||||
}: {
|
||||
trackId: string;
|
||||
isLoading: boolean;
|
||||
isPlaying: boolean;
|
||||
onClick: (track: Loaded<typeof MusicTrack>) => void;
|
||||
showAddToPlaylist: boolean;
|
||||
index: number;
|
||||
}) {
|
||||
const track = useCoState(MusicTrack, trackId);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const { me } = useAccount(MusicaAccount, {
|
||||
resolve: { root: { playlists: { $each: true } } },
|
||||
resolve: { root: { playlists: { $each: { tracks: true } } } },
|
||||
});
|
||||
|
||||
const playlists = me?.root.playlists ?? [];
|
||||
const isActiveTrack = trackId === me?.root._refs.activeTrack?.id;
|
||||
|
||||
function handleTrackClick() {
|
||||
if (!track) return;
|
||||
@@ -60,71 +70,118 @@ export function MusicTrackRow({
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
setIsEditDialogOpen(true);
|
||||
}
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDropdownOpen(true);
|
||||
}, []);
|
||||
|
||||
const showWaveform = isHovered || isActiveTrack;
|
||||
|
||||
return (
|
||||
<li
|
||||
className={"flex gap-1 hover:bg-slate-200 group py-2 px-2 cursor-pointer"}
|
||||
onClick={handleTrackClick}
|
||||
className={cn(
|
||||
"flex gap-1 hover:bg-slate-200 group py-2 cursor-pointer rounded-lg",
|
||||
isActiveTrack && "bg-slate-200",
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
setIsHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setIsHovered(false);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center bg-transparent w-8 h-8 ",
|
||||
!isPlaying && "group-hover:bg-slate-300 rounded-full",
|
||||
"flex items-center justify-center bg-transparent w-8 h-8 transition-opacity cursor-pointer",
|
||||
// Show play button on hover or when active, hide otherwise
|
||||
"md:opacity-0 opacity-50 group-hover:opacity-100",
|
||||
isActiveTrack && "md:opacity-100 opacity-100",
|
||||
)}
|
||||
onClick={handleTrackClick}
|
||||
aria-label={`${isPlaying ? "Pause" : "Play"} ${track?.title}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="animate-spin">߷</div>
|
||||
) : isPlaying ? (
|
||||
"⏸️"
|
||||
{isPlaying ? (
|
||||
<Pause height={16} width={16} fill="currentColor" />
|
||||
) : (
|
||||
"▶️"
|
||||
<Play height={16} width={16} fill="currentColor" />
|
||||
)}
|
||||
</button>
|
||||
<MusicTrackTitleInput trackId={trackId} />
|
||||
{/* Show track index when play button is hidden - hidden on mobile */}
|
||||
<div
|
||||
className={cn(
|
||||
"hidden md:flex items-center justify-center w-8 h-8 text-sm text-gray-500 font-mono transition-opacity",
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<button
|
||||
onContextMenu={handleContextMenu}
|
||||
onClick={handleTrackClick}
|
||||
className="flex items-center overflow-hidden text-ellipsis whitespace-nowrap cursor-pointer flex-1 min-w-0"
|
||||
>
|
||||
{track?.title}
|
||||
</button>
|
||||
|
||||
{/* Waveform that appears on hover */}
|
||||
{track && showWaveform && (
|
||||
<div className="flex-1 min-w-0 px-2 items-center hidden md:flex">
|
||||
<Waveform
|
||||
track={track}
|
||||
height={20}
|
||||
className="opacity-70 w-full"
|
||||
showProgress={isActiveTrack}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div onClick={(evt) => evt.stopPropagation()}>
|
||||
{showAddToPlaylist && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label={`Open ${track?.title} menu`}
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
key={`delete`}
|
||||
onSelect={async () => {
|
||||
if (!track) return;
|
||||
deleteTrack();
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
{playlists.map((playlist, index) => (
|
||||
<Fragment key={index}>
|
||||
<DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label={`Open ${track?.title} menu`}
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={handleEdit}>Edit</DropdownMenuItem>
|
||||
{playlists.map((playlist, playlistIndex) => (
|
||||
<Fragment key={playlistIndex}>
|
||||
{isPartOfThePlaylist(trackId, playlist) ? (
|
||||
<DropdownMenuItem
|
||||
key={`add-${index}`}
|
||||
onSelect={() => handleAddToPlaylist(playlist)}
|
||||
>
|
||||
Add to {playlist.title}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
key={`remove-${index}`}
|
||||
key={`remove-${playlistIndex}`}
|
||||
onSelect={() => handleRemoveFromPlaylist(playlist)}
|
||||
>
|
||||
Remove from {playlist.title}
|
||||
</DropdownMenuItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
key={`add-${playlistIndex}`}
|
||||
onSelect={() => handleAddToPlaylist(playlist)}
|
||||
>
|
||||
Add to {playlist.title}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{track && isEditDialogOpen && (
|
||||
<EditTrackDialog
|
||||
track={track}
|
||||
isOpen={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
onDelete={deleteTrack}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { usePlayState } from "@/lib/audio/usePlayState";
|
||||
import { useKeyboardListener } from "@/lib/useKeyboardListener";
|
||||
import { useAccount, useCoState } from "jazz-tools/react";
|
||||
import { Pause, Play, SkipBack, SkipForward } from "lucide-react";
|
||||
import { Waveform } from "./Waveform";
|
||||
import WaveformCanvas from "./WaveformCanvas";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
const playState = usePlayState();
|
||||
@@ -15,51 +16,61 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
resolve: { root: { activePlaylist: true } },
|
||||
}).me?.root.activePlaylist;
|
||||
|
||||
const activeTrack = useCoState(MusicTrack, mediaPlayer.activeTrackId, {
|
||||
resolve: { waveform: true },
|
||||
});
|
||||
const activeTrack = useCoState(MusicTrack, mediaPlayer.activeTrackId);
|
||||
|
||||
if (!activeTrack) return null;
|
||||
|
||||
const activeTrackTitle = activeTrack.title;
|
||||
|
||||
return (
|
||||
<footer className="flex items-center justify-between p-4 gap-4 bg-white border-t border-gray-200 fixed bottom-0 left-0 right-0 w-full">
|
||||
<div className="flex justify-center items-center space-x-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
<footer className="flex flex-wrap sm:flex-nowrap items-center justify-between pt-4 p-2 sm:p-4 gap-4 sm:gap-4 bg-white border-t border-gray-200 absolute bottom-0 left-0 right-0 w-full z-50">
|
||||
{/* Player Controls - Always on top */}
|
||||
<div className="flex justify-center items-center space-x-1 sm:space-x-2 flex-shrink-0 w-full sm:w-auto order-1 sm:order-none">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={mediaPlayer.playPrevTrack}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
aria-label="Previous track"
|
||||
>
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
<button
|
||||
<SkipBack className="h-5 w-5" fill="currentColor" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={playState.toggle}
|
||||
className="w-[42px] h-[42px] flex items-center justify-center bg-blue-600 rounded-full text-white hover:bg-blue-700"
|
||||
className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
aria-label={isPlaying ? "Pause active track" : "Play active track"}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={24} fill="currentColor" />
|
||||
<Pause className="h-5 w-5" fill="currentColor" />
|
||||
) : (
|
||||
<Play size={24} fill="currentColor" />
|
||||
<Play className="h-5 w-5" fill="currentColor" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={mediaPlayer.playNextTrack}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
aria-label="Next track"
|
||||
>
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
<SkipForward className="h-5 w-5" fill="currentColor" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className=" sm:hidden md:flex flex-col flex-shrink-1 items-center w-[75%]">
|
||||
<Waveform track={activeTrack} height={30} />
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 text-right min-w-fit w-[25%]">
|
||||
<h4 className="font-medium text-blue-800">{activeTrackTitle}</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
|
||||
{/* Waveform - Below controls on mobile, between controls and info on desktop */}
|
||||
<WaveformCanvas
|
||||
className="order-1 sm:order-none"
|
||||
track={activeTrack}
|
||||
height={50}
|
||||
/>
|
||||
|
||||
{/* Track Info - Below waveform on mobile, on the right on desktop */}
|
||||
<div className="flex flex-col gap-1 min-w-fit sm:flex-shrink-0 text-center w-full sm:text-right items-center sm:items-end sm:w-auto order-0 sm:order-none">
|
||||
<h4 className="font-medium text-blue-800 text-base sm:text-base truncate max-w-80 sm:max-w-80">
|
||||
{activeTrackTitle}
|
||||
</h4>
|
||||
<p className="hidden sm:block text-xs sm:text-sm text-gray-600 truncate sm:max-w-80">
|
||||
{activePlaylist?.title || "All tracks"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -69,7 +80,9 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
|
||||
export function KeyboardListener({
|
||||
mediaPlayer,
|
||||
}: { mediaPlayer: MediaPlayer }) {
|
||||
}: {
|
||||
mediaPlayer: MediaPlayer;
|
||||
}) {
|
||||
const playState = usePlayState();
|
||||
|
||||
useMediaEndListener(mediaPlayer.playNextTrack);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Playlist } from "@/1_schema";
|
||||
import { updatePlaylistTitle } from "@/4_actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCoState } from "jazz-tools/react";
|
||||
import { ChangeEvent, useState } from "react";
|
||||
|
||||
export function PlaylistTitleInput({
|
||||
playlistId,
|
||||
className,
|
||||
}: {
|
||||
playlistId: string | undefined;
|
||||
className?: string;
|
||||
}) {
|
||||
const playlist = useCoState(Playlist, playlistId);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -33,7 +36,10 @@ export function PlaylistTitleInput({
|
||||
<input
|
||||
value={inputValue}
|
||||
onChange={handleTitleChange}
|
||||
className="text-2xl font-bold text-blue-800 bg-transparent"
|
||||
className={cn(
|
||||
"text-2xl font-bold text-blue-800 bg-transparent",
|
||||
className,
|
||||
)}
|
||||
onFocus={handleFoucsIn}
|
||||
onBlur={handleFocusOut}
|
||||
aria-label={`Playlist title`}
|
||||
|
||||
108
examples/music-player/src/components/RenameTrackDialog.tsx
Normal file
108
examples/music-player/src/components/RenameTrackDialog.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { MusicTrack } from "@/1_schema";
|
||||
import { updateMusicTrackTitle } from "@/4_actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Loaded } from "jazz-tools";
|
||||
import { useState } from "react";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
|
||||
interface EditTrackDialogProps {
|
||||
track: Loaded<typeof MusicTrack>;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function EditTrackDialog({
|
||||
track,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onDelete,
|
||||
}: EditTrackDialogProps) {
|
||||
const [newTitle, setNewTitle] = useState(track.title);
|
||||
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
|
||||
|
||||
function handleSave() {
|
||||
if (track && newTitle.trim()) {
|
||||
updateMusicTrackTitle(track, newTitle.trim());
|
||||
onOpenChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setNewTitle(track?.title || "");
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleDeleteClick() {
|
||||
setIsDeleteConfirmOpen(true);
|
||||
}
|
||||
|
||||
function handleDeleteConfirm() {
|
||||
onDelete();
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent) {
|
||||
if (event.key === "Enter") {
|
||||
handleSave();
|
||||
} else if (event.key === "Escape") {
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Track</DialogTitle>
|
||||
<DialogDescription>Edit "{track?.title}".</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="py-4" onSubmit={handleSave}>
|
||||
<Input
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter track name..."
|
||||
autoFocus
|
||||
/>
|
||||
</form>
|
||||
<DialogFooter className="flex justify-between">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteClick}
|
||||
className="mr-auto"
|
||||
>
|
||||
Delete Track
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!newTitle.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
<ConfirmDialog
|
||||
isOpen={isDeleteConfirmOpen}
|
||||
onOpenChange={setIsDeleteConfirmOpen}
|
||||
title="Delete Track"
|
||||
description={`Are you sure you want to delete "${track.title}"? This action cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
variant="destructive"
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { MusicTrack, MusicaAccount } from "@/1_schema";
|
||||
import { MusicaAccount } from "@/1_schema";
|
||||
import { createNewPlaylist, deletePlaylist } from "@/4_actions";
|
||||
import { MediaPlayer } from "@/5_useMediaPlayer";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
@@ -14,22 +12,18 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { usePlayState } from "@/lib/audio/usePlayState";
|
||||
import { useAccount, useCoState } from "jazz-tools/react";
|
||||
import { Home, Music, Pause, Play, Plus, Trash2 } from "lucide-react";
|
||||
import { useAccount } from "jazz-tools/react";
|
||||
import { Home, Music, Plus, Trash2 } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { AuthButton } from "./AuthButton";
|
||||
|
||||
export function SidePanel({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
export function SidePanel() {
|
||||
const { playlistId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { me } = useAccount(MusicaAccount, {
|
||||
resolve: { root: { playlists: { $each: true } } },
|
||||
});
|
||||
|
||||
const playState = usePlayState();
|
||||
const isPlaying = playState.value === "play";
|
||||
|
||||
function handleAllTracksClick() {
|
||||
navigate(`/`);
|
||||
}
|
||||
@@ -50,12 +44,6 @@ export function SidePanel({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
navigate(`/playlist/${playlist.id}`);
|
||||
}
|
||||
|
||||
const activeTrack = useCoState(MusicTrack, mediaPlayer.activeTrackId, {
|
||||
resolve: { waveform: true },
|
||||
});
|
||||
|
||||
const activeTrackTitle = activeTrack?.title;
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarHeader>
|
||||
@@ -137,29 +125,6 @@ export function SidePanel({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
{activeTrack && (
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem className="flex justify-end">
|
||||
<SidebarMenuButton
|
||||
onClick={playState.toggle}
|
||||
aria-label={
|
||||
isPlaying ? "Pause active track" : "Play active track"
|
||||
}
|
||||
>
|
||||
<div className="w-[28px] h-[28px] flex items-center justify-center bg-blue-600 rounded-full text-white hover:bg-blue-700">
|
||||
{isPlaying ? (
|
||||
<Pause size={16} fill="currentColor" />
|
||||
) : (
|
||||
<Play size={16} fill="currentColor" />
|
||||
)}
|
||||
</div>
|
||||
<span>{activeTrackTitle}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
)}
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useCoState } from "jazz-tools/react";
|
||||
export function Waveform(props: {
|
||||
track: Loaded<typeof MusicTrack>;
|
||||
height: number;
|
||||
className?: string;
|
||||
showProgress?: boolean;
|
||||
}) {
|
||||
const { track, height } = props;
|
||||
const waveformData = useCoState(
|
||||
@@ -28,29 +30,24 @@ export function Waveform(props: {
|
||||
}
|
||||
|
||||
const barCount = waveformData.length;
|
||||
const activeBar = Math.ceil(barCount * (currentTime.value / duration));
|
||||
|
||||
function seek(i: number) {
|
||||
currentTime.setValue((i / barCount) * duration);
|
||||
}
|
||||
const activeBar = props.showProgress
|
||||
? Math.ceil(barCount * (currentTime.value / duration))
|
||||
: -1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex justify-center items-end w-full"
|
||||
className={cn("flex justify-center items-end w-full", props.className)}
|
||||
style={{
|
||||
height,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{waveformData.map((value, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
onClick={() => seek(i)}
|
||||
className={cn(
|
||||
"w-1 transition-colors rounded-none rounded-t-lg min-h-1",
|
||||
activeBar >= i ? "bg-gray-500" : "bg-gray-300",
|
||||
"hover:bg-black hover:border hover:border-solid hover:border-black",
|
||||
activeBar >= i ? "bg-gray-800" : "bg-gray-400",
|
||||
"focus-visible:outline-black focus:outline-hidden",
|
||||
)}
|
||||
style={{
|
||||
|
||||
282
examples/music-player/src/components/WaveformCanvas.tsx
Normal file
282
examples/music-player/src/components/WaveformCanvas.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import { MusicTrack, MusicTrackWaveform } from "@/1_schema";
|
||||
import { AudioManager, useAudioManager } from "@/lib/audio/AudioManager";
|
||||
import {
|
||||
getPlayerCurrentTime,
|
||||
setPlayerCurrentTime,
|
||||
subscribeToPlayerCurrentTime,
|
||||
usePlayerCurrentTime,
|
||||
} from "@/lib/audio/usePlayerCurrentTime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loaded } from "jazz-tools";
|
||||
import type React from "react";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type Props = {
|
||||
track: Loaded<typeof MusicTrack>;
|
||||
height?: number;
|
||||
barColor?: string;
|
||||
progressColor?: string;
|
||||
backgroundColor?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_HEIGHT = 96;
|
||||
|
||||
// Downsample PCM into N peaks (abs max in window)
|
||||
function buildPeaks(channelData: number[], samples: number): Float32Array {
|
||||
const length = channelData.length;
|
||||
if (channelData.length < samples) {
|
||||
// Create a peaks array that interpolates the channelData
|
||||
const interpolatedPeaks = new Float32Array(samples);
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const index = Math.floor(i * (length / samples));
|
||||
interpolatedPeaks[i] = channelData[index];
|
||||
}
|
||||
return interpolatedPeaks;
|
||||
}
|
||||
|
||||
const blockSize = Math.floor(length / samples);
|
||||
const peaks = new Float32Array(samples);
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const start = i * blockSize;
|
||||
let end = start + blockSize;
|
||||
if (end > length) end = length;
|
||||
let max = 0;
|
||||
for (let j = start; j < end; j++) {
|
||||
const v = Math.abs(channelData[j]);
|
||||
if (v > max) max = v;
|
||||
}
|
||||
peaks[i] = max;
|
||||
}
|
||||
return peaks;
|
||||
}
|
||||
|
||||
type DrawWaveformCanvasProps = {
|
||||
canvas: HTMLCanvasElement;
|
||||
waveformData: number[] | undefined;
|
||||
duration: number;
|
||||
currentTime: number;
|
||||
barColor?: string;
|
||||
progressColor?: string;
|
||||
backgroundColor?: string;
|
||||
isAnimating: boolean;
|
||||
animationProgress: number;
|
||||
progress: number;
|
||||
};
|
||||
|
||||
function drawWaveform(props: DrawWaveformCanvasProps) {
|
||||
const {
|
||||
canvas,
|
||||
waveformData,
|
||||
isAnimating,
|
||||
animationProgress,
|
||||
barColor = "hsl(215, 16%, 47%)",
|
||||
progressColor = "hsl(142, 71%, 45%)",
|
||||
backgroundColor = "transparent",
|
||||
progress,
|
||||
} = props;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssWidth = canvas.clientWidth;
|
||||
const cssHeight = canvas.clientHeight;
|
||||
canvas.width = Math.floor(cssWidth * dpr);
|
||||
canvas.height = Math.floor(cssHeight * dpr);
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = backgroundColor;
|
||||
ctx.fillRect(0, 0, cssWidth, cssHeight);
|
||||
|
||||
if (!waveformData || !waveformData.length) {
|
||||
// Draw placeholder line
|
||||
ctx.strokeStyle = "hsl(215, 20%, 65%)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, cssHeight / 2);
|
||||
ctx.lineTo(cssWidth, cssHeight / 2);
|
||||
ctx.stroke();
|
||||
return;
|
||||
}
|
||||
|
||||
const midY = cssHeight / 2;
|
||||
const barWidth = 2; // px
|
||||
const gap = 1;
|
||||
const totalBars = Math.floor(cssWidth / (barWidth + gap));
|
||||
const ds = buildPeaks(waveformData, totalBars);
|
||||
|
||||
const draw = (color: string, untilBar: number, start = 0) => {
|
||||
ctx.fillStyle = color;
|
||||
for (let i = start; i < untilBar; i++) {
|
||||
const v = ds[i] || 0;
|
||||
const h = Math.max(2, v * (cssHeight - 8)); // margin
|
||||
const x = i * (barWidth + gap);
|
||||
|
||||
// Apply staggered animation
|
||||
if (isAnimating) {
|
||||
const barProgress = Math.max(0, Math.min(1, animationProgress / 0.2));
|
||||
const animatedHeight = h * barProgress;
|
||||
|
||||
ctx.globalAlpha = barProgress;
|
||||
ctx.fillRect(x, midY - animatedHeight / 2, barWidth, animatedHeight);
|
||||
} else {
|
||||
ctx.fillRect(x, midY - h / 2, barWidth, h);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Progress overlay
|
||||
const progressBars = Math.floor(
|
||||
totalBars * Math.max(0, Math.min(1, progress || 0)),
|
||||
);
|
||||
draw(progressColor, progressBars);
|
||||
// Base waveform
|
||||
draw(barColor, totalBars, progressBars);
|
||||
}
|
||||
|
||||
type WaveformCanvasProps = {
|
||||
audioManager: AudioManager;
|
||||
canvas: HTMLCanvasElement;
|
||||
waveformId: string;
|
||||
duration: number;
|
||||
barColor?: string;
|
||||
progressColor?: string;
|
||||
backgroundColor?: string;
|
||||
};
|
||||
|
||||
async function renderWaveform(props: WaveformCanvasProps) {
|
||||
const { audioManager, canvas, waveformId, duration } = props;
|
||||
|
||||
let mounted = true;
|
||||
let currentTime = getPlayerCurrentTime(audioManager);
|
||||
let waveformData: undefined | number[] = undefined;
|
||||
let isAnimating = true;
|
||||
const startTime = performance.now();
|
||||
let animationProgress = 0;
|
||||
const animationDuration = 800;
|
||||
|
||||
function draw() {
|
||||
const progress = currentTime / duration;
|
||||
|
||||
drawWaveform({
|
||||
canvas,
|
||||
waveformData,
|
||||
duration,
|
||||
currentTime,
|
||||
isAnimating,
|
||||
animationProgress,
|
||||
progress,
|
||||
});
|
||||
}
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
if (!mounted) return;
|
||||
|
||||
const elapsed = currentTime - startTime;
|
||||
animationProgress = Math.min(elapsed / animationDuration, 1);
|
||||
|
||||
if (animationProgress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
isAnimating = false;
|
||||
}
|
||||
|
||||
draw();
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
|
||||
const unsubscribeFromCurrentTime = subscribeToPlayerCurrentTime(
|
||||
audioManager,
|
||||
(time) => {
|
||||
currentTime = time;
|
||||
draw();
|
||||
},
|
||||
);
|
||||
|
||||
const unsubscribeFromWaveform = MusicTrackWaveform.subscribe(
|
||||
waveformId,
|
||||
{},
|
||||
(newResult) => {
|
||||
waveformData = newResult.data;
|
||||
draw();
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
unsubscribeFromCurrentTime();
|
||||
unsubscribeFromWaveform();
|
||||
};
|
||||
}
|
||||
|
||||
export default function WaveformCanvas({
|
||||
track,
|
||||
height = DEFAULT_HEIGHT,
|
||||
barColor, // muted-foreground-ish
|
||||
progressColor, // green
|
||||
backgroundColor,
|
||||
className,
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const audioManager = useAudioManager();
|
||||
|
||||
const duration = track.duration;
|
||||
const waveformId = track._refs.waveform?.id;
|
||||
|
||||
// Animation effect
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
if (!waveformId) return;
|
||||
|
||||
renderWaveform({
|
||||
audioManager,
|
||||
canvas,
|
||||
waveformId,
|
||||
duration,
|
||||
barColor,
|
||||
progressColor,
|
||||
backgroundColor,
|
||||
});
|
||||
}, [audioManager, canvasRef, waveformId, duration]);
|
||||
|
||||
const onPointer = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = (e.clientX - rect.left) / rect.width;
|
||||
const time = Math.max(0, Math.min(1, ratio)) * duration;
|
||||
setPlayerCurrentTime(audioManager, time);
|
||||
};
|
||||
|
||||
const currentTime = usePlayerCurrentTime();
|
||||
const progress = currentTime.value / duration;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<div
|
||||
className="w-full rounded-md bg-background"
|
||||
style={{ height }}
|
||||
role="slider"
|
||||
aria-label="Waveform scrubber"
|
||||
aria-valuenow={Math.round((progress || 0) * 100)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full rounded-md cursor-pointer"
|
||||
onPointerDown={onPointer}
|
||||
onPointerMove={(e) => {
|
||||
if (e.buttons === 1) onPointer(e);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -532,7 +532,8 @@ const sidebarMenuButtonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
default:
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground cursor-pointer",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
html {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background-color: hsl(0 0% 99%);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(20 14.3% 4.1%);
|
||||
|
||||
@@ -15,6 +15,7 @@ export class AudioManager {
|
||||
if (this.audioObjectURL) {
|
||||
URL.revokeObjectURL(this.audioObjectURL);
|
||||
this.audioObjectURL = null;
|
||||
this.mediaElement.src = "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { useAudioManager } from "./AudioManager";
|
||||
import { AudioManager, useAudioManager } from "./AudioManager";
|
||||
|
||||
export function usePlayerCurrentTime() {
|
||||
const audioManager = useAudioManager();
|
||||
const [value, setValue] = useState<number>(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setValue(audioManager.mediaElement.currentTime);
|
||||
setValue(getPlayerCurrentTime(audioManager));
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
setValue(audioManager.mediaElement.currentTime);
|
||||
};
|
||||
|
||||
audioManager.mediaElement.addEventListener("timeupdate", onTimeUpdate);
|
||||
|
||||
return () => {
|
||||
audioManager.mediaElement.removeEventListener("timeupdate", onTimeUpdate);
|
||||
};
|
||||
return subscribeToPlayerCurrentTime(audioManager, setValue);
|
||||
}, [audioManager]);
|
||||
|
||||
function setCurrentTime(time: number) {
|
||||
@@ -31,3 +23,26 @@ export function usePlayerCurrentTime() {
|
||||
setValue: setCurrentTime,
|
||||
};
|
||||
}
|
||||
|
||||
export function setPlayerCurrentTime(audioManager: AudioManager, time: number) {
|
||||
audioManager.mediaElement.currentTime = time;
|
||||
}
|
||||
|
||||
export function getPlayerCurrentTime(audioManager: AudioManager): number {
|
||||
return audioManager.mediaElement.currentTime;
|
||||
}
|
||||
|
||||
export function subscribeToPlayerCurrentTime(
|
||||
audioManager: AudioManager,
|
||||
callback: (time: number) => void,
|
||||
) {
|
||||
const onTimeUpdate = () => {
|
||||
callback(audioManager.mediaElement.currentTime);
|
||||
};
|
||||
|
||||
audioManager.mediaElement.addEventListener("timeupdate", onTimeUpdate);
|
||||
|
||||
return () => {
|
||||
audioManager.mediaElement.removeEventListener("timeupdate", onTimeUpdate);
|
||||
};
|
||||
}
|
||||
|
||||
54
examples/music-player/src/lib/usePrepareAppState.ts
Normal file
54
examples/music-player/src/lib/usePrepareAppState.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { MusicaAccount, MusicaAccountRoot } from "@/1_schema";
|
||||
import { MediaPlayer } from "@/5_useMediaPlayer";
|
||||
import { co } from "jazz-tools";
|
||||
import { useAccount } from "jazz-tools/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { uploadMusicTracks } from "../4_actions";
|
||||
|
||||
export function usePrepareAppState(mediaPlayer: MediaPlayer) {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
const { agent } = useAccount();
|
||||
|
||||
useEffect(() => {
|
||||
loadInitialData(mediaPlayer).then(() => {
|
||||
setIsReady(true);
|
||||
});
|
||||
}, [agent]);
|
||||
|
||||
return isReady;
|
||||
}
|
||||
|
||||
async function loadInitialData(mediaPlayer: MediaPlayer) {
|
||||
const me = await MusicaAccount.getMe().ensureLoaded({
|
||||
resolve: {
|
||||
root: {
|
||||
rootPlaylist: { tracks: { $each: true } },
|
||||
activeTrack: true,
|
||||
activePlaylist: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
uploadOnboardingData(me.root);
|
||||
|
||||
// Load the active track in the AudioManager
|
||||
if (me.root.activeTrack) {
|
||||
mediaPlayer.loadTrack(me.root.activeTrack);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadOnboardingData(root: co.loaded<typeof MusicaAccountRoot>) {
|
||||
if (root.exampleDataLoaded) return;
|
||||
|
||||
root.exampleDataLoaded = true;
|
||||
|
||||
try {
|
||||
const trackFile = await (await fetch("/example.mp3")).blob();
|
||||
|
||||
await uploadMusicTracks([new File([trackFile], "Example song")], true);
|
||||
} catch (error) {
|
||||
root.exampleDataLoaded = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { MusicaAccount } from "@/1_schema";
|
||||
import { useAccount } from "jazz-tools/react";
|
||||
import { useEffect } from "react";
|
||||
import { uploadMusicTracks } from "../4_actions";
|
||||
|
||||
export function useUploadExampleData() {
|
||||
const { agent } = useAccount();
|
||||
|
||||
useEffect(() => {
|
||||
uploadOnboardingData();
|
||||
}, [agent]);
|
||||
}
|
||||
|
||||
async function uploadOnboardingData() {
|
||||
const me = await MusicaAccount.getMe().ensureLoaded({
|
||||
resolve: { root: true },
|
||||
});
|
||||
|
||||
if (me.root.exampleDataLoaded) return;
|
||||
|
||||
me.root.exampleDataLoaded = true;
|
||||
|
||||
try {
|
||||
const trackFile = await (await fetch("/example.mp3")).blob();
|
||||
|
||||
await uploadMusicTracks([new File([trackFile], "Example song")], true);
|
||||
} catch (error) {
|
||||
me.root.exampleDataLoaded = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -55,10 +55,20 @@ export class HomePage {
|
||||
|
||||
async editTrackTitle(trackTitle: string, newTitle: string) {
|
||||
await this.page
|
||||
.getByRole("textbox", {
|
||||
name: `Edit track title: ${trackTitle}`,
|
||||
.getByRole("button", {
|
||||
name: `Open ${trackTitle} menu`,
|
||||
})
|
||||
.fill(newTitle);
|
||||
.click();
|
||||
|
||||
await this.page
|
||||
.getByRole("menuitem", {
|
||||
name: `Edit`,
|
||||
})
|
||||
.click();
|
||||
|
||||
await this.page.getByPlaceholder("Enter track name...").fill(newTitle);
|
||||
|
||||
await this.page.getByRole("button", { name: "Save" }).click();
|
||||
}
|
||||
|
||||
async createPlaylist() {
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import path from "path";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import { defineConfig } from "vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
strategies: "generateSW",
|
||||
workbox: {
|
||||
globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
|
||||
maximumFileSizeToCacheInBytes: 1024 * 1024 * 5,
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.274.0",
|
||||
"lucide-react": "^0.536.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-router": "^6.16.0",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
JazzAccount,
|
||||
Organization,
|
||||
Project,
|
||||
validateDraftOrganization,
|
||||
} from "../schema.ts";
|
||||
import { Errors } from "./Errors.tsx";
|
||||
import { OrganizationForm } from "./OrganizationForm.tsx";
|
||||
@@ -21,8 +22,7 @@ export function CreateOrganization() {
|
||||
if (!me?.root?.organizations) return;
|
||||
|
||||
const onSave = (draft: Loaded<typeof DraftOrganization>) => {
|
||||
// validate if the draft is a valid organization
|
||||
const validation = DraftOrganization.validate(draft);
|
||||
const validation = validateDraftOrganization(draft);
|
||||
setErrors(validation.errors);
|
||||
if (validation.errors.length > 0) {
|
||||
return;
|
||||
|
||||
@@ -6,7 +6,9 @@ import { Organization } from "../schema.ts";
|
||||
|
||||
export function InviteLink({
|
||||
organization,
|
||||
}: { organization: Loaded<typeof Organization> }) {
|
||||
}: {
|
||||
organization: Loaded<typeof Organization>;
|
||||
}) {
|
||||
let [copyCount, setCopyCount] = useState(0);
|
||||
let copied = copyCount > 0;
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import { Organization } from "../schema.ts";
|
||||
|
||||
export function OrganizationMembers({
|
||||
organization,
|
||||
}: { organization: Loaded<typeof Organization> }) {
|
||||
}: {
|
||||
organization: Loaded<typeof Organization>;
|
||||
}) {
|
||||
const group = organization._owner.castAs(Group);
|
||||
|
||||
return (
|
||||
@@ -25,7 +27,11 @@ function MemberItem({
|
||||
account,
|
||||
role,
|
||||
group,
|
||||
}: { account: Account; role: string; group: Group }) {
|
||||
}: {
|
||||
account: Account;
|
||||
role: string;
|
||||
group: Group;
|
||||
}) {
|
||||
const { me } = useAccount();
|
||||
|
||||
const canRemoveMember = group.myRole() === "admin" && account.id !== me?.id;
|
||||
|
||||
@@ -10,24 +10,24 @@ export const Organization = co.map({
|
||||
projects: co.list(Project),
|
||||
});
|
||||
|
||||
export const DraftOrganization = co
|
||||
.map({
|
||||
name: z.optional(z.string()),
|
||||
projects: co.list(Project),
|
||||
})
|
||||
.withHelpers((Self) => ({
|
||||
validate(org: Loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
export const DraftOrganization = co.map({
|
||||
name: z.optional(z.string()),
|
||||
projects: co.list(Project),
|
||||
});
|
||||
|
||||
if (!org.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
export function validateDraftOrganization(
|
||||
org: Loaded<typeof DraftOrganization>,
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
|
||||
return {
|
||||
errors,
|
||||
};
|
||||
},
|
||||
}));
|
||||
if (!org.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
export const JazzAccountRoot = co.map({
|
||||
organizations: co.list(Organization),
|
||||
|
||||
@@ -79,8 +79,9 @@ main {
|
||||
background-color: white;
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px
|
||||
rgba(0, 0, 0, 0.06);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
width: 28rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,9 @@ export function ReactionsScreen(props: { id: string }) {
|
||||
|
||||
const ReactionButtons = ({
|
||||
reactions,
|
||||
}: { reactions: Loaded<typeof Reactions> }) => (
|
||||
}: {
|
||||
reactions: Loaded<typeof Reactions>;
|
||||
}) => (
|
||||
<div className="reaction-buttons">
|
||||
{ReactionTypes.map((reactionType) => (
|
||||
<button
|
||||
@@ -56,7 +58,9 @@ const ReactionButtons = ({
|
||||
|
||||
const ReactionOverview = ({
|
||||
reactions,
|
||||
}: { reactions: Loaded<typeof Reactions> }) => (
|
||||
}: {
|
||||
reactions: Loaded<typeof Reactions>;
|
||||
}) => (
|
||||
<>
|
||||
{Object.values(reactions.perAccount).map((reaction) => (
|
||||
<div key={reaction.by?.id} className="reaction-row">
|
||||
|
||||
@@ -17,7 +17,7 @@ createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<JazzReactProvider
|
||||
sync={{
|
||||
peer: `wss://cloud.jazz.tools/?key=${apiKey}`,
|
||||
peer: `ws://localhost:4200/?key=${apiKey}`,
|
||||
}}
|
||||
AccountSchema={JazzAccount}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { jazzServerAccount } from "@/jazzServerAccount";
|
||||
import { Game, Player, PlayerState, createGameState } from "@/schema";
|
||||
import { Game, createGameState } from "@/schema";
|
||||
import { serverApi } from "@/serverApi";
|
||||
import { Account, Group, JazzRequestError } from "jazz-tools";
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export type Game = co.loaded<typeof Game>;
|
||||
|
||||
export const WaitingRoom = co.map({
|
||||
creator: co.account(),
|
||||
game: z.optional(Game),
|
||||
game: co.optional(Game),
|
||||
});
|
||||
export type WaitingRoom = co.loaded<typeof WaitingRoom>;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.274.0",
|
||||
"lucide-react": "^0.536.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
|
||||
import { JazzReactProvider } from "jazz-tools/react";
|
||||
|
||||
export default function CovaluesLayout({
|
||||
children,
|
||||
}: {
|
||||
children: any;
|
||||
}) {
|
||||
export default function CovaluesLayout({ children }: { children: any }) {
|
||||
return (
|
||||
<JazzReactProvider sync={{ when: "never" }}>{children}</JazzReactProvider>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,10 @@ import { clsx } from "clsx";
|
||||
export function Card({
|
||||
children,
|
||||
className,
|
||||
}: { children: React.ReactNode; className?: string }) {
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx(className, "border rounded-xl shadow-sm")}>
|
||||
{children}
|
||||
|
||||
@@ -4,7 +4,11 @@ export function Label({
|
||||
label,
|
||||
htmlFor,
|
||||
className,
|
||||
}: { label: string; htmlFor: string; className?: string }) {
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<LabelRadix.Root className={className} htmlFor={htmlFor}>
|
||||
{label}
|
||||
|
||||
@@ -4,7 +4,11 @@ export function IconCoFeed({
|
||||
className,
|
||||
size,
|
||||
strokeWidth,
|
||||
}: { className?: string; size?: number; strokeWidth: number }) {
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
strokeWidth: number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -4,7 +4,12 @@ export function IconCoRecord({
|
||||
className,
|
||||
size,
|
||||
strokeWidth,
|
||||
}: { className?: string; size?: number; color?: string; strokeWidth: number }) {
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
strokeWidth: number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -4,7 +4,11 @@ export function JazzLogo({
|
||||
className,
|
||||
width = undefined,
|
||||
height = undefined,
|
||||
}: { className?: string; width?: number; height?: number }) {
|
||||
}: {
|
||||
className?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 386 146"
|
||||
|
||||
@@ -93,7 +93,11 @@ const TableDataContainer = ({
|
||||
children,
|
||||
className,
|
||||
isCopyable,
|
||||
}: { children: React.ReactNode; className?: string; isCopyable?: boolean }) => {
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
isCopyable?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("flex gap-2", className, isCopyable && "cursor-pointer")}
|
||||
|
||||
@@ -41,7 +41,9 @@ export function Footer({
|
||||
</Link>
|
||||
</div>
|
||||
<p className="col-span-full sm:col-span-6 md:col-span-4 text-sm sm:text-base">
|
||||
Playful software for serious problems.
|
||||
Computers are magic.
|
||||
<br />
|
||||
Time to make them less complex.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-y-8 grid-cols-12">
|
||||
|
||||
@@ -247,7 +247,13 @@ data-lsp {
|
||||
}
|
||||
.tag-container .twoslash-annotation {
|
||||
position: absolute;
|
||||
font-family: "JetBrains Mono", Menlo, Monaco, Consolas, Courier New, monospace;
|
||||
font-family:
|
||||
"JetBrains Mono",
|
||||
Menlo,
|
||||
Monaco,
|
||||
Consolas,
|
||||
Courier New,
|
||||
monospace;
|
||||
right: -10px;
|
||||
/** Default annotation text to 200px */
|
||||
width: 200px;
|
||||
|
||||
@@ -6,7 +6,9 @@ import Router from "next/router";
|
||||
|
||||
export default async function TeamMemberPage({
|
||||
params,
|
||||
}: { params: Promise<{ member: string }> }) {
|
||||
}: {
|
||||
params: Promise<{ member: string }>;
|
||||
}) {
|
||||
const { member } = await params;
|
||||
|
||||
const memberInfo = team.find((m) => m.slug === member);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ChatDemoSection } from "@/components/home/ChatDemoSection";
|
||||
import { CollaborationFeaturesSection } from "@/components/home/CollaborationFeaturesSection";
|
||||
import { ComingSoonSection } from "@/components/home/ComingSoonSection";
|
||||
import { EarlyAdopterSection } from "@/components/home/EarlyAdopterSection";
|
||||
import { EncryptionSection } from "@/components/home/EncryptionSection";
|
||||
import { FeaturesSection } from "@/components/home/FeaturesSection";
|
||||
@@ -15,9 +14,9 @@ import { Testimonial } from "@garden-co/design-system/src/components/molecules/T
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<HeroSection />
|
||||
|
||||
<HeroSection />
|
||||
<div className="container flex flex-col gap-12 lg:gap-20">
|
||||
|
||||
<GetStartedSnippetSelect />
|
||||
<SupportedEnvironmentsSection />
|
||||
<HowJazzWorksSection />
|
||||
@@ -54,8 +53,6 @@ export default function Home() {
|
||||
|
||||
<FeaturesSection />
|
||||
|
||||
<ComingSoonSection />
|
||||
|
||||
<EarlyAdopterSection />
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import CoPlainTextDescription from "@/app/(others)/(home)/coValueDescriptions/coPlainTextDescription.mdx";
|
||||
import CursorsAndCaretsDescription from "@/app/(others)/(home)/toolkit/cursorsAndCarets.mdx";
|
||||
import TwoWaySyncDescription from "@/app/(others)/(home)/toolkit/twoWaySync.mdx";
|
||||
import VideoPresenceCallsDescription from "@/app/(others)/(home)/toolkit/videoPresenceCalls.mdx";
|
||||
import { CodeRef } from "@garden-co/design-system/src/components/atoms/CodeRef";
|
||||
import { P } from "@garden-co/design-system/src/components/atoms/Paragraph";
|
||||
import { FeatureCard } from "@garden-co/design-system/src/components/molecules/FeatureCard";
|
||||
import { GappedGrid } from "@garden-co/design-system/src/components/molecules/GappedGrid";
|
||||
import { Prose } from "@garden-co/design-system/src/components/molecules/Prose";
|
||||
import { SectionHeader } from "@garden-co/design-system/src/components/molecules/SectionHeader";
|
||||
|
||||
export function ComingSoonSection() {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="More features coming soon" />
|
||||
|
||||
<GappedGrid cols={4}>
|
||||
<FeatureCard className="p-4" label={<h3>Cursors & carets</h3>}>
|
||||
<P>Ready-made spatial presence.</P>
|
||||
<Prose size="sm">
|
||||
<CursorsAndCaretsDescription />
|
||||
</Prose>
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard className="p-4" label={<h3>Two-way sync to your DB</h3>}>
|
||||
<P>Add Jazz to an existing app.</P>
|
||||
<Prose size="sm">
|
||||
<TwoWaySyncDescription />
|
||||
</Prose>
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard className="p-4" label={<h3>Video presence & calls</h3>}>
|
||||
<P>Stream and record audio & video.</P>
|
||||
<Prose size="sm">
|
||||
<VideoPresenceCallsDescription />
|
||||
</Prose>
|
||||
</FeatureCard>
|
||||
|
||||
<FeatureCard
|
||||
className="p-4"
|
||||
label={
|
||||
<h3>
|
||||
<CodeRef>CoPlainText</CodeRef> & <CodeRef>CoRichText</CodeRef>
|
||||
</h3>
|
||||
}
|
||||
>
|
||||
<Prose size="sm">
|
||||
<CoPlainTextDescription />
|
||||
</Prose>
|
||||
</FeatureCard>
|
||||
</GappedGrid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,81 +16,70 @@ const features: Array<{
|
||||
title: string;
|
||||
icon: IconName;
|
||||
}> = [
|
||||
{
|
||||
title: "Instant updates",
|
||||
icon: "instant",
|
||||
},
|
||||
{
|
||||
title: "Real-time sync",
|
||||
icon: "devices",
|
||||
},
|
||||
{
|
||||
title: "Multiplayer",
|
||||
icon: "spatialPresence",
|
||||
},
|
||||
{
|
||||
title: "File uploads",
|
||||
icon: "upload",
|
||||
},
|
||||
{
|
||||
title: "Social features",
|
||||
icon: "social",
|
||||
},
|
||||
{
|
||||
title: "Permissions",
|
||||
icon: "permissions",
|
||||
},
|
||||
{
|
||||
title: "E2E encryption",
|
||||
icon: "encryption",
|
||||
},
|
||||
{
|
||||
title: "Authentication",
|
||||
icon: "auth",
|
||||
},
|
||||
];
|
||||
{
|
||||
title: "Instant updates",
|
||||
icon: "instant",
|
||||
},
|
||||
{
|
||||
title: "Real-time sync",
|
||||
icon: "devices",
|
||||
},
|
||||
{
|
||||
title: "Multiplayer",
|
||||
icon: "spatialPresence",
|
||||
},
|
||||
{
|
||||
title: "File uploads",
|
||||
icon: "upload",
|
||||
},
|
||||
{
|
||||
title: "Social features",
|
||||
icon: "social",
|
||||
},
|
||||
{
|
||||
title: "Permissions",
|
||||
icon: "permissions",
|
||||
},
|
||||
{
|
||||
title: "E2E encryption",
|
||||
icon: "encryption",
|
||||
},
|
||||
{
|
||||
title: "Authentication",
|
||||
icon: "auth",
|
||||
},
|
||||
];
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<div className="container grid items-center gap-x-8 gap-y-12 my-12 md:my-16 lg:my-24 lg:gap-x-10 lg:grid-cols-12">
|
||||
<div className="container grid items-center gap-x-8 gap-y-12 mt-12 md:mt-16 lg:mt-24 mb-12 lg:gap-x-10 lg:grid-cols-12">
|
||||
<div className="flex flex-col justify-center gap-5 lg:col-span-11 lg:gap-8">
|
||||
<Kicker>Toolkit for backendless apps</Kicker>
|
||||
<Kicker>Reactive, distributed, secure</Kicker>
|
||||
<H1>
|
||||
<span className="inline-block text-highlight">
|
||||
{marketingCopy.headline}
|
||||
</span>
|
||||
</H1>
|
||||
|
||||
<Prose size="lg" className="text-pretty max-w-2xl dark:text-stone-200">
|
||||
<Prose size="lg" className="text-pretty max-w-2xl dark:text-stone-200 prose-p:leading-normal">
|
||||
<p>
|
||||
Jazz gives you data without needing a database — plus auth,
|
||||
permissions, files and multiplayer without needing a backend.
|
||||
Jazz is a new kind of database that's distributed across your frontend, containers, serverless functions and its own storage cloud.
|
||||
</p>
|
||||
<p>It syncs structured data, files and LLM streams instantly.<br/>It looks like local reactive JSON state.</p>
|
||||
<p>And you get auth, orgs & teams, real-time multiplayer, edit histories, permissions, E2E encryption and offline-support out of the box.</p>
|
||||
<p>
|
||||
Do everything right from the frontend and ship better apps, faster.
|
||||
This lets you get rid of 90% of the traditional backend, and most of your frontend state juggling.
|
||||
You'll ship better apps, faster.
|
||||
</p>
|
||||
<p>
|
||||
Open source. Self-host or use{" "}
|
||||
<p className="text-base">
|
||||
Self-host or use{" "}
|
||||
<Link className="text-reset" href="/cloud">
|
||||
Jazz Cloud
|
||||
</Link>{" "}
|
||||
for zero-config magic.
|
||||
for a zero-deploy globally-scaled DB.
|
||||
<br/>Open source (MIT)
|
||||
</p>
|
||||
</Prose>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 max-w-3xl sm:grid-cols-4 sm:gap-4">
|
||||
{features.map(({ title, icon }) => (
|
||||
<div
|
||||
key={title}
|
||||
className="flex text-xs sm:text-sm gap-2 items-center"
|
||||
>
|
||||
<span className="p-1.5 rounded-lg bg-primary-transparent">
|
||||
<Icon size="xs" name={icon} intent="primary" />
|
||||
</span>
|
||||
<p>{title}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -83,7 +83,7 @@ export function HowJazzWorksSection() {
|
||||
<div className="grid gap-3">
|
||||
<Kicker>How it works</Kicker>
|
||||
|
||||
<H2>Build entire apps using only client-side code</H2>
|
||||
<H2>Build entire apps with collaborative state</H2>
|
||||
</div>
|
||||
<GappedGrid>
|
||||
<Step
|
||||
|
||||
@@ -53,7 +53,7 @@ export function LocalFirstFeaturesSection() {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="The best of all worlds"
|
||||
title="Local-first state with global sync"
|
||||
slogan={
|
||||
<>
|
||||
<p>
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function ProblemStatementSection() {
|
||||
<div className="grid gap-4 lg:gap-8">
|
||||
<SectionHeader
|
||||
className="sm:text-center sm:mx-auto"
|
||||
title={"Powered by the first “flat stack”"}
|
||||
title={"A database that does what's actually needed"}
|
||||
slogan="A perspective shift worth 10,000 hours"
|
||||
/>
|
||||
|
||||
@@ -41,8 +41,7 @@ export default function ProblemStatementSection() {
|
||||
<Prose>
|
||||
<p>
|
||||
For each new app you tackle a{" "}
|
||||
<strong>mess of moving parts and infra worries.</strong> Or, you
|
||||
haven't even tried because "you're not full-stack".
|
||||
<strong>mess of moving parts and infra worries.</strong> Your backend is responsible for shuffling data around in a myriad of ways.
|
||||
</p>
|
||||
<p>
|
||||
Want to build a <strong>modern app</strong> with multiplayer or
|
||||
@@ -68,7 +67,7 @@ export default function ProblemStatementSection() {
|
||||
<strong>With users & permissions built-in.</strong>
|
||||
</p>
|
||||
<p>
|
||||
With completely <strong>app-independent infra,</strong> you get to
|
||||
With a <strong>DB and infra made for modern apps</strong> you get to
|
||||
focus on <strong>building the app your users want.</strong> You'll
|
||||
notice that <strong>90% of the work is now the UI.</strong>
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BunLogo } from "@/components/icons/BunLogo";
|
||||
import { CloudflareWorkerLogo } from "@/components/icons/CloudflareWorkerLogo";
|
||||
import { VercelLogo } from "@/components/icons/VercelLogo";
|
||||
import { ExpoLogo } from "@/components/icons/ExpoLogo";
|
||||
import { JavascriptLogo } from "@/components/icons/JavascriptLogo";
|
||||
import { NodejsLogo } from "@/components/icons/NodejsLogo";
|
||||
@@ -44,14 +45,18 @@ const serverWorkers = [
|
||||
icon: NodejsLogo,
|
||||
href: "/docs/react/server-workers",
|
||||
},
|
||||
{
|
||||
name: "Cloudflare Workers",
|
||||
icon: CloudflareWorkerLogo,
|
||||
},
|
||||
{
|
||||
name: "Bun",
|
||||
icon: BunLogo,
|
||||
},
|
||||
{
|
||||
name: "Vercel",
|
||||
icon: VercelLogo,
|
||||
},
|
||||
{
|
||||
name: "CF Workers",
|
||||
icon: CloudflareWorkerLogo,
|
||||
}
|
||||
];
|
||||
|
||||
export function SupportedEnvironmentsSection() {
|
||||
|
||||
16
homepage/homepage/components/icons/VercelLogo.tsx
Normal file
16
homepage/homepage/components/icons/VercelLogo.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
export function VercelLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="1.5em"
|
||||
height="1.5em"
|
||||
viewBox="0 0 76 65"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ function AuthStateIndicator() {
|
||||
const isGuest = agent._type !== "Account"
|
||||
|
||||
// Anonymous authentication: has an account but not fully authenticated
|
||||
const isAnonymous = agent._type === "Account" && !isAuthenticated;
|
||||
const isAnonymous = agent._type === "Account" && !isAuthenticated;
|
||||
return (
|
||||
<div>
|
||||
{isGuest && <span>Guest Mode</span>}
|
||||
|
||||
@@ -54,10 +54,10 @@ import { co, z, CoMap } from "jazz-tools";
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
});
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -73,17 +73,17 @@ import { co, z } from "jazz-tools";
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
});
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
// ---cut---
|
||||
// OrderForm.tsx
|
||||
export function OrderForm({
|
||||
order,
|
||||
onSave,
|
||||
}: {
|
||||
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
order: BubbleTeaOrder | DraftBubbleTeaOrder;
|
||||
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -118,16 +118,16 @@ import * as React from "react";
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
});
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
export function OrderForm({
|
||||
order,
|
||||
onSave,
|
||||
}: {
|
||||
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
order: BubbleTeaOrder | DraftBubbleTeaOrder;
|
||||
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -177,10 +177,10 @@ import { useState, useEffect } from "react";
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
});
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
export const AccountRoot = co.map({
|
||||
draft: DraftBubbleTeaOrder,
|
||||
@@ -218,7 +218,7 @@ export function OrderForm({
|
||||
// CreateOrder.tsx
|
||||
export function CreateOrder() {
|
||||
const { me } = useAccount();
|
||||
const [draft, setDraft] = useState<co.loaded<typeof DraftBubbleTeaOrder>>();
|
||||
const [draft, setDraft] = useState<DraftBubbleTeaOrder>();
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(DraftBubbleTeaOrder.create({}));
|
||||
@@ -228,7 +228,7 @@ export function CreateOrder() {
|
||||
e.preventDefault();
|
||||
if (!draft || !draft.name) return;
|
||||
|
||||
const order = draft as co.loaded<typeof BubbleTeaOrder>; // TODO: this should narrow correctly
|
||||
const order = draft as BubbleTeaOrder; // TODO: this should narrow correctly
|
||||
|
||||
console.log("Order created:", order);
|
||||
};
|
||||
@@ -244,26 +244,30 @@ export function CreateOrder() {
|
||||
|
||||
In a `BubbleTeaOrder`, the `name` field is required, so it would be a good idea to validate this before turning the draft into a real order.
|
||||
|
||||
Update the schema to include a `validate` helper.
|
||||
Update the schema to include a `validateDraftOrder` helper.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z } from "jazz-tools";
|
||||
// ---cut---
|
||||
// schema.ts
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
}).withHelpers((Self) => ({ // [!code ++:11]
|
||||
validate(draft: co.loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
return { errors };
|
||||
},
|
||||
}));
|
||||
export function validateDraftOrder(draft: DraftBubbleTeaOrder) { // [!code ++:9]
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
|
||||
return { errors };
|
||||
};
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -279,20 +283,20 @@ import { useState, useEffect } from "react";
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
}).withHelpers((Self) => ({
|
||||
validate(draft: co.loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
|
||||
const errors: string[] = [];
|
||||
|
||||
return { errors };
|
||||
},
|
||||
}));
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
|
||||
return { errors };
|
||||
};
|
||||
|
||||
export const AccountRoot = co.map({
|
||||
draft: DraftBubbleTeaOrder,
|
||||
@@ -307,7 +311,7 @@ export function OrderForm({
|
||||
order,
|
||||
onSave,
|
||||
}: {
|
||||
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
order: BubbleTeaOrder | DraftBubbleTeaOrder;
|
||||
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -330,7 +334,7 @@ export function OrderForm({
|
||||
// CreateOrder.tsx
|
||||
export function CreateOrder() {
|
||||
const { me } = useAccount();
|
||||
const [draft, setDraft] = useState<co.loaded<typeof DraftBubbleTeaOrder>>();
|
||||
const [draft, setDraft] = useState<DraftBubbleTeaOrder>();
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(DraftBubbleTeaOrder.create({}));
|
||||
@@ -340,13 +344,13 @@ export function CreateOrder() {
|
||||
e.preventDefault();
|
||||
if (!draft) return;
|
||||
|
||||
const validation = DraftBubbleTeaOrder.validate(draft); // [!code ++:5]
|
||||
const validation = validateDraftOrder(draft); // [!code ++:5]
|
||||
if (validation.errors.length > 0) {
|
||||
console.log(validation.errors);
|
||||
return;
|
||||
}
|
||||
|
||||
const order = draft as co.loaded<typeof BubbleTeaOrder>;
|
||||
const order = draft as BubbleTeaOrder;
|
||||
|
||||
console.log("Order created:", order);
|
||||
};
|
||||
@@ -372,10 +376,10 @@ import { co, z } from "jazz-tools";
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
});
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
export const AccountRoot = co.map({ // [!code ++:15]
|
||||
draft: DraftBubbleTeaOrder,
|
||||
@@ -403,10 +407,10 @@ import { co, z } from "jazz-tools";
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
});
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
export const AccountRoot = co.map({
|
||||
draft: DraftBubbleTeaOrder,
|
||||
@@ -452,20 +456,20 @@ import { co, z } from "jazz-tools";
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
}).withHelpers((Self) => ({
|
||||
validate(draft: co.loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
|
||||
const errors: string[] = [];
|
||||
|
||||
return { errors };
|
||||
},
|
||||
}));
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
|
||||
return { errors };
|
||||
};
|
||||
|
||||
export const AccountRoot = co.map({
|
||||
draft: DraftBubbleTeaOrder,
|
||||
@@ -485,14 +489,14 @@ export const JazzAccount = co.account({
|
||||
// @filename: CreateOrder.tsx
|
||||
import * as React from "react";
|
||||
import { useCoState, useAccount } from "jazz-tools/react";
|
||||
import { BubbleTeaOrder, DraftBubbleTeaOrder, JazzAccount } from "schema";
|
||||
import { BubbleTeaOrder, DraftBubbleTeaOrder, JazzAccount, validateDraftOrder } from "schema";
|
||||
import { co } from "jazz-tools";
|
||||
|
||||
export function OrderForm({
|
||||
order,
|
||||
onSave,
|
||||
}: {
|
||||
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
order: BubbleTeaOrder | DraftBubbleTeaOrder;
|
||||
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -527,13 +531,13 @@ export function CreateOrder() {
|
||||
const draft = me.root.draft; // [!code ++:2]
|
||||
if (!draft) return;
|
||||
|
||||
const validation = DraftBubbleTeaOrder.validate(draft);
|
||||
const validation = validateDraftOrder(draft);
|
||||
if (validation.errors.length > 0) {
|
||||
console.log(validation.errors);
|
||||
return;
|
||||
}
|
||||
|
||||
const order = draft as co.loaded<typeof BubbleTeaOrder>;
|
||||
const order = draft as BubbleTeaOrder;
|
||||
console.log("Order created:", order);
|
||||
|
||||
// create a new empty draft
|
||||
@@ -577,23 +581,27 @@ Simply add a `hasChanges` helper to your schema.
|
||||
import { co, z } from "jazz-tools";
|
||||
// ---cut---
|
||||
// schema.ts
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
}).withHelpers((Self) => ({
|
||||
validate(draft: co.loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
if (!draft.name) {
|
||||
errors.push("Plese enter a name.");
|
||||
}
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
return { errors };
|
||||
},
|
||||
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
|
||||
const errors: string[] = [];
|
||||
|
||||
hasChanges(draft?: co.loaded<typeof Self>) { // [!code ++:3]
|
||||
return draft ? Object.keys(draft._edits).length : false;
|
||||
},
|
||||
}));
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
|
||||
return { errors };
|
||||
};
|
||||
|
||||
export function hasChanges(draft?: DraftBubbleTeaOrder) { // [!code ++:3]
|
||||
return draft ? Object.keys(draft._edits).length : false;
|
||||
};
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -608,24 +616,24 @@ import * as React from "react";
|
||||
export const BubbleTeaOrder = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
|
||||
|
||||
export const DraftBubbleTeaOrder = co.map({
|
||||
name: z.optional(z.string()),
|
||||
}).withHelpers((Self) => ({
|
||||
validate(draft: co.loaded<typeof Self>) {
|
||||
const errors: string[] = [];
|
||||
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
|
||||
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
|
||||
|
||||
if (!draft.name) {
|
||||
errors.push("Plese enter a name.");
|
||||
}
|
||||
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
|
||||
const errors: string[] = [];
|
||||
|
||||
return { errors };
|
||||
},
|
||||
if (!draft.name) {
|
||||
errors.push("Please enter a name.");
|
||||
}
|
||||
|
||||
hasChanges(draft?: co.loaded<typeof Self>) { // [!code ++:3]
|
||||
return draft ? Object.keys(draft._edits).length : false;
|
||||
},
|
||||
}));
|
||||
return { errors };
|
||||
};
|
||||
|
||||
export function hasChanges(draft?: DraftBubbleTeaOrder) {
|
||||
return draft ? Object.keys(draft._edits).length : false;
|
||||
};
|
||||
|
||||
export const AccountRoot = co.map({
|
||||
draft: DraftBubbleTeaOrder,
|
||||
@@ -649,7 +657,7 @@ export function DraftIndicator() {
|
||||
resolve: { root: { draft: true } },
|
||||
});
|
||||
|
||||
if (DraftBubbleTeaOrder.hasChanges(me?.root.draft)) {
|
||||
if (hasChanges(me?.root.draft)) {
|
||||
return (
|
||||
<p>You have a draft</p>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ export const Organization = co.map({
|
||||
name: z.string(),
|
||||
|
||||
// shared data between users of each organization
|
||||
projects: co.list(Project),
|
||||
projects: co.list(Project),
|
||||
});
|
||||
|
||||
export const ListOfOrganizations = co.list(Organization);
|
||||
@@ -115,7 +115,7 @@ import * as React from "react";
|
||||
import { useAcceptInvite, useAccount } from "jazz-tools/react";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Project = z.object({
|
||||
const Project = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
|
||||
@@ -109,6 +109,16 @@ export const docNavigationItems = [
|
||||
// collapse: true,
|
||||
prefix: "/docs/upgrade",
|
||||
items: [
|
||||
{
|
||||
name: "0.17.0 - New image APIs",
|
||||
href: "/docs/upgrade/0-17-0",
|
||||
done: 100,
|
||||
},
|
||||
{
|
||||
name: "0.16.0 - Cleaner separation between Zod and CoValue schemas",
|
||||
href: "/docs/upgrade/0-16-0",
|
||||
done: 100,
|
||||
},
|
||||
{
|
||||
name: "0.15.0 - Everything inside `jazz-tools`",
|
||||
href: "/docs/upgrade/0-15-0",
|
||||
@@ -225,6 +235,7 @@ export const docNavigationItems = [
|
||||
"react-native": 100,
|
||||
"react-native-expo": 100,
|
||||
vanilla: 100,
|
||||
svelte: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -205,6 +205,101 @@ console.log(containingGroup.getParentGroups()); // [addedGroup]
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Group hierarchy on CoValue creation
|
||||
|
||||
When creating CoValues that contain other CoValues using plain JSON objects, Jazz not only creates
|
||||
the necessary CoValues automatically but it will also manage their group ownership.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
// ---cut---
|
||||
const Task = co.plainText();
|
||||
const Column = co.list(Task);
|
||||
const Board = co.map({
|
||||
title: z.string(),
|
||||
columns: co.list(Column),
|
||||
});
|
||||
|
||||
const board = Board.create({
|
||||
title: "My board",
|
||||
columns: [
|
||||
["Task 1.1", "Task 1.2"],
|
||||
["Task 2.1", "Task 2.2"],
|
||||
],
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
For each created column and task CoValue, Jazz also creates a new group as its owner and
|
||||
adds the referencing CoValue's owner as a member of that group. This means permissions for nested CoValues
|
||||
are inherited from the CoValue that references them, but can also be modified independently for each CoValue
|
||||
if needed.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z, Group, Account } from "jazz-tools";
|
||||
|
||||
const alice = {} as unknown as Account;
|
||||
const bob = {} as unknown as Account;
|
||||
const Task = co.plainText();
|
||||
const Column = co.list(Task);
|
||||
const Board = co.map({
|
||||
title: z.string(),
|
||||
columns: co.list(Column),
|
||||
});
|
||||
// ---cut---
|
||||
const writeAccess = Group.create();
|
||||
writeAccess.addMember(bob, "writer");
|
||||
|
||||
// Give Bob write access to the board, columns and tasks
|
||||
const board = Board.create({
|
||||
title: "My board",
|
||||
columns: [
|
||||
["Task 1.1", "Task 1.2"],
|
||||
["Task 2.1", "Task 2.2"],
|
||||
],
|
||||
}, writeAccess);
|
||||
|
||||
// Give Alice read access to one specific task
|
||||
const task = board.columns[0][0];
|
||||
const taskGroup = task._owner.castAs(Group);
|
||||
taskGroup.addMember(alice, "reader");
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
If you prefer to manage permissions differently, you can always create CoValues explicitly:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, Group, z, Account } from "jazz-tools";
|
||||
|
||||
const bob = {} as unknown as Account;
|
||||
const Task = co.plainText();
|
||||
const Column = co.list(Task);
|
||||
const Board = co.map({
|
||||
title: z.string(),
|
||||
columns: co.list(Column),
|
||||
});
|
||||
|
||||
// ---cut---
|
||||
const writeAccess = Group.create();
|
||||
writeAccess.addMember(bob, "writer");
|
||||
const readAccess = Group.create();
|
||||
readAccess.addMember(bob, "reader");
|
||||
|
||||
// Give Bob read access to the board and write access to the columns and tasks
|
||||
const board = Board.create({
|
||||
title: "My board",
|
||||
columns: co.list(Column).create([
|
||||
["Task 1.1", "Task 1.2"],
|
||||
["Task 2.1", "Task 2.2"],
|
||||
], writeAccess),
|
||||
}, readAccess);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Example: Team Hierarchy
|
||||
|
||||
Here's a practical example of using group inheritance for team permissions:
|
||||
|
||||
@@ -7,9 +7,11 @@ export const metadata = {
|
||||
|
||||
# Learn some <span className="sr-only">Jazz</span> <JazzLogo className="h-[41px] -ml-0.5 -mt-[3px] inline" />
|
||||
|
||||
**Jazz is a toolkit for building backendless apps**. You get data without needing a database — plus auth, permissions, files and multiplayer without needing a backend. Jazz lets you do everything right from the frontend and you'll ship better apps, faster.
|
||||
**Jazz is a new kind of database** that's **distributed** across your frontend, containers, serverless functions and its own storage cloud.
|
||||
|
||||
Instead of wrestling with databases, APIs, and server infrastructure, you work with **CoValues** ("collaborative values") — your new cloud-synced building blocks that feel like local state but automatically sync across all devices and users in real-time.
|
||||
It syncs structured data, files and LLM streams instantly, and looks like local reactive JSON state.
|
||||
|
||||
It also provides auth, orgs & teams, real-time multiplayer, edit histories, permissions, E2E encryption and offline-support out of the box.
|
||||
|
||||
---
|
||||
|
||||
@@ -19,7 +21,7 @@ You can use [`create-jazz-app`](/docs/tools/create-jazz-app) to create a new Jaz
|
||||
|
||||
<CodeGroup>
|
||||
```sh
|
||||
npx create-jazz-app@latest --api-key you@example.com
|
||||
npx create-jazz-app@latest --api-key you@example.com
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -30,21 +32,10 @@ Or you can follow this [React step-by-step guide](/docs/react/guide) where we wa
|
||||
|
||||
</ContentByFramework> */}
|
||||
|
||||
## Why Jazz is different
|
||||
|
||||
Most apps rebuild the same thing: shared state that syncs between users and devices. Jazz starts from that shared state, giving you:
|
||||
|
||||
- **No backend required** — Focus on building features, not infrastructure
|
||||
- **Real-time sync** — Changes appear everywhere immediately
|
||||
- **Multiplayer by default** — Collaboration just works
|
||||
- **Local-first** — Your app works offline and feels instant
|
||||
|
||||
Think Figma, Notion, or Linear — but you don't need years to build a custom stack.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Define your data** with CoValues schemas
|
||||
2. **Connect to sync infrastructure** (Jazz Cloud or self-hosted)
|
||||
2. **Connect to storage infrastructure** (Jazz Cloud or self-hosted)
|
||||
3. **Create and edit CoValues** like normal objects
|
||||
4. **Get automatic sync and persistence** across all devices and users
|
||||
|
||||
|
||||
@@ -28,18 +28,19 @@ See the [schema docs](/docs/schemas/covalues) for more information.
|
||||
<CodeGroup>
|
||||
```ts
|
||||
// src/lib/schema.ts
|
||||
import { Account, Profile, coField } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools"
|
||||
|
||||
export class MyProfile extends Profile {
|
||||
name = coField.string;
|
||||
counter = coField.number; // This will be publically visible
|
||||
}
|
||||
export const MyProfile = co.profile({
|
||||
name: z.string(),
|
||||
counter: z.number()
|
||||
});
|
||||
|
||||
export class MyAccount extends Account {
|
||||
profile = coField.ref(MyProfile);
|
||||
export const root = co.map({});
|
||||
|
||||
// ...
|
||||
}
|
||||
export const UserAccount = co.account({
|
||||
root,
|
||||
profile: MyProfile
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -48,17 +49,17 @@ export class MyAccount extends Account {
|
||||
<CodeGroup>
|
||||
```svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
|
||||
<script lang="ts">
|
||||
import { JazzSvelteProvider } from 'jazz-tools/svelte';
|
||||
import { JazzSvelteProvider } from "jazz-tools/svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Example configuration for authentication and peer connection
|
||||
let sync = { peer: "wss://cloud.jazz.tools/?key=you@example.com" };
|
||||
let AccountSchema = MyAccount;
|
||||
</script>
|
||||
|
||||
<JazzSvelteProvider {sync} {AccountSchema}>
|
||||
<App />
|
||||
<JazzSvelteProvider {sync} AccountSchema={MyAccount}>
|
||||
{@render children?.()}
|
||||
</JazzSvelteProvider>
|
||||
```
|
||||
</CodeGroup>
|
||||
@@ -69,12 +70,11 @@ export class MyAccount extends Account {
|
||||
```svelte
|
||||
<!-- src/routes/+page.svelte -->
|
||||
<script lang="ts">
|
||||
import { useCoState, useAccount } from 'jazz-tools/svelte';
|
||||
import { MyProfile } from './schema';
|
||||
import { CoState, AccountCoState } from "jazz-tools/svelte";
|
||||
import { MyProfile, UserAccount } from "$lib/schema";
|
||||
|
||||
const { me } = useAccount();
|
||||
|
||||
const profile = $derived(useCoState(MyProfile, me._refs.profile.id));
|
||||
const me = new AccountCoState(UserAccount);
|
||||
const profile = new CoState(MyProfile, me.current?._refs.profile?.id);
|
||||
|
||||
function increment() {
|
||||
if (!profile.current) return;
|
||||
@@ -82,7 +82,7 @@ export class MyAccount extends Account {
|
||||
}
|
||||
</script>
|
||||
|
||||
<button on:click={increment}>
|
||||
<button onclick={increment}>
|
||||
Count: {profile.current?.counter}
|
||||
</button>
|
||||
```
|
||||
|
||||
@@ -89,7 +89,7 @@ export const MyAppRoot = co.map({
|
||||
|
||||
export const MyAppProfile = co.profile({ // [!code ++:4]
|
||||
name: z.string(), // compatible with default Profile schema
|
||||
avatar: z.optional(co.image()),
|
||||
avatar: co.optional(co.image()),
|
||||
});
|
||||
|
||||
export const MyAppAccount = co.account({
|
||||
@@ -241,7 +241,7 @@ const MyAppProfile = co.profile({
|
||||
// ---cut---
|
||||
const MyAppRoot = co.map({
|
||||
myChats: co.list(Chat),
|
||||
myBookmarks: z.optional(co.list(Bookmark)), // [!code ++:1]
|
||||
myBookmarks: co.optional(co.list(Bookmark)), // [!code ++:1]
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export const metadata = {
|
||||
};
|
||||
|
||||
import { CodeGroup, ComingSoon } from "@/components/forMdx";
|
||||
import { Alert } from "@garden-co/design-system/src/components/atoms/Alert";
|
||||
|
||||
# Defining schemas: CoValues
|
||||
|
||||
@@ -80,6 +81,40 @@ const project = TodoProject.create(
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
When creating CoValues that contain other CoValues, you can pass in a plain JSON object.
|
||||
Jazz will automatically create the CoValues for you.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
// @filename: schema.ts
|
||||
import { co, z, CoMap, CoList } from "jazz-tools";
|
||||
|
||||
export const ListOfTasks = co.list(z.string());
|
||||
|
||||
export const TodoProject = co.map({
|
||||
title: z.string(),
|
||||
tasks: ListOfTasks,
|
||||
});
|
||||
|
||||
// @filename: app.ts
|
||||
// ---cut---
|
||||
// app.ts
|
||||
import { Group } from "jazz-tools";
|
||||
import { TodoProject, ListOfTasks } from "./schema";
|
||||
|
||||
const group = Group.create().makePublic();
|
||||
const project = TodoProject.create({
|
||||
title: "New Project",
|
||||
tasks: [], // Permissions are inherited, so the tasks list will also be public
|
||||
}, group);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Alert variant="info" className="flex gap-2 items-center my-4">
|
||||
To learn more about how permissions work when creating nested CoValues with plain JSON objects,
|
||||
refer to [Group hierarchy on CoValue creation](/docs/groups/inheritance#group-hierarchy-on-covalue-creation).
|
||||
</Alert>
|
||||
|
||||
## Types of CoValues
|
||||
|
||||
### `CoMap` (declaration)
|
||||
@@ -320,6 +355,10 @@ const Company = co.map({
|
||||
</CodeGroup>
|
||||
|
||||
#### Optional References
|
||||
You can make schema fields optional using either `z.optional()` or `co.optional()`, depending on the type of value:
|
||||
|
||||
- Use `z.optional()` for primitive Zod values like `z.string()`, `z.number()`, or `z.boolean()`
|
||||
- Use `co.optional()` for CoValues like `co.map()`, `co.list()`, or `co.record()`
|
||||
|
||||
You can make references optional with `co.optional()`:
|
||||
|
||||
@@ -331,7 +370,8 @@ const Pet = co.map({
|
||||
});
|
||||
// ---cut---
|
||||
const Person = co.map({
|
||||
pet: co.optional(Pet),
|
||||
age: z.optional(z.number()), // primitive
|
||||
pet: co.optional(Pet), // CoValue
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
@@ -353,16 +393,16 @@ const Person = co.map({
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
You can use the same technique for mutually recursive references, but you'll need to help TypeScript along:
|
||||
You can use the same technique for mutually recursive references:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
// ---cut---
|
||||
import { co, z, CoListSchema } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
get friends(): CoListSchema<typeof Person> {
|
||||
get friends() {
|
||||
return ListOfPeople;
|
||||
}
|
||||
});
|
||||
@@ -372,22 +412,6 @@ const ListOfPeople = co.list(Person);
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Note: similarly, if you use modifiers like `co.optional()` you'll need to help TypeScript along:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z } from "jazz-tools";
|
||||
// ---cut---
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
get bestFriend(): z.ZodOptional<typeof Person> {
|
||||
return co.optional(Person);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### Helper methods
|
||||
|
||||
If you find yourself repeating the same logic to access computed CoValues properties,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Alert } from "@garden-co/design-system/src/components/atoms/Alert";
|
||||
|
||||
# Inbox API with Server Workers
|
||||
|
||||
The Inbox API provides a message-based communication system for Server Workers in Jazz.
|
||||
The Inbox API provides a message-based communication system for Server Workers in Jazz.
|
||||
|
||||
It works on top of the Jazz APIs and uses sync to transfer messages between the client and the server.
|
||||
|
||||
@@ -154,8 +154,8 @@ function EventComponent({ event }: { event: Event }) {
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
The `sendInboxMessage` API returns a Promise that waits for the message to be handled by a Worker.
|
||||
A message is considered to be handled when the Promise returned by `inbox.subscribe` resolves.
|
||||
The `sendInboxMessage` API returns a Promise that waits for the message to be handled by a Worker.
|
||||
A message is considered to be handled when the Promise returned by `inbox.subscribe` resolves.
|
||||
The value returned will be the id of the CoValue returned in the `inbox.subscribe` resolved promise.
|
||||
|
||||
|
||||
@@ -163,4 +163,4 @@ The value returned will be the id of the CoValue returned in the `inbox.subscrib
|
||||
|
||||
Multi-region deployments are not supported when using the Inbox API.
|
||||
|
||||
If you need to split the workload across multiple regions, you can use the [HTTP API](./http-requests.mdx) instead.
|
||||
If you need to split the workload across multiple regions, you can use the [HTTP API](./http-requests) instead.
|
||||
|
||||
158
homepage/homepage/content/docs/upgrade/0-16-0.mdx
Normal file
158
homepage/homepage/content/docs/upgrade/0-16-0.mdx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { CodeGroup } from '@/components/forMdx'
|
||||
|
||||
# Jazz 0.16.0 - Cleaner separation between Zod and CoValue schemas
|
||||
|
||||
This release introduces a cleaner separation between Zod and CoValue schemas, improves type inference with circular references, and simplifies how you access internal schemas.
|
||||
While most applications won't require extensive refactors, some breaking changes will require action.
|
||||
|
||||
## Motivation
|
||||
|
||||
Before 0.16.0, CoValue schemas were a thin wrapper around Zod schemas. This made it easy to use Zod methods on CoValue schemas,
|
||||
but it also prevented the type checker from detecting issues when combining Zod and CoValue schemas.
|
||||
|
||||
For example, the following code would previously compile without errors, but would have severe limitations:
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Dog = co.map({
|
||||
breed: z.string(),
|
||||
});
|
||||
const Person = co.map({
|
||||
pets: z.array(Dog),
|
||||
});
|
||||
|
||||
// You can create a CoMap with a z.array field that contains another CoMap
|
||||
const map = Person.create({
|
||||
pets: [Dog.create({ breed: "Labrador" })],
|
||||
});
|
||||
|
||||
// But then you cannot eagerly load the nested CoMap, because
|
||||
// there's a plain JS object in between. So this would fail:
|
||||
Person.load(map.id, { resolve: { pets: { $each: true } } });
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
Schema composition rules are now stricter: Zod schemas can only be composed with other Zod schemas.
|
||||
CoValue schemas can be composed with either Zod or other CoValue schemas. These rules are enforced at the type level, to make it easier
|
||||
to spot errors in schema definitions and avoid possible footguns when mixing Zod and CoValue schemas.
|
||||
|
||||
Having a stricter separation between Zod and CoValue schemas also allowed us to improve type inference with circular references.
|
||||
Previously, the type checker would not be able to infer types for even simple circular references, but now it can!
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
get friends(): CoListSchema<typeof Person> { // [!code --]
|
||||
get friends() { // [!code ++]
|
||||
return co.list(Person);
|
||||
},
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
There are some scenarios where recursive type inference can still fail due to TypeScript limitations, but these should be rare.
|
||||
|
||||
## Breaking changes
|
||||
|
||||
### The Account root id is now discoverable
|
||||
|
||||
In prior Jazz releases, the Account root id was stored encrypted and accessible only by the account owner.
|
||||
|
||||
This made it impossible to load the account root this way:
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
const bob = MyAppAccount.load(bobId, { resolve: { root: true }, loadAs: me });
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
So we changed Account root id to be discoverable by everyone.
|
||||
**This doesn't affect the visibility of the account root**, which still follows the permissions defined in its group.
|
||||
|
||||
For existing accounts, the change is applied the next time the user loads their account.
|
||||
|
||||
No action is required on your side, but we preferred to mark this as a breaking change because it
|
||||
minimally affects access to the account root. (e.g., if in your app the root is public, now users can access other users' root by knowing their account ID)
|
||||
|
||||
### `z.optional()` and `z.discriminatedUnion()` no longer work with CoValue schemas
|
||||
|
||||
You'll now need to use the `co.optional()` and `co.discriminatedUnion()` equivalents.
|
||||
This change may require you to update any explicitly typed cyclic references.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
get bestFriend(): z.ZodOptional<typeof Person> { // [!code --]
|
||||
return z.optional(Person); // [!code --]
|
||||
get bestFriend(): co.Optional<typeof Person> { // [!code ++]
|
||||
return co.optional(Person); // [!code ++]
|
||||
}
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### CoValue schema types are now under the `co.` namespace
|
||||
|
||||
All CoValue schema types are now accessed via the `co.` namespace. If you're using explicit types (especially in recursive schemas), you'll need to update them accordingly.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Person = co.map({
|
||||
name: z.string(),
|
||||
get friends(): CoListSchema<typeof Person> { // [!code --]
|
||||
get friends(): co.List<typeof Person> { // [!code ++]
|
||||
return co.list(Person);
|
||||
}
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Unsupported Zod methods have been removed from CoMap schemas
|
||||
|
||||
CoMap schemas no longer incorrectly inherit Zod methods like `.extend()` and `.partial()`. These methods previously appeared to work but could behave unpredictably. They have now been disabled.
|
||||
|
||||
We're keeping `.optional()` and plan to introduce more Zod-like methods in future releases.
|
||||
|
||||
### Internal schema access is now simpler
|
||||
|
||||
You no longer need to use Zod's `.def` to access schema internals. Instead, you can directly use methods like `CoMapSchema.shape`, `CoListSchema.element`, and `CoOptionalSchema.innerType`.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Message = co.map({
|
||||
content: co.richText(),
|
||||
});
|
||||
|
||||
const Thread = co.map({
|
||||
messages: co.list(Message),
|
||||
});
|
||||
|
||||
const thread = Thread.create({
|
||||
messages: Thread.def.shape.messages.create([ // [!code --]
|
||||
messages: Thread.shape.messages.create([ // [!code ++]
|
||||
Message.create({
|
||||
content: co.richText().create("Hi!"),
|
||||
}),
|
||||
Message.create({
|
||||
content: co.richText().create("What's up?"),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Removed the deprecated `withHelpers` method from CoValue schemas
|
||||
|
||||
The deprecated `withHelpers()` method has been removed from CoValue schemas. You can define helper functions manually to encapsulate CoValue-related logic.
|
||||
[Learn how to define helper methods](https://jazz.tools/docs/vanilla/schemas/covalues#helper-methods).
|
||||
98
homepage/homepage/content/docs/upgrade/0-17-0.mdx
Normal file
98
homepage/homepage/content/docs/upgrade/0-17-0.mdx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { CodeGroup } from '@/components/forMdx'
|
||||
|
||||
# Jazz 0.17.0 - New Image APIs
|
||||
|
||||
This release introduces a comprehensive refactoring of the image API, from creation to consumption. The result is a more flexible set of components and lower-level primitives that provide better developer experience and performance.
|
||||
|
||||
## Motivation
|
||||
|
||||
Before 0.17.0, the image APIs had several limitations:
|
||||
- Progressive loading created confusion in usage patterns, and the API lacked flexibility to support all use cases
|
||||
- The resize methods were overly opinionated, and the chosen library had compatibility issues in incognito mode
|
||||
- The imperative functions for loading images were unnecessarily complex for simple use cases
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
- The `createImage` options have been restructured, and the function has been moved to the `jazz-tools/media` namespace for both React and React Native
|
||||
- The `<ProgressiveImg>` component has been replaced with `<Image>` from `jazz-tools/react`
|
||||
- The `<ProgressiveImgNative>` component has been replaced with `<Image>` from `jazz-tools/react-native`
|
||||
- The `highestResAvailable` function has been moved from `ImageDefinition.highestResAvailable` to `import { highestResAvailable } from "jazz-tools/media"`
|
||||
- Existing image data remains compatible and accessible
|
||||
- Progressive images created with previous versions will continue to work
|
||||
|
||||
## Changes
|
||||
|
||||
### `createImage` Function
|
||||
|
||||
The `createImage` function has been refactored to allow opt-in specific features and moved to the `jazz-tools/media` namespace.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
export type CreateImageOptions = {
|
||||
owner?: Group | Account;
|
||||
placeholder?: "blur" | false;
|
||||
maxSize?: number;
|
||||
progressive?: boolean;
|
||||
};
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
- By default, images are now created with only the original size saved (no progressive loading or placeholder)
|
||||
- The `maxSize` property is no longer restricted and affects the original size saved
|
||||
- Placeholder generation is now a configurable property, disabled by default. Currently, only `"blur"` is supported, with more built-in options planned for future releases
|
||||
- The `progressive` property creates internal resizes used exclusively via public APIs. Direct manipulation of internal resize state is no longer recommended
|
||||
|
||||
The `pica` library used internally for browser image resizing has been replaced with a simpler canvas-based implementation. Since every image manipulation library has trade-offs, we've chosen the simplest solution while providing flexibility through `createImageFactory`. This new factory function allows you to create custom `createImage` instances with your preferred libraries for resizing, placeholder generation, and source reading. It's used internally to create default instances for browsers, React Native, and Node.js environments.
|
||||
|
||||
### Replaced `<ProgressiveImg>` Component with `<Image>`
|
||||
|
||||
The `<ProgressiveImg>` component has been replaced with `<Image>` component for both React and React Native.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
// Before
|
||||
import { ProgressiveImg } from "jazz-tools/react";
|
||||
|
||||
<ProgressiveImg image={me.profile.image}>
|
||||
{({ src }) => <img alt="" src={src} className="w-full h-auto" />}
|
||||
</ProgressiveImg>
|
||||
|
||||
// After
|
||||
import { Image } from "jazz-tools/react";
|
||||
|
||||
<Image imageId={me.profile.image.id} alt="Profile" width={600} />
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
The `width` and `height` props are now used internally to load the optimal image size, but only if progressive loading was enabled during image creation.
|
||||
|
||||
For detailed usage examples and API reference, see the [Image component documentation](/docs/react/using-covalues/imagedef#displaying-images).
|
||||
|
||||
### New `Image` Component for Svelte
|
||||
|
||||
A new `Image` component has been added for Svelte, featuring the same API as the React and React Native components.
|
||||
|
||||
<CodeGroup>
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { Image } from 'jazz-tools/svelte';
|
||||
</script>
|
||||
|
||||
<Image
|
||||
imageId={image.id}
|
||||
alt=""
|
||||
class="h-auto max-h-[20rem] max-w-full rounded-t-xl mb-1"
|
||||
width={600}
|
||||
/>
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
For detailed usage examples and API reference, see the [Image component documentation](/docs/svelte/using-covalues/imagedef#displaying-images).
|
||||
|
||||
### New Image Loading Utilities
|
||||
|
||||
Two new utility functions are now available from the `jazz-tools/media` package:
|
||||
- `loadImage` - Fetches the original image file by ID
|
||||
- `loadImageBySize` - Fetches the best stored size for a given width and height
|
||||
|
||||
For detailed usage examples and API reference, see the [Image component documentation](/docs/vanilla/using-covalues/imagedef#displaying-images).
|
||||
@@ -343,14 +343,14 @@ CoLists can be used to create one-to-many relationships:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z, CoListSchema } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Task = co.map({
|
||||
title: z.string(),
|
||||
status: z.literal(["todo", "in-progress", "complete"]),
|
||||
|
||||
get project(): z.ZodOptional<typeof Project> {
|
||||
return z.optional(Project);
|
||||
get project(): co.Optional<typeof Project> {
|
||||
return co.optional(Project);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -359,7 +359,7 @@ const ListOfTasks = co.list(Task);
|
||||
const Project = co.map({
|
||||
name: z.string(),
|
||||
|
||||
get tasks(): CoListSchema<typeof Task> {
|
||||
get tasks(): co.List<typeof Task> {
|
||||
return ListOfTasks;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -213,14 +213,14 @@ const Member = co.map({
|
||||
name: z.string(),
|
||||
});
|
||||
// ---cut---
|
||||
import { co, z, CoListSchema } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Project = co.map({
|
||||
name: z.string(),
|
||||
startDate: z.date(),
|
||||
status: z.literal(["planning", "active", "completed"]),
|
||||
coordinator: co.optional(Member),
|
||||
get subProjects(): z.ZodOptional<CoListSchema<typeof Project>> {
|
||||
get subProjects(): co.Optional<co.List<typeof Project>> {
|
||||
return co.optional(co.list(Project));
|
||||
}
|
||||
});
|
||||
@@ -228,6 +228,54 @@ export type Project = co.loaded<typeof Project>;
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Partial
|
||||
|
||||
For convenience Jazz provies a dedicated API for making all the properties of a CoMap optional:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Project = co.map({
|
||||
name: z.string(),
|
||||
startDate: z.date(),
|
||||
status: z.literal(["planning", "active", "completed"]),
|
||||
});
|
||||
|
||||
const ProjectDraft = Project.partial();
|
||||
|
||||
// The fields are all optional now
|
||||
const project = ProjectDraft.create({});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Pick
|
||||
|
||||
You can also pick specific fields from a CoMap:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const Project = co.map({
|
||||
name: z.string(),
|
||||
startDate: z.date(),
|
||||
status: z.literal(["planning", "active", "completed"]),
|
||||
});
|
||||
|
||||
const ProjectStep1 = Project.pick({
|
||||
name: true,
|
||||
startDate: true,
|
||||
});
|
||||
|
||||
// We don't provide the status field
|
||||
const project = ProjectStep1.create({
|
||||
name: "My project",
|
||||
startDate: new Date("2025-04-01"),
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Working with Record CoMaps
|
||||
|
||||
For record-type CoMaps, you can access values using bracket notation:
|
||||
|
||||
@@ -50,51 +50,3 @@ export type User = co.loaded<typeof User>;
|
||||
This direct linking approach offers a single source of truth. When you update a referenced CoValue, all other CoValues that point to it are automatically updated, ensuring data consistency across your application.
|
||||
|
||||
By connecting CoValues through these direct references, you can build robust and collaborative applications where data is consistent, efficient to manage, and relationships are clearly defined. The ability to link different CoValue types to the same underlying data is fundamental to building complex applications with Jazz.
|
||||
|
||||
|
||||
## Recursive references with DiscriminatedUnion
|
||||
In advanced schemas, you may want a CoValue that recursively references itself. For example, a `ReferenceItem` that contains a list of other items like `NoteItem` or `AttachmentItem`. This is common in tree-like structures such as threaded comments or nested project outlines.
|
||||
|
||||
You can model this with a Zod `z.discriminatedUnion`, but TypeScript’s type inference doesn't handle recursive unions well without a workaround.
|
||||
|
||||
Here’s how to structure your schema to avoid circular reference errors.
|
||||
|
||||
### Use this pattern for recursive discriminated unions
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { CoListSchema, co, z } from "jazz-tools";
|
||||
|
||||
// Recursive item modeling pattern using discriminated unions
|
||||
// First, define the non-recursive types
|
||||
export const NoteItem = co.map({
|
||||
type: z.literal("note"),
|
||||
internal: z.boolean(),
|
||||
content: co.plainText(),
|
||||
});
|
||||
|
||||
export const AttachmentItem = co.map({
|
||||
type: z.literal("attachment"),
|
||||
internal: z.boolean(),
|
||||
content: co.fileStream(),
|
||||
});
|
||||
|
||||
export const ReferenceItem = co.map({
|
||||
type: z.literal("reference"),
|
||||
internal: z.boolean(),
|
||||
content: z.string(),
|
||||
|
||||
// Workaround: declare the field type using CoListSchema and ZodDiscriminatedUnion so TS can safely recurse
|
||||
get children(): CoListSchema<z.ZodDiscriminatedUnion<[typeof NoteItem, typeof AttachmentItem, typeof ReferenceItem]>> {
|
||||
return ProjectContextItemList;
|
||||
},
|
||||
});
|
||||
|
||||
// Create the recursive union
|
||||
export const ProjectContextItem = z.discriminatedUnion("type", [NoteItem, AttachmentItem, ReferenceItem]);
|
||||
|
||||
// Final list of recursive types
|
||||
export const ProjectContextItemList = co.list(ProjectContextItem);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
Even though this seems like a shortcut, TypeScript and Zod can't resolve the circular reference this way. Always define the discriminated union before introducing recursive links.
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { CodeGroup } from "@/components/forMdx";
|
||||
|
||||
export const metadata = {
|
||||
description: "ImageDefinition is a CoValue for managing images, storage of multiple resolutions, and progressive loading."
|
||||
description: "ImageDefinition is a CoValue for storing images with built-in UX features."
|
||||
};
|
||||
|
||||
# ImageDefinition
|
||||
|
||||
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting multiple resolutions of the same image and progressive loading patterns.
|
||||
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting a blurry placeholder, built-in resizing, and progressive loading patterns.
|
||||
|
||||
We also offer [`createImage()`](#creating-images), a higher-level function to create an `ImageDefinition` from a file.
|
||||
Beyond ImageDefinition, Jazz offers higher-level functions and components that make it easier to use images:
|
||||
- [`createImage()`](#creating-images) - function to create an `ImageDefinition` from a file
|
||||
- [`loadImage`, `loadImageBySize`, `highestResAvailable`](#displaying-images) - functions to load and display images
|
||||
|
||||
If you're building with React, we recommend starting with our [React-specific image documentation](/docs/react/using-covalues/imagedef) which covers higher-level components and hooks for working with images.
|
||||
|
||||
The [Image Upload example](https://github.com/gardencmp/jazz/tree/main/examples/image-upload) demonstrates use of `ImageDefinition`.
|
||||
The [Image Upload example](https://github.com/gardencmp/jazz/tree/main/examples/image-upload) demonstrates use of images in Jazz.
|
||||
|
||||
## Creating Images
|
||||
|
||||
@@ -20,314 +20,258 @@ The easiest way to create and use images in your Jazz application is with the `c
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { Group, co, z } from "jazz-tools";
|
||||
import { Account, Group, ImageDefinition } from "jazz-tools";
|
||||
|
||||
const MyProfile = co.profile({
|
||||
name: z.string(),
|
||||
image: z.optional(co.image()),
|
||||
});
|
||||
|
||||
const MyAccount = co.account({
|
||||
root: co.map({}),
|
||||
profile: MyProfile,
|
||||
});
|
||||
|
||||
MyAccount.withMigration((account, creationProps) => {
|
||||
if (account.profile === undefined) {
|
||||
const profileGroup = Group.create();
|
||||
profileGroup.makePublic();
|
||||
account.profile = MyProfile.create(
|
||||
{
|
||||
name: creationProps?.name ?? "New user",
|
||||
},
|
||||
profileGroup,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const me = await MyAccount.create({ creationProps: { name: "John Doe" } });
|
||||
|
||||
const myGroup = Group.create();
|
||||
declare const me: {
|
||||
_owner: Account | Group;
|
||||
profile: {
|
||||
image: ImageDefinition;
|
||||
};
|
||||
};
|
||||
|
||||
// ---cut---
|
||||
import { createImage } from "jazz-tools/browser-media-images";
|
||||
import { createImage } from "jazz-tools/media";
|
||||
|
||||
// Create an image from a file input
|
||||
async function handleFileUpload(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
async function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
// Creates ImageDefinition with multiple resolutions automatically
|
||||
const image = await createImage(file, { owner: myGroup });
|
||||
// Creates ImageDefinition with a blurry placeholder, limited to 1024px on the longest side, and multiple resolutions automatically
|
||||
const image = await createImage(file, {
|
||||
owner: me._owner,
|
||||
maxSize: 1024,
|
||||
placeholder: "blur",
|
||||
progressive: true,
|
||||
});
|
||||
|
||||
// Store the image in your application data
|
||||
me.profile.image = image;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
**Note:** `createImage()` requires a browser environment as it uses browser APIs to process images.
|
||||
**Note:** `createImage()` currently supports browser and react-native environments.
|
||||
|
||||
The `createImage()` function:
|
||||
- Creates an `ImageDefinition` with the right properties
|
||||
- Generates a small placeholder for immediate display
|
||||
- Creates multiple resolution variants of your image
|
||||
- Returns the ID of the created `ImageDefinition`
|
||||
- Returns the created `ImageDefinition`
|
||||
|
||||
### Configuration Options
|
||||
|
||||
You can configure `createImage()` with additional options:
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import type { ImageDefinition, Group, Account } from "jazz-tools";
|
||||
// ---cut---
|
||||
declare function createImage(
|
||||
image: Blob | File | string,
|
||||
options: {
|
||||
owner?: Group | Account;
|
||||
placeholder?: "blur" | false;
|
||||
maxSize?: number;
|
||||
progressive?: boolean;
|
||||
}): Promise<ImageDefinition>
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
#### `image`
|
||||
|
||||
The image to create an `ImageDefinition` from. On browser environments, this can be a `Blob` or a `File`. On React Native, this must be a `string` with the file path.
|
||||
|
||||
#### `owner`
|
||||
|
||||
The owner of the `ImageDefinition`. This is used to control access to the image. See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
|
||||
|
||||
#### `placeholder`
|
||||
|
||||
Sometimes the wanted image is not loaded yet. The placeholder is a base64 encoded image that is displayed while the image is loading. Currently, only `"blur"` is a supported.
|
||||
|
||||
#### `maxSize`
|
||||
|
||||
The image generation process includes a maximum size setting that controls the longest side of the image. A built-in resizing feature is applied based on this setting.
|
||||
|
||||
|
||||
#### `progressive`
|
||||
|
||||
The progressive loading pattern is a technique that allows images to load incrementally, starting with a small version and gradually replacing it with a larger version as it becomes available. This is useful for improving the user experience by showing a placeholder while the image is loading.
|
||||
|
||||
Passing `progressive: true` to `createImage()` will create internal smaller versions of the image for future uses.
|
||||
|
||||
### Create multiple resized copies
|
||||
|
||||
To create multiple resized copies of an original image for better layout control, you can utilize the `createImage` function multiple times with different parameters for each desired size. Here’s an example of how you might implement this:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
declare const myBlob: Blob;
|
||||
// ---cut---
|
||||
import { co } from "jazz-tools";
|
||||
import { createImage } from "jazz-tools/media";
|
||||
|
||||
// Jazz Schema
|
||||
const ProductImage = co.map({
|
||||
image: co.image(),
|
||||
thumbnail: co.image(),
|
||||
});
|
||||
|
||||
const mainImage = await createImage(myBlob);
|
||||
const thumbnail = await createImage(myBlob, {
|
||||
maxSize: 100,
|
||||
});
|
||||
|
||||
// or, in case of migration, you can use the original stored image.
|
||||
const newThumb = await createImage(mainImage!.original!.toBlob()!, {
|
||||
maxSize: 100,
|
||||
});
|
||||
|
||||
const imageSet = ProductImage.create({
|
||||
image: mainImage,
|
||||
thumbnail,
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Displaying Images
|
||||
|
||||
Like other CoValues, `ImageDefinition` can be used to load the object.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx twoslash
|
||||
import { createImage } from "jazz-tools/browser-media-images";
|
||||
import { createJazzTestAccount } from 'jazz-tools/testing';
|
||||
const me = await createJazzTestAccount();
|
||||
const file = new File([], "test.jpg", { type: "image/jpeg" });
|
||||
// ---cut---
|
||||
// Configuration options
|
||||
const options = {
|
||||
owner: me, // Owner for access control
|
||||
maxSize: 1024 as 1024 // Maximum resolution to generate
|
||||
};
|
||||
|
||||
// Setting maxSize controls which resolutions are generated:
|
||||
// 256: Only creates the smallest resolution (256px on longest side)
|
||||
// 1024: Creates 256px and 1024px resolutions
|
||||
// 2048: Creates 256px, 1024px, and 2048px resolutions
|
||||
// undefined: Creates all resolutions including the original size
|
||||
|
||||
const image = await createImage(file, options);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Ownership
|
||||
|
||||
Like other CoValues, you can specify ownership when creating image definitions.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { Group } from "jazz-tools";
|
||||
import { createImage } from "jazz-tools/browser-media-images";
|
||||
import { createJazzTestAccount } from 'jazz-tools/testing';
|
||||
const me = await createJazzTestAccount();
|
||||
const colleagueAccount = await createJazzTestAccount();
|
||||
|
||||
const file = new File([], "test.jpg", { type: "image/jpeg" });
|
||||
|
||||
// ---cut---
|
||||
const teamGroup = Group.create();
|
||||
teamGroup.addMember(colleagueAccount, "writer");
|
||||
|
||||
// Create an image with shared ownership
|
||||
const teamImage = await createImage(file, { owner: teamGroup });
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
|
||||
|
||||
## Creating ImageDefinitions
|
||||
|
||||
Create an `ImageDefinition` by specifying the original dimensions and an optional placeholder:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { ImageDefinition } from "jazz-tools";
|
||||
|
||||
// Create with original dimensions
|
||||
const image = ImageDefinition.create({
|
||||
originalSize: [1920, 1080],
|
||||
});
|
||||
|
||||
// With a placeholder for immediate display
|
||||
const imageWithPlaceholder = ImageDefinition.create({
|
||||
originalSize: [1920, 1080],
|
||||
placeholderDataURL: "data:image/jpeg;base64,/9j/4AAQSkZJ...",
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Structure
|
||||
|
||||
`ImageDefinition` stores:
|
||||
- The original image dimensions (`originalSize`)
|
||||
- An optional placeholder (`placeholderDataURL`, typically a tiny base64-encoded preview)
|
||||
- Multiple resolution variants of the same image as [`FileStream`s](./using-covalues/filestreams)
|
||||
|
||||
Each resolution is stored with a key in the format `"widthxheight"` (e.g., `"1920x1080"`, `"800x450"`).
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { ImageDefinition, co, z } from "jazz-tools";
|
||||
|
||||
const Gallery = co.map({
|
||||
title: z.string(),
|
||||
images: co.list(co.image()),
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Adding Image Resolutions
|
||||
|
||||
Add multiple resolutions to an `ImageDefinition` by creating `FileStream`s for each size:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { FileStream, ImageDefinition } from "jazz-tools";
|
||||
import { createJazzTestAccount } from 'jazz-tools/testing';
|
||||
const me = await createJazzTestAccount();
|
||||
|
||||
const fullSizeBlob = new Blob([], { type: "image/jpeg" });
|
||||
const mediumSizeBlob = new Blob([], { type: "image/jpeg" });
|
||||
const thumbnailBlob = new Blob([], { type: "image/jpeg" });
|
||||
|
||||
const image = ImageDefinition.create({
|
||||
originalSize: [1920, 1080],
|
||||
}, { owner: me });
|
||||
// ---cut---
|
||||
// Create FileStreams for different resolutions
|
||||
const fullRes = await FileStream.createFromBlob(fullSizeBlob);
|
||||
const mediumRes = await FileStream.createFromBlob(mediumSizeBlob);
|
||||
const thumbnailRes = await FileStream.createFromBlob(thumbnailBlob);
|
||||
|
||||
// Add to the ImageDefinition with appropriate resolution keys
|
||||
image["1920x1080"] = fullRes;
|
||||
image["800x450"] = mediumRes;
|
||||
image["320x180"] = thumbnailRes;
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Retrieving Images
|
||||
|
||||
The `highestResAvailable` method helps select the best image resolution for the current context:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { ImageDefinition, FileStream } from "jazz-tools";
|
||||
import { createJazzTestAccount } from "jazz-tools/testing";
|
||||
|
||||
// Simple document environment
|
||||
global.document = {
|
||||
createElement: () =>
|
||||
({ src: "", onload: null }) as unknown as HTMLImageElement,
|
||||
} as unknown as Document;
|
||||
global.window = { innerWidth: 1000 } as unknown as Window & typeof globalThis;
|
||||
|
||||
// Setup
|
||||
const fakeBlob = new Blob(["fake image data"], { type: "image/jpeg" });
|
||||
const me = await createJazzTestAccount();
|
||||
const image = ImageDefinition.create(
|
||||
{ originalSize: [1920, 1080] },
|
||||
{ owner: me },
|
||||
);
|
||||
image["1920x1080"] = await FileStream.createFromBlob(fakeBlob, { owner: me });
|
||||
const imageElement = document.createElement("img");
|
||||
|
||||
// ---cut---
|
||||
// Get highest resolution available (unconstrained)
|
||||
const highestRes = ImageDefinition.highestResAvailable(image);
|
||||
console.log(highestRes);
|
||||
if (highestRes) {
|
||||
const blob = highestRes.stream.toBlob();
|
||||
if (blob) {
|
||||
// Create a URL for the blob
|
||||
const url = URL.createObjectURL(blob);
|
||||
imageElement.src = url;
|
||||
// Revoke the URL when the image is loaded
|
||||
imageElement.onload = () => URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
// Get appropriate resolution for specific width
|
||||
const appropriateRes = ImageDefinition.highestResAvailable(image, {
|
||||
targetWidth: window.innerWidth,
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Fallback Behavior
|
||||
|
||||
`highestResAvailable` returns the largest resolution that fits your constraints. If a resolution has incomplete data, it falls back to the next available lower resolution.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { ImageDefinition, FileStream } from "jazz-tools";
|
||||
|
||||
const mediumSizeBlob = new Blob([], { type: "image/jpeg" });
|
||||
// ---cut---
|
||||
const image = ImageDefinition.create({
|
||||
originalSize: [1920, 1080],
|
||||
});
|
||||
|
||||
image["1920x1080"] = FileStream.create(); // Empty image upload
|
||||
image["800x450"] = await FileStream.createFromBlob(mediumSizeBlob);
|
||||
|
||||
const highestRes = ImageDefinition.highestResAvailable(image);
|
||||
console.log(highestRes?.res); // 800x450
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Progressive Loading Patterns
|
||||
|
||||
`ImageDefinition` supports simple progressive loading with placeholders and resolution selection:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { FileStream, ImageDefinition } from "jazz-tools";
|
||||
import { createJazzTestAccount } from "jazz-tools/testing";
|
||||
|
||||
// Simple document environment
|
||||
global.document = {
|
||||
createElement: () =>
|
||||
({ src: "", onload: null }) as unknown as HTMLImageElement,
|
||||
} as unknown as Document;
|
||||
global.window = { innerWidth: 1000 } as unknown as Window & typeof globalThis;
|
||||
|
||||
const me = await createJazzTestAccount();
|
||||
|
||||
const mediumSizeBlob = new Blob([], { type: "image/jpeg" });
|
||||
const image = ImageDefinition.create(
|
||||
{
|
||||
originalSize: [1920, 1080],
|
||||
const image = await ImageDefinition.load("123", {
|
||||
resolve: {
|
||||
original: true,
|
||||
},
|
||||
{ owner: me },
|
||||
);
|
||||
image["1920x1080"] = await FileStream.createFromBlob(mediumSizeBlob, {
|
||||
owner: me,
|
||||
});
|
||||
const imageElement = document.createElement("img");
|
||||
// ---cut---
|
||||
// Start with placeholder for immediate display
|
||||
if (image.placeholderDataURL) {
|
||||
imageElement.src = image.placeholderDataURL;
|
||||
}
|
||||
|
||||
// Then load the best resolution for the current display
|
||||
const screenWidth = window.innerWidth;
|
||||
const bestRes = ImageDefinition.highestResAvailable(image, {
|
||||
targetWidth: screenWidth,
|
||||
});
|
||||
|
||||
if (bestRes) {
|
||||
const blob = bestRes.stream.toBlob();
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
imageElement.src = url;
|
||||
|
||||
// Remember to revoke the URL when no longer needed
|
||||
imageElement.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}
|
||||
if(image) {
|
||||
console.log({
|
||||
originalSize: image.originalSize,
|
||||
placeholderDataUrl: image.placeholderDataURL,
|
||||
original: image.original, // this FileStream may be not loaded yet
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
</CodeGroup>
|
||||
## Best Practices
|
||||
|
||||
- **Generate resolutions server-side** when possible for optimal quality
|
||||
`image.original` is a `FileStream` and its content can be read as described in the [FileStream](/docs/vanilla/using-covalues/filestreams#reading-from-filestreams) documentation.
|
||||
|
||||
Since FileStream objects are also CoValues, they must be loaded before use. To simplify loading, if you want to load the binary data saved as Original, you can use the `loadImage` function.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
declare const imageDefinitionOrId: string;
|
||||
// ---cut---
|
||||
import { loadImage } from "jazz-tools/media";
|
||||
|
||||
const image = await loadImage(imageDefinitionOrId);
|
||||
if(image === null) {
|
||||
throw new Error("Image not found");
|
||||
}
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.width = image.width;
|
||||
img.height = image.height;
|
||||
img.src = URL.createObjectURL(image.image.toBlob()!);
|
||||
img.onload = () => URL.revokeObjectURL(img.src);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
If the image was generated with progressive loading, and you want to access the best-fit resolution, use `loadImageBySize`. It will load the image of the best resolution that fits the wanted width and height.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
declare const imageDefinitionOrId: string;
|
||||
// ---cut---
|
||||
import { loadImageBySize } from "jazz-tools/media";
|
||||
|
||||
const image = await loadImageBySize(imageDefinitionOrId, 600, 600); // 600x600
|
||||
|
||||
if(image) {
|
||||
console.log({
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
image: image.image,
|
||||
});
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
|
||||
If want to dynamically listen to the _loaded_ resolution that best fits the wanted width and height, you can use the `subscribe` and the `highestResAvailable` function.
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { ImageDefinition } from "jazz-tools";
|
||||
// function highestResAvailable(image: ImageDefinition, wantedWidth: number, wantedHeight: number): FileStream | null
|
||||
import { highestResAvailable } from "jazz-tools/media";
|
||||
|
||||
const image = await ImageDefinition.load(imageId);
|
||||
|
||||
if(image === null) {
|
||||
throw new Error("Image not found");
|
||||
}
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.width = 600;
|
||||
img.height = 600;
|
||||
|
||||
// start with the placeholder
|
||||
if(image.placeholderDataURL) {
|
||||
img.src = image.placeholderDataURL;
|
||||
}
|
||||
|
||||
// then listen to the image changes
|
||||
image.subscribe({}, (image) => {
|
||||
const bestImage = highestResAvailable(image, 600, 600);
|
||||
|
||||
if(bestImage) {
|
||||
// bestImage is again a FileStream
|
||||
const blob = bestImage.image.toBlob();
|
||||
if(blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
img.src = url;
|
||||
img.onload = () => URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Best Practices
|
||||
|
||||
- **Set image sizes** when possible to avoid layout shifts
|
||||
- **Use placeholders** (like LQIP - Low Quality Image Placeholders) for instant rendering
|
||||
- **Prioritize loading** the resolution appropriate for the current viewport
|
||||
- **Consider device pixel ratio** (window.devicePixelRatio) for high-DPI displays
|
||||
- **Always call URL.revokeObjectURL** after the image loads to prevent memory leaks
|
||||
|
||||
## Image manipulation custom implementation
|
||||
|
||||
To manipulate the images (like placeholders, resizing, etc.), `createImage()` uses different implementations depending on the environment. Currently, the image manipulation is supported on browser and react-native environments.
|
||||
|
||||
On the browser, the image manipulation is done using the `canvas` API. If you want to use a custom implementation, you can use the `createImageFactory` function in order create your own `createImage` function and use your preferred image manipulation library.
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { createImageFactory } from "jazz-tools/media";
|
||||
|
||||
const createImage = createImageFactory({
|
||||
createFileStreamFromSource: async (source, owner) => {
|
||||
// ...
|
||||
},
|
||||
getImageSize: async (image) => {
|
||||
// ...
|
||||
},
|
||||
getPlaceholderBase64: async (image) => {
|
||||
// ...
|
||||
},
|
||||
resize: async (image, width, height) => {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -1,63 +1,75 @@
|
||||
import { CodeGroup } from "@/components/forMdx";
|
||||
|
||||
export const metadata = {
|
||||
description: "ImageDefinition is a CoValue for managing images, storage of multiple resolutions, and progressive loading."
|
||||
export const metadata = {
|
||||
description: "ImageDefinition is a CoValue for storing images with built-in UX features."
|
||||
};
|
||||
|
||||
# ImageDefinition
|
||||
|
||||
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz. It extends beyond basic file storage by supporting multiple resolutions of the same image, optimized for mobile devices.
|
||||
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting a blurry placeholder, built-in resizing, and progressive loading patterns.
|
||||
|
||||
**Note**: This guide applies to both Expo and framework-less React Native implementations. The functionality described here is identical regardless of which implementation you're using
|
||||
**Note**: This guide applies to both Expo and framework-less React Native implementations.
|
||||
|
||||
Jazz offers several tools to work with images in React Native:
|
||||
- [`createImageNative()`](#creating-images) - function to create an `ImageDefinition` from a base64 image data URI
|
||||
- [`ProgressiveImgNative`](#displaying-images-with-progressiveimgnative) - React component to display an image with progressive loading
|
||||
- [`useProgressiveImgNative`](#using-useprogressiveimgnative-hook) - React hook to load an image in your own component
|
||||
Beyond ImageDefinition, Jazz offers higher-level functions and components that make it easier to use images:
|
||||
- [`createImage()`](#creating-images) - function to create an `ImageDefinition` from a file
|
||||
- [`Image`](#displaying-images) - React Native component to display a stored image
|
||||
|
||||
For examples of use, see our example apps:
|
||||
- [React Native Chat](https://github.com/gardencmp/jazz/tree/main/examples/chat-rn) (Framework-less implementation)
|
||||
- [Expo Chat](https://github.com/gardencmp/jazz/tree/main/examples/chat-rn-expo) (Expo implementation)
|
||||
- [Expo Clerk](https://github.com/gardencmp/jazz/tree/main/examples/clerk-expo) (Expo with Clerk-based authentication)
|
||||
|
||||
## Installation
|
||||
|
||||
The Jazz's images implementation is based on `@bam.tech/react-native-image-resizer`. Check the [installation guide](/docs/react-native-expo/project-setup#install-dependencies) for more details.
|
||||
|
||||
## Creating Images
|
||||
|
||||
The easiest way to create and use images in your Jazz application is with the `createImageNative()` function:
|
||||
The easiest way to create and use images in your Jazz application is with the `createImage()` function:
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
import { createImageNative } from "jazz-tools/expo-media-images";
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
```ts
|
||||
import { Account, Group, ImageDefinition } from "jazz-tools";
|
||||
|
||||
declare const me: {
|
||||
_owner: Account | Group;
|
||||
profile: {
|
||||
image: ImageDefinition;
|
||||
};
|
||||
};
|
||||
|
||||
// ---cut---
|
||||
|
||||
import { createImage } from "jazz-tools/media";
|
||||
import { launchImageLibrary } from 'react-native-image-picker';
|
||||
|
||||
async function handleImagePicker() {
|
||||
try {
|
||||
// Launch the image picker
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
base64: true,
|
||||
quality: 1,
|
||||
// Use your favorite image picker library to get the image URI
|
||||
const result = await launchImageLibrary({
|
||||
mediaType: 'photo',
|
||||
quality: 1,
|
||||
});
|
||||
|
||||
if (!result.didCancel && result.assets && result.assets.length > 0) {
|
||||
// Creates ImageDefinition with a blurry placeholder, limited to 1024px on the longest side, and multiple resolutions automatically.
|
||||
// See the options below for more details.
|
||||
const image = await createImage(result.assets[0].uri, {
|
||||
owner: me._owner,
|
||||
maxSize: 1024,
|
||||
placeholder: "blur",
|
||||
progressive: true,
|
||||
});
|
||||
|
||||
if (!result.canceled) {
|
||||
const base64Uri = `data:image/jpeg;base64,${result.assets[0].base64}`;
|
||||
|
||||
// Creates ImageDefinition with multiple resolutions automatically
|
||||
const image = await createImageNative(base64Uri, {
|
||||
owner: me.profile._owner,
|
||||
maxSize: 2048, // Optional: limit maximum resolution
|
||||
});
|
||||
|
||||
// Store the image
|
||||
me.profile.image = image;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating image:", error);
|
||||
// Store the image
|
||||
me.profile.image = image;
|
||||
}
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
The `createImageNative()` function:
|
||||
**Note:** `createImage()` currently supports browser and react-native environments.
|
||||
|
||||
The `createImage()` function:
|
||||
- Creates an `ImageDefinition` with the right properties
|
||||
- Generates a small placeholder for immediate display
|
||||
- Creates multiple resolution variants of your image
|
||||
@@ -65,49 +77,96 @@ The `createImageNative()` function:
|
||||
|
||||
### Configuration Options
|
||||
|
||||
You can configure `createImageNative()` with additional options:
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
// Configuration options
|
||||
const options = {
|
||||
owner: me, // Owner for access control
|
||||
maxSize: 1024 // Maximum resolution to generate
|
||||
};
|
||||
|
||||
// Setting maxSize controls which resolutions are generated:
|
||||
// 256: Only creates the smallest resolution (256px on longest side)
|
||||
// 1024: Creates 256px and 1024px resolutions
|
||||
// 2048: Creates 256px, 1024px, and 2048px resolutions
|
||||
// undefined: Creates all resolutions including the original size
|
||||
|
||||
const image = await createImageNative(base64Uri, options);
|
||||
```ts twoslash
|
||||
import type { ImageDefinition, Group, Account } from "jazz-tools";
|
||||
// ---cut---
|
||||
declare function createImage(
|
||||
image: Blob | File | string,
|
||||
options: {
|
||||
owner?: Group | Account;
|
||||
placeholder?: "blur" | false;
|
||||
maxSize?: number;
|
||||
progressive?: boolean;
|
||||
}): Promise<ImageDefinition>
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Displaying Images with `ProgressiveImgNative`
|
||||
#### `image`
|
||||
|
||||
The image to create an `ImageDefinition` from. On browser environments, this can be a `Blob` or a `File`. On React Native, this must be a `string` with the file path.
|
||||
|
||||
#### `owner`
|
||||
|
||||
The owner of the `ImageDefinition`. This is used to control access to the image. See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
|
||||
|
||||
#### `placeholder`
|
||||
|
||||
Sometimes the wanted image is not loaded yet. The placeholder is a base64 encoded image that is displayed while the image is loading. Currently, only `"blur"` is a supported.
|
||||
|
||||
#### `maxSize`
|
||||
|
||||
The image generation process includes a maximum size setting that controls the longest side of the image. A built-in resizing feature is applied based on this setting.
|
||||
|
||||
|
||||
#### `progressive`
|
||||
|
||||
The progressive loading pattern is a technique that allows images to load incrementally, starting with a small version and gradually replacing it with a larger version as it becomes available. This is useful for improving the user experience by showing a placeholder while the image is loading.
|
||||
|
||||
Passing `progressive: true` to `createImage()` will create internal smaller versions of the image for future uses.
|
||||
|
||||
### Create multiple resized copies
|
||||
|
||||
To create multiple resized copies of an original image for better layout control, you can utilize the `createImage` function multiple times with different parameters for each desired size. Here’s an example of how you might implement this:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
declare const myFile: string;
|
||||
// ---cut---
|
||||
import { co } from "jazz-tools";
|
||||
import { createImage } from "jazz-tools/media";
|
||||
|
||||
// Jazz Schema
|
||||
const ProductImage = co.map({
|
||||
image: co.image(),
|
||||
thumbnail: co.image(),
|
||||
});
|
||||
|
||||
const mainImage = await createImage(myFile);
|
||||
const thumbnail = await createImage(myFile, {
|
||||
maxSize: 100,
|
||||
});
|
||||
|
||||
// or, in case of migration, you can use the original stored image.
|
||||
const newThumb = await createImage(mainImage!.original!.toBlob()!, {
|
||||
maxSize: 100,
|
||||
});
|
||||
|
||||
const imageSet = ProductImage.create({
|
||||
image: mainImage,
|
||||
thumbnail,
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Displaying Images
|
||||
|
||||
The Image component is the best way to let Jazz handle the image loading.
|
||||
|
||||
For a complete progressive loading experience, use the `ProgressiveImgNative` component:
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
import { ProgressiveImgNative } from "jazz-tools/expo";
|
||||
import { Image, StyleSheet } from "react-native";
|
||||
import { Image } from "jazz-tools/expo";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
function GalleryView({ image }) {
|
||||
return (
|
||||
<ProgressiveImgNative
|
||||
image={image} // The image definition to load
|
||||
targetWidth={800} // Looks for the best available resolution for a 800px image
|
||||
>
|
||||
{({ src }) => (
|
||||
<Image
|
||||
source={{ uri: src }}
|
||||
style={styles.galleryImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
)}
|
||||
</ProgressiveImgNative>
|
||||
<Image
|
||||
imageId={image.id}
|
||||
style={styles.galleryImage}
|
||||
width={400}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,120 +180,178 @@ const styles = StyleSheet.create({
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
The `ProgressiveImgNative` component handles:
|
||||
- Showing a placeholder while loading
|
||||
- Automatically selecting the appropriate resolution
|
||||
- Progressive enhancement as higher resolutions become available
|
||||
|
||||
The `Image` component handles:
|
||||
- Showing a placeholder while loading, if generated
|
||||
- Automatically selecting the appropriate resolution, if generated with progressive loading
|
||||
- Progressive enhancement as higher resolutions become available, if generated with progressive loading
|
||||
- Determining the correct width/height attributes to avoid layout shifting
|
||||
- Cleaning up resources when unmounted
|
||||
|
||||
## Using `useProgressiveImgNative` Hook
|
||||
The component's props are:
|
||||
|
||||
For more control over image loading, you can implement your own progressive image component:
|
||||
<CodeGroup>
|
||||
```ts
|
||||
export type ImageProps = Omit<
|
||||
RNImageProps,
|
||||
"width" | "height" | "source"
|
||||
> & {
|
||||
imageId: string;
|
||||
width?: number | "original";
|
||||
height?: number | "original";
|
||||
};
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
|
||||
#### Width and Height props
|
||||
|
||||
The `width` and `height` props are used to control the best resolution to use but also the width and height attributes of the image tag.
|
||||
|
||||
Let's say we have an image with a width of 1920px and a height of 1080px.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
import { useProgressiveImgNative } from "jazz-tools/expo";
|
||||
import { Image, View, Text, ActivityIndicator } from "react-native";
|
||||
<Image imageId="123" />
|
||||
// <RNImage src={...} /> with the highest resolution available
|
||||
|
||||
function CustomImageComponent({ image }) {
|
||||
const {
|
||||
src, // Data URI containing the image data as a base64 string,
|
||||
// or a placeholder image URI
|
||||
res, // The current resolution
|
||||
originalSize // The original size of the image
|
||||
} = useProgressiveImgNative({
|
||||
image: image, // The image definition to load
|
||||
targetWidth: 800 // Limit to resolutions up to 800px wide
|
||||
<Image imageId="123" width="original" height="original" />
|
||||
// <RNImage width="1920" height="1080" />
|
||||
|
||||
<Image imageId="123" width="600" />
|
||||
// <RNImage width="600" /> BAD! See https://reactnative.dev/docs/images#network-images
|
||||
|
||||
<Image imageId="123" width="600" height="original" />
|
||||
// <RNImage width="600" height="338" /> keeping the aspect ratio
|
||||
|
||||
<Image imageId="123" width="original" height="600" />
|
||||
// <RNImage width="1067" height="600" /> keeping the aspect ratio
|
||||
|
||||
<Image imageId="123" width="600" height="600" />
|
||||
// <RNImage width="600" height="600" />
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
If the image was generated with progressive loading, the `width` and `height` props will determine the best resolution to use.
|
||||
|
||||
### Imperative usage
|
||||
|
||||
Like other CoValues, `ImageDefinition` can be used to load the object.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx twoslash
|
||||
import { ImageDefinition } from "jazz-tools";
|
||||
|
||||
const image = await ImageDefinition.load("123", {
|
||||
resolve: {
|
||||
original: true,
|
||||
},
|
||||
});
|
||||
|
||||
if(image) {
|
||||
console.log({
|
||||
originalSize: image.originalSize,
|
||||
placeholderDataUrl: image.placeholderDataURL,
|
||||
original: image.original, // this FileStream may be not loaded yet
|
||||
});
|
||||
|
||||
// When image is not available yet
|
||||
if (!src) {
|
||||
return (
|
||||
<View style={{ height: 200, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f0f0f0' }}>
|
||||
<ActivityIndicator size="small" color="#0000ff" />
|
||||
<Text style={{ marginTop: 10 }}>Loading image...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// When using placeholder
|
||||
if (res === "placeholder") {
|
||||
return (
|
||||
<View style={{ position: 'relative' }}>
|
||||
<Image
|
||||
source={{ uri: src }}
|
||||
style={{ width: '100%', height: 200, opacity: 0.7 }}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<ActivityIndicator
|
||||
size="large"
|
||||
color="#ffffff"
|
||||
style={{ position: 'absolute', top: '50%', left: '50%', marginLeft: -20, marginTop: -20 }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Full image display with custom overlay
|
||||
return (
|
||||
<View style={{ position: 'relative', width: '100%', height: 200 }}>
|
||||
<Image
|
||||
source={{ uri: src }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<View style={{ position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: 'rgba(0,0,0,0.5)', padding: 8 }}>
|
||||
<Text style={{ color: 'white' }}>Resolution: {res}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Understanding ImageDefinition
|
||||
`image.original` is a `FileStream` and its content can be read as described in the [FileStream](/docs/react/using-covalues/filestreams#reading-from-filestreams) documentation.
|
||||
|
||||
Behind the scenes, `ImageDefinition` is a specialized CoValue that stores:
|
||||
|
||||
- The original image dimensions (`originalSize`)
|
||||
- An optional placeholder (`placeholderDataURL`) for immediate display
|
||||
- Multiple resolution variants of the same image as [`FileStream`s](../using-covalues/filestreams)
|
||||
|
||||
Each resolution is stored with a key in the format `"widthxheight"` (e.g., `"1920x1080"`, `"800x450"`).
|
||||
Since FileStream objects are also CoValues, they must be loaded before use. To simplify loading, if you want to load the binary data saved as Original, you can use the `loadImage` function.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
// Structure of an ImageDefinition
|
||||
const image = ImageDefinition.create({
|
||||
originalSize: [1920, 1080],
|
||||
placeholderDataURL: "data:image/jpeg;base64,/9j/4AAQSkZJRg...",
|
||||
});
|
||||
```ts twoslash
|
||||
declare const imageDefinitionOrId: string;
|
||||
// ---cut---
|
||||
import { loadImage } from "jazz-tools/media";
|
||||
|
||||
// Accessing the highest available resolution
|
||||
const highestRes = image.highestResAvailable();
|
||||
if (highestRes) {
|
||||
console.log(`Found resolution: ${highestRes.res}`);
|
||||
console.log(`Stream: ${highestRes.stream}`);
|
||||
const image = await loadImage(imageDefinitionOrId);
|
||||
if(image) {
|
||||
console.log({
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
image: image.image,
|
||||
});
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
For more details on using `ImageDefinition` directly, see the [VanillaJS docs](/docs/vanilla/using-covalues/imagedef).
|
||||
|
||||
### Fallback Behavior
|
||||
|
||||
`highestResAvailable` returns the largest resolution that fits your constraints. If a resolution has incomplete data, it falls back to the next available lower resolution.
|
||||
If the image was generated with progressive loading, and you want to access the best-fit resolution, use `loadImageBySize`. It will load the image of the best resolution that fits the wanted width and height.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
const image = ImageDefinition.create({
|
||||
originalSize: [1920, 1080],
|
||||
});
|
||||
```ts twoslash
|
||||
declare const imageDefinitionOrId: string;
|
||||
// ---cut---
|
||||
import { loadImageBySize } from "jazz-tools/media";
|
||||
|
||||
image["1920x1080"] = FileStream.create(); // Empty image upload
|
||||
image["800x450"] = await FileStream.createFromBlob(mediumSizeBlob);
|
||||
const image = await loadImageBySize(imageDefinitionOrId, 600, 600); // 600x600
|
||||
|
||||
const highestRes = image.highestResAvailable();
|
||||
console.log(highestRes.res); // 800x450
|
||||
if(image) {
|
||||
|
||||
console.log({
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
image: image.image,
|
||||
});
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
|
||||
If want to dynamically listen to the _loaded_ resolution that best fits the wanted width and height, you can use the `subscribe` and the `highestResAvailable` function.
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { ImageDefinition } from "jazz-tools";
|
||||
// ---cut---
|
||||
// function highestResAvailable(image: ImageDefinition, wantedWidth: number, wantedHeight: number): FileStream | null
|
||||
import { highestResAvailable } from "jazz-tools/media";
|
||||
|
||||
const image = await ImageDefinition.load("123");
|
||||
|
||||
image?.subscribe({}, (image) => {
|
||||
const bestImage = highestResAvailable(image, 600, 600);
|
||||
|
||||
if(bestImage) {
|
||||
// bestImage is again a FileStream
|
||||
const blob = bestImage.image.toBlob();
|
||||
if(blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
// ...
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
|
||||
|
||||
## Image manipulation custom implementation
|
||||
|
||||
As mentioned, to manipulate the images (like placeholders, resizing, etc.), `createImage()` uses different implementations depending on the environment. Currently, the image manipulation is supported on browser and react-native environments.
|
||||
|
||||
On react-native, the image manipulation is done using the `@bam.tech/react-native-image-resizer` library. If you want to use a custom implementation, you can use the `createImageFactory` function in order create your own `createImage` function and use your preferred image manipulation library.
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { createImageFactory } from "jazz-tools/media";
|
||||
|
||||
const createImage = createImageFactory({
|
||||
createFileStreamFromSource: async (source, owner) => {
|
||||
// ...
|
||||
},
|
||||
getImageSize: async (image) => {
|
||||
// ...
|
||||
},
|
||||
getPlaceholderBase64: async (image) => {
|
||||
// ...
|
||||
},
|
||||
resize: async (image, width, height) => {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -1,63 +1,75 @@
|
||||
import { CodeGroup } from "@/components/forMdx";
|
||||
|
||||
export const metadata = {
|
||||
description: "ImageDefinition is a CoValue for managing images, storage of multiple resolutions, and progressive loading."
|
||||
export const metadata = {
|
||||
description: "ImageDefinition is a CoValue for storing images with built-in UX features."
|
||||
};
|
||||
|
||||
# ImageDefinition
|
||||
|
||||
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz. It extends beyond basic file storage by supporting multiple resolutions of the same image, optimized for mobile devices.
|
||||
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting a blurry placeholder, built-in resizing, and progressive loading patterns.
|
||||
|
||||
**Note**: This guide applies to both Expo and framework-less React Native implementations.
|
||||
|
||||
Jazz offers several tools to work with images in React Native:
|
||||
- [`createImageNative()`](#creating-images) - function to create an `ImageDefinition` from a base64 image data URI
|
||||
- [`ProgressiveImgNative`](#displaying-images-with-progressiveimgnative) - React component to display an image with progressive loading
|
||||
- [`useProgressiveImgNative`](#using-useprogressiveimgnative-hook) - React hook to load an image in your own component
|
||||
Beyond ImageDefinition, Jazz offers higher-level functions and components that make it easier to use images:
|
||||
- [`createImage()`](#creating-images) - function to create an `ImageDefinition` from a file
|
||||
- [`Image`](#displaying-images) - React Native component to display a stored image
|
||||
|
||||
For examples of use, see our example apps:
|
||||
- [React Native Chat](https://github.com/gardencmp/jazz/tree/main/examples/chat-rn) (Framework-less implementation)
|
||||
- [Expo Chat](https://github.com/gardencmp/jazz/tree/main/examples/chat-rn-expo) (Expo implementation)
|
||||
- [Expo Clerk](https://github.com/gardencmp/jazz/tree/main/examples/clerk-expo) (Expo with Clerk-based authentication)
|
||||
|
||||
## Installation
|
||||
|
||||
The Jazz's images implementation is based on `@bam.tech/react-native-image-resizer`. Check the [installation guide](/docs/react-native/project-setup#install-dependencies) for more details.
|
||||
|
||||
## Creating Images
|
||||
|
||||
The easiest way to create and use images in your Jazz application is with the `createImageNative()` function:
|
||||
The easiest way to create and use images in your Jazz application is with the `createImage()` function:
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
import { createImageNative } from "jazz-tools/react-native-media-images";
|
||||
```ts
|
||||
import { Account, Group, ImageDefinition } from "jazz-tools";
|
||||
|
||||
declare const me: {
|
||||
_owner: Account | Group;
|
||||
profile: {
|
||||
image: ImageDefinition;
|
||||
};
|
||||
};
|
||||
|
||||
// ---cut---
|
||||
|
||||
import { createImage } from "jazz-tools/media";
|
||||
import { launchImageLibrary } from 'react-native-image-picker';
|
||||
|
||||
async function handleImagePicker() {
|
||||
try {
|
||||
// Launch the image picker
|
||||
const result = await launchImageLibrary({
|
||||
mediaType: 'photo',
|
||||
includeBase64: true,
|
||||
quality: 1,
|
||||
// Use your favorite image picker library to get the image URI
|
||||
const result = await launchImageLibrary({
|
||||
mediaType: 'photo',
|
||||
quality: 1,
|
||||
});
|
||||
|
||||
if (!result.didCancel && result.assets && result.assets.length > 0) {
|
||||
// Creates ImageDefinition with a blurry placeholder, limited to 1024px on the longest side, and multiple resolutions automatically.
|
||||
// See the options below for more details.
|
||||
const image = await createImage(result.assets[0].uri, {
|
||||
owner: me._owner,
|
||||
maxSize: 1024,
|
||||
placeholder: "blur",
|
||||
progressive: true,
|
||||
});
|
||||
|
||||
if (!result.canceled) {
|
||||
const base64Uri = `data:image/jpeg;base64,${result.assets[0].base64}`;
|
||||
|
||||
// Creates ImageDefinition with multiple resolutions automatically
|
||||
const image = await createImageNative(base64Uri, {
|
||||
owner: me.profile._owner,
|
||||
maxSize: 2048, // Optional: limit maximum resolution
|
||||
});
|
||||
|
||||
// Store the image
|
||||
me.profile.image = image;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating image:", error);
|
||||
// Store the image
|
||||
me.profile.image = image;
|
||||
}
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
The `createImageNative()` function:
|
||||
**Note:** `createImage()` currently supports browser and react-native environments.
|
||||
|
||||
The `createImage()` function:
|
||||
- Creates an `ImageDefinition` with the right properties
|
||||
- Generates a small placeholder for immediate display
|
||||
- Creates multiple resolution variants of your image
|
||||
@@ -65,49 +77,96 @@ The `createImageNative()` function:
|
||||
|
||||
### Configuration Options
|
||||
|
||||
You can configure `createImageNative()` with additional options:
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
// Configuration options
|
||||
const options = {
|
||||
owner: me, // Owner for access control
|
||||
maxSize: 1024 // Maximum resolution to generate
|
||||
};
|
||||
|
||||
// Setting maxSize controls which resolutions are generated:
|
||||
// 256: Only creates the smallest resolution (256px on longest side)
|
||||
// 1024: Creates 256px and 1024px resolutions
|
||||
// 2048: Creates 256px, 1024px, and 2048px resolutions
|
||||
// undefined: Creates all resolutions including the original size
|
||||
|
||||
const image = await createImageNative(base64Uri, options);
|
||||
```ts twoslash
|
||||
import type { ImageDefinition, Group, Account } from "jazz-tools";
|
||||
// ---cut---
|
||||
declare function createImage(
|
||||
image: Blob | File | string,
|
||||
options: {
|
||||
owner?: Group | Account;
|
||||
placeholder?: "blur" | false;
|
||||
maxSize?: number;
|
||||
progressive?: boolean;
|
||||
}): Promise<ImageDefinition>
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Displaying Images with `ProgressiveImgNative`
|
||||
#### `image`
|
||||
|
||||
The image to create an `ImageDefinition` from. On browser environments, this can be a `Blob` or a `File`. On React Native, this must be a `string` with the file path.
|
||||
|
||||
#### `owner`
|
||||
|
||||
The owner of the `ImageDefinition`. This is used to control access to the image. See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
|
||||
|
||||
#### `placeholder`
|
||||
|
||||
Sometimes the wanted image is not loaded yet. The placeholder is a base64 encoded image that is displayed while the image is loading. Currently, only `"blur"` is a supported.
|
||||
|
||||
#### `maxSize`
|
||||
|
||||
The image generation process includes a maximum size setting that controls the longest side of the image. A built-in resizing feature is applied based on this setting.
|
||||
|
||||
|
||||
#### `progressive`
|
||||
|
||||
The progressive loading pattern is a technique that allows images to load incrementally, starting with a small version and gradually replacing it with a larger version as it becomes available. This is useful for improving the user experience by showing a placeholder while the image is loading.
|
||||
|
||||
Passing `progressive: true` to `createImage()` will create internal smaller versions of the image for future uses.
|
||||
|
||||
### Create multiple resized copies
|
||||
|
||||
To create multiple resized copies of an original image for better layout control, you can utilize the `createImage` function multiple times with different parameters for each desired size. Here’s an example of how you might implement this:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
declare const myFile: string;
|
||||
// ---cut---
|
||||
import { co } from "jazz-tools";
|
||||
import { createImage } from "jazz-tools/media";
|
||||
|
||||
// Jazz Schema
|
||||
const ProductImage = co.map({
|
||||
image: co.image(),
|
||||
thumbnail: co.image(),
|
||||
});
|
||||
|
||||
const mainImage = await createImage(myFile);
|
||||
const thumbnail = await createImage(myFile, {
|
||||
maxSize: 100,
|
||||
});
|
||||
|
||||
// or, in case of migration, you can use the original stored image.
|
||||
const newThumb = await createImage(mainImage!.original!.toBlob()!, {
|
||||
maxSize: 100,
|
||||
});
|
||||
|
||||
const imageSet = ProductImage.create({
|
||||
image: mainImage,
|
||||
thumbnail,
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Displaying Images
|
||||
|
||||
The Image component is the best way to let Jazz handle the image loading.
|
||||
|
||||
For a complete progressive loading experience, use the `ProgressiveImgNative` component:
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
import { ProgressiveImgNative } from "jazz-tools/react-native";
|
||||
import { Image, StyleSheet } from "react-native";
|
||||
import { Image } from "jazz-tools/react-native";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
function GalleryView({ image }) {
|
||||
return (
|
||||
<ProgressiveImgNative
|
||||
image={image} // The image definition to load
|
||||
targetWidth={800} // Looks for the best available resolution for a 800px image
|
||||
>
|
||||
{({ src }) => (
|
||||
<Image
|
||||
source={{ uri: src }}
|
||||
style={styles.galleryImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
)}
|
||||
</ProgressiveImgNative>
|
||||
<Image
|
||||
imageId={image.id}
|
||||
style={styles.galleryImage}
|
||||
width={400}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,120 +180,177 @@ const styles = StyleSheet.create({
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
The `ProgressiveImgNative` component handles:
|
||||
- Showing a placeholder while loading
|
||||
- Automatically selecting the appropriate resolution
|
||||
- Progressive enhancement as higher resolutions become available
|
||||
|
||||
The `Image` component handles:
|
||||
- Showing a placeholder while loading, if generated
|
||||
- Automatically selecting the appropriate resolution, if generated with progressive loading
|
||||
- Progressive enhancement as higher resolutions become available, if generated with progressive loading
|
||||
- Determining the correct width/height attributes to avoid layout shifting
|
||||
- Cleaning up resources when unmounted
|
||||
|
||||
## Using `useProgressiveImgNative` Hook
|
||||
The component's props are:
|
||||
|
||||
For more control over image loading, you can implement your own progressive image component:
|
||||
<CodeGroup>
|
||||
```ts
|
||||
export type ImageProps = Omit<
|
||||
RNImageProps,
|
||||
"width" | "height" | "source"
|
||||
> & {
|
||||
imageId: string;
|
||||
width?: number | "original";
|
||||
height?: number | "original";
|
||||
};
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
|
||||
#### Width and Height props
|
||||
|
||||
The `width` and `height` props are used to control the best resolution to use but also the width and height attributes of the image tag.
|
||||
|
||||
Let's say we have an image with a width of 1920px and a height of 1080px.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
import { useProgressiveImgNative } from "jazz-tools/react-native";
|
||||
import { Image, View, Text, ActivityIndicator } from "react-native";
|
||||
<Image imageId="123" />
|
||||
// <RNImage src={...} /> with the highest resolution available
|
||||
|
||||
function CustomImageComponent({ image }) {
|
||||
const {
|
||||
src, // Data URI containing the image data as a base64 string,
|
||||
// or a placeholder image URI
|
||||
res, // The current resolution
|
||||
originalSize // The original size of the image
|
||||
} = useProgressiveImgNative({
|
||||
image: image, // The image definition to load
|
||||
targetWidth: 800 // Limit to resolutions up to 800px wide
|
||||
<Image imageId="123" width="original" height="original" />
|
||||
// <RNImage width="1920" height="1080" />
|
||||
|
||||
<Image imageId="123" width="600" />
|
||||
// <RNImage width="600" /> BAD! See https://reactnative.dev/docs/images#network-images
|
||||
|
||||
<Image imageId="123" width="600" height="original" />
|
||||
// <RNImage width="600" height="338" /> keeping the aspect ratio
|
||||
|
||||
<Image imageId="123" width="original" height="600" />
|
||||
// <RNImage width="1067" height="600" /> keeping the aspect ratio
|
||||
|
||||
<Image imageId="123" width="600" height="600" />
|
||||
// <RNImage width="600" height="600" />
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
If the image was generated with progressive loading, the `width` and `height` props will determine the best resolution to use.
|
||||
|
||||
### Imperative usage
|
||||
|
||||
Like other CoValues, `ImageDefinition` can be used to load the object.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx twoslash
|
||||
import { ImageDefinition } from "jazz-tools";
|
||||
|
||||
const image = await ImageDefinition.load("123", {
|
||||
resolve: {
|
||||
original: true,
|
||||
},
|
||||
});
|
||||
|
||||
if(image) {
|
||||
console.log({
|
||||
originalSize: image.originalSize,
|
||||
placeholderDataUrl: image.placeholderDataURL,
|
||||
original: image.original, // this FileStream may be not loaded yet
|
||||
});
|
||||
|
||||
// When image is not available yet
|
||||
if (!src) {
|
||||
return (
|
||||
<View style={{ height: 200, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f0f0f0' }}>
|
||||
<ActivityIndicator size="small" color="#0000ff" />
|
||||
<Text style={{ marginTop: 10 }}>Loading image...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// When using placeholder
|
||||
if (res === "placeholder") {
|
||||
return (
|
||||
<View style={{ position: 'relative' }}>
|
||||
<Image
|
||||
source={{ uri: src }}
|
||||
style={{ width: '100%', height: 200, opacity: 0.7 }}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<ActivityIndicator
|
||||
size="large"
|
||||
color="#ffffff"
|
||||
style={{ position: 'absolute', top: '50%', left: '50%', marginLeft: -20, marginTop: -20 }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Full image display with custom overlay
|
||||
return (
|
||||
<View style={{ position: 'relative', width: '100%', height: 200 }}>
|
||||
<Image
|
||||
source={{ uri: src }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<View style={{ position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: 'rgba(0,0,0,0.5)', padding: 8 }}>
|
||||
<Text style={{ color: 'white' }}>Resolution: {res}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Understanding ImageDefinition
|
||||
`image.original` is a `FileStream` and its content can be read as described in the [FileStream](/docs/react/using-covalues/filestreams#reading-from-filestreams) documentation.
|
||||
|
||||
Behind the scenes, `ImageDefinition` is a specialized CoValue that stores:
|
||||
|
||||
- The original image dimensions (`originalSize`)
|
||||
- An optional placeholder (`placeholderDataURL`) for immediate display
|
||||
- Multiple resolution variants of the same image as [`FileStream`s](../using-covalues/filestreams)
|
||||
|
||||
Each resolution is stored with a key in the format `"widthxheight"` (e.g., `"1920x1080"`, `"800x450"`).
|
||||
Since FileStream objects are also CoValues, they must be loaded before use. To simplify loading, if you want to load the binary data saved as Original, you can use the `loadImage` function.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
// Structure of an ImageDefinition
|
||||
const image = ImageDefinition.create({
|
||||
originalSize: [1920, 1080],
|
||||
placeholderDataURL: "data:image/jpeg;base64,/9j/4AAQSkZJRg...",
|
||||
});
|
||||
```ts twoslash
|
||||
declare const imageDefinitionOrId: string;
|
||||
// ---cut---
|
||||
import { loadImage } from "jazz-tools/media";
|
||||
|
||||
// Accessing the highest available resolution
|
||||
const highestRes = image.highestResAvailable();
|
||||
if (highestRes) {
|
||||
console.log(`Found resolution: ${highestRes.res}`);
|
||||
console.log(`Stream: ${highestRes.stream}`);
|
||||
const image = await loadImage(imageDefinitionOrId);
|
||||
if(image) {
|
||||
console.log({
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
image: image.image,
|
||||
});
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
For more details on using `ImageDefinition` directly, see the [VanillaJS docs](/docs/vanilla/using-covalues/imagedef).
|
||||
|
||||
### Fallback Behavior
|
||||
|
||||
`highestResAvailable` returns the largest resolution that fits your constraints. If a resolution has incomplete data, it falls back to the next available lower resolution.
|
||||
If the image was generated with progressive loading, and you want to access the best-fit resolution, use `loadImageBySize`. It will load the image of the best resolution that fits the wanted width and height.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
const image = ImageDefinition.create({
|
||||
originalSize: [1920, 1080],
|
||||
});
|
||||
```ts twoslash
|
||||
declare const imageDefinitionOrId: string;
|
||||
// ---cut---
|
||||
import { loadImageBySize } from "jazz-tools/media";
|
||||
|
||||
image["1920x1080"] = FileStream.create(); // Empty image upload
|
||||
image["800x450"] = await FileStream.createFromBlob(mediumSizeBlob);
|
||||
const image = await loadImageBySize(imageDefinitionOrId, 600, 600); // 600x600
|
||||
|
||||
const highestRes = image.highestResAvailable();
|
||||
console.log(highestRes.res); // 800x450
|
||||
if(image) {
|
||||
|
||||
console.log({
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
image: image.image,
|
||||
});
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
|
||||
If want to dynamically listen to the _loaded_ resolution that best fits the wanted width and height, you can use the `subscribe` and the `highestResAvailable` function.
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { ImageDefinition } from "jazz-tools";
|
||||
// ---cut---
|
||||
// function highestResAvailable(image: ImageDefinition, wantedWidth: number, wantedHeight: number): FileStream | null
|
||||
import { highestResAvailable } from "jazz-tools/media";
|
||||
|
||||
const image = await ImageDefinition.load("123");
|
||||
|
||||
image?.subscribe({}, (image) => {
|
||||
const bestImage = highestResAvailable(image, 600, 600);
|
||||
|
||||
if(bestImage) {
|
||||
// bestImage is again a FileStream
|
||||
const blob = bestImage.image.toBlob();
|
||||
if(blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
// ...
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
|
||||
## Image manipulation custom implementation
|
||||
|
||||
As mentioned, to manipulate the images (like placeholders, resizing, etc.), `createImage()` uses different implementations depending on the environment. Currently, the image manipulation is supported on browser and react-native environments.
|
||||
|
||||
On react-native, the image manipulation is done using the `@bam.tech/react-native-image-resizer` library. If you want to use a custom implementation, you can use the `createImageFactory` function in order create your own `createImage` function and use your preferred image manipulation library.
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { createImageFactory } from "jazz-tools/media";
|
||||
|
||||
const createImage = createImageFactory({
|
||||
createFileStreamFromSource: async (source, owner) => {
|
||||
// ...
|
||||
},
|
||||
getImageSize: async (image) => {
|
||||
// ...
|
||||
},
|
||||
getPlaceholderBase64: async (image) => {
|
||||
// ...
|
||||
},
|
||||
resize: async (image, width, height) => {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { CodeGroup } from "@/components/forMdx";
|
||||
|
||||
export const metadata = {
|
||||
description: "ImageDefinition is a CoValue for managing images, storage of multiple resolutions, and progressive loading."
|
||||
description: "ImageDefinition is a CoValue for storing images with built-in UX features."
|
||||
};
|
||||
|
||||
# ImageDefinition
|
||||
|
||||
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting multiple resolutions of the same image and progressive loading patterns.
|
||||
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting a blurry placeholder, built-in resizing, and progressive loading patterns.
|
||||
|
||||
Beyond [`ImageDefinition`](#understanding-imagedefinition), Jazz offers higher-level functions and components that make it easier to use images:
|
||||
Beyond ImageDefinition, Jazz offers higher-level functions and components that make it easier to use images:
|
||||
- [`createImage()`](#creating-images) - function to create an `ImageDefinition` from a file
|
||||
- [`ProgressiveImg`](#displaying-images-with-progressiveimg) - React component to display an image with progressive loading
|
||||
- [`useProgressiveImg`](#using-useprogressiveimg-hook) - React hook to load an image in your own component
|
||||
- [`Image`](#displaying-images) - React component to display a stored image
|
||||
|
||||
The [Image Upload example](https://github.com/gardencmp/jazz/tree/main/examples/image-upload) demonstrates use of `ProgressiveImg` and `ImageDefinition`.
|
||||
The [Image Upload example](https://github.com/gardencmp/jazz/tree/main/examples/image-upload) demonstrates use of images in Jazz.
|
||||
|
||||
## Creating Images
|
||||
|
||||
@@ -21,54 +20,38 @@ The easiest way to create and use images in your Jazz application is with the `c
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { Group, co, z } from "jazz-tools";
|
||||
import { Account, Group, ImageDefinition } from "jazz-tools";
|
||||
|
||||
const MyProfile = co.profile({
|
||||
name: z.string(),
|
||||
image: z.optional(co.image()),
|
||||
});
|
||||
|
||||
const MyAccount = co.account({
|
||||
root: co.map({}),
|
||||
profile: MyProfile,
|
||||
});
|
||||
|
||||
MyAccount.withMigration((account, creationProps) => {
|
||||
if (account.profile === undefined) {
|
||||
const profileGroup = Group.create();
|
||||
profileGroup.makePublic();
|
||||
account.profile = MyProfile.create(
|
||||
{
|
||||
name: creationProps?.name ?? "New user",
|
||||
},
|
||||
profileGroup,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const me = await MyAccount.create({});
|
||||
|
||||
const myGroup = Group.create();
|
||||
declare const me: {
|
||||
_owner: Account | Group;
|
||||
profile: {
|
||||
image: ImageDefinition;
|
||||
};
|
||||
};
|
||||
|
||||
// ---cut---
|
||||
import { createImage } from "jazz-tools/browser-media-images";
|
||||
import { createImage } from "jazz-tools/media";
|
||||
|
||||
// Create an image from a file input
|
||||
async function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
// Creates ImageDefinition with multiple resolutions automatically
|
||||
const image = await createImage(file, { owner: myGroup });
|
||||
// Creates ImageDefinition with a blurry placeholder, limited to 1024px on the longest side, and multiple resolutions automatically
|
||||
const image = await createImage(file, {
|
||||
owner: me._owner,
|
||||
maxSize: 1024,
|
||||
placeholder: "blur",
|
||||
progressive: true,
|
||||
});
|
||||
|
||||
// Store the image in your application data
|
||||
me.profile.image = image;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
**Note:** `createImage()` requires a browser environment as it uses browser APIs to process images.
|
||||
**Note:** `createImage()` currently supports browser and react-native environments.
|
||||
|
||||
The `createImage()` function:
|
||||
- Creates an `ImageDefinition` with the right properties
|
||||
@@ -78,194 +61,285 @@ The `createImage()` function:
|
||||
|
||||
### Configuration Options
|
||||
|
||||
You can configure `createImage()` with additional options:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { createImage } from "jazz-tools/browser-media-images";
|
||||
import { createJazzTestAccount } from 'jazz-tools/testing';
|
||||
const me = await createJazzTestAccount();
|
||||
const file = new File([], "test.jpg", { type: "image/jpeg" });
|
||||
```ts
|
||||
import type { ImageDefinition, Group, Account } from "jazz-tools";
|
||||
// ---cut---
|
||||
// Configuration options
|
||||
const options = {
|
||||
owner: me, // Owner for access control
|
||||
maxSize: 1024 as 1024 // Maximum resolution to generate
|
||||
};
|
||||
|
||||
// Setting maxSize controls which resolutions are generated:
|
||||
// 256: Only creates the smallest resolution (256px on longest side)
|
||||
// 1024: Creates 256px and 1024px resolutions
|
||||
// 2048: Creates 256px, 1024px, and 2048px resolutions
|
||||
// undefined: Creates all resolutions including the original size
|
||||
|
||||
const image = await createImage(file, options);
|
||||
declare function createImage(
|
||||
image: Blob | File | string,
|
||||
options: {
|
||||
owner?: Group | Account;
|
||||
placeholder?: "blur" | false;
|
||||
maxSize?: number;
|
||||
progressive?: boolean;
|
||||
}): Promise<ImageDefinition>
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Ownership
|
||||
#### `image`
|
||||
|
||||
Like other CoValues, you can specify ownership when creating image definitions.
|
||||
The image to create an `ImageDefinition` from. On browser environments, this can be a `Blob` or a `File`. On React Native, this must be a `string` with the file path.
|
||||
|
||||
#### `owner`
|
||||
|
||||
The owner of the `ImageDefinition`. This is used to control access to the image. See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
|
||||
|
||||
#### `placeholder`
|
||||
|
||||
Sometimes the wanted image is not loaded yet. The placeholder is a base64 encoded image that is displayed while the image is loading. Currently, only `"blur"` is a supported.
|
||||
|
||||
#### `maxSize`
|
||||
|
||||
The image generation process includes a maximum size setting that controls the longest side of the image. A built-in resizing feature is applied based on this setting.
|
||||
|
||||
|
||||
#### `progressive`
|
||||
|
||||
The progressive loading pattern is a technique that allows images to load incrementally, starting with a small version and gradually replacing it with a larger version as it becomes available. This is useful for improving the user experience by showing a placeholder while the image is loading.
|
||||
|
||||
Passing `progressive: true` to `createImage()` will create internal smaller versions of the image for future uses.
|
||||
|
||||
### Create multiple resized copies
|
||||
|
||||
To create multiple resized copies of an original image for better layout control, you can utilize the `createImage` function multiple times with different parameters for each desired size. Here’s an example of how you might implement this:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { Group } from "jazz-tools";
|
||||
import { createImage } from "jazz-tools/browser-media-images";
|
||||
import { createJazzTestAccount } from 'jazz-tools/testing';
|
||||
const me = await createJazzTestAccount();
|
||||
const colleagueAccount = await createJazzTestAccount();
|
||||
|
||||
const file = new File([], "test.jpg", { type: "image/jpeg" });
|
||||
|
||||
declare const myBlob: Blob;
|
||||
// ---cut---
|
||||
const teamGroup = Group.create();
|
||||
teamGroup.addMember(colleagueAccount, "writer");
|
||||
|
||||
// Create an image with shared ownership
|
||||
const teamImage = await createImage(file, { owner: teamGroup });
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
|
||||
|
||||
## Displaying Images with `ProgressiveImg`
|
||||
|
||||
For a complete progressive loading experience, use the `ProgressiveImg` component:
|
||||
|
||||
<CodeGroup>
|
||||
```tsx twoslash
|
||||
import * as React from "react";
|
||||
// ---cut---
|
||||
import { ProgressiveImg } from "jazz-tools/react";
|
||||
import { co } from "jazz-tools";
|
||||
const Image = co.image();
|
||||
import { createImage } from "jazz-tools/media";
|
||||
|
||||
function GalleryView({ image }: { image: co.loaded<typeof Image> }) {
|
||||
// Jazz Schema
|
||||
const ProductImage = co.map({
|
||||
image: co.image(),
|
||||
thumbnail: co.image(),
|
||||
});
|
||||
|
||||
const mainImage = await createImage(myBlob);
|
||||
const thumbnail = await createImage(myBlob, {
|
||||
maxSize: 100,
|
||||
});
|
||||
|
||||
// or, in case of migration, you can use the original stored image.
|
||||
const newThumb = await createImage(mainImage!.original!.toBlob()!, {
|
||||
maxSize: 100,
|
||||
});
|
||||
|
||||
const imageSet = ProductImage.create({
|
||||
image: mainImage,
|
||||
thumbnail,
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Displaying Images
|
||||
|
||||
To use the stored ImageDefinition, there are two ways: the `Image` react component, and the helpers functions.
|
||||
|
||||
### `<Image>` component
|
||||
|
||||
The Image component is the best way to let Jazz handle the image loading.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
import * as React from "react";
|
||||
import { co } from "jazz-tools";
|
||||
const ImageDef = co.image();
|
||||
// ---cut---
|
||||
import { Image } from "jazz-tools/react";
|
||||
|
||||
function GalleryView({ image }: { image: co.loaded<typeof ImageDef> }) {
|
||||
return (
|
||||
<div className="image-container">
|
||||
<ProgressiveImg
|
||||
image={image} // The image definition to load
|
||||
targetWidth={800} // Looks for the best available resolution for a 800px image
|
||||
>
|
||||
{({ src }) => (
|
||||
<img
|
||||
src={src}
|
||||
alt="Gallery image"
|
||||
className="gallery-image"
|
||||
/>
|
||||
)}
|
||||
</ProgressiveImg>
|
||||
<Image imageId={image.id} alt="Profile" width={600} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
The `ProgressiveImg` component handles:
|
||||
- Showing a placeholder while loading
|
||||
- Automatically selecting the appropriate resolution
|
||||
- Progressive enhancement as higher resolutions become available
|
||||
The `Image` component handles:
|
||||
- Showing a placeholder while loading, if generated
|
||||
- Automatically selecting the appropriate resolution, if generated with progressive loading
|
||||
- Progressive enhancement as higher resolutions become available, if generated with progressive loading
|
||||
- Determining the correct width/height attributes to avoid layout shifting
|
||||
- Cleaning up resources when unmounted
|
||||
|
||||
## Using `useProgressiveImg` Hook
|
||||
The component's props are:
|
||||
|
||||
For more control over image loading, you can implement your own progressive image component:
|
||||
<CodeGroup>
|
||||
```ts
|
||||
export type ImageProps = Omit<
|
||||
JSX.IntrinsicElements["img"],
|
||||
"src" | "srcSet" | "width" | "height"
|
||||
> & {
|
||||
imageId: string;
|
||||
width?: number | "original";
|
||||
height?: number | "original";
|
||||
};
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
#### Width and Height props
|
||||
|
||||
The `width` and `height` props are used to control the best resolution to use but also the width and height attributes of the image tag.
|
||||
|
||||
Let's say we have an image with a width of 1920px and a height of 1080px.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
<Image imageId="123" />
|
||||
// <img src={...} /> with the highest resolution available
|
||||
|
||||
<Image imageId="123" width="original" height="original" />
|
||||
// <img width="1920" height="1080" />
|
||||
|
||||
<Image imageId="123" width="600" />
|
||||
// <img width="600" /> leaving the browser to compute the height (might cause layout shift)
|
||||
|
||||
<Image imageId="123" width="600" height="original" />
|
||||
// <img width="600" height="338" /> keeping the aspect ratio
|
||||
|
||||
<Image imageId="123" width="original" height="600" />
|
||||
// <img width="1067" height="600" /> keeping the aspect ratio
|
||||
|
||||
<Image imageId="123" width="600" height="600" />
|
||||
// <img width="600" height="600" />
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
If the image was generated with progressive loading, the `width` and `height` props will determine the best resolution to use.
|
||||
|
||||
|
||||
#### Lazy loading
|
||||
|
||||
The `Image` component supports lazy loading based on [browser's strategy](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#loading). It will generate the blob url for the image when the browser's viewport reaches the image.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx
|
||||
<Image imageId="123" width="original" height="original" loading="lazy" />
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Imperative usage
|
||||
|
||||
Like other CoValues, `ImageDefinition` can be used to load the object.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx twoslash
|
||||
import * as React from "react";
|
||||
import { co } from "jazz-tools";
|
||||
const Image = co.image();
|
||||
// ---cut---
|
||||
import { useProgressiveImg } from "jazz-tools/react";
|
||||
import { ImageDefinition } from "jazz-tools";
|
||||
|
||||
function CustomImageComponent({ image }: { image: co.loaded<typeof Image> }) {
|
||||
const {
|
||||
src, // Data URI containing the image data as a base64 string,
|
||||
// or a placeholder image URI
|
||||
res, // The current resolution
|
||||
originalSize // The original size of the image
|
||||
} = useProgressiveImg({
|
||||
image: image, // The image definition to load
|
||||
targetWidth: 800 // Limit to resolutions up to 800px wide
|
||||
const image = await ImageDefinition.load("123", {
|
||||
resolve: {
|
||||
original: true,
|
||||
},
|
||||
});
|
||||
|
||||
if(image) {
|
||||
console.log({
|
||||
originalSize: image.originalSize,
|
||||
placeholderDataUrl: image.placeholderDataURL,
|
||||
original: image.original, // this FileStream may be not loaded yet
|
||||
});
|
||||
|
||||
// When image is not available yet
|
||||
if (!src) {
|
||||
return <div className="image-loading-fallback">Loading image...</div>;
|
||||
}
|
||||
|
||||
// When image is loading, show a placeholder
|
||||
if (res === "placeholder") {
|
||||
return <img src={src} alt="Loading..." className="blur-effect" />;
|
||||
}
|
||||
|
||||
// Full image display with custom overlay
|
||||
return (
|
||||
<div className="custom-image-wrapper">
|
||||
<img
|
||||
src={src}
|
||||
alt="Custom image"
|
||||
className="custom-image"
|
||||
/>
|
||||
<div className="image-overlay">
|
||||
<span className="image-caption">Resolution: {res}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Understanding ImageDefinition
|
||||
`image.original` is a `FileStream` and its content can be read as described in the [FileStream](/docs/react/using-covalues/filestreams#reading-from-filestreams) documentation.
|
||||
|
||||
Behind the scenes, `ImageDefinition` is a specialized CoValue that stores:
|
||||
|
||||
- The original image dimensions (`originalSize`)
|
||||
- An optional placeholder (`placeholderDataURL`) for immediate display
|
||||
- Multiple resolution variants of the same image as [`FileStream`s](../using-covalues/filestreams)
|
||||
|
||||
Each resolution is stored with a key in the format `"widthxheight"` (e.g., `"1920x1080"`, `"800x450"`).
|
||||
Since FileStream objects are also CoValues, they must be loaded before use. To simplify loading, if you want to load the binary data saved as Original, you can use the `loadImage` function.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
declare const imageDefinitionOrId: string;
|
||||
// ---cut---
|
||||
import { loadImage } from "jazz-tools/media";
|
||||
|
||||
const image = await loadImage(imageDefinitionOrId);
|
||||
if(image) {
|
||||
console.log({
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
image: image.image,
|
||||
});
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
If the image was generated with progressive loading, and you want to access the best-fit resolution, use `loadImageBySize`. It will load the image of the best resolution that fits the wanted width and height.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
declare const imageDefinitionOrId: string;
|
||||
// ---cut---
|
||||
import { loadImageBySize } from "jazz-tools/media";
|
||||
|
||||
const image = await loadImageBySize(imageDefinitionOrId, 600, 600); // 600x600
|
||||
|
||||
if(image) {
|
||||
|
||||
console.log({
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
image: image.image,
|
||||
});
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
|
||||
If want to dynamically listen to the _loaded_ resolution that best fits the wanted width and height, you can use the `subscribe` and the `highestResAvailable` function.
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { ImageDefinition } from "jazz-tools";
|
||||
// ---cut---
|
||||
// Structure of an ImageDefinition
|
||||
const image = ImageDefinition.create({
|
||||
originalSize: [1920, 1080],
|
||||
placeholderDataURL: "data:image/jpeg;base64,/9j/4AAQSkZJRg...",
|
||||
});
|
||||
// function highestResAvailable(image: ImageDefinition, wantedWidth: number, wantedHeight: number): FileStream | null
|
||||
import { highestResAvailable } from "jazz-tools/media";
|
||||
|
||||
// Accessing the highest available resolution
|
||||
const highestRes = ImageDefinition.highestResAvailable(image);
|
||||
if (highestRes) {
|
||||
console.log(`Found resolution: ${highestRes.res}`);
|
||||
console.log(`Stream: ${highestRes.stream}`);
|
||||
}
|
||||
const image = await ImageDefinition.load("123");
|
||||
|
||||
image?.subscribe({}, (image) => {
|
||||
const bestImage = highestResAvailable(image, 600, 600);
|
||||
|
||||
if(bestImage) {
|
||||
// bestImage is again a FileStream
|
||||
const blob = bestImage.image.toBlob();
|
||||
if(blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
// ...
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
For more details on using `ImageDefinition` directly, see the [VanillaJS docs](/docs/vanilla/using-covalues/imagedef).
|
||||
|
||||
### Fallback Behavior
|
||||
## Image manipulation custom implementation
|
||||
|
||||
`highestResAvailable` returns the largest resolution that fits your constraints. If a resolution has incomplete data, it falls back to the next available lower resolution.
|
||||
To manipulate the images (like placeholders, resizing, etc.), `createImage()` uses different implementations depending on the environment. Currently, the image manipulation is supported on browser and react-native environments.
|
||||
|
||||
On the browser, the image manipulation is done using the `canvas` API. If you want to use a custom implementation, you can use the `createImageFactory` function in order create your own `createImage` function and use your preferred image manipulation library.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { ImageDefinition, FileStream } from "jazz-tools";
|
||||
const mediumSizeBlob = new Blob([], { type: "image/jpeg" });
|
||||
```ts
|
||||
import { createImageFactory } from "jazz-tools/media";
|
||||
|
||||
// ---cut---
|
||||
const image = ImageDefinition.create({
|
||||
originalSize: [1920, 1080],
|
||||
const createImage = createImageFactory({
|
||||
createFileStreamFromSource: async (source, owner) => {
|
||||
// ...
|
||||
},
|
||||
getImageSize: async (image) => {
|
||||
// ...
|
||||
},
|
||||
getPlaceholderBase64: async (image) => {
|
||||
// ...
|
||||
},
|
||||
resize: async (image, width, height) => {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
|
||||
image["1920x1080"] = FileStream.create(); // Empty image upload
|
||||
image["800x450"] = await FileStream.createFromBlob(mediumSizeBlob);
|
||||
|
||||
const highestRes = ImageDefinition.highestResAvailable(image);
|
||||
console.log(highestRes?.res); // 800x450
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { CodeGroup } from "@/components/forMdx";
|
||||
|
||||
export const metadata = {
|
||||
description: "ImageDefinition is a CoValue for storing images with built-in UX features."
|
||||
};
|
||||
|
||||
# ImageDefinition
|
||||
|
||||
`ImageDefinition` is a specialized CoValue designed specifically for managing images in Jazz applications. It extends beyond basic file storage by supporting a blurry placeholder, built-in resizing, and progressive loading patterns.
|
||||
|
||||
Beyond [`ImageDefinition`](#understanding-imagedefinition), Jazz offers higher-level functions and components that make it easier to use images:
|
||||
- [`createImage()`](#creating-images) - function to create an `ImageDefinition` from a file
|
||||
- [`Image`](#displaying-images) - Svelte component to display a stored image
|
||||
|
||||
The [Image Upload example](https://github.com/gardencmp/jazz/tree/main/examples/image-upload) demonstrates use of images in Jazz.
|
||||
|
||||
## Creating Images
|
||||
|
||||
The easiest way to create and use images in your Jazz application is with the `createImage()` function:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { Account, Group, ImageDefinition } from "jazz-tools";
|
||||
|
||||
declare const me: {
|
||||
_owner: Account | Group;
|
||||
profile: {
|
||||
image: ImageDefinition;
|
||||
};
|
||||
};
|
||||
|
||||
// ---cut---
|
||||
import { createImage } from "jazz-tools/media";
|
||||
|
||||
// Create an image from a file input
|
||||
async function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
// Creates ImageDefinition with a blurry placeholder, limited to 1024px on the longest side, and multiple resolutions automatically
|
||||
const image = await createImage(file, {
|
||||
owner: me._owner,
|
||||
maxSize: 1024,
|
||||
placeholder: "blur",
|
||||
progressive: true,
|
||||
});
|
||||
|
||||
// Store the image in your application data
|
||||
me.profile.image = image;
|
||||
}
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
**Note:** `createImage()` currently supports browser and react-native environments.
|
||||
|
||||
The `createImage()` function:
|
||||
- Creates an `ImageDefinition` with the right properties
|
||||
- Generates a small placeholder for immediate display
|
||||
- Creates multiple resolution variants of your image
|
||||
- Returns the created `ImageDefinition`
|
||||
|
||||
### Configuration Options
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import type { ImageDefinition, Group, Account } from "jazz-tools";
|
||||
// ---cut---
|
||||
declare function createImage(
|
||||
image: Blob | File | string,
|
||||
options: {
|
||||
owner?: Group | Account;
|
||||
placeholder?: "blur" | false;
|
||||
maxSize?: number;
|
||||
progressive?: boolean;
|
||||
}): Promise<ImageDefinition>
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
#### `image`
|
||||
|
||||
The image to create an `ImageDefinition` from. On browser environments, this can be a `Blob` or a `File`. On React Native, this must be a `string` with the file path.
|
||||
|
||||
#### `owner`
|
||||
|
||||
The owner of the `ImageDefinition`. This is used to control access to the image. See [Groups as permission scopes](/docs/groups/intro) for more information on how to use groups to control access to images.
|
||||
|
||||
#### `placeholder`
|
||||
|
||||
Sometimes the wanted image is not loaded yet. The placeholder is a base64 encoded image that is displayed while the image is loading. Currently, only `"blur"` is a supported.
|
||||
|
||||
#### `maxSize`
|
||||
|
||||
The image generation process includes a maximum size setting that controls the longest side of the image. A built-in resizing feature is applied based on this setting.
|
||||
|
||||
|
||||
#### `progressive`
|
||||
|
||||
The progressive loading pattern is a technique that allows images to load incrementally, starting with a small version and gradually replacing it with a larger version as it becomes available. This is useful for improving the user experience by showing a placeholder while the image is loading.
|
||||
|
||||
Passing `progressive: true` to `createImage()` will create internal smaller versions of the image for future uses.
|
||||
|
||||
### Create multiple resized copies
|
||||
|
||||
To create multiple resized copies of an original image for better layout control, you can utilize the `createImage` function multiple times with different parameters for each desired size. Here’s an example of how you might implement this:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
declare const myBlob: Blob;
|
||||
// ---cut---
|
||||
import { co } from "jazz-tools";
|
||||
import { createImage } from "jazz-tools/media";
|
||||
|
||||
// Jazz Schema
|
||||
const ProductImage = co.map({
|
||||
image: co.image(),
|
||||
thumbnail: co.image(),
|
||||
});
|
||||
|
||||
const mainImage = await createImage(myBlob);
|
||||
const thumbnail = await createImage(myBlob, {
|
||||
maxSize: 100,
|
||||
});
|
||||
|
||||
// or, in case of migration, you can use the original stored image.
|
||||
const newThumb = await createImage(mainImage!.original!.toBlob()!, {
|
||||
maxSize: 100,
|
||||
});
|
||||
|
||||
const imageSet = ProductImage.create({
|
||||
image: mainImage,
|
||||
thumbnail,
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Displaying Images
|
||||
|
||||
To use the stored ImageDefinition, there are two ways: the `Image` react component, and the helpers functions.
|
||||
|
||||
### `<Image>` component
|
||||
|
||||
The Image component is the best way to let Jazz handle the image loading.
|
||||
|
||||
<CodeGroup>
|
||||
```svelte twoslash
|
||||
<script lang="ts">
|
||||
import { ImageDefinition, type Loaded } from 'jazz-tools';
|
||||
import { Image } from 'jazz-tools/svelte';
|
||||
let { image }: { image: Loaded<typeof ImageDefinition> } = $props();
|
||||
</script>
|
||||
|
||||
<Image
|
||||
imageId={image.id}
|
||||
alt=""
|
||||
class="h-auto max-h-[20rem] max-w-full rounded-t-xl mb-1"
|
||||
/>
|
||||
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
The `Image` component handles:
|
||||
- Showing a placeholder while loading, if generated
|
||||
- Automatically selecting the appropriate resolution, if generated with progressive loading
|
||||
- Progressive enhancement as higher resolutions become available, if generated with progressive loading
|
||||
- Determining the correct width/height attributes to avoid layout shifting
|
||||
- Cleaning up resources when unmounted
|
||||
|
||||
The component's props are:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
interface ImageProps extends Omit<HTMLImgAttributes, "width" | "height"> {
|
||||
imageId: string;
|
||||
width?: number | "original";
|
||||
height?: number | "original";
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
#### Width and Height props
|
||||
|
||||
The `width` and `height` props are used to control the best resolution to use but also the width and height attributes of the image tag.
|
||||
|
||||
Let's say we have an image with a width of 1920px and a height of 1080px.
|
||||
|
||||
<CodeGroup>
|
||||
```svelte
|
||||
<Image imageId="123" />
|
||||
// <img src={...} /> with the highest resolution available
|
||||
|
||||
<Image imageId="123" width="original" height="original" />
|
||||
// <img width="1920" height="1080" />
|
||||
|
||||
<Image imageId="123" width="600" />
|
||||
// <img width="600" /> leaving the browser to compute the height (might cause layout shift)
|
||||
|
||||
<Image imageId="123" width="600" height="original" />
|
||||
// <img width="600" height="338" /> keeping the aspect ratio
|
||||
|
||||
<Image imageId="123" width="original" height="600" />
|
||||
// <img width="1067" height="600" /> keeping the aspect ratio
|
||||
|
||||
<Image imageId="123" width="600" height="600" />
|
||||
// <img width="600" height="600" />
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
If the image was generated with progressive loading, the `width` and `height` props will determine the best resolution to use.
|
||||
|
||||
### Imperative usage
|
||||
|
||||
Like other CoValues, `ImageDefinition` can be used to load the object.
|
||||
|
||||
<CodeGroup>
|
||||
```tsx twoslash
|
||||
import { ImageDefinition } from "jazz-tools";
|
||||
|
||||
const image = await ImageDefinition.load("123", {
|
||||
resolve: {
|
||||
original: true,
|
||||
},
|
||||
});
|
||||
|
||||
if(image) {
|
||||
console.log({
|
||||
originalSize: image.originalSize,
|
||||
placeholderDataUrl: image.placeholderDataURL,
|
||||
original: image.original, // this FileStream may be not loaded yet
|
||||
});
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
`image.original` is a `FileStream` and its content can be read as described in the [FileStream](/docs/react/using-covalues/filestreams#reading-from-filestreams) documentation.
|
||||
|
||||
Since FileStream objects are also CoValues, they must be loaded before use. To simplify loading, if you want to load the binary data saved as Original, you can use the `loadImage` function.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
declare const imageDefinitionOrId: string;
|
||||
// ---cut---
|
||||
import { loadImage } from "jazz-tools/media";
|
||||
|
||||
const image = await loadImage(imageDefinitionOrId);
|
||||
if(image) {
|
||||
console.log({
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
image: image.image,
|
||||
});
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
If the image was generated with progressive loading, and you want to access the best-fit resolution, use `loadImageBySize`. It will load the image of the best resolution that fits the wanted width and height.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
declare const imageDefinitionOrId: string;
|
||||
// ---cut---
|
||||
import { loadImageBySize } from "jazz-tools/media";
|
||||
|
||||
const image = await loadImageBySize(imageDefinitionOrId, 600, 600); // 600x600
|
||||
|
||||
if(image) {
|
||||
|
||||
console.log({
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
image: image.image,
|
||||
});
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
|
||||
If want to dynamically listen to the _loaded_ resolution that best fits the wanted width and height, you can use the `subscribe` and the `highestResAvailable` function.
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { ImageDefinition } from "jazz-tools";
|
||||
// ---cut---
|
||||
// function highestResAvailable(image: ImageDefinition, wantedWidth: number, wantedHeight: number): FileStream | null
|
||||
import { highestResAvailable } from "jazz-tools/media";
|
||||
|
||||
const image = await ImageDefinition.load("123");
|
||||
|
||||
image?.subscribe({}, (image) => {
|
||||
const bestImage = highestResAvailable(image, 600, 600);
|
||||
|
||||
if(bestImage) {
|
||||
// bestImage is again a FileStream
|
||||
const blob = bestImage.image.toBlob();
|
||||
if(blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
// ...
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
|
||||
|
||||
## Image manipulation custom implementation
|
||||
|
||||
To manipulate the images (like placeholders, resizing, etc.), `createImage()` uses different implementations depending on the environment. Currently, the image manipulation is supported on browser and react-native environments.
|
||||
|
||||
On the browser, the image manipulation is done using the `canvas` API. If you want to use a custom implementation, you can use the `createImageFactory` function in order create your own `createImage` function and use your preferred image manipulation library.
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { createImageFactory } from "jazz-tools/media";
|
||||
|
||||
const createImage = createImageFactory({
|
||||
createFileStreamFromSource: async (source, owner) => {
|
||||
// ...
|
||||
},
|
||||
getImageSize: async (image) => {
|
||||
// ...
|
||||
},
|
||||
getPlaceholderBase64: async (image) => {
|
||||
// ...
|
||||
},
|
||||
resize: async (image, width, height) => {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user