Compare commits
302 Commits
cojson@0.1
...
cojson@0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c936c8c611 | ||
|
|
58c6013770 | ||
|
|
3eb3291a97 | ||
|
|
6b659f2df3 | ||
|
|
dcc9c9a5ec | ||
|
|
fe9a244363 | ||
|
|
9440bbc058 | ||
|
|
1c92cc2997 | ||
|
|
33ebbf0bdd | ||
|
|
d630b5bde5 | ||
|
|
1c6ae12cd9 | ||
|
|
21bcaabd5a | ||
|
|
17b4d5b668 | ||
|
|
3cd15862d5 | ||
|
|
b3d1ad7201 | ||
|
|
d87df11795 | ||
|
|
82c2a62b2a | ||
|
|
0a9112506e | ||
|
|
fbc29f2f17 | ||
|
|
f6361ee43b | ||
|
|
726dbfb6df | ||
|
|
267f689f10 | ||
|
|
893ad3ae23 | ||
|
|
f5590b1be8 | ||
|
|
17a01f57e8 | ||
|
|
7318d86f52 | ||
|
|
1c8403e87a | ||
|
|
dd747c068a | ||
|
|
1f0f230fe2 | ||
|
|
da655cbff5 | ||
|
|
02f6c6220e | ||
|
|
0755cd198e | ||
|
|
c4a8227b66 | ||
|
|
86f0302233 | ||
|
|
165a6170cd | ||
|
|
5148419df9 | ||
|
|
fc0ecb0968 | ||
|
|
802b5a3060 | ||
|
|
e47af262b3 | ||
|
|
e98b610fd0 | ||
|
|
b554983558 | ||
|
|
d95dcbe7db | ||
|
|
f9d538f049 | ||
|
|
93e68c62f5 | ||
|
|
dadee9dcc5 | ||
|
|
6724c4bd83 | ||
|
|
1942bd5de4 | ||
|
|
16764f6365 | ||
|
|
b56cfc2e1f | ||
|
|
7091bcf9c0 | ||
|
|
436cbfa095 | ||
|
|
104e664bbb | ||
|
|
f199b451eb | ||
|
|
70bc48458e | ||
|
|
f28b2a6135 | ||
|
|
55b770b7c9 | ||
|
|
e6838dfb98 | ||
|
|
5e34061fdc | ||
|
|
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 | ||
|
|
1b881cc89f | ||
|
|
af295d816a | ||
|
|
fe8d3497c0 | ||
|
|
c2899e94ca | ||
|
|
f4be67e9b6 | ||
|
|
ba9ad295b6 | ||
|
|
9ed5a96ef8 | ||
|
|
4272ea9019 | ||
|
|
9509307ed1 | ||
|
|
be08921bc5 | ||
|
|
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 | ||
|
|
239da90c9f | ||
|
|
972791e7a8 | ||
|
|
0c0178764e | ||
|
|
928350b821 | ||
|
|
be3fd9c696 | ||
|
|
269c028df0 | ||
|
|
e4df837138 | ||
|
|
54fe6d93ba | ||
|
|
979689c6d8 | ||
|
|
859a37868f | ||
|
|
57bd32d77e | ||
|
|
e21cbccd4b | ||
|
|
a66ab7d174 | ||
|
|
78e91f4030 | ||
|
|
7a915c198e | ||
|
|
c9b0420746 | ||
|
|
2303f3e70a | ||
|
|
a7bc9569a3 | ||
|
|
f351ba0fcd | ||
|
|
d3e554f491 | ||
|
|
b5e31456ad | ||
|
|
42d07ba7b4 | ||
|
|
b81b6ba69b | ||
|
|
1bc1759bb4 | ||
|
|
225bc1f63f | ||
|
|
5d94564f99 | ||
|
|
9633d0187f | ||
|
|
b82ecaa3ca | ||
|
|
111ec8d351 | ||
|
|
512aacdbc2 | ||
|
|
7ad843aa3e | ||
|
|
071128339b | ||
|
|
688ced499d | ||
|
|
ac91c8e7c2 | ||
|
|
c3b303c310 | ||
|
|
fc027a56db | ||
|
|
959a7a3927 | ||
|
|
2548085b59 | ||
|
|
b27bb3e65b | ||
|
|
d1efde468f | ||
|
|
4d4fd0beaa | ||
|
|
2b61e853a7 | ||
|
|
6f79b45544 | ||
|
|
2e1ff99579 | ||
|
|
7361854ee4 | ||
|
|
4a775fada3 | ||
|
|
fe37516786 | ||
|
|
4beafb7cf3 | ||
|
|
82a592e08a | ||
|
|
4c6926153a | ||
|
|
c51b088243 | ||
|
|
3a1fdd7600 | ||
|
|
3fdbb43b54 | ||
|
|
02969ee89b | ||
|
|
9b4988a514 | ||
|
|
8aa4b59d49 | ||
|
|
ac782674de | ||
|
|
5eb406d54d | ||
|
|
a3be832414 | ||
|
|
7ca8dd960e | ||
|
|
62c8aff73f | ||
|
|
7731109a28 | ||
|
|
4a29999c6a | ||
|
|
73b99c6c1a | ||
|
|
039b92c839 | ||
|
|
618af5f1e3 | ||
|
|
dfc4286694 | ||
|
|
970ff0d813 | ||
|
|
65eee0ef01 | ||
|
|
eee221f563 | ||
|
|
97f6bcedbd | ||
|
|
7c63e6bb0f | ||
|
|
08aedcf517 | ||
|
|
3e12ee127f | ||
|
|
2283d375ef | ||
|
|
202e763380 | ||
|
|
52bbdb37a9 | ||
|
|
96f743b2f4 | ||
|
|
f5c47feeb6 | ||
|
|
6f9ee31179 | ||
|
|
52f324ffc4 | ||
|
|
2d86f53575 | ||
|
|
56ccf9ab9d | ||
|
|
b8b0851433 | ||
|
|
2bbb07b0bf | ||
|
|
d3053955d8 | ||
|
|
f40484eca9 | ||
|
|
d581a59aa1 | ||
|
|
0ca09f75c1 | ||
|
|
e8fcd101f2 | ||
|
|
cf43fa7529 | ||
|
|
df1cdda4e8 | ||
|
|
be46042cdc | ||
|
|
7a60d7bb76 | ||
|
|
f8263a8358 | ||
|
|
f6da966922 | ||
|
|
8a2ab51543 | ||
|
|
8bfaa0a18b | ||
|
|
147be76399 | ||
|
|
36770bed52 | ||
|
|
466e6c44ee | ||
|
|
5bd8277161 | ||
|
|
8b4261f7d8 | ||
|
|
0ec917e453 | ||
|
|
6326d0fc45 | ||
|
|
d746b1279a | ||
|
|
c09dcdfc76 | ||
|
|
4402c553b6 | ||
|
|
e76fe343da | ||
|
|
dc183a19b2 | ||
|
|
fef55a4cd6 | ||
|
|
ddef54048f | ||
|
|
a2626a0f38 | ||
|
|
ec579bcaf7 | ||
|
|
8aa2d2a789 | ||
|
|
a39d009b87 | ||
|
|
6b662b0efe | ||
|
|
e9af90c841 | ||
|
|
2b7c6f5aa7 | ||
|
|
d73a3d9d46 | ||
|
|
8af39077a3 | ||
|
|
54bd487818 | ||
|
|
f01dab5c8f | ||
|
|
a8b3ec7bb0 | ||
|
|
a7f6870048 | ||
|
|
3b294f6994 | ||
|
|
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 | ||
|
|
255a947ea6 | ||
|
|
530a263d35 | ||
|
|
745020b7a8 | ||
|
|
991aebf7a7 | ||
|
|
9b75880b10 | ||
|
|
98fe72ed42 | ||
|
|
1f5c81c2ea | ||
|
|
fc41aa165b | ||
|
|
41466ea399 | ||
|
|
3b91594d10 | ||
|
|
265a6405af | ||
|
|
a4d23d527b |
1
.github/workflows/playwright.yml
vendored
1
.github/workflows/playwright.yml
vendored
@@ -55,6 +55,7 @@ jobs:
|
||||
"examples/inspector"
|
||||
"examples/music-player"
|
||||
"examples/organization"
|
||||
"examples/server-worker-http"
|
||||
"starters/react-passkey-auth"
|
||||
"starters/svelte-passkey-auth"
|
||||
"tests/jazz-svelte"
|
||||
|
||||
@@ -1,5 +1,65 @@
|
||||
# passkey-svelte
|
||||
|
||||
## 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
|
||||
|
||||
- Updated dependencies [9633d01]
|
||||
- Updated dependencies [4beafb7]
|
||||
- jazz-tools@0.15.16
|
||||
|
||||
## 0.0.104
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chat-svelte",
|
||||
"version": "0.0.104",
|
||||
"version": "0.0.111",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"lucide-react": "^0.274.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,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,10 +1,9 @@
|
||||
import { Loaded } from "jazz-tools";
|
||||
import { BubbleTeaOrder } from "./schema.ts";
|
||||
|
||||
export function OrderThumbnail({
|
||||
order,
|
||||
}: {
|
||||
order: Loaded<typeof BubbleTeaOrder>;
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
1
examples/jazz-nextjs/src/apiKey.ts
Normal file
1
examples/jazz-nextjs/src/apiKey.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const apiKey = "jazz-nextjs@garden.co";
|
||||
@@ -1,12 +1,13 @@
|
||||
import { JazzInspector } from "jazz-tools/inspector";
|
||||
import { JazzReactProvider } from "jazz-tools/react";
|
||||
import { apiKey } from "./apiKey";
|
||||
|
||||
export function Jazz({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<JazzReactProvider
|
||||
enableSSR
|
||||
sync={{
|
||||
peer: `wss://cloud.jazz.tools/`,
|
||||
peer: `wss://cloud.jazz.tools/?key=${apiKey}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -66,8 +71,8 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
return (
|
||||
<SidebarInset className="flex flex-col h-screen text-gray-800 bg-blue-50">
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<SidePanel mediaPlayer={mediaPlayer} />
|
||||
<main className="flex-1 p-6 overflow-y-auto overflow-x-hidden">
|
||||
<SidePanel />
|
||||
<main className="flex-1 p-6 overflow-y-auto overflow-x-hidden relative">
|
||||
<SidebarTrigger />
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{isRootPlaylist ? (
|
||||
@@ -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(
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -10,27 +10,34 @@ 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 { Fragment, useCallback, useState } from "react";
|
||||
import { EditTrackDialog } from "./RenameTrackDialog";
|
||||
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,
|
||||
}: {
|
||||
trackId: string;
|
||||
isLoading: boolean;
|
||||
isPlaying: boolean;
|
||||
onClick: (track: Loaded<typeof MusicTrack>) => void;
|
||||
showAddToPlaylist: boolean;
|
||||
}) {
|
||||
const track = useCoState(MusicTrack, trackId);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
const { me } = useAccount(MusicaAccount, {
|
||||
resolve: { root: { playlists: { $each: true } } },
|
||||
resolve: { root: { playlists: { $each: { tracks: true } } } },
|
||||
});
|
||||
|
||||
const playlists = me?.root.playlists ?? [];
|
||||
@@ -60,10 +67,18 @@ export function MusicTrackRow({
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
setIsEditDialogOpen(true);
|
||||
}
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDropdownOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={"flex gap-1 hover:bg-slate-200 group py-2 px-2 cursor-pointer"}
|
||||
onClick={handleTrackClick}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
@@ -81,50 +96,57 @@ export function MusicTrackRow({
|
||||
"▶️"
|
||||
)}
|
||||
</button>
|
||||
<MusicTrackTitleInput trackId={trackId} />
|
||||
<button
|
||||
onContextMenu={handleContextMenu}
|
||||
onClick={handleTrackClick}
|
||||
className="w-full flex items-center overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{track?.title}
|
||||
</button>
|
||||
<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}>
|
||||
<DropdownMenuItem
|
||||
key={`add-${index}`}
|
||||
onSelect={() => handleAddToPlaylist(playlist)}
|
||||
>
|
||||
Add to {playlist.title}
|
||||
</DropdownMenuItem>
|
||||
<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, index) => (
|
||||
<Fragment key={index}>
|
||||
{isPartOfThePlaylist(trackId, playlist) ? (
|
||||
<DropdownMenuItem
|
||||
key={`remove-${index}`}
|
||||
onSelect={() => handleRemoveFromPlaylist(playlist)}
|
||||
>
|
||||
Remove from {playlist.title}
|
||||
</DropdownMenuItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
key={`add-${index}`}
|
||||
onSelect={() => handleAddToPlaylist(playlist)}
|
||||
>
|
||||
Add to {playlist.title}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{track && isEditDialogOpen && (
|
||||
<EditTrackDialog
|
||||
track={track}
|
||||
isOpen={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
onDelete={deleteTrack}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,25 +24,25 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
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">
|
||||
<footer className="flex items-center justify-between p-2 sm:p-4 gap-2 sm:gap-4 bg-white border-t border-gray-200 absolute bottom-0 left-0 right-0 w-full z-50">
|
||||
<div className="flex justify-center items-center space-x-1 sm:space-x-2 flex-shrink-0">
|
||||
<div className="flex items-center space-x-2 sm:space-x-4">
|
||||
<button
|
||||
onClick={mediaPlayer.playPrevTrack}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
aria-label="Previous track"
|
||||
>
|
||||
<SkipBack size={20} />
|
||||
<SkipBack size={16} className="sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
<button
|
||||
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="w-8 h-8 sm:w-[42px] sm:h-[42px] flex items-center justify-center bg-blue-600 rounded-full text-white hover:bg-blue-700"
|
||||
aria-label={isPlaying ? "Pause active track" : "Play active track"}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={24} fill="currentColor" />
|
||||
<Pause size={16} className="sm:w-6 sm:h-6" fill="currentColor" />
|
||||
) : (
|
||||
<Play size={24} fill="currentColor" />
|
||||
<Play size={16} className="sm:w-6 sm:h-6" fill="currentColor" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
@@ -50,16 +50,22 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
aria-label="Next track"
|
||||
>
|
||||
<SkipForward size={20} />
|
||||
<SkipForward size={16} className="sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className=" sm:hidden md:flex flex-col flex-shrink-1 items-center w-[75%]">
|
||||
<Waveform track={activeTrack} height={30} />
|
||||
<div className="md:hidden sm:hidden lg:flex flex-1 justify-center items-center min-w-0 px-2">
|
||||
<Waveform
|
||||
track={activeTrack}
|
||||
height={30}
|
||||
className="h-5 sm:h-6 md:h-8 lg:h-10"
|
||||
/>
|
||||
</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">
|
||||
<div className="flex flex-col items-end gap-1 text-right min-w-fit flex-shrink-0">
|
||||
<h4 className="font-medium text-blue-800 text-sm sm:text-base truncate max-w-32 sm:max-w-80">
|
||||
{activeTrackTitle}
|
||||
</h4>
|
||||
<p className="text-xs sm:text-sm text-gray-600 truncate max-w-32 sm:max-w-80">
|
||||
{activePlaylist?.title || "All tracks"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
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,7 @@ import { useCoState } from "jazz-tools/react";
|
||||
export function Waveform(props: {
|
||||
track: Loaded<typeof MusicTrack>;
|
||||
height: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const { track, height } = props;
|
||||
const waveformData = useCoState(
|
||||
@@ -36,7 +37,7 @@ export function Waveform(props: {
|
||||
|
||||
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,
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
html {
|
||||
overflow: hidden;
|
||||
max-width: 1200px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(20 14.3% 4.1%);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
47
examples/server-worker-http/.gitignore
vendored
Normal file
47
examples/server-worker-http/.gitignore
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
105
examples/server-worker-http/README.md
Normal file
105
examples/server-worker-http/README.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Rock Paper Scissors with Jazz HTTP API
|
||||
|
||||
This example demonstrates how to use the **Jazz HTTP API** with **Next.js** to implement updates in a trusted environment. The application implements a multiplayer Rock Paper Scissors game where the server validates game actions and reveals player intentions only after both players have made their moves.
|
||||
|
||||
## 🎯 Key Concepts Demonstrated
|
||||
|
||||
### Trusted Environment Updates
|
||||
- **Server-side validation**: All game actions are validated by the server before being applied
|
||||
- **Secure reveal mechanism**: Player choices are hidden until both players have acted, built with Jazz group permissions
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 20+
|
||||
- pnpm (recommended) or npm
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Install dependencies**:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. **Generate environment variables**:
|
||||
```bash
|
||||
pnpm generate-env
|
||||
```
|
||||
|
||||
3. **Start the development server**:
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
4. **Open your browser** to [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
### Environment Setup
|
||||
|
||||
The `generate-env.ts` script creates the necessary environment variables for Jazz:
|
||||
- `NEXT_PUBLIC_JAZZ_WORKER_ACCOUNT`: Server worker account ID
|
||||
- `JAZZ_WORKER_SECRET`: Server worker secret key
|
||||
|
||||
## 🔧 Key Implementation Details
|
||||
|
||||
### Server API Definition
|
||||
```typescript
|
||||
const playRequest = experimental_defineRequest({
|
||||
url: "/api/play",
|
||||
workerId,
|
||||
request: {
|
||||
schema: {
|
||||
game: Game,
|
||||
selection: z.literal(["rock", "paper", "scissors"]),
|
||||
},
|
||||
resolve: {
|
||||
game: {
|
||||
player1: { account: true, playSelection: { group: true } },
|
||||
player2: { account: true, playSelection: { group: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
schema: { game: Game },
|
||||
resolve: { game: true },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Secure Move Handling
|
||||
```typescript
|
||||
// Create restricted group for the move
|
||||
const group = Group.create({ owner: jazzServerAccount.worker });
|
||||
group.addMember(madeBy, "reader");
|
||||
|
||||
// Store move with restricted access
|
||||
const playSelection = PlaySelection.create(
|
||||
{ value: selection, group },
|
||||
group,
|
||||
);
|
||||
|
||||
// Reveal moves only after both players have acted
|
||||
if (player1PlaySelection && player2PlaySelection) {
|
||||
player1PlaySelection.group.addMember(game.player2.account, "reader");
|
||||
player2PlaySelection.group.addMember(game.player1.account, "reader");
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 Use Cases
|
||||
|
||||
This pattern is ideal for applications requiring:
|
||||
|
||||
- **Fair play guarantees**: Preventing cheating in games
|
||||
- **Simultaneous reveals**: Auctions, voting, or sealed-bid systems
|
||||
- **Trusted computation**: Server-side validation of complex business logic
|
||||
- **Real-time collaboration**: Multi-user applications with strict rules
|
||||
|
||||
## 📚 Learn More
|
||||
|
||||
- [Jazz Documentation](https://jazz.tools/docs)
|
||||
- [Next.js App Router](https://nextjs.org/docs/app)
|
||||
- [Groups & Permissions](https://jazz.tools/docs/react/groups/intro)
|
||||
- [HTTP API with experimental_defineRequest](https://jazz.tools/docs/react/server-side/http-requests)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
This example is part of the Jazz framework. Feel free to submit issues and enhancement requests!
|
||||
21
examples/server-worker-http/components.json
Normal file
21
examples/server-worker-http/components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
26
examples/server-worker-http/generate-env.ts
Normal file
26
examples/server-worker-http/generate-env.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as fs from "fs";
|
||||
import { createWorkerAccount } from "jazz-run/createWorkerAccount";
|
||||
|
||||
if (fs.existsSync(".env")) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const account = await createWorkerAccount({
|
||||
name: "jazz-paper-scissors-worker",
|
||||
peer: "wss://cloud.jazz.tools/?key=jazz-paper-scissors@garden.co",
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
".env",
|
||||
`
|
||||
JAZZ_WORKER_ACCOUNT=${account.accountID}
|
||||
NEXT_PUBLIC_JAZZ_WORKER_ACCOUNT=${account.accountID}
|
||||
JAZZ_WORKER_SECRET=${account.agentSecret}
|
||||
`,
|
||||
);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
5
examples/server-worker-http/next.config.ts
Normal file
5
examples/server-worker-http/next.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
40
examples/server-worker-http/package.json
Normal file
40
examples/server-worker-http/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "server-worker-http",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"generate-env": "tsx generate-env.ts",
|
||||
"build": "pnpm generate-env && next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "15.3.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"jazz-run": "workspace:*",
|
||||
"tsx": "^4.19.3",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
55
examples/server-worker-http/playwright.config.ts
Normal file
55
examples/server-worker-http/playwright.config.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import isCI from "is-ci";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: isCI,
|
||||
/* Retry on CI only */
|
||||
retries: isCI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: isCI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
|
||||
timeout: 20_000,
|
||||
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://localhost:3000/",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
permissions: ["clipboard-read", "clipboard-write"],
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: [
|
||||
{
|
||||
command: "pnpm start",
|
||||
url: "http://localhost:3000/",
|
||||
reuseExistingServer: !isCI,
|
||||
},
|
||||
],
|
||||
});
|
||||
2
examples/server-worker-http/postcss.config.mjs
Normal file
2
examples/server-worker-http/postcss.config.mjs
Normal file
@@ -0,0 +1,2 @@
|
||||
const config = { plugins: { "@tailwindcss/postcss": {} } };
|
||||
export default config;
|
||||
1
examples/server-worker-http/src/apiKey.ts
Normal file
1
examples/server-worker-http/src/apiKey.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const apiKey = "server-side-validation@garden.co";
|
||||
23
examples/server-worker-http/src/app/api/create-game/route.ts
Normal file
23
examples/server-worker-http/src/app/api/create-game/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { jazzServerAccount } from "@/jazzServerAccount";
|
||||
import { WaitingRoom } from "@/schema";
|
||||
import { serverApi } from "@/serverApi";
|
||||
import { Group } from "jazz-tools";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const response = await serverApi.createGame.handle(
|
||||
request,
|
||||
jazzServerAccount.worker,
|
||||
async (_, madeBy) => {
|
||||
const waitingRoom = WaitingRoom.create(
|
||||
{ creator: madeBy },
|
||||
Group.create(jazzServerAccount.worker).makePublic(),
|
||||
);
|
||||
|
||||
return {
|
||||
waitingRoom,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
49
examples/server-worker-http/src/app/api/join-game/route.ts
Normal file
49
examples/server-worker-http/src/app/api/join-game/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { jazzServerAccount } from "@/jazzServerAccount";
|
||||
import { Game, createGameState } from "@/schema";
|
||||
import { serverApi } from "@/serverApi";
|
||||
import { Account, Group, JazzRequestError } from "jazz-tools";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return serverApi.joinGame.handle(
|
||||
request,
|
||||
jazzServerAccount.worker,
|
||||
async ({ waitingRoom }, madeBy) => {
|
||||
if (madeBy.id === waitingRoom.creator.id) {
|
||||
throw new JazzRequestError("You can't join your own waiting room", 400);
|
||||
}
|
||||
|
||||
waitingRoom.game = createGame({
|
||||
account1: waitingRoom.creator,
|
||||
account2: madeBy,
|
||||
worker: jazzServerAccount.worker,
|
||||
});
|
||||
|
||||
return {
|
||||
waitingRoom,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateGameParams {
|
||||
account1: Account;
|
||||
account2: Account;
|
||||
worker: Account;
|
||||
}
|
||||
|
||||
function createGame({ account1, account2, worker }: CreateGameParams) {
|
||||
const gameGroup = Group.create({ owner: worker });
|
||||
gameGroup.addMember(account1, "reader");
|
||||
gameGroup.addMember(account2, "reader");
|
||||
|
||||
const game = Game.create(
|
||||
{
|
||||
...createGameState({ account1, account2, worker }),
|
||||
player1Score: 0,
|
||||
player2Score: 0,
|
||||
},
|
||||
gameGroup,
|
||||
);
|
||||
|
||||
return game;
|
||||
}
|
||||
35
examples/server-worker-http/src/app/api/new-game/route.ts
Normal file
35
examples/server-worker-http/src/app/api/new-game/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { jazzServerAccount } from "@/jazzServerAccount";
|
||||
import { createGameState } from "@/schema";
|
||||
import { serverApi } from "@/serverApi";
|
||||
import { JazzRequestError } from "jazz-tools";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const response = await serverApi.newGame.handle(
|
||||
request,
|
||||
jazzServerAccount.worker,
|
||||
async ({ game }, madeBy) => {
|
||||
const isPlayer1 = game.player1.account.id === madeBy.id;
|
||||
const isPlayer2 = game.player2.account.id === madeBy.id;
|
||||
|
||||
if (!isPlayer1 && !isPlayer2) {
|
||||
throw new JazzRequestError("You are not a player in this game", 400);
|
||||
}
|
||||
|
||||
if (game.outcome) {
|
||||
game.applyDiff(
|
||||
createGameState({
|
||||
account1: game.player1.account,
|
||||
account2: game.player2.account,
|
||||
worker: jazzServerAccount.worker,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
game,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
89
examples/server-worker-http/src/app/api/play/route.ts
Normal file
89
examples/server-worker-http/src/app/api/play/route.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { jazzServerAccount } from "@/jazzServerAccount";
|
||||
import { PlaySelection } from "@/schema";
|
||||
import { serverApi } from "@/serverApi";
|
||||
import { Group, JazzRequestError } from "jazz-tools";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const response = await serverApi.play.handle(
|
||||
request,
|
||||
jazzServerAccount.worker,
|
||||
async ({ game, selection }, madeBy) => {
|
||||
const isPlayer1 = game.player1.account.id === madeBy.id;
|
||||
const isPlayer2 = game.player2.account.id === madeBy.id;
|
||||
|
||||
if (!isPlayer1 && !isPlayer2) {
|
||||
throw new JazzRequestError("You are not a player in this game", 400);
|
||||
}
|
||||
|
||||
const group = Group.create({ owner: jazzServerAccount.worker });
|
||||
group.addMember(madeBy, "reader");
|
||||
|
||||
if (isPlayer1 && game.player1.playSelection) {
|
||||
throw new JazzRequestError("You have already played", 400);
|
||||
} else if (isPlayer2 && game.player2.playSelection) {
|
||||
throw new JazzRequestError("You have already played", 400);
|
||||
}
|
||||
|
||||
const playSelection = PlaySelection.create(
|
||||
{ value: selection, group },
|
||||
group,
|
||||
);
|
||||
|
||||
if (isPlayer1) {
|
||||
game.player1.playSelection = playSelection;
|
||||
} else {
|
||||
game.player2.playSelection = playSelection;
|
||||
}
|
||||
|
||||
if (game.player1.playSelection && game.player2.playSelection) {
|
||||
game.outcome = determineOutcome(
|
||||
game.player1.playSelection.value,
|
||||
game.player2.playSelection.value,
|
||||
);
|
||||
|
||||
// Reveal the play selections to the other player
|
||||
game.player1.playSelection.group.addMember(
|
||||
game.player2.account,
|
||||
"reader",
|
||||
);
|
||||
game.player2.playSelection.group.addMember(
|
||||
game.player1.account,
|
||||
"reader",
|
||||
);
|
||||
|
||||
if (game.outcome === "player1") {
|
||||
game.player1Score++;
|
||||
} else if (game.outcome === "player2") {
|
||||
game.player2Score++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
game,
|
||||
result: "success",
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a player selections, returns the winner of the current game.
|
||||
*/
|
||||
function determineOutcome(
|
||||
player1Choice: "rock" | "paper" | "scissors",
|
||||
player2Choice: "rock" | "paper" | "scissors",
|
||||
) {
|
||||
if (player1Choice === player2Choice) {
|
||||
return "draw";
|
||||
} else if (
|
||||
(player1Choice === "rock" && player2Choice === "scissors") ||
|
||||
(player1Choice === "paper" && player2Choice === "rock") ||
|
||||
(player1Choice === "scissors" && player2Choice === "paper")
|
||||
) {
|
||||
return "player1";
|
||||
} else {
|
||||
return "player2";
|
||||
}
|
||||
}
|
||||
388
examples/server-worker-http/src/app/game/[id]/page.tsx
Normal file
388
examples/server-worker-http/src/app/game/[id]/page.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Game, PlaySelection, PlayerState } from "@/schema";
|
||||
import { serverApi } from "@/serverApi";
|
||||
import { Group, isJazzRequestError } from "jazz-tools";
|
||||
import { useAccount, useCoState } from "jazz-tools/react";
|
||||
import {
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Gamepad2,
|
||||
Minus,
|
||||
Sparkles,
|
||||
Trophy,
|
||||
Users,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const playIcon = (
|
||||
selection: "rock" | "paper" | "scissors" | undefined,
|
||||
size: "sm" | "lg" = "sm",
|
||||
) => {
|
||||
const emojiSize = size === "lg" ? "text-4xl" : "text-2xl";
|
||||
|
||||
switch (selection) {
|
||||
case "rock":
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${emojiSize} `} style={{ animationDelay: "0ms" }}>
|
||||
🪨
|
||||
</span>
|
||||
<span className="font-semibold">Rock</span>
|
||||
</div>
|
||||
);
|
||||
case "paper":
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${emojiSize} `} style={{ animationDelay: "150ms" }}>
|
||||
📄
|
||||
</span>
|
||||
<span className="font-semibold">Paper</span>
|
||||
</div>
|
||||
);
|
||||
case "scissors":
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${emojiSize} `} style={{ animationDelay: "300ms" }}>
|
||||
✂️
|
||||
</span>
|
||||
<span className="font-semibold">Scissors</span>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className={size === "lg" ? "w-8 h-8" : "w-6 h-6"} />
|
||||
<span>Waiting for selection</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getOutcomeIcon = (outcome: string | undefined, player: string) => {
|
||||
if (outcome === player) {
|
||||
return <Trophy className="w-6 h-6 text-yellow-500" />;
|
||||
} else if (outcome === "draw") {
|
||||
return <Minus className="w-6 h-6 text-blue-500" />;
|
||||
} else {
|
||||
return <XCircle className="w-6 h-6 text-red-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
export default function RouteComponent() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const game = useCoState(Game, params.id, {
|
||||
resolve: {
|
||||
player1State: {
|
||||
$onError: null,
|
||||
},
|
||||
player2State: {
|
||||
$onError: null,
|
||||
},
|
||||
player1: {
|
||||
account: true,
|
||||
playSelection: {
|
||||
$onError: null,
|
||||
},
|
||||
},
|
||||
player2: {
|
||||
account: true,
|
||||
playSelection: {
|
||||
$onError: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const isPlayer1 = game?.player1?.account?.isMe;
|
||||
const player = isPlayer1 ? "player1" : "player2";
|
||||
|
||||
if (!game) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center">
|
||||
<div className="animate-pulse">
|
||||
<Gamepad2 className="w-12 h-12 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gameComplete = game.outcome !== undefined;
|
||||
|
||||
const opponent = isPlayer1 ? "player2" : "player1";
|
||||
|
||||
const currentPlayer = game[player];
|
||||
const opponentPlayer = game[opponent];
|
||||
|
||||
const currentPlayerState = game[isPlayer1 ? "player1State" : "player2State"];
|
||||
|
||||
const opponentSelection = opponentPlayer?.playSelection;
|
||||
const opponentHasSelected = Boolean(opponentPlayer._refs.playSelection);
|
||||
|
||||
const handleSelection = (selection: "rock" | "paper" | "scissors") => {
|
||||
if (!currentPlayerState) return;
|
||||
if (currentPlayerState.submitted) return;
|
||||
|
||||
currentPlayerState.currentSelection = selection;
|
||||
};
|
||||
|
||||
const handleSubmit = async (
|
||||
playSelection: "rock" | "paper" | "scissors" | undefined,
|
||||
) => {
|
||||
if (!playSelection) return;
|
||||
if (!currentPlayerState) return;
|
||||
|
||||
currentPlayerState.submitted = true;
|
||||
|
||||
try {
|
||||
await serverApi.play.send({
|
||||
game,
|
||||
selection: playSelection,
|
||||
});
|
||||
} catch (error) {
|
||||
currentPlayerState.submitted = false;
|
||||
if (isJazzRequestError(error)) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
console.error(error);
|
||||
toast.error("An unexpected error occurred");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewGame = async () => {
|
||||
if (!currentPlayerState) return;
|
||||
|
||||
currentPlayerState.resetRequested = true;
|
||||
|
||||
try {
|
||||
await serverApi.newGame.send({
|
||||
game,
|
||||
});
|
||||
} catch (error) {
|
||||
currentPlayerState.resetRequested = false;
|
||||
|
||||
if (isJazzRequestError(error)) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
console.error(error);
|
||||
toast.error("An unexpected error occurred");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const playSelection = currentPlayerState?.currentSelection;
|
||||
|
||||
const submitDisabled =
|
||||
!currentPlayerState?.currentSelection || currentPlayerState?.submitted;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 py-8 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<Gamepad2 className="w-8 h-8 text-primary" />
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-primary to-purple-600 bg-clip-text text-transparent">
|
||||
Rock, Paper, Scissors
|
||||
</h1>
|
||||
<Sparkles className="w-6 h-6 text-yellow-500" />
|
||||
</div>
|
||||
|
||||
{/* Player Info */}
|
||||
<div className="flex items-center justify-center gap-4 mb-6">
|
||||
<Badge variant="secondary" className="px-4 py-2">
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
{isPlayer1 ? "Player 1" : "Player 2"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Score Board */}
|
||||
<Card className="inline-block bg-white/80 backdrop-blur-sm border-0 shadow-lg">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-6 text-2xl font-bold">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-blue-600">Player 1</span>
|
||||
<span className="text-3xl">{game?.player1Score ?? 0}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">-</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-3xl">{game?.player2Score ?? 0}</span>
|
||||
<span className="text-purple-600">Player 2</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Game Outcome */}
|
||||
{gameComplete && (
|
||||
<Card className="mb-8 bg-gradient-to-r from-yellow-50 to-orange-50 border-yellow-200">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
{getOutcomeIcon(game.outcome, player)}
|
||||
<h2 className="text-2xl font-bold">
|
||||
{game?.outcome === player
|
||||
? "🎉 You Win! 🎉"
|
||||
: game?.outcome === "draw"
|
||||
? "🤝 It's a Draw! 🤝"
|
||||
: "😔 You Lose! 😔"}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
disabled={currentPlayerState?.resetRequested}
|
||||
onClick={handleNewGame}
|
||||
className="bg-gradient-to-r from-primary to-purple-600 hover:from-primary/90 hover:to-purple-600/90 text-white"
|
||||
size="lg"
|
||||
>
|
||||
<Gamepad2 className="w-5 h-5 mr-2" />
|
||||
Start New Game
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Game Board */}
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* Your Selection */}
|
||||
<Card className="bg-white/80 backdrop-blur-sm border-0 shadow-lg">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-xl font-semibold text-blue-600">
|
||||
Your Selection
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<div className="mb-6">{playIcon(playSelection, "lg")}</div>
|
||||
|
||||
{!gameComplete && (
|
||||
<>
|
||||
{/* Choice Buttons */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<Button
|
||||
variant={playSelection === "rock" ? "default" : "outline"}
|
||||
size="lg"
|
||||
className={`h-20 transition-all duration-200 ${
|
||||
playSelection === "rock"
|
||||
? "bg-gradient-to-br from-gray-400 to-gray-600 text-white shadow-lg scale-105"
|
||||
: "hover:scale-105"
|
||||
} text-3xl`}
|
||||
onClick={() => handleSelection("rock")}
|
||||
aria-label="Select Rock"
|
||||
aria-selected={playSelection === "rock"}
|
||||
>
|
||||
🪨
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
playSelection === "paper" ? "default" : "outline"
|
||||
}
|
||||
size="lg"
|
||||
className={`h-20 transition-all duration-200 ${
|
||||
playSelection === "paper"
|
||||
? "bg-gradient-to-br from-blue-400 to-blue-600 text-white shadow-lg scale-105"
|
||||
: "hover:scale-105"
|
||||
} text-3xl`}
|
||||
onClick={() => handleSelection("paper")}
|
||||
aria-label="Select Paper"
|
||||
aria-selected={playSelection === "paper"}
|
||||
>
|
||||
📄
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
playSelection === "scissors" ? "default" : "outline"
|
||||
}
|
||||
size="lg"
|
||||
className={`h-20 transition-all duration-200 ${
|
||||
playSelection === "scissors"
|
||||
? "bg-gradient-to-br from-red-400 to-red-600 text-white shadow-lg scale-105"
|
||||
: "hover:scale-105"
|
||||
} text-3xl`}
|
||||
onClick={() => handleSelection("scissors")}
|
||||
aria-label="Select Scissors"
|
||||
aria-selected={playSelection === "scissors"}
|
||||
>
|
||||
✂️
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
disabled={submitDisabled}
|
||||
onClick={() => handleSubmit(playSelection)}
|
||||
className="w-full bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 text-white font-semibold py-3 text-lg"
|
||||
size="lg"
|
||||
>
|
||||
{currentPlayerState?.submitted ? (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5 mr-2" />
|
||||
Selection Made!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Gamepad2 className="w-5 h-5 mr-2" />
|
||||
Make Your Move!
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Opponent Selection */}
|
||||
<Card className="bg-white/80 backdrop-blur-sm border-0 shadow-lg">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-xl font-semibold text-purple-600">
|
||||
Opponent's Selection
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
{opponentSelection && (
|
||||
<div className="mb-6">
|
||||
{playIcon(opponentSelection?.value, "lg")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!opponentSelection && !gameComplete ? (
|
||||
opponentHasSelected ? (
|
||||
<div className="text-muted-foreground">
|
||||
<Clock className="w-8 h-8 mx-auto mb-2 animate-pulse" />
|
||||
<p>The opponent has made their move</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
<Clock className="w-8 h-8 mx-auto mb-2 animate-pulse" />
|
||||
<p>Waiting for opponent...</p>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Game Status */}
|
||||
{!gameComplete && (
|
||||
<Card className="mt-8 bg-white/60 backdrop-blur-sm border-0">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="text-sm">
|
||||
{currentPlayer?.playSelection && !opponentSelection
|
||||
? "Waiting for opponent to make their move..."
|
||||
: "Make your selection to start the game"}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
examples/server-worker-http/src/app/globals.css
Normal file
120
examples/server-worker-http/src/app/globals.css
Normal file
@@ -0,0 +1,120 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
37
examples/server-worker-http/src/app/layout.tsx
Normal file
37
examples/server-worker-http/src/app/layout.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { Jazz } from "../jazz";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Jazz Server Side Validation",
|
||||
description: "An example of server side validation with Jazz",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<Jazz>{children}</Jazz>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
97
examples/server-worker-http/src/app/page.tsx
Normal file
97
examples/server-worker-http/src/app/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { serverApi } from "@/serverApi";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function HomeComponent() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const onNewGameClick = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const { waitingRoom } = await serverApi.createGame.send({});
|
||||
|
||||
router.push(`/waiting-room/${waitingRoom.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-900 flex flex-col items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="relative z-10 text-center space-y-8 max-w-2xl mx-auto">
|
||||
{/* Game title and emojis */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center items-center space-x-4 text-6xl mb-6">
|
||||
<span className="animate-bounce" style={{ animationDelay: "0ms" }}>
|
||||
🪨
|
||||
</span>
|
||||
<span
|
||||
className="animate-bounce"
|
||||
style={{ animationDelay: "150ms" }}
|
||||
>
|
||||
📄
|
||||
</span>
|
||||
<span
|
||||
className="animate-bounce"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
>
|
||||
✂️
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-5xl font-bold text-white mb-2">
|
||||
Rock, Paper, Scissors
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300 max-w-md mx-auto">
|
||||
Challenge your friends in this classic multiplayer game powered by
|
||||
Jazz
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Game card */}
|
||||
<Card className="w-full max-w-md mx-auto bg-white/10 backdrop-blur-lg border-white/20 shadow-2xl">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-2xl font-bold text-white">
|
||||
Ready to Play?
|
||||
</CardTitle>
|
||||
<p className="text-gray-300 text-sm mt-2">
|
||||
Create a new game and invite your friends
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Button
|
||||
onClick={onNewGameClick}
|
||||
disabled={isLoading}
|
||||
className="w-full h-12 text-lg font-semibold bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 border-0 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-105"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
<span>Creating game...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>🎮</span>
|
||||
<span>Start New Game</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center text-gray-400 text-sm mt-12">
|
||||
<p>Built with Jazz Framework</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
173
examples/server-worker-http/src/app/waiting-room/[id]/page.tsx
Normal file
173
examples/server-worker-http/src/app/waiting-room/[id]/page.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { WaitingRoom } from "@/schema";
|
||||
import { serverApi } from "@/serverApi";
|
||||
import { Account, JazzRequestError, co } from "jazz-tools";
|
||||
import { useCoState } from "jazz-tools/react-core";
|
||||
import { ClipboardCopyIcon, Loader2Icon } from "lucide-react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function useWindowLocation() {
|
||||
const [location, setLocation] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
setLocation(window.location.href);
|
||||
}, []);
|
||||
|
||||
return location;
|
||||
}
|
||||
|
||||
async function askToJoinGame(
|
||||
waitingRoom: co.loaded<typeof WaitingRoom, { creator: true }>,
|
||||
) {
|
||||
if (waitingRoom.creator.isMe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await serverApi.joinGame.send({
|
||||
waitingRoom,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof JazzRequestError) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.error("An unexpected error occurred");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function RouteComponent() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const waitingRoom = useCoState(WaitingRoom, params.id, {
|
||||
resolve: {
|
||||
creator: true,
|
||||
game: true,
|
||||
},
|
||||
});
|
||||
const [copied, setCopied] = useState(false);
|
||||
const router = useRouter();
|
||||
const location = useWindowLocation();
|
||||
const joinLocation = location + "?join=true";
|
||||
|
||||
const isJoining = !waitingRoom
|
||||
? searchParams.get("join") === "true"
|
||||
: Account.getMe().id !== waitingRoom.creator.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (!waitingRoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
askToJoinGame(waitingRoom);
|
||||
}, [waitingRoom?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!waitingRoom?.game?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/game/${waitingRoom.game.id}`);
|
||||
}, [waitingRoom?.game?.id]);
|
||||
|
||||
const onCopyClick = () => {
|
||||
navigator.clipboard.writeText(joinLocation);
|
||||
setCopied(true);
|
||||
toast.success("Link copied to clipboard!");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-900 flex flex-col items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="relative z-10 text-center space-y-8 max-w-2xl mx-auto">
|
||||
{/* Game title and emojis */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center items-center space-x-4 text-6xl mb-6">
|
||||
<span className="animate-bounce" style={{ animationDelay: "0ms" }}>
|
||||
🪨
|
||||
</span>
|
||||
<span
|
||||
className="animate-bounce"
|
||||
style={{ animationDelay: "150ms" }}
|
||||
>
|
||||
📄
|
||||
</span>
|
||||
<span
|
||||
className="animate-bounce"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
>
|
||||
✂️
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-5xl font-bold text-white mb-2">Waiting Room</h1>
|
||||
{!isJoining && (
|
||||
<p className="text-xl text-gray-300 max-w-md mx-auto">
|
||||
Share this link with your friend to join the game
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Waiting room card */}
|
||||
<Card className="w-full max-w-md mx-auto bg-white/10 backdrop-blur-lg border-white/20 shadow-2xl">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-2xl font-bold text-white flex items-center justify-center">
|
||||
<Loader2Icon className="animate-spin inline h-8 w-8 mr-3" />
|
||||
{isJoining ? "Joining the game" : "Waiting for opponent"}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-300 text-sm mt-2">
|
||||
The game will automatically start once{" "}
|
||||
{isJoining ? "ready" : "they join"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{!isJoining && (
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex">
|
||||
<Input
|
||||
className="w-full border-white/20 bg-white/5 text-white placeholder:text-gray-400 rounded-e-none focus:border-white/40 focus:ring-white/20"
|
||||
readOnly
|
||||
value={joinLocation}
|
||||
/>
|
||||
<Button
|
||||
onClick={onCopyClick}
|
||||
className="rounded-s-none w-25 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 border-0 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-105"
|
||||
>
|
||||
{copied ? (
|
||||
"Copied!"
|
||||
) : (
|
||||
<>
|
||||
<ClipboardCopyIcon className="w-5 h-5" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center text-gray-400 text-sm mt-12">
|
||||
<p>Built with Jazz Framework</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
examples/server-worker-http/src/components/ui/alert.tsx
Normal file
66
examples/server-worker-http/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
46
examples/server-worker-http/src/components/ui/badge.tsx
Normal file
46
examples/server-worker-http/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
59
examples/server-worker-http/src/components/ui/button.tsx
Normal file
59
examples/server-worker-http/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
92
examples/server-worker-http/src/components/ui/card.tsx
Normal file
92
examples/server-worker-http/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
21
examples/server-worker-http/src/components/ui/input.tsx
Normal file
21
examples/server-worker-http/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
24
examples/server-worker-http/src/components/ui/label.tsx
Normal file
24
examples/server-worker-http/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
28
examples/server-worker-http/src/components/ui/separator.tsx
Normal file
28
examples/server-worker-http/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
25
examples/server-worker-http/src/components/ui/sonner.tsx
Normal file
25
examples/server-worker-http/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
15
examples/server-worker-http/src/jazz.tsx
Normal file
15
examples/server-worker-http/src/jazz.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { JazzReactProvider } from "jazz-tools/react";
|
||||
import { apiKey } from "./apiKey";
|
||||
|
||||
export function Jazz({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<JazzReactProvider
|
||||
enableSSR
|
||||
sync={{
|
||||
peer: `wss://cloud.jazz.tools/?key=${apiKey}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</JazzReactProvider>
|
||||
);
|
||||
}
|
||||
5
examples/server-worker-http/src/jazzServerAccount.ts
Normal file
5
examples/server-worker-http/src/jazzServerAccount.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { startWorker } from "jazz-tools/worker";
|
||||
|
||||
export const jazzServerAccount = await startWorker({
|
||||
syncServer: "wss://cloud.jazz.tools/?key=jazz-paper-scissors@garden.co ",
|
||||
});
|
||||
6
examples/server-worker-http/src/lib/utils.ts
Normal file
6
examples/server-worker-http/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
94
examples/server-worker-http/src/schema.ts
Normal file
94
examples/server-worker-http/src/schema.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Account, Group, co, z } from "jazz-tools";
|
||||
|
||||
export const PlayerState = co.map({
|
||||
currentSelection: z.literal(["rock", "paper", "scissors"]).optional(),
|
||||
submitted: z.boolean(),
|
||||
resetRequested: z.boolean(),
|
||||
});
|
||||
export type PlayerState = co.loaded<typeof PlayerState>;
|
||||
|
||||
export const PlaySelection = co.map({
|
||||
value: z.literal(["rock", "paper", "scissors"]),
|
||||
group: Group,
|
||||
});
|
||||
export type PlaySelection = co.loaded<typeof PlaySelection>;
|
||||
|
||||
export const Player = co.map({
|
||||
account: co.account(),
|
||||
playSelection: PlaySelection.optional(),
|
||||
});
|
||||
export type Player = co.loaded<typeof Player>;
|
||||
|
||||
export const Game = co.map({
|
||||
player1: Player,
|
||||
player2: Player,
|
||||
player1State: PlayerState,
|
||||
player2State: PlayerState,
|
||||
outcome: z.literal(["player1", "player2", "draw"]).optional(),
|
||||
player1Score: z.number(),
|
||||
player2Score: z.number(),
|
||||
});
|
||||
export type Game = co.loaded<typeof Game>;
|
||||
|
||||
export const WaitingRoom = co.map({
|
||||
creator: co.account(),
|
||||
game: co.optional(Game),
|
||||
});
|
||||
export type WaitingRoom = co.loaded<typeof WaitingRoom>;
|
||||
|
||||
export function createGameState(params: {
|
||||
account1: Account;
|
||||
account2: Account;
|
||||
worker: Account;
|
||||
}) {
|
||||
const { account1, account2, worker } = params;
|
||||
const gameGroup = Group.create({ owner: worker });
|
||||
gameGroup.addMember(account1, "reader");
|
||||
gameGroup.addMember(account2, "reader");
|
||||
|
||||
const player1 = Player.create(
|
||||
{
|
||||
account: account1,
|
||||
},
|
||||
gameGroup,
|
||||
);
|
||||
|
||||
const player2 = Player.create(
|
||||
{
|
||||
account: account2,
|
||||
},
|
||||
gameGroup,
|
||||
);
|
||||
|
||||
const player1StateGroup = Group.create(worker);
|
||||
player1StateGroup.addMember(account1, "writer");
|
||||
|
||||
const player1State = PlayerState.create(
|
||||
{
|
||||
currentSelection: undefined,
|
||||
submitted: false,
|
||||
resetRequested: false,
|
||||
},
|
||||
player1StateGroup,
|
||||
);
|
||||
|
||||
const player2StateGroup = Group.create(worker);
|
||||
player2StateGroup.addMember(account2, "writer");
|
||||
|
||||
const player2State = PlayerState.create(
|
||||
{
|
||||
currentSelection: undefined,
|
||||
submitted: false,
|
||||
resetRequested: false,
|
||||
},
|
||||
player2StateGroup,
|
||||
);
|
||||
|
||||
return {
|
||||
player1,
|
||||
player2,
|
||||
player1State,
|
||||
player2State,
|
||||
outcome: undefined,
|
||||
};
|
||||
}
|
||||
125
examples/server-worker-http/src/serverApi.ts
Normal file
125
examples/server-worker-http/src/serverApi.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { experimental_defineRequest, z } from "jazz-tools";
|
||||
import { Game, WaitingRoom } from "./schema";
|
||||
|
||||
const workerId = process.env.NEXT_PUBLIC_JAZZ_WORKER_ACCOUNT!;
|
||||
|
||||
const createGameRequest = experimental_defineRequest({
|
||||
url: "/api/create-game",
|
||||
workerId,
|
||||
request: {},
|
||||
response: {
|
||||
schema: { waitingRoom: WaitingRoom },
|
||||
resolve: { waitingRoom: { creator: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const joinGameRequest = experimental_defineRequest({
|
||||
url: "/api/join-game",
|
||||
workerId,
|
||||
request: {
|
||||
schema: {
|
||||
waitingRoom: WaitingRoom,
|
||||
},
|
||||
resolve: {
|
||||
waitingRoom: {
|
||||
creator: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
schema: {
|
||||
waitingRoom: WaitingRoom,
|
||||
},
|
||||
resolve: {
|
||||
waitingRoom: {
|
||||
game: {
|
||||
player1: {
|
||||
account: true,
|
||||
},
|
||||
player2: {
|
||||
account: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const newGameRequest = experimental_defineRequest({
|
||||
url: "/api/new-game",
|
||||
workerId,
|
||||
request: {
|
||||
schema: {
|
||||
game: Game,
|
||||
},
|
||||
resolve: {
|
||||
game: {
|
||||
player1: {
|
||||
account: true,
|
||||
},
|
||||
player2: {
|
||||
account: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
schema: {
|
||||
game: Game,
|
||||
},
|
||||
resolve: {
|
||||
game: {
|
||||
player1State: {
|
||||
$onError: null,
|
||||
},
|
||||
player2State: {
|
||||
$onError: null,
|
||||
},
|
||||
player1: true,
|
||||
player2: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const playRequest = experimental_defineRequest({
|
||||
url: "/api/play",
|
||||
workerId,
|
||||
request: {
|
||||
schema: {
|
||||
game: Game,
|
||||
selection: z.literal(["rock", "paper", "scissors"]),
|
||||
},
|
||||
resolve: {
|
||||
game: {
|
||||
player1: {
|
||||
account: true,
|
||||
playSelection: {
|
||||
group: true,
|
||||
},
|
||||
},
|
||||
player2: {
|
||||
account: true,
|
||||
playSelection: {
|
||||
group: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
schema: {
|
||||
game: Game,
|
||||
},
|
||||
resolve: {
|
||||
game: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const serverApi = {
|
||||
createGame: createGameRequest,
|
||||
joinGame: joinGameRequest,
|
||||
newGame: newGameRequest,
|
||||
play: playRequest,
|
||||
};
|
||||
37
examples/server-worker-http/tests/play.spec.ts
Normal file
37
examples/server-worker-http/tests/play.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("start a new game and play", async ({ page: marioPage, browser }) => {
|
||||
await marioPage.goto("/");
|
||||
|
||||
await marioPage.getByRole("button", { name: /Start New Game/ }).click();
|
||||
|
||||
await expect(marioPage.getByText("Waiting for opponent")).toBeVisible();
|
||||
|
||||
const url = await marioPage.url();
|
||||
|
||||
const luigiContext = await browser.newContext();
|
||||
const luigiPage = await luigiContext.newPage();
|
||||
await luigiPage.goto(url);
|
||||
|
||||
await expect(marioPage.getByText("Waiting for selection")).toBeVisible();
|
||||
await expect(luigiPage.getByText("Waiting for selection")).toBeVisible();
|
||||
await expect(luigiPage.getByText("Waiting for opponent")).toBeVisible();
|
||||
|
||||
await marioPage.getByLabel("Select Rock").click();
|
||||
await marioPage.getByRole("button", { name: /Make your move!/i }).click();
|
||||
|
||||
await expect(
|
||||
luigiPage.getByText("The opponent has made their move"),
|
||||
).toBeVisible();
|
||||
|
||||
await luigiPage.getByLabel("Select Paper").click();
|
||||
await luigiPage.getByRole("button", { name: /Make your move!/i }).click();
|
||||
|
||||
await expect(luigiPage.getByText(/🎉 You Win! 🎉/)).toBeVisible();
|
||||
await expect(marioPage.getByText(/😔 You Lose! 😔/)).toBeVisible();
|
||||
|
||||
await marioPage.getByRole("button", { name: /Start New Game/ }).click();
|
||||
|
||||
await expect(marioPage.getByText("Waiting for opponent")).toBeVisible();
|
||||
await expect(luigiPage.getByText("Waiting for opponent")).toBeVisible();
|
||||
});
|
||||
27
examples/server-worker-http/tsconfig.json
Normal file
27
examples/server-worker-http/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"ignoreCommand": "echo true"
|
||||
"ignoreCommand": "npx turbo-ignore"
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -431,16 +431,16 @@ const reactExamples: Example[] = [
|
||||
demoUrl: "https://music.demo.jazz.tools",
|
||||
illustration: <MusicIllustration />,
|
||||
},
|
||||
// {
|
||||
// name: "Jazz paper scissors",
|
||||
// slug: "jazz-paper-scissors",
|
||||
// description:
|
||||
// "A game that shows how to communicate with other accounts through the experimental Inbox API.",
|
||||
// tech: [tech.react],
|
||||
// features: [features.serverWorker, features.inbox],
|
||||
// illustration: <JazzPaperScissorsIllustration />,
|
||||
// demoUrl: "https://jazz-paper-scissors.demo.jazz.tools",
|
||||
// },
|
||||
{
|
||||
name: "Server-side HTTP requests",
|
||||
slug: "server-worker-http",
|
||||
description:
|
||||
"A game that shows how to manage state in a trusted environment through the experimental HTTP API.",
|
||||
tech: [tech.react],
|
||||
features: [features.serverWorker],
|
||||
illustration: <JazzPaperScissorsIllustration />,
|
||||
demoUrl: "https://jazz-paper-scissors.demo.jazz.tools",
|
||||
},
|
||||
{
|
||||
name: "Clerk",
|
||||
slug: "clerk",
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -47,11 +47,6 @@ export const docNavigationItems = [
|
||||
href: "/docs/sync-and-storage",
|
||||
done: 100,
|
||||
},
|
||||
{
|
||||
name: "Node.JS / server workers",
|
||||
href: "/docs/project-setup/server-side",
|
||||
done: 80,
|
||||
},
|
||||
{
|
||||
name: "Providers",
|
||||
href: "/docs/project-setup/providers",
|
||||
@@ -84,11 +79,41 @@ export const docNavigationItems = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Server-side",
|
||||
items: [
|
||||
{
|
||||
name: "Setup",
|
||||
href: "/docs/server-side/setup",
|
||||
done: 100,
|
||||
},
|
||||
{
|
||||
name: "Communicating with workers",
|
||||
href: "/docs/server-side/communicating-with-workers",
|
||||
done: 100,
|
||||
},
|
||||
{
|
||||
name: "HTTP requests",
|
||||
href: "/docs/server-side/http-requests",
|
||||
done: 100,
|
||||
},
|
||||
{
|
||||
name: "Inbox",
|
||||
href: "/docs/server-side/inbox",
|
||||
done: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Upgrade guides",
|
||||
// collapse: true,
|
||||
prefix: "/docs/upgrade",
|
||||
items: [
|
||||
{
|
||||
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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
export const metadata = {
|
||||
description: "Use Jazz server-side through Server Workers which act like Jazz accounts."
|
||||
};
|
||||
|
||||
import { CodeGroup } from "@/components/forMdx";
|
||||
import { Alert } from "@garden-co/design-system/src/components/atoms/Alert";
|
||||
|
||||
# Node.JS / server workers
|
||||
|
||||
The main detail to understand when using Jazz server-side is that Server Workers have Jazz `Accounts`, just like normal users do.
|
||||
|
||||
This lets you share CoValues with Server Workers, having precise access control by adding the Worker to `Groups` with specific roles just like you would with other users.
|
||||
|
||||
[See the full example here.](https://github.com/garden-co/jazz/tree/main/examples/server-worker-inbox)
|
||||
|
||||
<Alert variant="info" className="mt-4 flex gap-2 items-center">Requires at least Node.js v20.</Alert>
|
||||
|
||||
## Generating credentials
|
||||
|
||||
Server Workers typically have static credentials, consisting of a public Account ID and a private Account Secret.
|
||||
|
||||
To generate new credentials for a Server Worker, you can run:
|
||||
|
||||
<CodeGroup>
|
||||
```sh
|
||||
npx jazz-run account create --name "My Server Worker"
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
The name will be put in the public profile of the Server Worker's `Account`, which can be helpful when inspecting metadata of CoValue edits that the Server Worker has done.
|
||||
|
||||
## Storing & providing credentials
|
||||
|
||||
Server Worker credentials are typically stored and provided as environmental variables.
|
||||
|
||||
**Take extra care with the Account Secret — handle it like any other secret environment variable such as a DB password.**
|
||||
|
||||
## Starting a server worker
|
||||
|
||||
You can use `startWorker` from `jazz-nodejs` to start a Server Worker. Similarly to setting up a client-side Jazz context, it:
|
||||
|
||||
- takes a custom `AccountSchema` if you have one (for example, because the worker needs to store information in it's private account root)
|
||||
- takes a URL for a sync & storage server
|
||||
|
||||
`startWorker` expects credentials in the `JAZZ_WORKER_ACCOUNT` and `JAZZ_WORKER_SECRET` environment variables by default (as printed by `npx account create ...`), but you can also pass them manually as `accountID` and `accountSecret` parameters if you get them from elsewhere.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co } from "jazz-tools";
|
||||
const MyWorkerAccount = co.account();
|
||||
type MyWorkerAccount = co.loaded<typeof MyWorkerAccount>;
|
||||
// ---cut---
|
||||
import { startWorker } from 'jazz-tools/worker';
|
||||
|
||||
const { worker } = await startWorker({
|
||||
AccountSchema: MyWorkerAccount,
|
||||
syncServer: 'wss://cloud.jazz.tools/?key=you@example.com',
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
`worker` acts like `me` (as returned by `useAccount` on the client) - you can use it to:
|
||||
|
||||
- load/subscribe to CoValues: `MyCoValue.subscribe(id, worker, {...})`
|
||||
- create CoValues & Groups `const val = MyCoValue.create({...}, { owner: worker })`
|
||||
|
||||
## Using CoValues instead of requests
|
||||
|
||||
Just like traditional backend functions, you can use Server Workers to do useful stuff (computations, calls to third-party APIs etc.) and put the results back into CoValues, which subscribed clients automatically get notified about.
|
||||
|
||||
What's less clear is how you can trigger this work to happen.
|
||||
|
||||
- One option is to define traditional HTTP API handlers that use the Jazz Worker internally. This is helpful if you need to mutate Jazz state in response to HTTP requests such as for webhooks or non-Jazz API clients
|
||||
- The other option is to have the Jazz Worker subscribe to CoValues which they will then collaborate on with clients.
|
||||
- A common pattern is to implement a state machine represented by a CoValue, where the client will do some state transitions (such as `draft -> ready`), which the worker will notice and then do some work in response, feeding the result back in a further state transition (such as `ready -> success & data`, or `ready -> failure & error details`).
|
||||
- This way, client and worker don't have to explicitly know about each other or communicate directly, but can rely on Jazz as a communication mechanism - with computation progressing in a distributed manner wherever and whenever possible.
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
@@ -53,9 +53,7 @@ import { co, z } from "jazz-tools";
|
||||
// ...somewhere in jazz-tools itself...
|
||||
const Account = co.account({
|
||||
root: co.map({}),
|
||||
profile: co.profile({
|
||||
name: z.string(),
|
||||
}),
|
||||
profile: co.profile(),
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
@@ -91,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({
|
||||
@@ -243,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,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export const metadata = {
|
||||
description: "How to send data to Server Workers, set permissions and subscriptions."
|
||||
};
|
||||
|
||||
import { CodeGroup } from "@/components/forMdx";
|
||||
import { Alert } from "@garden-co/design-system/src/components/atoms/Alert";
|
||||
|
||||
# Communicating with Server Workers
|
||||
|
||||
Server Workers in Jazz can receive data from clients through two different APIs, each with their own characteristics and use cases.
|
||||
This guide covers the key properties of each approach to help you choose the right one for your application.
|
||||
|
||||
## Overview
|
||||
|
||||
Jazz provides two ways to communicate with Server Workers:
|
||||
|
||||
1. **HTTP Requests** - The easiest to work with and deploy, uses standard Request/Response objects
|
||||
2. **Inbox** - Fully built using the Jazz data model with offline support
|
||||
|
||||
## HTTP Requests (Recommended)
|
||||
|
||||
HTTP requests are the most straightforward way to communicate with Server Workers. They work well with any framework or runtime that supports standard Request and Response objects, can be scaled horizontally, and put clients and workers in direct communication.
|
||||
|
||||
### When to use HTTP Requests
|
||||
|
||||
Use HTTP requests when you need immediate responses, are deploying to serverless environments, need horizontal scaling, or are working with standard web frameworks.
|
||||
|
||||
It's also a good solution when using full-stack frameworks like Next.js, where you can use the API routes to handle the server-side logic.
|
||||
|
||||
[Learn more about HTTP Requests →](/docs/server-side/http-requests)
|
||||
|
||||
## Inbox
|
||||
|
||||
The Inbox API is fully built using the Jazz data model and provides offline support. Requests and responses are synced as soon as the device becomes online, but require the Worker to always be online to work properly.
|
||||
|
||||
### When to use Inbox
|
||||
|
||||
Use Inbox when you need offline support, want to leverage the Jazz data model, can ensure the worker stays online, need persistent message storage, or want to review message history.
|
||||
|
||||
It works great when you don't want to expose your server with a public address, because it uses Jazz's sync to make the communication happen.
|
||||
|
||||
Since Jazz handles all the network communication, the entire class of network errors that usually come with traditional HTTP requests are not a problem when
|
||||
using the Inbox API.
|
||||
|
||||
[Learn more about Inbox →](/docs/server-side/inbox)
|
||||
264
homepage/homepage/content/docs/server-side/http-requests.mdx
Normal file
264
homepage/homepage/content/docs/server-side/http-requests.mdx
Normal file
@@ -0,0 +1,264 @@
|
||||
export const metadata = {
|
||||
description: "How to use HTTP requests to communicate with Server Workers using experimental_defineRequest."
|
||||
};
|
||||
|
||||
import { CodeGroup } from "@/components/forMdx";
|
||||
import { Alert } from "@garden-co/design-system/src/components/atoms/Alert";
|
||||
|
||||
# HTTP Requests with Server Workers
|
||||
|
||||
HTTP requests are the easiest way to communicate with Server Workers in Jazz. They work well with any framework or runtime that supports standard Request and Response objects, can be scaled horizontally, and put clients and workers in direct communication.
|
||||
|
||||
## Setting up HTTP requests
|
||||
|
||||
### Defining request schemas
|
||||
|
||||
Use `experimental_defineRequest` to define your API schema:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { experimental_defineRequest, z } from "jazz-tools";
|
||||
import { Event, Ticket } from "./schema";
|
||||
|
||||
const workerId = process.env.NEXT_PUBLIC_JAZZ_WORKER_ACCOUNT!;
|
||||
|
||||
export const bookEventTicket = experimental_defineRequest({
|
||||
url: "/api/book-event-ticket",
|
||||
// The id of the worker Account or Group
|
||||
workerId,
|
||||
// The schema definition of the data we send to the server
|
||||
request: {
|
||||
schema: {
|
||||
event: Event,
|
||||
},
|
||||
// The data that will be considered as "loaded" in the server input
|
||||
resolve: {
|
||||
event: { reservations: true },
|
||||
},
|
||||
},
|
||||
// The schema definition of the data we expect to receive from the server
|
||||
response: {
|
||||
schema: { ticket: Ticket },
|
||||
// The data that will be considered as "loaded" in the client response
|
||||
// It defines the content that the server directly sends to the client, without involving the sync server
|
||||
resolve: { ticket: true },
|
||||
},
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Setting up the Server Worker
|
||||
|
||||
We need to start a Server Worker instance that will be able to sync data with the sync server, and handle the requests.
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { startWorker } from "jazz-tools/worker";
|
||||
|
||||
export const jazzServer = await startWorker({
|
||||
syncServer: "wss://cloud.jazz.tools/?key=your-api-key",
|
||||
accountID: process.env.JAZZ_WORKER_ACCOUNT,
|
||||
accountSecret: process.env.JAZZ_WORKER_SECRET,
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Handling requests on the server
|
||||
|
||||
### Creating API routes
|
||||
|
||||
Create API routes to handle the defined requests. Here's an example using Next.js API routes:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { jazzServer } from "@/jazzServer";
|
||||
import { bookEventTicket, Ticket, Event } from "@/schema";
|
||||
import { JazzRequestError } from "jazz-tools";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return bookEventTicket.handle(
|
||||
request,
|
||||
jazzServer.worker,
|
||||
async ({ event }, madeBy) => {
|
||||
const ticketGroup = Group.create(jazzServer.worker);
|
||||
const ticket = Ticket.create({
|
||||
account: madeBy,
|
||||
event,
|
||||
});
|
||||
|
||||
// Give access to the ticket to the client
|
||||
ticketGroup.addMember(madeBy, "reader");
|
||||
|
||||
event.reservations.push(ticket);
|
||||
|
||||
return {
|
||||
ticket,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Making requests from the client
|
||||
|
||||
### Using the defined API
|
||||
|
||||
Make requests from the client using the defined API:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { bookEventTicket, Ticket, Event } from "@/schema";
|
||||
import { isJazzRequestError } from "jazz-tools";
|
||||
|
||||
export async function sendEventBookingRequest(event: Event) {
|
||||
const { ticket } = await bookEventTicket.send({ event });
|
||||
|
||||
return ticket;
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Error handling
|
||||
|
||||
### Server-side error handling
|
||||
|
||||
Use `JazzRequestError` to return proper HTTP error responses:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { jazzServer } from "@/jazzServer";
|
||||
import { bookEventTicket, Ticket, Event } from "@/schema";
|
||||
import { JazzRequestError } from "jazz-tools";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return bookEventTicket.handle(
|
||||
request,
|
||||
jazzServer.worker,
|
||||
async ({ event }, madeBy) => {
|
||||
// Check if the event is full
|
||||
if (event.reservations.length >= event.capacity) {
|
||||
// The JazzRequestError is propagated to the client, use it for any validation errors
|
||||
throw new JazzRequestError("Event is full", 400);
|
||||
}
|
||||
|
||||
const ticketGroup = Group.create(jazzServer.worker);
|
||||
const ticket = Ticket.create({
|
||||
account: madeBy,
|
||||
event,
|
||||
});
|
||||
|
||||
// Give access to the ticket to the client
|
||||
ticketGroup.addMember(madeBy, "reader");
|
||||
|
||||
event.reservations.push(ticket);
|
||||
|
||||
return {
|
||||
ticket,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Alert variant="info" className="mt-4">
|
||||
**Note**: To ensure that the limit is correctly enforced, the handler should be deployed in a single worker instance (e.g. a single Cloudflare DurableObject).
|
||||
|
||||
Details on how to deploy a single instance Worker are available in the [Deployments & Transactionality](#deployments--transactionality) section.
|
||||
</Alert>
|
||||
|
||||
### Client-side error handling
|
||||
|
||||
Handle errors on the client side:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { bookEventTicket, Ticket, Event } from "@/schema";
|
||||
import { isJazzRequestError } from "jazz-tools";
|
||||
|
||||
export async function sendEventBookingRequest(event: Event) {
|
||||
try {
|
||||
const { ticket } = await bookEventTicket.send({ event });
|
||||
|
||||
return ticket;
|
||||
} catch (error) {
|
||||
// This works as a type guard, so you can easily get the error message and details
|
||||
if (isJazzRequestError(error)) {
|
||||
alert(error.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Alert variant="info" className="mt-4">
|
||||
**Note**: The `experimental_defineRequest` API is still experimental and may change in future versions. For production applications, consider the stability implications.
|
||||
</Alert>
|
||||
|
||||
## Security safeguards provided by Jazz
|
||||
|
||||
Jazz HTTP requests include several built-in security measures to protect against common attacks:
|
||||
|
||||
### Cryptographic Authentication
|
||||
- **Digital Signatures**: Each request is cryptographically signed using the sender's private key
|
||||
- **Signature Verification**: The server verifies the signature using the sender's public key to ensure message authenticity and to identify the sender account
|
||||
- **Tamper Protection**: Any modification to the request payload will invalidate the signature
|
||||
|
||||
### Replay Attack Prevention
|
||||
- **Unique Message IDs**: Each request has a unique identifier (`co_z${string}`)
|
||||
- **Duplicate Detection**: incoming messages ids are tracked to prevent replay attacks
|
||||
- **Message Expiration**: Requests expire after 60 seconds to provide additional protection
|
||||
|
||||
These safeguards ensure that HTTP requests in Jazz are secure, authenticated, and protected against common attack vectors while maintaining the simplicity of standard HTTP communication.
|
||||
|
||||
## Deployments & Transactionality
|
||||
|
||||
### Single Instance Requirements
|
||||
|
||||
Some operations need to happen one at a time and in the same place, otherwise the data can get out of sync.
|
||||
|
||||
For example, if you are checking capacity for an event and creating tickets, you must ensure only one server is doing it.
|
||||
If multiple servers check at the same time, they might all think there is space and allow too many tickets.
|
||||
|
||||
Jazz uses eventual consistency (data takes a moment to sync between regions), so this problem is worse if you run multiple server copies in different locations.
|
||||
|
||||
Until Jazz supports transactions across regions, the solution is to deploy a single server instance for these sensitive operations.
|
||||
|
||||
Examples of when you must deploy on a single instance are:
|
||||
1. Distribute a limited number of tickets
|
||||
* Limiting ticket sales so that only 100 tickets are sold for an event.
|
||||
* The check (“is there space left?”) and ticket creation must happen together, or you risk overselling.
|
||||
2. Inventory stock deduction
|
||||
* Managing a product stock count (e.g., 5 items left in store).
|
||||
* Multiple instances could let multiple buyers purchase the last item at the same time.
|
||||
3. Sequential ID or token generation
|
||||
* Generating unique incremental order numbers (e.g., #1001, #1002).
|
||||
* Multiple instances could produce duplicates if not coordinated.
|
||||
|
||||
Single servers are necessary to enforce invariants or provide a consistent view of the data.
|
||||
|
||||
As a rule of thumb, when the output of the request depends on the state of the database, you should probably deploy on a single instance.
|
||||
|
||||
### Multi-Region Deployment
|
||||
|
||||
If your code doesn’t need strict rules to keep data in sync (no counters, no limits, no “check‑then‑update” logic), you can run your workers in many regions at the same time.
|
||||
|
||||
This way:
|
||||
* Users connect to the closest server (faster).
|
||||
* If one region goes down, others keep running (more reliable).
|
||||
|
||||
Examples of when it's acceptable to deploy across multiple regions are:
|
||||
1. Sending confirmation emails
|
||||
* After an action is complete, sending an email to the user does not depend on current database state.
|
||||
2. Pushing notifications
|
||||
* Broadcasting “event booked” notifications to multiple users can be done from any region.
|
||||
3. Logging or analytics events
|
||||
* Recording “user clicked this button” or “page viewed” events, since these are additive and don’t require strict ordering.
|
||||
4. Calling external APIs (e.g., LLMs, payment confirmations)
|
||||
* If the response does not modify shared counters or limits, it can be done from any region.
|
||||
5. Pre-computing cached data or summaries
|
||||
* Generating read-only previews or cached summaries where stale data is acceptable and does not affect core logic.
|
||||
|
||||
Generally speaking, if the output of the request does not depend on the state of the database, you can deploy across multiple regions.
|
||||
166
homepage/homepage/content/docs/server-side/inbox.mdx
Normal file
166
homepage/homepage/content/docs/server-side/inbox.mdx
Normal file
@@ -0,0 +1,166 @@
|
||||
export const metadata = {
|
||||
description: "How to use the Inbox API to communicate with Server Workers using experimental_useInboxSender and inbox.subscribe."
|
||||
};
|
||||
|
||||
import { CodeGroup } from "@/components/forMdx";
|
||||
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.
|
||||
|
||||
It works on top of the Jazz APIs and uses sync to transfer messages between the client and the server.
|
||||
|
||||
## Setting up the Inbox API
|
||||
|
||||
### Define the inbox message schema
|
||||
|
||||
Define the inbox message schema in your schema file:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
export const BookTicketMessage = co.map({
|
||||
type: co.literal("bookTicket"),
|
||||
event: Event,
|
||||
})
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
Any kind of CoMap is valid as an inbox message.
|
||||
|
||||
### Setting up the Server Worker
|
||||
|
||||
Run a server worker and subscribe to the `inbox`:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { startWorker } from "jazz-tools/worker";
|
||||
import { BookTicketMessage } from "@/schema";
|
||||
|
||||
const {
|
||||
worker,
|
||||
experimental: { inbox },
|
||||
} = await startWorker({
|
||||
accountID: process.env.JAZZ_WORKER_ACCOUNT,
|
||||
accountSecret: process.env.JAZZ_WORKER_SECRET,
|
||||
syncServer: "wss://cloud.jazz.tools/?key=your-api-key",
|
||||
});
|
||||
|
||||
inbox.subscribe(
|
||||
BookTicketMessage,
|
||||
async (message, senderID) => {
|
||||
const madeBy = await co.account().load(senderID, { loadAs: worker });
|
||||
|
||||
const { event } = await message.ensureLoaded({
|
||||
resolve: {
|
||||
event: {
|
||||
reservations: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const ticketGroup = Group.create(jazzServer.worker);
|
||||
const ticket = Ticket.create({
|
||||
account: madeBy,
|
||||
event,
|
||||
});
|
||||
|
||||
// Give access to the ticket to the client
|
||||
ticketGroup.addMember(madeBy, "reader");
|
||||
|
||||
event.reservations.push(ticket);
|
||||
|
||||
return ticket;
|
||||
},
|
||||
);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Handling multiple message types
|
||||
|
||||
`inbox.subscribe` should be called once per worker instance.
|
||||
|
||||
If you need to handle multiple message types, you can use the `co.discriminatedUnion` function to create a union of the message types.
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
const CancelReservationMessage = co.map({
|
||||
type: co.literal("cancelReservation"),
|
||||
event: Event,
|
||||
ticket: Ticket,
|
||||
});
|
||||
|
||||
export const InboxMessage = co.discriminatedUnion("type", [
|
||||
BookTicketMessage,
|
||||
CancelReservationMessage
|
||||
]);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
And check the message type in the handler:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { InboxMessage } from "@/schema";
|
||||
|
||||
inbox.subscribe(
|
||||
InboxMessage,
|
||||
async (message, senderID) => {
|
||||
switch (message.type) {
|
||||
case "bookTicket":
|
||||
return await handleBookTicket(message, senderID);
|
||||
case "cancelReservation":
|
||||
return await handleCancelReservation(message, senderID);
|
||||
}
|
||||
},
|
||||
);
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
## Sending messages from the client
|
||||
|
||||
### Using the Inbox Sender hook
|
||||
|
||||
Use `experimental_useInboxSender` to send messages from React components:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
import { experimental_useInboxSender } from "jazz-tools/react";
|
||||
import { BookTicketMessage, Event } from "@/schema";
|
||||
|
||||
function EventComponent({ event }: { event: Event }) {
|
||||
const sendInboxMessage = experimental_useInboxSender(WORKER_ID);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onBookTicketClick = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const ticketId = await sendInboxMessage(
|
||||
BookTicketMessage.create({
|
||||
type: "bookTicket",
|
||||
event: event,
|
||||
}),
|
||||
);
|
||||
|
||||
alert(`Ticket booked: ${ticketId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={onBookTicketClick} loading={isLoading}>
|
||||
Book Ticket
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
</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 value returned will be the id of the CoValue returned in the `inbox.subscribe` resolved promise.
|
||||
|
||||
|
||||
## Deployment considerations
|
||||
|
||||
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) instead.
|
||||
78
homepage/homepage/content/docs/server-side/setup.mdx
Normal file
78
homepage/homepage/content/docs/server-side/setup.mdx
Normal file
@@ -0,0 +1,78 @@
|
||||
export const metadata = {
|
||||
description: "Use Jazz server-side through Server Workers which act like Jazz accounts."
|
||||
};
|
||||
|
||||
import { CodeGroup } from "@/components/forMdx";
|
||||
import { Alert } from "@garden-co/design-system/src/components/atoms/Alert";
|
||||
|
||||
# Running Jazz on the server
|
||||
|
||||
Jazz is a distributed database that can be used on both clients or servers without any distinction.
|
||||
|
||||
You can use servers to:
|
||||
- perform operations that can't be done on the client (e.g. sending emails, making HTTP requests, etc.)
|
||||
- validate actions that require a central authority (e.g. a payment gateway, booking a hotel, etc.)
|
||||
|
||||
We call the code that runs on the server a "Server Worker".
|
||||
|
||||
The main difference to keep in mind when working with Jazz compared to traditional systems is that server code doesn't have any
|
||||
special or privileged access to the user data. You need to be explicit about what you want to share with the server.
|
||||
|
||||
This means that your server workers will have their own accounts, and they need to be explicitly given access to the CoValues they need to work on.
|
||||
|
||||
## Generating credentials
|
||||
|
||||
Server Workers typically have static credentials, consisting of a public Account ID and a private Account Secret.
|
||||
|
||||
To generate new credentials for a Server Worker, you can run:
|
||||
|
||||
<CodeGroup>
|
||||
```sh
|
||||
npx jazz-run account create --name "My Server Worker"
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
The name will be put in the public profile of the Server Worker's `Account`, which can be helpful when inspecting metadata of CoValue edits that the Server Worker has done.
|
||||
|
||||
<Alert variant="info" className="mt-4">
|
||||
**Note**: By default the account will be stored in Jazz Cloud. You can use the `--peer` flag to store the account on a different sync server.
|
||||
</Alert>
|
||||
|
||||
## Running a server worker
|
||||
|
||||
You can use `startWorker` to run a Server Worker. Similarly to setting up a client-side Jazz context, it:
|
||||
|
||||
- takes a custom `AccountSchema` if you have one (for example, because the worker needs to store information in its private account root)
|
||||
- takes a URL for a sync & storage server
|
||||
|
||||
The migration defined in the `AccountSchema` will be executed every time the worker starts, the same way as it would be for a client-side Jazz context.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co } from "jazz-tools";
|
||||
const MyWorkerAccount = co.account();
|
||||
type MyWorkerAccount = co.loaded<typeof MyWorkerAccount>;
|
||||
|
||||
// ---cut---
|
||||
import { startWorker } from 'jazz-tools/worker';
|
||||
|
||||
const { worker } = await startWorker({
|
||||
AccountSchema: MyWorkerAccount,
|
||||
syncServer: 'wss://cloud.jazz.tools/?key=you@example.com',
|
||||
accountID: process.env.JAZZ_WORKER_ACCOUNT,
|
||||
accountSecret: process.env.JAZZ_WORKER_SECRET,
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
`worker` is an instance of the `Account` schema provided, and acts like `me` (as returned by `useAccount` on the client).
|
||||
|
||||
It will implicitly become the current account, and you can avoid mentioning it in most cases.
|
||||
|
||||
For this reason we also recommend running a single worker instance per server, because it makes your code much more predictable.
|
||||
|
||||
## Storing & providing credentials
|
||||
|
||||
Server Worker credentials are typically stored and provided as environment variables.
|
||||
|
||||
**Take extra care with the Account Secret — handle it like any other secret environment variable such as a DB password.**
|
||||
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).
|
||||
@@ -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.
|
||||
|
||||
@@ -24,7 +24,7 @@ import { Group, co, z } from "jazz-tools";
|
||||
|
||||
const MyProfile = co.profile({
|
||||
name: z.string(),
|
||||
image: z.optional(co.image()),
|
||||
image: co.optional(co.image()),
|
||||
});
|
||||
|
||||
const MyAccount = co.account({
|
||||
|
||||
@@ -25,7 +25,7 @@ import { Group, co, z } from "jazz-tools";
|
||||
|
||||
const MyProfile = co.profile({
|
||||
name: z.string(),
|
||||
image: z.optional(co.image()),
|
||||
image: co.optional(co.image()),
|
||||
});
|
||||
|
||||
const MyAccount = co.account({
|
||||
|
||||
@@ -249,7 +249,7 @@ Resolve queries let you declare exactly which references to load and how deep to
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z, CoListSchema } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
const projectId = "co_123";
|
||||
|
||||
// ---cut-before---
|
||||
@@ -259,8 +259,8 @@ const TeamMember = co.map({
|
||||
|
||||
const Task = co.map({
|
||||
title: z.string(),
|
||||
assignee: z.optional(TeamMember),
|
||||
get subtasks(): CoListSchema<typeof Task> { return co.list(Task) },
|
||||
assignee: co.optional(TeamMember),
|
||||
get subtasks(): co.List<typeof Task> { return co.list(Task) },
|
||||
});
|
||||
|
||||
const Project = co.map({
|
||||
@@ -349,7 +349,7 @@ When a user tries to load a reference they don't have access to:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z, CoListSchema } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const TeamMember = co.map({
|
||||
name: z.string(),
|
||||
@@ -357,8 +357,8 @@ const TeamMember = co.map({
|
||||
|
||||
const Task = co.map({
|
||||
title: z.string(),
|
||||
assignee: z.optional(TeamMember),
|
||||
get subtasks(): CoListSchema<typeof Task> { return co.list(Task) },
|
||||
assignee: co.optional(TeamMember),
|
||||
get subtasks(): co.List<typeof Task> { return co.list(Task) },
|
||||
});
|
||||
|
||||
const Project = co.map({
|
||||
@@ -388,7 +388,7 @@ When a list contains references to items the user can't access:
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z, CoListSchema } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const TeamMember = co.map({
|
||||
name: z.string(),
|
||||
@@ -396,8 +396,8 @@ const TeamMember = co.map({
|
||||
|
||||
const Task = co.map({
|
||||
title: z.string(),
|
||||
assignee: z.optional(TeamMember),
|
||||
get subtasks(): CoListSchema<typeof Task> { return co.list(Task) },
|
||||
assignee: co.optional(TeamMember),
|
||||
get subtasks(): co.List<typeof Task> { return co.list(Task) },
|
||||
});
|
||||
|
||||
const Project = co.map({
|
||||
@@ -424,7 +424,7 @@ When trying to load an object with an inaccessible reference without directly re
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z, CoListSchema } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const TeamMember = co.map({
|
||||
name: z.string(),
|
||||
@@ -432,8 +432,8 @@ const TeamMember = co.map({
|
||||
|
||||
const Task = co.map({
|
||||
title: z.string(),
|
||||
assignee: z.optional(TeamMember),
|
||||
get subtasks(): CoListSchema<typeof Task> { return co.list(Task) },
|
||||
assignee: co.optional(TeamMember),
|
||||
get subtasks(): co.List<typeof Task> { return co.list(Task) },
|
||||
});
|
||||
|
||||
const Project = co.map({
|
||||
@@ -468,7 +468,7 @@ This way the inaccessible items are replaced with `null` in the returned list.
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { co, z, CoListSchema, Group } from "jazz-tools";
|
||||
import { co, z, Group } from "jazz-tools";
|
||||
import { createJazzTestAccount } from "jazz-tools/testing";
|
||||
|
||||
const me = await createJazzTestAccount();
|
||||
@@ -653,7 +653,7 @@ The `co.loaded` type is especially useful when passing data between components,
|
||||
<ContentByFramework framework="react">
|
||||
<CodeGroup>
|
||||
```tsx twoslash
|
||||
import { CoListSchema, co, z } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
import React from "react";
|
||||
|
||||
const TeamMember = co.map({
|
||||
@@ -662,8 +662,8 @@ const TeamMember = co.map({
|
||||
|
||||
const Task = co.map({
|
||||
title: z.string(),
|
||||
assignee: z.optional(TeamMember),
|
||||
get subtasks(): CoListSchema<typeof Task> {
|
||||
assignee: co.optional(TeamMember),
|
||||
get subtasks(): co.List<typeof Task> {
|
||||
return co.list(Task);
|
||||
},
|
||||
});
|
||||
@@ -727,7 +727,7 @@ function processProject(project: FullyLoadedProject) {
|
||||
<ContentByFramework framework="vanilla">
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { CoListSchema, co, z } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const TeamMember = co.map({
|
||||
name: z.string(),
|
||||
@@ -735,8 +735,8 @@ const TeamMember = co.map({
|
||||
|
||||
const Task = co.map({
|
||||
title: z.string(),
|
||||
assignee: z.optional(TeamMember),
|
||||
get subtasks(): CoListSchema<typeof Task> {
|
||||
assignee: co.optional(TeamMember),
|
||||
get subtasks(): co.List<typeof Task> {
|
||||
return co.list(Task);
|
||||
},
|
||||
});
|
||||
@@ -799,7 +799,7 @@ Sometimes you need to make sure data is loaded before proceeding with an operati
|
||||
|
||||
<CodeGroup>
|
||||
```ts twoslash
|
||||
import { CoListSchema, co, z } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools";
|
||||
|
||||
const TeamMember = co.map({
|
||||
name: z.string(),
|
||||
@@ -809,7 +809,7 @@ const Task = co.map({
|
||||
title: z.string(),
|
||||
status: z.literal(["todo", "in-progress", "completed"]),
|
||||
assignee: z.string().optional(),
|
||||
get subtasks(): CoListSchema<typeof Task> {
|
||||
get subtasks(): co.List<typeof Task> {
|
||||
return co.list(Task);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const marketingCopy = {
|
||||
headline: "Whip up an app",
|
||||
headline: "Smooth database.",
|
||||
description:
|
||||
"Jazz gives you data without needing a database — plus auth, permissions, files and multiplayer without needing a backend. Do everything right from the frontend and ship better apps, faster.",
|
||||
"Jazz is a database that's distributed across your frontend, containers and functions. It syncs structured data, files and LLM streams instantly and looks like local reactive JSON state.",
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ import { readFile, readdir } from "fs/promises";
|
||||
import { DOC_SECTIONS } from "./utils/config.mjs";
|
||||
import { writeDocsFile } from "./utils/index.mjs";
|
||||
|
||||
const exclude = [/\/upgrade\//];
|
||||
|
||||
async function readMdxContent(url) {
|
||||
try {
|
||||
// Special case for the introduction
|
||||
@@ -31,12 +33,17 @@ async function readMdxContent(url) {
|
||||
|
||||
// If it's a directory, try to read all framework variants
|
||||
const fullPath = path.join(baseDir, relativePath);
|
||||
|
||||
if (exclude.some((pattern) => pattern.test(fullPath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
// Read all MDX files in the directory
|
||||
const files = await fs.readdir(fullPath);
|
||||
const mdxFiles = files.filter((f) => f.endsWith(".mdx"));
|
||||
const mdxFiles = files.filter((f) => f.endsWith(".mdx")).filter((f) => !exclude.some((pattern) => pattern.test(f)));
|
||||
|
||||
if (mdxFiles.length === 0) return null;
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"build:packages": "turbo build --filter='./packages/*'",
|
||||
"lint": "turbo lint && cd homepage/homepage && pnpm run lint",
|
||||
"test": "vitest",
|
||||
"test:ci": "vitest run --watch=false --coverage.enabled=true",
|
||||
"test:ci": "vitest run --watch=false",
|
||||
"test:coverage": "vitest --ui --coverage.enabled=true",
|
||||
"format-and-lint": "biome check .",
|
||||
"format-and-lint:fix": "biome check . --write",
|
||||
|
||||
@@ -1,5 +1,52 @@
|
||||
# cojson-storage-indexeddb
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- cojson@0.16.5
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f9d538f]
|
||||
- Updated dependencies [802b5a3]
|
||||
- cojson@0.16.4
|
||||
|
||||
## 0.16.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.16.3
|
||||
|
||||
## 0.16.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.16.2
|
||||
|
||||
## 0.16.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.16.1
|
||||
|
||||
## 0.16.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [c09dcdf]
|
||||
- cojson@0.16.0
|
||||
|
||||
## 0.15.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cojson@0.15.16
|
||||
|
||||
## 0.15.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cojson-storage-indexeddb",
|
||||
"version": "0.15.15",
|
||||
"version": "0.16.5",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -179,8 +179,9 @@ test("should load dependencies correctly (group inheritance)", async () => {
|
||||
),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"client -> CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> CONTENT Group header: true new: After: 0 New: 3",
|
||||
"client -> CONTENT ParentGroup header: true new: After: 0 New: 4",
|
||||
"client -> CONTENT Group header: false new: After: 3 New: 2",
|
||||
"client -> CONTENT Map header: true new: After: 0 New: 1",
|
||||
]
|
||||
`);
|
||||
@@ -561,9 +562,10 @@ test("should sync and load accounts from storage", async () => {
|
||||
),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
"client -> CONTENT Account header: true new: After: 0 New: 4",
|
||||
"client -> CONTENT Account header: true new: After: 0 New: 3",
|
||||
"client -> CONTENT ProfileGroup header: true new: After: 0 New: 5",
|
||||
"client -> CONTENT Profile header: true new: After: 0 New: 1",
|
||||
"client -> CONTENT Account header: false new: After: 3 New: 1",
|
||||
]
|
||||
`);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user