Compare commits
229 Commits
jazz-react
...
cojson-sim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90520dddd7 | ||
|
|
03eb77070a | ||
|
|
4ba5c255b6 | ||
|
|
01817db873 | ||
|
|
46fcbd6c01 | ||
|
|
aa3e3de09e | ||
|
|
af3d48764d | ||
|
|
091f36b736 | ||
|
|
7107f79f42 | ||
|
|
9922db2336 | ||
|
|
75db570198 | ||
|
|
28a09f377b | ||
|
|
fd2e0855bb | ||
|
|
82e1d57bd6 | ||
|
|
a2fbb0b0c8 | ||
|
|
8feddf9932 | ||
|
|
feed34b1cf | ||
|
|
662c980cf2 | ||
|
|
f5ae530890 | ||
|
|
46bf7dd3ce | ||
|
|
5d4eb38204 | ||
|
|
66da658075 | ||
|
|
3477b74573 | ||
|
|
f3de4906b7 | ||
|
|
caded3f189 | ||
|
|
5196395495 | ||
|
|
8089a7ed9f | ||
|
|
99230d31d2 | ||
|
|
94bca03f59 | ||
|
|
49719b6e6d | ||
|
|
1bdb781452 | ||
|
|
c336f69a6b | ||
|
|
c8cb1ce208 | ||
|
|
814a6a80cd | ||
|
|
5fdfe18b32 | ||
|
|
7b7a74778b | ||
|
|
39dbd46556 | ||
|
|
1db4a14be4 | ||
|
|
4a4ea4e196 | ||
|
|
e0724441eb | ||
|
|
5d47895515 | ||
|
|
c1dfac7260 | ||
|
|
bf29cb3bae | ||
|
|
a0a9b3f851 | ||
|
|
4c4deb22c9 | ||
|
|
a42c497055 | ||
|
|
f1dcdb20bc | ||
|
|
46330ae201 | ||
|
|
bfe3595b4c | ||
|
|
34c39e6a55 | ||
|
|
5a85501919 | ||
|
|
97a4282e5e | ||
|
|
39c13b50a3 | ||
|
|
ad304e321b | ||
|
|
8c0b2da461 | ||
|
|
72fce45b2b | ||
|
|
1f49d7fda6 | ||
|
|
eec8ee7027 | ||
|
|
188eb2e1e3 | ||
|
|
62867b32d9 | ||
|
|
ccebd2447d | ||
|
|
08dca75789 | ||
|
|
16b3e1381b | ||
|
|
f1cd639a09 | ||
|
|
be18e4de14 | ||
|
|
7e62c91d44 | ||
|
|
b2d5a103b5 | ||
|
|
4ee2cad39e | ||
|
|
b7c8a0038b | ||
|
|
8c27e8c379 | ||
|
|
0133aa47ff | ||
|
|
5659c925a2 | ||
|
|
27779ac792 | ||
|
|
3f1bfa4629 | ||
|
|
15a693c3ed | ||
|
|
b1d620e145 | ||
|
|
478fbd0aa9 | ||
|
|
ee906b7351 | ||
|
|
dd15f21ccb | ||
|
|
d7cd5fda7c | ||
|
|
174300b00f | ||
|
|
b2c8d8c855 | ||
|
|
2bad2b6bfe | ||
|
|
880d0ff855 | ||
|
|
e66cbee6cd | ||
|
|
03e470721e | ||
|
|
ecf73bcfa7 | ||
|
|
2c3a500286 | ||
|
|
8b83061cf4 | ||
|
|
e75c3207d6 | ||
|
|
41d4b5ba0b | ||
|
|
21fa1b168b | ||
|
|
91e5e7f2ab | ||
|
|
e3f7e2f1bd | ||
|
|
084cf80c60 | ||
|
|
632e3bbb08 | ||
|
|
17d17833b2 | ||
|
|
8e22bd9c1e | ||
|
|
98213743f3 | ||
|
|
bb855ed83d | ||
|
|
a8ef49e228 | ||
|
|
e0ad32dbd2 | ||
|
|
62bf769cad | ||
|
|
7488ff25b2 | ||
|
|
b69c9da983 | ||
|
|
d30fdef8aa | ||
|
|
9c5a6b9833 | ||
|
|
d300d265c4 | ||
|
|
1d72ce587f | ||
|
|
3fdb41dcb9 | ||
|
|
f20de2f04a | ||
|
|
31b31f111b | ||
|
|
2ae9fb9778 | ||
|
|
cd0da0f6bf | ||
|
|
cd9bfbb9fa | ||
|
|
ed0428bf97 | ||
|
|
c038a02051 | ||
|
|
31abcfeef4 | ||
|
|
5f32d9ccf5 | ||
|
|
0510600104 | ||
|
|
7f30fbf3c5 | ||
|
|
3d56260ca4 | ||
|
|
1137775da9 | ||
|
|
3951fdc938 | ||
|
|
5779e357dd | ||
|
|
2842d80f26 | ||
|
|
96387d8023 | ||
|
|
6720c19233 | ||
|
|
ef732b4700 | ||
|
|
ee7e3ee5a7 | ||
|
|
ceeed88fa5 | ||
|
|
79353a1d97 | ||
|
|
7fdc42c62f | ||
|
|
3a2e854a88 | ||
|
|
661a2d023a | ||
|
|
6ef5b6b2ab | ||
|
|
1384ebed84 | ||
|
|
17e53f9998 | ||
|
|
cfb1f39efe | ||
|
|
2234276dcf | ||
|
|
bb0a6a0600 | ||
|
|
0a6eb0c10a | ||
|
|
88b67d89e0 | ||
|
|
1a65d826b2 | ||
|
|
6c65ec2b46 | ||
|
|
5b578a832d | ||
|
|
042afc52d7 | ||
|
|
1b83493964 | ||
|
|
3b50da1a74 | ||
|
|
8e0fc74d9f | ||
|
|
e28326f32c | ||
|
|
d7e8b0b9da | ||
|
|
c46a1f6b0a | ||
|
|
7947918278 | ||
|
|
50c36e7255 | ||
|
|
c39a7ed1b7 | ||
|
|
83762dbb0f | ||
|
|
7c82e12508 | ||
|
|
6db149be36 | ||
|
|
909a101f99 | ||
|
|
df0b6fe138 | ||
|
|
0543756016 | ||
|
|
92eae0e180 | ||
|
|
9ccc97fcd3 | ||
|
|
120ba57274 | ||
|
|
0679a64002 | ||
|
|
e9d561adbd | ||
|
|
bb5fd24f6a | ||
|
|
18d5b9146f | ||
|
|
39850d465f | ||
|
|
27e0d6df46 | ||
|
|
6d0c820724 | ||
|
|
78a1d5a614 | ||
|
|
33c2705329 | ||
|
|
4873a634a4 | ||
|
|
edb43cd070 | ||
|
|
b128a2d6f7 | ||
|
|
27abcb4f6f | ||
|
|
e9b41c4344 | ||
|
|
d93b376e4b | ||
|
|
aeb38eb7d5 | ||
|
|
07bffb5050 | ||
|
|
012bd43865 | ||
|
|
ffc1181b81 | ||
|
|
4ca5e258b5 | ||
|
|
2255c824b7 | ||
|
|
8ed59e40e9 | ||
|
|
03b34b4b66 | ||
|
|
53c93f6a0b | ||
|
|
4af7f25eab | ||
|
|
6d6e8a0e28 | ||
|
|
4a617c8323 | ||
|
|
eaed275a79 | ||
|
|
01fdcaed34 | ||
|
|
7aeb1a789b | ||
|
|
a00649fa29 | ||
|
|
764954c727 | ||
|
|
b0ec93eb3a | ||
|
|
4dd226bc95 | ||
|
|
1692340856 | ||
|
|
fbda78f908 | ||
|
|
61e9f6afad | ||
|
|
246bbb119d | ||
|
|
80054515c9 | ||
|
|
f9486a82c3 | ||
|
|
d0babab822 | ||
|
|
ab34172e01 | ||
|
|
b779a91611 | ||
|
|
297a8646dd | ||
|
|
25eb3e097f | ||
|
|
fe1092ccf6 | ||
|
|
29abbc455c | ||
|
|
f6864e0f93 | ||
|
|
9440b5306c | ||
|
|
aa34f1e8a6 | ||
|
|
24ce7dbdf1 | ||
|
|
65a7a66c15 | ||
|
|
0f999a2c2d | ||
|
|
2247c97080 | ||
|
|
cbdc722959 | ||
|
|
bb157b6099 | ||
|
|
e1f8ec6f11 | ||
|
|
9854238346 | ||
|
|
3b5ab90006 | ||
|
|
988dc37902 | ||
|
|
4ef4b87d95 | ||
|
|
27f811b9e9 | ||
|
|
52be603996 | ||
|
|
d1123866c2 |
8
.changeset/README.md
Normal file
8
.changeset/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
||||
11
.changeset/config.json
Normal file
11
.changeset/config.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
||||
135
.github/workflows/build-and-deploy.yaml
vendored
135
.github/workflows/build-and-deploy.yaml
vendored
@@ -7,8 +7,12 @@ on:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
build-examples:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
# example: ["chat", "todo", "pets", "twit", "file-drop"]
|
||||
example: ["twit", "chat"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -17,40 +21,78 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 16
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: yarn.lock
|
||||
- name: Nuke Workspace
|
||||
run: |
|
||||
rm package.json yarn.lock;
|
||||
- name: Yarn Build
|
||||
run: |
|
||||
yarn install --frozen-lockfile;
|
||||
yarn build;
|
||||
working-directory: ./examples/todo
|
||||
|
||||
- uses: satackey/action-docker-layer-caching@v0.0.11
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: docker-layer-caching-${{ github.workflow }}-{hash}
|
||||
restore-keys: |
|
||||
docker-layer-caching-${{ github.workflow }}-
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: gardencmp
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker Build & Push
|
||||
- name: Nuke Workspace
|
||||
run: |
|
||||
export DOCKER_TAG=ghcr.io/gardencmp/jazz-example-todo:${{github.head_ref || github.ref_name}}-${{github.sha}}-$(date +%s) ;
|
||||
docker build . --file Dockerfile --tag $DOCKER_TAG;
|
||||
docker push $DOCKER_TAG;
|
||||
echo "DOCKER_TAG=$DOCKER_TAG" >> $GITHUB_ENV
|
||||
working-directory: ./examples/todo
|
||||
rm package.json yarn.lock;
|
||||
|
||||
- name: Yarn Build
|
||||
run: |
|
||||
yarn install --frozen-lockfile;
|
||||
yarn build;
|
||||
working-directory: ./examples/${{ matrix.example }}
|
||||
|
||||
- name: Docker Build & Push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: ./examples/${{ matrix.example }}
|
||||
push: true
|
||||
tags: ghcr.io/gardencmp/${{github.event.repository.name}}-example-${{ matrix.example }}:${{github.head_ref || github.ref_name}}-${{github.sha}}-${{github.run_number}}-${{github.run_attempt}}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# build-homepage:
|
||||
# runs-on: ubuntu-latest
|
||||
|
||||
# steps:
|
||||
# - uses: actions/checkout@v3
|
||||
# with:
|
||||
# submodules: true
|
||||
|
||||
# - name: Set up Docker Buildx
|
||||
# uses: docker/setup-buildx-action@v2
|
||||
|
||||
# - name: Login to GitHub Container Registry
|
||||
# uses: docker/login-action@v2
|
||||
# with:
|
||||
# registry: ghcr.io
|
||||
# username: gardencmp
|
||||
# password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# - name: Docker Build & Push
|
||||
# uses: docker/build-push-action@v4
|
||||
# with:
|
||||
# context: ./homepage/homepage-jazz
|
||||
# push: true
|
||||
# tags: ghcr.io/gardencmp/${{github.event.repository.name}}-homepage-jazz:${{github.head_ref || github.ref_name}}-${{github.sha}}-${{github.run_number}}-${{github.run_attempt}}
|
||||
# cache-from: type=gha
|
||||
# cache-to: type=gha,mode=max
|
||||
|
||||
deploy-examples:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-examples
|
||||
strategy:
|
||||
matrix:
|
||||
# example: ["chat", "todo", "pets", "twit", "file-drop"]
|
||||
example: ["twit", "chat"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
- uses: gacts/install-nomad@v1
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@v1
|
||||
@@ -69,13 +111,42 @@ jobs:
|
||||
|
||||
export DOCKER_USER=gardencmp;
|
||||
export DOCKER_PASSWORD=${{ secrets.DOCKER_PULL_PAT }};
|
||||
export DOCKER_TAG=${{ env.DOCKER_TAG }};
|
||||
export DOCKER_TAG=ghcr.io/gardencmp/${{github.event.repository.name}}-example-${{ matrix.example }}:${{github.head_ref || github.ref_name}}-${{github.sha}}-${{github.run_number}}-${{github.run_attempt}};
|
||||
|
||||
for region in ${{ vars.DEPLOY_REGIONS }}
|
||||
do
|
||||
export REGION=$region;
|
||||
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN} ${REGION}' < job-template.nomad > job-instance.nomad;
|
||||
cat job-instance.nomad;
|
||||
NOMAD_ADDR='${{ secrets.NOMAD_ADDR }}' nomad job run job-instance.nomad;
|
||||
done
|
||||
working-directory: ./examples/todo
|
||||
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN}' < job-template.nomad > job-instance.nomad;
|
||||
cat job-instance.nomad;
|
||||
NOMAD_ADDR='http://control1v2-london:4646' nomad job run job-instance.nomad;
|
||||
working-directory: ./examples/${{ matrix.example }}
|
||||
|
||||
# deploy-homepage:
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: build-homepage
|
||||
|
||||
# steps:
|
||||
# - uses: actions/checkout@v3
|
||||
# with:
|
||||
# submodules: true
|
||||
# - uses: gacts/install-nomad@v1
|
||||
# - name: Tailscale
|
||||
# uses: tailscale/github-action@v1
|
||||
# with:
|
||||
# authkey: ${{ secrets.TAILSCALE_AUTHKEY }}
|
||||
|
||||
# - name: Deploy on Nomad
|
||||
# run: |
|
||||
# if [ "${{github.ref_name}}" == "main" ]; then
|
||||
# export BRANCH_SUFFIX="";
|
||||
# export BRANCH_SUBDOMAIN="";
|
||||
# else
|
||||
# export BRANCH_SUFFIX=-${{github.head_ref || github.ref_name}};
|
||||
# export BRANCH_SUBDOMAIN=${{github.head_ref || github.ref_name}}.;
|
||||
# fi
|
||||
|
||||
# export DOCKER_USER=gardencmp;
|
||||
# export DOCKER_PASSWORD=${{ secrets.DOCKER_PULL_PAT }};
|
||||
# export DOCKER_TAG=ghcr.io/gardencmp/${{github.event.repository.name}}-homepage-jazz:${{github.head_ref || github.ref_name}}-${{github.sha}}-${{github.run_number}}-${{github.run_attempt}};
|
||||
|
||||
# envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN}' < job-template.nomad > job-instance.nomad;
|
||||
# cat job-instance.nomad;
|
||||
# NOMAD_ADDR='http://control1v2-london:4646' nomad job run job-instance.nomad;
|
||||
# working-directory: ./homepage/homepage-jazz
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
yarn-error.log
|
||||
lerna-debug.log
|
||||
lerna-debug.log
|
||||
docsTmp
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
160
README.md
160
README.md
@@ -1,114 +1,116 @@
|
||||
# Jazz - instant sync
|
||||
|
||||
Homepage: [jazz.tools](https://jazz.tools) — [Discord](https://discord.gg/utDMjHYg42)
|
||||
<sub>Homepage: [jazz.tools](https://jazz.tools) — Docs: [DOCS.md](./DOCS.md) — Community & support: [Discord](https://discord.gg/utDMjHYg42) — Updates: [Twitter](https://twitter.com/jazz_tools) & [Email](https://gcmp.io/news)</sub>
|
||||
|
||||
Jazz is an open-source toolkit for *permissioned telepathic data.*
|
||||
**Jazz is an open-source toolkit for building apps with *secure sync.***
|
||||
|
||||
- Ship faster & simplify your frontend and backend
|
||||
- Get cross-device sync, real-time collaboration & offline support for free
|
||||
Quickly build and ship apps with:
|
||||
|
||||
[Jazz Global Mesh](https://jazz.tools/mesh) is serverless sync & storage for Jazz apps. (currently free!)
|
||||
- **Cross-device sync**
|
||||
- **Collaborative features** (incl. real-time multiplayer)
|
||||
- **Instantly reacting UIs**
|
||||
- Local-first storage & offline support
|
||||
- File upload and real-time media streaming
|
||||
|
||||
# What is *secure sync*?
|
||||
|
||||
**Sync** means that, *instead of making API requests*, you:
|
||||
|
||||
- **Read and write data as if it was local** — from anywhere in your app.
|
||||
- **Always have data synced to wherever it's needed, instantly:** to other devices of the same user, to other users, to your backend, to your local machine for debugging, etc.
|
||||
|
||||
**Secure** means that, *instead of relying on your API or DB for access control*, you:
|
||||
|
||||
- **Set fine-grained, role-based permissions in `Group`s** that are **synced along with your data**.
|
||||
- **Permissions *verifiably enforced* everywhere,** using encryption & signatures under the hood.
|
||||
- **Change roles dynamically** for evolving teams, expiring invite links and more.
|
||||
|
||||
# What's special about Jazz?
|
||||
|
||||
Compared to other libraries and frameworks for local-first, sync-based or real-time apps, these are some of the things that make Jazz unique:
|
||||
|
||||
- **Jazz is a *batteries-included,* vertically integrated toolkit,** offering everything you need to build an app, including auth, permissions, data model, sync, conflict resolution, blob storage, file uploads, real-time media streaming and more.
|
||||
- **Jazz has a *small API surface* of only a few abstractions to learn,** which combine in powerful ways to implement a broad set of features.
|
||||
- **Jazz *granularly* loads and caches *only the data that is needed*,** combining *local-first* instant UI reactivity and offline support with the on-demand data efficiency of conventional APIs
|
||||
- **Jazz supports end-to-end encryption, but doesn't require it,** allowing you to either manage your user's secret keys for them (based on existing auth flows) or letting your users
|
||||
- **Jazz is based on CoJSON, a soon-to-be *open standard,*** which means that there will be a whole ecosystem of compatible libraries and frameworks in a variety of environments — and it will be easy to achieve (secure) interop between Jazz/CoJSON-based apps and services.
|
||||
|
||||
# Jazz Global Mesh
|
||||
|
||||
Jazz is open source and you can run your own sync & storage server, but to really provide you with everything you need, we're also running
|
||||
**[Jazz Global Mesh](https://jazz.tools/mesh)**, a globally distributed mesh of servers optimized for:
|
||||
|
||||
- **Ultra-low-latency sync** (with geo-aware edge caching and optimal routing)
|
||||
- **Low-cost, reliable storage**
|
||||
|
||||
|
||||
**Jazz Global Mesh is free for small volumes of data** and it's the **default syncing peer,** so you can **start building multi-user Jazz apps with persistent data in minutes,** using only frontend code!
|
||||
|
||||
## What is Permissioned Telepathic Data?
|
||||
# Getting started
|
||||
|
||||
**Telepathic** means:
|
||||
## Example App Walkthrough
|
||||
|
||||
- **Read and write data as if it was local,** from anywhere in your app.
|
||||
- **Always have that data synced, instantly.** Across devices of the same user — or to other users (coming soon: to your backend, workers, etc.)
|
||||
**For now the best tutorial is the walkthrough of the [Todo List Example App](#todo-list).**
|
||||
|
||||
**Permissioned** means:
|
||||
## General Scenarios
|
||||
|
||||
- **Fine-grained, role-based permissions are *baked into* your data.**
|
||||
- **Permissions are enforced everywhere, locally.** (using cryptography instead of through an API)
|
||||
- Roles can be changed dynamically, supporting changing teams, invite links and more.
|
||||
### Building a new, entirely sync-based React app
|
||||
|
||||
## How to build an app with Jazz?
|
||||
1. Define your data model with [cojson Collaborative Values (CoValues)](./DOCS.md#covalue).
|
||||
2. Implement permission logic using [cojson Groups](./DOCS.md#group).
|
||||
3. Build a user interface with [jazz-react](./DOCS.md#jazz-react) and [auto-sub](./DOCS.md#useautosubid).
|
||||
|
||||
### Building a new app, completely with Jazz
|
||||
### Gradually adding sync to an existing React app
|
||||
|
||||
It's still a bit early, but these are the rough steps:
|
||||
Gradually migrate app features to use sync:
|
||||
|
||||
1. Define your data model with [CoJSON Values](#cojson).
|
||||
2. Implement permission logic using [CoJSON Groups](#group).
|
||||
3. Hook up a user interface with [jazz-react](#jazz-react).
|
||||
1. Define data model for small aspect of your app with [cojson Collaborative Values (CoValues)](./DOCS.md#covalue).
|
||||
- Schema adapters/importers for Prisma/Drizzle/PostgreSQL introspection coming soon.
|
||||
2. Map existing permission logic with [cojson Groups](./DOCS.md#group) & integrate existing auth.
|
||||
- Auth integrations coming soon.
|
||||
3. Replace some of the React state and API requests in your UI with [jazz-react](./DOCS.md#jazz-react) and [auto-sub](./DOCS.md#useautosubid).
|
||||
|
||||
The best example is currently the [Todo List app](#example-app-todo-list).
|
||||
# Example Apps
|
||||
|
||||
### Gradually adding Jazz to an existing app
|
||||
## Todo List
|
||||
|
||||
Coming soon: Jazz will support gradual adoption by integrating with your existing UI, auth and database.
|
||||
**A simple collaborative todo list app.**
|
||||
|
||||
## Example App: Todo List
|
||||
Live version: https://example-todo.jazz.tools
|
||||
|
||||
The best example of Jazz is currently the Todo List app.
|
||||
Source code & walkthrough: [`./examples/todo`](./examples/todo)
|
||||
|
||||
- Live version: https://example-todo.jazz.tools
|
||||
- Source code: [`./examples/todo`](./examples/todo). See the README there for a walk-through and running instructions.
|
||||
|
||||
# API Reference
|
||||
|
||||
Note: Since it's early days, this is the only source of documentation so far.
|
||||
|
||||
If you want to build something with Jazz, [join the Jazz Discord](https://discord.gg/utDMjHYg42) for encouragement and help!
|
||||
|
||||
## Overview: Main Packages
|
||||
|
||||
**`cojson`**
|
||||
|
||||
A library implementing abstractions and protocols for "Collaborative JSON". This will soon be standardized and forms the basis of permissioned telepathic data.
|
||||
|
||||
**`jazz-react`**
|
||||
|
||||
Provides you with everything you need to build react apps around CoJSON, including reactive hooks for telepathic data, local IndexedDB persistence, support for different auth providers and helpers for simple invite links for CoJSON groups.
|
||||
|
||||
### Supporting packages
|
||||
<small>
|
||||
|
||||
**`cojson-simple-sync`**
|
||||
|
||||
A generic CoJSON sync server you can run locally if you don't want to use Jazz Global Mesh (the default sync backend, at `wss://sync.jazz.tools`)
|
||||
|
||||
**`jazz-browser`**
|
||||
|
||||
framework-agnostic primitives that allow you to use CoJSON in the browser. Used to implement `jazz-react`, will be used to implement bindings for other frameworks in the future.
|
||||
|
||||
**`jazz-react-auth-local`** (and `jazz-browser-auth-local`): A simple auth provider that stores cryptographic keys on user devices using WebAuthentication/Passkeys. Lets you build Jazz apps completely without a backend, with end-to-end encryption by default.
|
||||
|
||||
**`jazz-storage-indexeddb`**
|
||||
|
||||
Provides local, offline-capable persistence. Included and enabled in `jazz-react` by default.
|
||||
</small>
|
||||
|
||||
## `CoJSON`
|
||||
|
||||
CoJSON is the core implementation of permissioned telepathic data. It provides abstractions for Collaborative JSON values ("`CoValues`"), groups for permission management and a protocol for syncing between nodes. Our goal is to standardise CoJSON soon and port it to other languages and platforms.
|
||||
|
||||
### `LocalNode`
|
||||
|
||||
A `LocalNode` represents a local view of a set of loaded `CoValue`s, from the perspective of a particular account (or primitive cryptographic agent).
|
||||
Demonstrates:
|
||||
- Defining a data model with `CoMap`s and `CoList`s
|
||||
- Creating data and setting permissions with `Group`s
|
||||
- Fetching, rendering & editing data from nested `CoValue`s with reactive synced queries
|
||||
|
||||
|
||||
## Rate-My-Pet
|
||||
|
||||
### `Group`
|
||||
**A simple social polling app.**
|
||||
|
||||
### `CoValue` & `ContentType`s
|
||||
Live version: https://example-pets.jazz.tools
|
||||
|
||||
#### `CoMap`
|
||||
Source code (walkthrough coming soon): [`./examples/pets`](./examples/pets)
|
||||
|
||||
#### `CoList` (coming soon)
|
||||
Demonstrates:
|
||||
- Implementing per-account data streams (reactions) with `CoStream`s
|
||||
- Implementing image upload and progressive image streaming using helpers from `jazz-react-media-images` (on top of CoJSON's `BinaryCoStreams` & `ImageDefinition` convention)
|
||||
|
||||
#### `CoStram` (coming soon)
|
||||
|
||||
#### `Static` (coming soon)
|
||||
# Documentation & API Reference
|
||||
|
||||
## `jazz-react`
|
||||
For now, docs are hosted in a single well-structured markdown file: [`./DOCS.md`](./DOCS.md).
|
||||
|
||||
### `<WithJazz>`
|
||||
- [Package Overview](./DOCS.md#overview)
|
||||
- [`jazz-react` API](./DOCS.md#jazz-react)
|
||||
- [`cojson` API](./DOCS.md#cojson)
|
||||
- [`jazz-browser-media-images` API](./DOCS.md#jazz-browser-media-images)
|
||||
|
||||
### `useJazz()`
|
||||
|
||||
### `useTelepathicData(coID)`
|
||||
In the future we'll build a dedicated docs page on the Jazz homepage.
|
||||
|
||||
### `useProfile(accountID)`
|
||||
----
|
||||
|
||||
Copyright 2023 — Garden Computing, Inc.
|
||||
18
examples/chat/.eslintrc.cjs
Normal file
18
examples/chat/.eslintrc.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
24
examples/chat/.gitignore
vendored
Normal file
24
examples/chat/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
9
examples/chat/CHANGELOG.md
Normal file
9
examples/chat/CHANGELOG.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# jazz-example-chat
|
||||
|
||||
## 0.0.46
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- jazz-react@0.5.0
|
||||
- jazz-react-auth-local@0.4.16
|
||||
4
examples/chat/Dockerfile
Normal file
4
examples/chat/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM caddy:2.7.3-alpine
|
||||
LABEL org.opencontainers.image.source="https://github.com/gardencmp/jazz"
|
||||
|
||||
COPY ./dist /usr/share/caddy/
|
||||
64
examples/chat/README.md
Normal file
64
examples/chat/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Jazz Todo List Example
|
||||
|
||||
Live version: https://example-todo.jazz.tools
|
||||
|
||||
## Installing & running the example locally
|
||||
|
||||
Start by checking out just the example app to a folder:
|
||||
|
||||
```bash
|
||||
npx degit gardencmp/jazz/examples/todo jazz-example-todo
|
||||
cd jazz-example-todo
|
||||
```
|
||||
|
||||
(This ensures that you have the example app without git history or our multi-package monorepo)
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Start the dev server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- [`src/basicComponents`](./src/basicComponents): simple components to build the UI, unrelated to Jazz (uses [shadcn/ui](https://ui.shadcn.com))
|
||||
- [`src/components`](./src/components/): helper components that do contain Jazz-specific logic, but aren't very relevant to understand the basics of Jazz and CoJSON
|
||||
- [`src/1_types.ts`](./src/1_types.ts),
|
||||
[`src/2_main.tsx`](./src/2_main.tsx),
|
||||
[`src/3_NewProjectForm.tsx`](./src/3_NewProjectForm.tsx),
|
||||
[`src/4_ProjectTodoTable.tsx`](./src/4_ProjectTodoTable.tsx): the main files for this example, see the walkthrough below
|
||||
|
||||
## Walkthrough
|
||||
|
||||
### Main parts
|
||||
|
||||
1. Defining the data model with CoJSON: [`src/1_types.ts`](./src/1_types.ts)
|
||||
|
||||
2. The top-level provider `<WithJazz/>` and routing: [`src/2_main.tsx`](./src/2_main.tsx)
|
||||
|
||||
3. Creating a new todo project: [`src/3_NewProjectForm.tsx`](./src/3_NewProjectForm.tsx)
|
||||
|
||||
4. Reactively rendering a todo project as a table, adding and editing tasks: [`src/4_ProjectTodoTable.tsx`](./src/4_ProjectTodoTable.tsx)
|
||||
|
||||
### Helpers
|
||||
|
||||
- (not yet explained) Creating invite links/QR codes with `<InviteButton/>`: [`src/components/InviteButton.tsx`](./src/components/InviteButton.tsx)
|
||||
|
||||
This is the whole Todo List app!
|
||||
|
||||
## Questions / problems / feedback
|
||||
|
||||
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
|
||||
|
||||
|
||||
## Configuration: sync server
|
||||
|
||||
By default, the example app uses [Jazz Global Mesh](https://jazz.tools/mesh) (`wss://sync.jazz.tools`) - so cross-device use, invites and collaboration should just work.
|
||||
|
||||
You can also run a local sync server by running `npx cojson-simple-sync` and adding the query param `?sync=ws://localhost:4200` to the URL of the example app (for example: `http://localhost:5173/?sync=ws://localhost:4200`), or by setting the `sync` parameter of the `<WithJazz>` provider component in [./src/2_main.tsx](./src/2_main.tsx).
|
||||
14
examples/chat/index.html
Normal file
14
examples/chat/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/jazz-logo.png" />
|
||||
<link rel="stylesheet" href="/src/index.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Jazz Chat Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/app.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
56
examples/chat/job-template.nomad
Normal file
56
examples/chat/job-template.nomad
Normal file
@@ -0,0 +1,56 @@
|
||||
job "chat$BRANCH_SUFFIX" {
|
||||
region = "global"
|
||||
datacenters = ["*"]
|
||||
|
||||
group "static" {
|
||||
count = 4
|
||||
|
||||
network {
|
||||
port "http" {
|
||||
to = 80
|
||||
}
|
||||
}
|
||||
|
||||
constraint {
|
||||
attribute = "${node.class}"
|
||||
operator = "="
|
||||
value = "mesh"
|
||||
}
|
||||
|
||||
spread {
|
||||
attribute = "${node.datacenter}"
|
||||
weight = 100
|
||||
}
|
||||
|
||||
constraint {
|
||||
distinct_hosts = true
|
||||
}
|
||||
|
||||
task "server" {
|
||||
driver = "docker"
|
||||
|
||||
config {
|
||||
image = "$DOCKER_TAG"
|
||||
ports = ["http"]
|
||||
|
||||
auth = {
|
||||
username = "$DOCKER_USER"
|
||||
password = "$DOCKER_PASSWORD"
|
||||
}
|
||||
}
|
||||
|
||||
service {
|
||||
tags = ["public"]
|
||||
name = "chat$BRANCH_SUFFIX"
|
||||
port = "http"
|
||||
provider = "consul"
|
||||
}
|
||||
|
||||
resources {
|
||||
cpu = 50 # MHz
|
||||
memory = 50 # MB
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# deploy bump 4
|
||||
48
examples/chat/package.json
Normal file
48
examples/chat/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "jazz-example-chat",
|
||||
"private": true,
|
||||
"version": "0.0.46",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"@types/qrcode": "^1.5.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"hash-slash": "^0.1.3",
|
||||
"jazz-react": "^0.5.0",
|
||||
"jazz-react-auth-local": "^0.4.16",
|
||||
"lucide-react": "^0.274.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router": "^6.16.0",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"react-use": "^17.4.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uniqolor": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.3",
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.5"
|
||||
}
|
||||
}
|
||||
6
examples/chat/postcss.config.js
Normal file
6
examples/chat/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
examples/chat/public/jazz-logo.png
Normal file
BIN
examples/chat/public/jazz-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
35
examples/chat/src/app.tsx
Normal file
35
examples/chat/src/app.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { WithJazz, useJazz, DemoAuth } from 'jazz-react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { HashRoute } from 'hash-slash';
|
||||
import { ChatWindow } from './chatWindow.tsx';
|
||||
import { Chat } from './dataModel.ts';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<WithJazz auth={DemoAuth({ appName: 'Jazz Chat Example' })} apiKey="api_z9d034j3t34ht034ir">
|
||||
<App />
|
||||
</WithJazz>,
|
||||
);
|
||||
|
||||
function App() {
|
||||
return <div className='flex flex-col items-center justify-between w-screen h-screen p-2 dark:bg-black dark:text-white'>
|
||||
<button onClick={useJazz().logOut} className='rounded mb-5 px-2 py-1 bg-stone-200 dark:bg-stone-800 dark:text-white self-end'>
|
||||
Log Out
|
||||
</button>
|
||||
{HashRoute({
|
||||
'/': <Home />,
|
||||
'/chat/:id': (id) => <ChatWindow chatId={id as Chat['id']} />,
|
||||
}, { reportToParentFrame: true })}
|
||||
</div>
|
||||
}
|
||||
|
||||
function Home() {
|
||||
const { me } = useJazz();
|
||||
return <button className='rounded py-2 px-4 bg-stone-200 dark:bg-stone-800 dark:text-white my-auto'
|
||||
onClick={() => {
|
||||
const group = me.createGroup().addMember('everyone', 'writer');
|
||||
const chat = group.createList<Chat>();
|
||||
location.hash = '/chat/' + chat.id;
|
||||
}}>
|
||||
Create New Chat
|
||||
</button>
|
||||
}
|
||||
43
examples/chat/src/chatWindow.tsx
Normal file
43
examples/chat/src/chatWindow.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useAutoSub } from 'jazz-react';
|
||||
import { Chat, Message } from './dataModel.ts';
|
||||
|
||||
export function ChatWindow(props: { chatId: Chat['id'] }) {
|
||||
const chat = useAutoSub(props.chatId);
|
||||
|
||||
return chat ? <div className='w-full max-w-xl h-full flex flex-col items-stretch'>
|
||||
{
|
||||
chat.map((msg, i) => (
|
||||
<ChatBubble key={msg?.id}
|
||||
text={msg?.text}
|
||||
by={chat.meta.edits[i].by?.profile?.name}
|
||||
byMe={chat.meta.edits[i].by?.isMe}
|
||||
at={chat.meta.edits[i].at} />
|
||||
))
|
||||
}
|
||||
<ChatInput onSubmit={(text) => {
|
||||
const msg = chat.meta.group.createMap<Message>({ text });
|
||||
chat.append(msg.id);
|
||||
}}/>
|
||||
</div> : <div>Loading...</div>;
|
||||
}
|
||||
|
||||
function ChatBubble(props: { text?: string, by?: string, at?: Date, byMe?: boolean }) {
|
||||
return <div className={`${props.byMe ? 'items-end' : 'items-start'} flex flex-col`}>
|
||||
<div className='rounded-xl bg-stone-100 dark:bg-stone-700 dark:text-white py-2 px-4 mt-2 min-w-[5rem]'>
|
||||
{ props.text }
|
||||
</div>
|
||||
<div className='text-xs text-neutral-500 ml-2'>
|
||||
{ props.by } { props.at?.getHours() }:{ props.at?.getMinutes() }
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ChatInput(props: { onSubmit: (text: string) => void }) {
|
||||
return <input className='rounded p-2 border mt-auto dark:bg-black dark:text-white dark:border-stone-700'
|
||||
placeholder='Type a message and press Enter'
|
||||
onKeyDown={({ key, currentTarget: input }) => {
|
||||
if (key !== 'Enter' || !input.value) return;
|
||||
props.onSubmit(input.value);
|
||||
input.value = '';
|
||||
}}/>
|
||||
}
|
||||
4
examples/chat/src/dataModel.ts
Normal file
4
examples/chat/src/dataModel.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { CoMap, CoList } from 'cojson';
|
||||
|
||||
export type Chat = CoList<Message['id']>;
|
||||
export type Message = CoMap<{ text: string }>;
|
||||
78
examples/chat/src/index.css
Normal file
78
examples/chat/src/index.css
Normal file
@@ -0,0 +1,78 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 20 14.3% 4.1%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 20 14.3% 4.1%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 20 14.3% 4.1%;
|
||||
|
||||
--primary: 24 9.8% 10%;
|
||||
--primary-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--secondary: 60 4.8% 95.9%;
|
||||
--secondary-foreground: 24 9.8% 10%;
|
||||
|
||||
--muted: 60 4.8% 95.9%;
|
||||
--muted-foreground: 25 5.3% 44.7%;
|
||||
|
||||
--accent: 60 4.8% 95.9%;
|
||||
--accent-foreground: 24 9.8% 10%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--border: 20 5.9% 90%;
|
||||
--input: 20 5.9% 90%;
|
||||
--ring: 20 14.3% 4.1%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 20 14.3% 4.1%;
|
||||
--foreground: 60 9.1% 97.8%;
|
||||
|
||||
--card: 20 14.3% 4.1%;
|
||||
--card-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--popover: 20 14.3% 4.1%;
|
||||
--popover-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--primary: 60 9.1% 97.8%;
|
||||
--primary-foreground: 24 9.8% 10%;
|
||||
|
||||
--secondary: 12 6.5% 15.1%;
|
||||
--secondary-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--muted: 12 6.5% 15.1%;
|
||||
--muted-foreground: 24 5.4% 63.9%;
|
||||
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--border: 12 6.5% 15.1%;
|
||||
--input: 12 6.5% 15.1%;
|
||||
--ring: 24 5.7% 82.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
1
examples/chat/src/vite-env.d.ts
vendored
Normal file
1
examples/chat/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
75
examples/chat/tailwind.config.js
Normal file
75
examples/chat/tailwind.config.js
Normal file
@@ -0,0 +1,75 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: 0 },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: 0 },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
29
examples/chat/tsconfig.json
Normal file
29
examples/chat/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
examples/chat/tsconfig.node.json
Normal file
10
examples/chat/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
examples/chat/vite.config.ts
Normal file
16
examples/chat/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import path from "path";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
minify: false
|
||||
}
|
||||
})
|
||||
18
examples/file-drop/.eslintrc.cjs
Normal file
18
examples/file-drop/.eslintrc.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
24
examples/file-drop/.gitignore
vendored
Normal file
24
examples/file-drop/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
9
examples/file-drop/CHANGELOG.md
Normal file
9
examples/file-drop/CHANGELOG.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# jazz-example-file-drop
|
||||
|
||||
## 0.0.63
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- jazz-react@0.5.0
|
||||
- jazz-react-auth-local@0.4.16
|
||||
4
examples/file-drop/Dockerfile
Normal file
4
examples/file-drop/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM caddy:2.7.3-alpine
|
||||
LABEL org.opencontainers.image.source="https://github.com/gardencmp/jazz"
|
||||
|
||||
COPY ./dist /usr/share/caddy/
|
||||
64
examples/file-drop/README.md
Normal file
64
examples/file-drop/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Jazz Todo List Example
|
||||
|
||||
Live version: https://example-todo.jazz.tools
|
||||
|
||||
## Installing & running the example locally
|
||||
|
||||
Start by checking out just the example app to a folder:
|
||||
|
||||
```bash
|
||||
npx degit gardencmp/jazz/examples/todo jazz-example-todo
|
||||
cd jazz-example-todo
|
||||
```
|
||||
|
||||
(This ensures that you have the example app without git history or our multi-package monorepo)
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Start the dev server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- [`src/basicComponents`](./src/basicComponents): simple components to build the UI, unrelated to Jazz (uses [shadcn/ui](https://ui.shadcn.com))
|
||||
- [`src/components`](./src/components/): helper components that do contain Jazz-specific logic, but aren't very relevant to understand the basics of Jazz and CoJSON
|
||||
- [`src/1_types.ts`](./src/1_types.ts),
|
||||
[`src/2_main.tsx`](./src/2_main.tsx),
|
||||
[`src/3_NewProjectForm.tsx`](./src/3_NewProjectForm.tsx),
|
||||
[`src/4_ProjectTodoTable.tsx`](./src/4_ProjectTodoTable.tsx): the main files for this example, see the walkthrough below
|
||||
|
||||
## Walkthrough
|
||||
|
||||
### Main parts
|
||||
|
||||
1. Defining the data model with CoJSON: [`src/1_types.ts`](./src/1_types.ts)
|
||||
|
||||
2. The top-level provider `<WithJazz/>` and routing: [`src/2_main.tsx`](./src/2_main.tsx)
|
||||
|
||||
3. Creating a new todo project: [`src/3_NewProjectForm.tsx`](./src/3_NewProjectForm.tsx)
|
||||
|
||||
4. Reactively rendering a todo project as a table, adding and editing tasks: [`src/4_ProjectTodoTable.tsx`](./src/4_ProjectTodoTable.tsx)
|
||||
|
||||
### Helpers
|
||||
|
||||
- (not yet explained) Creating invite links/QR codes with `<InviteButton/>`: [`src/components/InviteButton.tsx`](./src/components/InviteButton.tsx)
|
||||
|
||||
This is the whole Todo List app!
|
||||
|
||||
## Questions / problems / feedback
|
||||
|
||||
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
|
||||
|
||||
|
||||
## Configuration: sync server
|
||||
|
||||
By default, the example app uses [Jazz Global Mesh](https://jazz.tools/mesh) (`wss://sync.jazz.tools`) - so cross-device use, invites and collaboration should just work.
|
||||
|
||||
You can also run a local sync server by running `npx cojson-simple-sync` and adding the query param `?sync=ws://localhost:4200` to the URL of the example app (for example: `http://localhost:5173/?sync=ws://localhost:4200`), or by setting the `sync` parameter of the `<WithJazz>` provider component in [./src/2_main.tsx](./src/2_main.tsx).
|
||||
16
examples/file-drop/components.json
Normal file
16
examples/file-drop/components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "stone",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/basicComponents",
|
||||
"utils": "@/basicComponents/lib/utils"
|
||||
}
|
||||
}
|
||||
13
examples/file-drop/index.html
Normal file
13
examples/file-drop/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/jazz-logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Jazz File Drop Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/2_main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
56
examples/file-drop/job-template.nomad
Normal file
56
examples/file-drop/job-template.nomad
Normal file
@@ -0,0 +1,56 @@
|
||||
job "example-file-drop$BRANCH_SUFFIX" {
|
||||
region = "global"
|
||||
datacenters = ["*"]
|
||||
|
||||
group "static" {
|
||||
count = 4
|
||||
|
||||
network {
|
||||
port "http" {
|
||||
to = 80
|
||||
}
|
||||
}
|
||||
|
||||
constraint {
|
||||
attribute = "${node.class}"
|
||||
operator = "="
|
||||
value = "mesh"
|
||||
}
|
||||
|
||||
spread {
|
||||
attribute = "${node.datacenter}"
|
||||
weight = 100
|
||||
}
|
||||
|
||||
constraint {
|
||||
distinct_hosts = true
|
||||
}
|
||||
|
||||
task "server" {
|
||||
driver = "docker"
|
||||
|
||||
config {
|
||||
image = "$DOCKER_TAG"
|
||||
ports = ["http"]
|
||||
|
||||
auth = {
|
||||
username = "$DOCKER_USER"
|
||||
password = "$DOCKER_PASSWORD"
|
||||
}
|
||||
}
|
||||
|
||||
service {
|
||||
tags = ["public"]
|
||||
name = "example-file-drop$BRANCH_SUFFIX"
|
||||
port = "http"
|
||||
provider = "consul"
|
||||
}
|
||||
|
||||
resources {
|
||||
cpu = 50 # MHz
|
||||
memory = 50 # MB
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# deploy bump 4
|
||||
46
examples/file-drop/package.json
Normal file
46
examples/file-drop/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "jazz-example-file-drop",
|
||||
"private": true,
|
||||
"version": "0.0.63",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 6610",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"@types/qrcode": "^1.5.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-react": "^0.5.0",
|
||||
"jazz-react-auth-local": "^0.4.16",
|
||||
"lucide-react": "^0.274.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router": "^6.16.0",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uniqolor": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.3",
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.5"
|
||||
}
|
||||
}
|
||||
6
examples/file-drop/postcss.config.js
Normal file
6
examples/file-drop/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
examples/file-drop/public/jazz-logo.png
Normal file
BIN
examples/file-drop/public/jazz-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
5
examples/file-drop/src/1_types.ts
Normal file
5
examples/file-drop/src/1_types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { CoMap, BinaryCoStream } from "cojson";
|
||||
|
||||
export type FileBundle = CoMap<{
|
||||
[filename: string]: BinaryCoStream['id']
|
||||
}>;
|
||||
187
examples/file-drop/src/2_main.tsx
Normal file
187
examples/file-drop/src/2_main.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import React, { ChangeEvent, useCallback, useState } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import {
|
||||
RouterProvider,
|
||||
createHashRouter,
|
||||
useNavigate,
|
||||
useParams,
|
||||
} from "react-router-dom";
|
||||
import "./index.css";
|
||||
|
||||
import { WithJazz, useJazz, useAcceptInvite, useAutoSub } from "jazz-react";
|
||||
import { LocalAuth } from "jazz-react-auth-local";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
ThemeProvider,
|
||||
TitleAndLogo,
|
||||
} from "./basicComponents/index.ts";
|
||||
import { PrettyAuthUI } from "./components/Auth.tsx";
|
||||
import { FileBundle } from "./1_types.ts";
|
||||
import {
|
||||
createBinaryStreamFromBlob,
|
||||
readBlobFromBinaryStream,
|
||||
} from "jazz-browser";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
|
||||
const appName = "Jazz File Drop Example";
|
||||
|
||||
const auth = LocalAuth({
|
||||
appName,
|
||||
Component: PrettyAuthUI,
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<TitleAndLogo name={appName} />
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
||||
<WithJazz auth={auth}>
|
||||
<App />
|
||||
</WithJazz>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
function App() {
|
||||
// logOut logs out the AuthProvider passed to `<WithJazz/>` above.
|
||||
const { logOut } = useJazz();
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <FileDropUI />,
|
||||
},
|
||||
{
|
||||
path: "/bundle/:bundleId",
|
||||
element: <FileDropUIPage />,
|
||||
},
|
||||
{
|
||||
path: "/invite/*",
|
||||
element: <p>Accepting invite...</p>,
|
||||
},
|
||||
]);
|
||||
|
||||
// `useAcceptInvite()` is a hook that accepts an invite link from the URL hash,
|
||||
// and on success calls our callback where we navigate to the project that we were just invited to.
|
||||
useAcceptInvite((bundleId) => router.navigate("/v/" + bundleId));
|
||||
|
||||
return (
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
|
||||
<Button
|
||||
onClick={() => router.navigate("/").then(logOut)}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileDropUIPage() {
|
||||
const { bundleId } = useParams<{ bundleId: FileBundle["id"] }>();
|
||||
|
||||
return <FileDropUI bundleId={bundleId} />;
|
||||
}
|
||||
|
||||
export function FileDropUI({ bundleId }: { bundleId?: FileBundle["id"] }) {
|
||||
const navigate = useNavigate();
|
||||
const { me, localNode } = useJazz();
|
||||
const fileBundle = useAutoSub(bundleId);
|
||||
|
||||
const [progressMessage, setProgressMessage] = useState<{
|
||||
[name: string]: string;
|
||||
}>({});
|
||||
|
||||
const onChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
let fileBundleToUse = fileBundle?.meta.coValue;
|
||||
let isFirstUpload = false;
|
||||
|
||||
if (!fileBundleToUse) {
|
||||
const group = me.createGroup().addMember("everyone", "reader");
|
||||
fileBundleToUse = group.createMap<FileBundle>();
|
||||
isFirstUpload = true;
|
||||
}
|
||||
|
||||
const files = [...(event.target.files || [])];
|
||||
|
||||
Promise.all(
|
||||
files.map((file) =>
|
||||
createBinaryStreamFromBlob(
|
||||
file,
|
||||
fileBundleToUse!.group,
|
||||
{ type: "binary" },
|
||||
(progress) =>
|
||||
setProgressMessage((old) => ({
|
||||
...old,
|
||||
[file.name]: `Creating ${Math.round(
|
||||
progress * 100
|
||||
)}%`,
|
||||
}))
|
||||
).then((stream) => {
|
||||
fileBundleToUse!.set(file.name, stream.id);
|
||||
})
|
||||
)
|
||||
).then(() => {
|
||||
if (isFirstUpload) {
|
||||
navigate("/bundle/" + fileBundleToUse!.id);
|
||||
}
|
||||
});
|
||||
|
||||
event.target.value = "";
|
||||
},
|
||||
[me, navigate, fileBundle]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-full p-5 w-[40rem]">
|
||||
<h1 className="text-3xl font-bold mb-5">File Drop</h1>
|
||||
{[
|
||||
...new Set([
|
||||
...Object.keys(fileBundle || {}),
|
||||
...Object.keys(progressMessage),
|
||||
]),
|
||||
].map((name) => (
|
||||
<div className="mb-5 flex justify-between" key={name}>
|
||||
{name} {progressMessage[name]}
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!(name in (fileBundle || {}))}
|
||||
onClick={() => {
|
||||
const streamId = fileBundle?.meta.coValue.get(name);
|
||||
streamId &&
|
||||
readBlobFromBinaryStream(
|
||||
streamId,
|
||||
localNode,
|
||||
false,
|
||||
(progress) =>
|
||||
setProgressMessage((old) => ({
|
||||
...old,
|
||||
[name]: `Loading ${Math.round(
|
||||
progress * 100
|
||||
)}%`,
|
||||
}))
|
||||
).then((blob) => {
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{(!fileBundle || fileBundle.meta.group.myRole() === "admin") && (
|
||||
<Input type="file" onChange={onChange} multiple />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Walkthrough: Continue with ./3_NewProjectForm.tsx */
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/basicComponents/ui/input";
|
||||
import { Button } from "@/basicComponents/ui/button";
|
||||
|
||||
export function SubmittableInput({
|
||||
onSubmit,
|
||||
10
examples/file-drop/src/basicComponents/TitleAndLogo.tsx
Normal file
10
examples/file-drop/src/basicComponents/TitleAndLogo.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Toaster } from ".";
|
||||
|
||||
export function TitleAndLogo({name}: {name: string}) {
|
||||
return <>
|
||||
<div className="flex items-center gap-2 justify-center mt-5">
|
||||
<img src="jazz-logo.png" className="h-5" /> {name}
|
||||
</div>
|
||||
<Toaster />
|
||||
</>
|
||||
}
|
||||
17
examples/file-drop/src/basicComponents/index.ts
Normal file
17
examples/file-drop/src/basicComponents/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export { Button } from "./ui/button";
|
||||
export { Checkbox } from "./ui/checkbox";
|
||||
export { Input } from "./ui/input";
|
||||
export { Skeleton } from "./ui/skeleton";
|
||||
export { Toaster } from "./ui/toaster";
|
||||
export { useToast } from "./ui/use-toast";
|
||||
export { SubmittableInput } from "./SubmittableInput";
|
||||
export { TitleAndLogo } from "./TitleAndLogo";
|
||||
export { ThemeProvider } from "./themeProvider";
|
||||
export {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "./ui/table";
|
||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
@@ -3,7 +3,7 @@ import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
} from "@/basicComponents/ui/toast"
|
||||
import { useToast } from "@/basicComponents/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
} from "@/basicComponents/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
@@ -1,9 +1,10 @@
|
||||
import { LocalAuthComponent } from "jazz-react-auth-local";
|
||||
import { useState } from "react";
|
||||
import { Input } from "./ui/input";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export const PrettyAuthComponent: LocalAuthComponent = ({
|
||||
import { LocalAuthComponent } from "jazz-react-auth-local";
|
||||
|
||||
import { Input, Button } from "../basicComponents";
|
||||
|
||||
export const PrettyAuthUI: LocalAuthComponent = ({
|
||||
loading,
|
||||
logIn,
|
||||
signUp,
|
||||
45
examples/file-drop/src/components/InviteButton.tsx
Normal file
45
examples/file-drop/src/components/InviteButton.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import QRCode from "qrcode";
|
||||
|
||||
import { useToast, Button } from "../basicComponents";
|
||||
import { CoValue } from "cojson";
|
||||
import { Resolved, createInviteLink } from "jazz-react";
|
||||
|
||||
export function InviteButton<T extends CoValue>({ value }: { value?: Resolved<T> }) {
|
||||
const [existingInviteLink, setExistingInviteLink] = useState<string>();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
value?.meta.group?.myRole() === "admin" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="py-0"
|
||||
disabled={!value.meta.group || !value.id}
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
let inviteLink = existingInviteLink;
|
||||
if (value.meta.group && value.id && !inviteLink) {
|
||||
inviteLink = createInviteLink(value, "writer");
|
||||
setExistingInviteLink(inviteLink);
|
||||
}
|
||||
if (inviteLink) {
|
||||
const qr = await QRCode.toDataURL(inviteLink, {
|
||||
errorCorrectionLevel: "L",
|
||||
});
|
||||
navigator.clipboard.writeText(inviteLink).then(() =>
|
||||
toast({
|
||||
title: "Copied invite link to clipboard!",
|
||||
description: (
|
||||
<img src={qr} className="w-20 h-20" />
|
||||
),
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
76
examples/file-drop/src/index.css
Normal file
76
examples/file-drop/src/index.css
Normal file
@@ -0,0 +1,76 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 20 14.3% 4.1%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 20 14.3% 4.1%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 20 14.3% 4.1%;
|
||||
|
||||
--primary: 24 9.8% 10%;
|
||||
--primary-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--secondary: 60 4.8% 95.9%;
|
||||
--secondary-foreground: 24 9.8% 10%;
|
||||
|
||||
--muted: 60 4.8% 95.9%;
|
||||
--muted-foreground: 25 5.3% 44.7%;
|
||||
|
||||
--accent: 60 4.8% 95.9%;
|
||||
--accent-foreground: 24 9.8% 10%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--border: 20 5.9% 90%;
|
||||
--input: 20 5.9% 90%;
|
||||
--ring: 20 14.3% 4.1%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 20 14.3% 4.1%;
|
||||
--foreground: 60 9.1% 97.8%;
|
||||
|
||||
--card: 20 14.3% 4.1%;
|
||||
--card-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--popover: 20 14.3% 4.1%;
|
||||
--popover-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--primary: 60 9.1% 97.8%;
|
||||
--primary-foreground: 24 9.8% 10%;
|
||||
|
||||
--secondary: 12 6.5% 15.1%;
|
||||
--secondary-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--muted: 12 6.5% 15.1%;
|
||||
--muted-foreground: 24 5.4% 63.9%;
|
||||
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--border: 12 6.5% 15.1%;
|
||||
--input: 12 6.5% 15.1%;
|
||||
--ring: 24 5.7% 82.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
1
examples/file-drop/src/vite-env.d.ts
vendored
Normal file
1
examples/file-drop/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
76
examples/file-drop/tailwind.config.js
Normal file
76
examples/file-drop/tailwind.config.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: 0 },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: 0 },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
29
examples/file-drop/tsconfig.json
Normal file
29
examples/file-drop/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
examples/file-drop/tsconfig.node.json
Normal file
10
examples/file-drop/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
examples/file-drop/vite.config.ts
Normal file
16
examples/file-drop/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import path from "path";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
minify: false
|
||||
}
|
||||
})
|
||||
18
examples/pets/.eslintrc.cjs
Normal file
18
examples/pets/.eslintrc.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
24
examples/pets/.gitignore
vendored
Normal file
24
examples/pets/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
10
examples/pets/CHANGELOG.md
Normal file
10
examples/pets/CHANGELOG.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# jazz-example-pets
|
||||
|
||||
## 0.0.63
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- jazz-browser-media-images@0.5.0
|
||||
- jazz-react@0.5.0
|
||||
- jazz-react-auth-local@0.4.16
|
||||
4
examples/pets/Dockerfile
Normal file
4
examples/pets/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM caddy:2.7.3-alpine
|
||||
LABEL org.opencontainers.image.source="https://github.com/gardencmp/jazz"
|
||||
|
||||
COPY ./dist /usr/share/caddy/
|
||||
51
examples/pets/README.md
Normal file
51
examples/pets/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Jazz Rate-My-Pet List Example
|
||||
|
||||
Live version: https://example-pets.jazz.tools
|
||||
|
||||
## Installing & running the example locally
|
||||
|
||||
Start by checking out just the example app to a folder:
|
||||
|
||||
```bash
|
||||
npx degit gardencmp/jazz/examples/pets jazz-example-pets
|
||||
cd jazz-example-pets
|
||||
```
|
||||
|
||||
(This ensures that you have the example app without git history or our multi-package monorepo)
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Start the dev server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
TODO
|
||||
|
||||
## Walkthrough
|
||||
|
||||
### Main parts
|
||||
|
||||
TODO
|
||||
|
||||
### Helpers
|
||||
|
||||
TODO
|
||||
|
||||
## Questions / problems / feedback
|
||||
|
||||
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
|
||||
|
||||
|
||||
## Configuration: sync server
|
||||
|
||||
By default, the example app uses [Jazz Global Mesh](https://jazz.tools/mesh) (`wss://sync.jazz.tools`) - so cross-device use, invites and collaboration should just work.
|
||||
|
||||
You can also run a local sync server by running `npx cojson-simple-sync` and adding the query param `?sync=ws://localhost:4200` to the URL of the example app (for example: `http://localhost:5173/?sync=ws://localhost:4200`), or by setting the `sync` parameter of the `<WithJazz>` provider component in [./src/0_main.tsx](./src/0_main.tsx).
|
||||
16
examples/pets/components.json
Normal file
16
examples/pets/components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "stone",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/basicComponents",
|
||||
"utils": "@/basicComponents/lib/utils"
|
||||
}
|
||||
}
|
||||
13
examples/pets/index.html
Normal file
13
examples/pets/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/jazz-logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Jazz Rate My Pet Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/2_main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
56
examples/pets/job-template.nomad
Normal file
56
examples/pets/job-template.nomad
Normal file
@@ -0,0 +1,56 @@
|
||||
job "example-pets$BRANCH_SUFFIX" {
|
||||
region = "global"
|
||||
datacenters = ["*"]
|
||||
|
||||
group "static" {
|
||||
count = 4
|
||||
|
||||
network {
|
||||
port "http" {
|
||||
to = 80
|
||||
}
|
||||
}
|
||||
|
||||
constraint {
|
||||
attribute = "${node.class}"
|
||||
operator = "="
|
||||
value = "mesh"
|
||||
}
|
||||
|
||||
spread {
|
||||
attribute = "${node.datacenter}"
|
||||
weight = 100
|
||||
}
|
||||
|
||||
constraint {
|
||||
distinct_hosts = true
|
||||
}
|
||||
|
||||
task "server" {
|
||||
driver = "docker"
|
||||
|
||||
config {
|
||||
image = "$DOCKER_TAG"
|
||||
ports = ["http"]
|
||||
|
||||
auth = {
|
||||
username = "$DOCKER_USER"
|
||||
password = "$DOCKER_PASSWORD"
|
||||
}
|
||||
}
|
||||
|
||||
service {
|
||||
tags = ["public"]
|
||||
name = "example-pets$BRANCH_SUFFIX"
|
||||
port = "http"
|
||||
provider = "consul"
|
||||
}
|
||||
|
||||
resources {
|
||||
cpu = 50 # MHz
|
||||
memory = 50 # MB
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# deploy bump 4
|
||||
47
examples/pets/package.json
Normal file
47
examples/pets/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "jazz-example-pets",
|
||||
"private": true,
|
||||
"version": "0.0.63",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"@types/qrcode": "^1.5.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-browser-media-images": "^0.5.0",
|
||||
"jazz-react": "^0.5.0",
|
||||
"jazz-react-auth-local": "^0.4.16",
|
||||
"lucide-react": "^0.274.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router": "^6.16.0",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uniqolor": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.3",
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.5"
|
||||
}
|
||||
}
|
||||
6
examples/pets/postcss.config.js
Normal file
6
examples/pets/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
examples/pets/public/jazz-logo.png
Normal file
BIN
examples/pets/public/jazz-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
52
examples/pets/src/1_types.ts
Normal file
52
examples/pets/src/1_types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
AccountMigration,
|
||||
CoList,
|
||||
CoMap,
|
||||
CoStream,
|
||||
Media,
|
||||
Profile,
|
||||
} from "cojson";
|
||||
|
||||
/** Walkthrough: Defining the data model with CoJSON
|
||||
*
|
||||
* Here, we define our main data model of TODO
|
||||
*
|
||||
* TODO
|
||||
**/
|
||||
|
||||
export type PetPost = CoMap<{
|
||||
name: string;
|
||||
image: Media.ImageDefinition["id"];
|
||||
reactions: PetReactions["id"];
|
||||
}>;
|
||||
|
||||
export const REACTION_TYPES = [
|
||||
"aww",
|
||||
"love",
|
||||
"haha",
|
||||
"wow",
|
||||
"tiny",
|
||||
"chonkers",
|
||||
] as const;
|
||||
|
||||
export type ReactionType = (typeof REACTION_TYPES)[number];
|
||||
|
||||
export type PetReactions = CoStream<ReactionType>;
|
||||
|
||||
export type ListOfPosts = CoList<PetPost["id"]>;
|
||||
|
||||
export type PetAccountRoot = CoMap<{
|
||||
posts: ListOfPosts["id"];
|
||||
}>;
|
||||
|
||||
export const migration: AccountMigration<Profile, PetAccountRoot> = (account) => {
|
||||
if (!account.get("root")) {
|
||||
const root = account.createMap<PetAccountRoot>({
|
||||
posts: account.createList<ListOfPosts>().id,
|
||||
});
|
||||
account.set("root", root.id);
|
||||
console.log("Created root", root.id);
|
||||
}
|
||||
};
|
||||
|
||||
/** Walkthrough: Continue with ./2_App.tsx */
|
||||
115
examples/pets/src/2_main.tsx
Normal file
115
examples/pets/src/2_main.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { Link, RouterProvider, createHashRouter } from "react-router-dom";
|
||||
import "./index.css";
|
||||
|
||||
import { WithJazz, useJazz, useAcceptInvite } from "jazz-react";
|
||||
import { LocalAuth } from "jazz-react-auth-local";
|
||||
|
||||
import {
|
||||
Button,
|
||||
ThemeProvider,
|
||||
TitleAndLogo,
|
||||
} from "./basicComponents/index.ts";
|
||||
import { PrettyAuthUI } from "./components/Auth.tsx";
|
||||
import { NewPetPostForm } from "./3_NewPetPostForm.tsx";
|
||||
import { RatePetPostUI } from "./4_RatePetPostUI.tsx";
|
||||
import { PetAccountRoot, migration } from "./1_types.ts";
|
||||
import { AccountMigration, Profile } from "cojson";
|
||||
|
||||
/** Walkthrough: The top-level provider `<WithJazz/>`
|
||||
*
|
||||
* This shows how to use the top-level provider `<WithJazz/>`,
|
||||
* which provides the rest of the app with a `LocalNode` (used through `useJazz` later),
|
||||
* based on `LocalAuth` that uses PassKeys (aka WebAuthn) to store a user's account secret
|
||||
* - no backend needed. */
|
||||
|
||||
const appName = "Jazz Rate My Pet Example";
|
||||
|
||||
const auth = LocalAuth({
|
||||
appName,
|
||||
Component: PrettyAuthUI,
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<TitleAndLogo name={appName} />
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
||||
<WithJazz auth={auth} migration={migration as AccountMigration}>
|
||||
<App />
|
||||
</WithJazz>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
/** Walkthrough: Creating pet posts & routing in `<App/>`
|
||||
*
|
||||
* <App> is the main app component, handling client-side routing based
|
||||
* on the CoValue ID (CoID) of our PetPost, stored in the URL hash
|
||||
* - which can also contain invite links.
|
||||
*/
|
||||
|
||||
export default function App() {
|
||||
const { logOut } = useJazz();
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <PostOverview />,
|
||||
},
|
||||
{
|
||||
path: "/new",
|
||||
element: <NewPetPostForm />,
|
||||
},
|
||||
{
|
||||
path: "/pet/:petPostId",
|
||||
element: <RatePetPostUI />,
|
||||
},
|
||||
{
|
||||
path: "/invite/*",
|
||||
element: <p>Accepting invite...</p>,
|
||||
},
|
||||
]);
|
||||
|
||||
useAcceptInvite((petPostID) => router.navigate("/pet/" + petPostID));
|
||||
|
||||
return (
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
|
||||
<Button
|
||||
onClick={() => router.navigate("/").then(logOut)}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PostOverview() {
|
||||
const { me } = useJazz<Profile, PetAccountRoot>();
|
||||
|
||||
const myPosts = me.root?.posts;
|
||||
|
||||
return (
|
||||
<>
|
||||
{myPosts?.length ? (
|
||||
<>
|
||||
<h1>My posts</h1>
|
||||
{myPosts.map(
|
||||
(post) =>
|
||||
post && (
|
||||
<Link key={post.id} to={"/pet/" + post.id}>
|
||||
{post.name}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
) : undefined}
|
||||
<Link to="/new">New post</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
107
examples/pets/src/3_NewPetPostForm.tsx
Normal file
107
examples/pets/src/3_NewPetPostForm.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { ChangeEvent, useCallback, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { CoID, CoMap, Media, Profile } from "cojson";
|
||||
import { useAutoSub, useJazz } from "jazz-react";
|
||||
import { BrowserImage, createImage } from "jazz-browser-media-images";
|
||||
|
||||
import { PetAccountRoot, PetPost, PetReactions } from "./1_types";
|
||||
|
||||
import { Input, Button } from "./basicComponents";
|
||||
|
||||
/** Walkthrough: TODO
|
||||
*/
|
||||
|
||||
type PartialPetPost = CoMap<{
|
||||
name: string;
|
||||
image?: Media.ImageDefinition["id"];
|
||||
reactions: PetReactions["id"];
|
||||
}>;
|
||||
|
||||
export function NewPetPostForm() {
|
||||
const { me } = useJazz<Profile, PetAccountRoot>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [newPostId, setNewPostId] = useState<
|
||||
CoID<PartialPetPost> | undefined
|
||||
>(undefined);
|
||||
|
||||
const newPetPost = useAutoSub(newPostId);
|
||||
|
||||
const onChangeName = useCallback(
|
||||
(name: string) => {
|
||||
if (newPetPost) {
|
||||
newPetPost.set({ name });
|
||||
} else {
|
||||
const petPostGroup = me.createGroup();
|
||||
const petPost = petPostGroup.createMap<PartialPetPost>({
|
||||
name,
|
||||
reactions: petPostGroup.createStream<PetReactions>().id,
|
||||
});
|
||||
|
||||
setNewPostId(petPost.id);
|
||||
}
|
||||
},
|
||||
[me, newPetPost]
|
||||
);
|
||||
|
||||
const onImageSelected = useCallback(
|
||||
async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!newPetPost || !event.target.files) return;
|
||||
|
||||
const image = await createImage(
|
||||
event.target.files[0],
|
||||
newPetPost.meta.group
|
||||
);
|
||||
|
||||
newPetPost.set({ image: image.id });
|
||||
},
|
||||
[newPetPost]
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
if (!newPetPost) return;
|
||||
const myPosts = me.root?.posts;
|
||||
|
||||
if (!myPosts) {
|
||||
throw new Error("No posts list found");
|
||||
}
|
||||
|
||||
myPosts.append(newPetPost.id as PetPost["id"]);
|
||||
|
||||
navigate("/pet/" + newPetPost.id);
|
||||
}, [me.root?.posts, newPetPost, navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<p>Share your pet with friends!</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Pet Name"
|
||||
className="text-3xl py-6"
|
||||
onChange={(event) => onChangeName(event.target.value)}
|
||||
value={newPetPost?.name || ""}
|
||||
/>
|
||||
|
||||
{newPetPost?.image ? (
|
||||
<img
|
||||
className="w-80 max-w-full rounded"
|
||||
src={
|
||||
newPetPost?.image.as(BrowserImage)
|
||||
?.highestResSrcOrPlaceholder
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="file"
|
||||
disabled={!newPetPost?.name}
|
||||
onChange={onImageSelected}
|
||||
/>
|
||||
)}
|
||||
|
||||
{newPetPost?.name && newPetPost?.image && (
|
||||
<Button onClick={onSubmit}>Submit Post</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
examples/pets/src/4_RatePetPostUI.tsx
Normal file
126
examples/pets/src/4_RatePetPostUI.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useParams } from "react-router";
|
||||
import { CoID } from "cojson";
|
||||
|
||||
import { PetPost, ReactionType, REACTION_TYPES, PetReactions } from "./1_types";
|
||||
|
||||
import { ShareButton } from "./components/ShareButton";
|
||||
import { Button, Skeleton } from "./basicComponents";
|
||||
import { BrowserImage } from "jazz-browser-media-images";
|
||||
import uniqolor from "uniqolor";
|
||||
import { Resolved, useAutoSub } from "jazz-react";
|
||||
|
||||
/** Walkthrough: TODO
|
||||
*/
|
||||
|
||||
const reactionEmojiMap: { [reaction in ReactionType]: string } = {
|
||||
aww: "😍",
|
||||
love: "❤️",
|
||||
haha: "😂",
|
||||
wow: "😮",
|
||||
tiny: "🐥",
|
||||
chonkers: "🐘",
|
||||
};
|
||||
|
||||
export function RatePetPostUI() {
|
||||
const petPostID = useParams<{ petPostId: CoID<PetPost> }>().petPostId;
|
||||
|
||||
const petPost = useAutoSub(petPostID);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex justify-between">
|
||||
<h1 className="text-3xl font-bold">{petPost?.name}</h1>
|
||||
<ShareButton petPost={petPost} />
|
||||
</div>
|
||||
|
||||
{petPost?.image && (
|
||||
<img
|
||||
className="w-80 max-w-full rounded"
|
||||
src={
|
||||
petPost.image.as(BrowserImage)
|
||||
?.highestResSrcOrPlaceholder
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between max-w-xs flex-wrap">
|
||||
{REACTION_TYPES.map((reactionType) => (
|
||||
<Button
|
||||
key={reactionType}
|
||||
variant={
|
||||
petPost?.reactions?.me?.last === reactionType
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => {
|
||||
petPost?.reactions?.push(reactionType);
|
||||
}}
|
||||
title={`React with ${reactionType}`}
|
||||
className="text-2xl px-2"
|
||||
>
|
||||
{reactionEmojiMap[reactionType]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{petPost?.meta.group.myRole() === "admin" && petPost.reactions && (
|
||||
<ReactionOverview petReactions={petPost.reactions} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReactionOverview({
|
||||
petReactions,
|
||||
}: {
|
||||
petReactions: Resolved<PetReactions>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Reactions</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
{REACTION_TYPES.map((reactionType) => {
|
||||
const reactionsOfThisType = petReactions.perAccount
|
||||
.map(([, reaction]) => reaction)
|
||||
.filter(({ last }) => last === reactionType);
|
||||
|
||||
if (reactionsOfThisType.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2 items-center"
|
||||
key={reactionType}
|
||||
>
|
||||
{reactionEmojiMap[reactionType]}{" "}
|
||||
{reactionsOfThisType.map((reaction, idx) =>
|
||||
reaction.by?.profile?.name ? (
|
||||
<span
|
||||
className="rounded-full py-0.5 px-2 text-xs"
|
||||
style={uniqueColoring(reaction.by.id)}
|
||||
key={reaction.by.id}
|
||||
>
|
||||
{reaction.by.profile.name}
|
||||
</span>
|
||||
) : (
|
||||
<Skeleton
|
||||
className="mt-1 w-[50px] h-[1em] rounded-full"
|
||||
key={idx}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueColoring(seed: string) {
|
||||
const darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
return {
|
||||
color: uniqolor(seed, { lightness: darkMode ? 80 : 20 }).color,
|
||||
background: uniqolor(seed, { lightness: darkMode ? 20 : 80 }).color,
|
||||
};
|
||||
}
|
||||
12
examples/pets/src/basicComponents/TitleAndLogo.tsx
Normal file
12
examples/pets/src/basicComponents/TitleAndLogo.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Toaster } from ".";
|
||||
|
||||
export function TitleAndLogo({ name }: { name: string }) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 justify-center mt-5">
|
||||
<img src="jazz-logo.png" className="h-5" /> {name}
|
||||
</div>
|
||||
<Toaster />
|
||||
</>
|
||||
);
|
||||
}
|
||||
7
examples/pets/src/basicComponents/index.ts
Normal file
7
examples/pets/src/basicComponents/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { Button } from "./ui/button";
|
||||
export { Input } from "./ui/input";
|
||||
export { Toaster } from "./ui/toaster";
|
||||
export { useToast } from "./ui/use-toast";
|
||||
export { Skeleton } from "./ui/skeleton";
|
||||
export { TitleAndLogo } from "./TitleAndLogo";
|
||||
export { ThemeProvider } from "./themeProvider";
|
||||
6
examples/pets/src/basicComponents/lib/utils.ts
Normal file
6
examples/pets/src/basicComponents/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))
|
||||
}
|
||||
72
examples/pets/src/basicComponents/themeProvider.tsx
Normal file
72
examples/pets/src/basicComponents/themeProvider.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: string;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: string;
|
||||
setTheme: (theme: string) => void;
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState(
|
||||
() => localStorage.getItem(storageKey) || defaultTheme
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)"
|
||||
).matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: string) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context;
|
||||
};
|
||||
56
examples/pets/src/basicComponents/ui/button.tsx
Normal file
56
examples/pets/src/basicComponents/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
25
examples/pets/src/basicComponents/ui/input.tsx
Normal file
25
examples/pets/src/basicComponents/ui/input.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
15
examples/pets/src/basicComponents/ui/skeleton.tsx
Normal file
15
examples/pets/src/basicComponents/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
127
examples/pets/src/basicComponents/ui/toast.tsx
Normal file
127
examples/pets/src/basicComponents/ui/toast.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/basicComponents/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
33
examples/pets/src/basicComponents/ui/toaster.tsx
Normal file
33
examples/pets/src/basicComponents/ui/toaster.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/basicComponents/ui/toast"
|
||||
import { useToast } from "@/basicComponents/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
192
examples/pets/src/basicComponents/ui/use-toast.ts
Normal file
192
examples/pets/src/basicComponents/ui/use-toast.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/basicComponents/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_VALUE
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
48
examples/pets/src/components/Auth.tsx
Normal file
48
examples/pets/src/components/Auth.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { LocalAuthComponent } from "jazz-react-auth-local";
|
||||
|
||||
import { Input, Button } from "../basicComponents";
|
||||
|
||||
export const PrettyAuthUI: LocalAuthComponent = ({
|
||||
loading,
|
||||
logIn,
|
||||
signUp,
|
||||
}) => {
|
||||
const [username, setUsername] = useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center p-5">
|
||||
{loading ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
<div className="w-72 flex flex-col gap-4">
|
||||
<form
|
||||
className="w-72 flex flex-col gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
signUp(username);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="Display name"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="webauthn"
|
||||
className="text-base"
|
||||
/>
|
||||
<Button asChild>
|
||||
<Input
|
||||
type="submit"
|
||||
value="Sign Up as new account"
|
||||
/>
|
||||
</Button>
|
||||
</form>
|
||||
<Button onClick={logIn}>
|
||||
Log In with existing account
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
46
examples/pets/src/components/ShareButton.tsx
Normal file
46
examples/pets/src/components/ShareButton.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { PetPost } from "../1_types";
|
||||
|
||||
import { Resolved, createInviteLink } from "jazz-react";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
import { useToast, Button } from "../basicComponents";
|
||||
|
||||
export function ShareButton({ petPost }: { petPost?: Resolved<PetPost> }) {
|
||||
const [existingInviteLink, setExistingInviteLink] = useState<string>();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
petPost?.meta.group.myRole() === "admin" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="py-0"
|
||||
disabled={!petPost}
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
let inviteLink = existingInviteLink;
|
||||
if (petPost && !inviteLink) {
|
||||
inviteLink = createInviteLink(petPost, "writer");
|
||||
setExistingInviteLink(inviteLink);
|
||||
}
|
||||
if (inviteLink) {
|
||||
const qr = await QRCode.toDataURL(inviteLink, {
|
||||
errorCorrectionLevel: "L",
|
||||
});
|
||||
navigator.clipboard.writeText(inviteLink).then(() =>
|
||||
toast({
|
||||
title: "Copied invite link to clipboard!",
|
||||
description: (
|
||||
<img src={qr} className="w-20 h-20" />
|
||||
),
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
76
examples/pets/src/index.css
Normal file
76
examples/pets/src/index.css
Normal file
@@ -0,0 +1,76 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 20 14.3% 4.1%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 20 14.3% 4.1%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 20 14.3% 4.1%;
|
||||
|
||||
--primary: 24 9.8% 10%;
|
||||
--primary-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--secondary: 60 4.8% 95.9%;
|
||||
--secondary-foreground: 24 9.8% 10%;
|
||||
|
||||
--muted: 60 4.8% 95.9%;
|
||||
--muted-foreground: 25 5.3% 44.7%;
|
||||
|
||||
--accent: 60 4.8% 95.9%;
|
||||
--accent-foreground: 24 9.8% 10%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--border: 20 5.9% 90%;
|
||||
--input: 20 5.9% 90%;
|
||||
--ring: 20 14.3% 4.1%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 20 14.3% 4.1%;
|
||||
--foreground: 60 9.1% 97.8%;
|
||||
|
||||
--card: 20 14.3% 4.1%;
|
||||
--card-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--popover: 20 14.3% 4.1%;
|
||||
--popover-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--primary: 60 9.1% 97.8%;
|
||||
--primary-foreground: 24 9.8% 10%;
|
||||
|
||||
--secondary: 12 6.5% 15.1%;
|
||||
--secondary-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--muted: 12 6.5% 15.1%;
|
||||
--muted-foreground: 24 5.4% 63.9%;
|
||||
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--border: 12 6.5% 15.1%;
|
||||
--input: 12 6.5% 15.1%;
|
||||
--ring: 24 5.7% 82.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
1
examples/pets/src/vite-env.d.ts
vendored
Normal file
1
examples/pets/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
76
examples/pets/tailwind.config.js
Normal file
76
examples/pets/tailwind.config.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: 0 },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: 0 },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
29
examples/pets/tsconfig.json
Normal file
29
examples/pets/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
examples/pets/tsconfig.node.json
Normal file
10
examples/pets/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
examples/pets/vite.config.ts
Normal file
16
examples/pets/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import path from "path";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
minify: false
|
||||
}
|
||||
})
|
||||
9
examples/todo/CHANGELOG.md
Normal file
9
examples/todo/CHANGELOG.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# jazz-example-todo
|
||||
|
||||
## 0.0.63
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- jazz-react@0.5.0
|
||||
- jazz-react-auth-local@0.4.16
|
||||
@@ -2,343 +2,63 @@
|
||||
|
||||
Live version: https://example-todo.jazz.tools
|
||||
|
||||
More comprehensive guide coming soon, but these are the most important bits, with explanations:
|
||||
## Installing & running the example locally
|
||||
|
||||
From `./src/main.tsx`
|
||||
|
||||
```typescript
|
||||
// ...
|
||||
|
||||
import { WithJazz } from "jazz-react";
|
||||
import { LocalAuth } from "jazz-react-auth-local";
|
||||
|
||||
// ...
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<div className="flex items-center gap-2 justify-center mt-5">
|
||||
<img src="jazz-logo.png" className="h-5" /> Jazz Todo List
|
||||
Example
|
||||
</div>
|
||||
<WithJazz
|
||||
auth={LocalAuth({
|
||||
appName: "Jazz Todo List Example",
|
||||
Component: PrettyAuthComponent,
|
||||
})}
|
||||
>
|
||||
<App />
|
||||
</WithJazz>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
Start by checking out just the example app to a folder:
|
||||
|
||||
```bash
|
||||
npx degit gardencmp/jazz/examples/todo jazz-example-todo
|
||||
cd jazz-example-todo
|
||||
```
|
||||
|
||||
This shows how to use the top-level component `<WithJazz/>`, which provides the rest of the app with a `LocalNode` (used through `useJazz` later), based on `LocalAuth` that uses Passkeys to store a user's account secret - no backend needed.
|
||||
(This ensures that you have the example app without git history or our multi-package monorepo)
|
||||
|
||||
Let's move on to the main app code.
|
||||
Install dependencies:
|
||||
|
||||
---
|
||||
|
||||
From `./src/App.tsx`
|
||||
|
||||
```typescript
|
||||
// ...
|
||||
|
||||
import { CoMap, CoID, AccountID } from "cojson";
|
||||
import {
|
||||
consumeInviteLinkFromWindowLocation,
|
||||
useJazz,
|
||||
useProfile,
|
||||
useTelepathicState,
|
||||
createInviteLink
|
||||
} from "jazz-react";
|
||||
|
||||
// ...
|
||||
|
||||
type TaskContent = { done: boolean; text: string };
|
||||
type Task = CoMap<TaskContent>;
|
||||
|
||||
type TodoListContent = {
|
||||
title: string;
|
||||
// other keys form a set of task IDs
|
||||
[taskId: CoID<Task>]: true;
|
||||
};
|
||||
type TodoList = CoMap<TodoListContent>;
|
||||
|
||||
// ...
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
First, we define our main data model of tasks and todo lists, using CoJSON's collaborative map type, `CoMap`. We reference CoMaps of individual tasks by using them as keys inside the `TodoList` CoMap - as a makeshift solution until `CoList` is implemented.
|
||||
Start the dev server:
|
||||
|
||||
---
|
||||
|
||||
```typescript
|
||||
// ...
|
||||
|
||||
export default function App() {
|
||||
const [listId, setListId] = useState<CoID<TodoList>>();
|
||||
|
||||
const { localNode, logOut } = useJazz();
|
||||
|
||||
useEffect(() => {
|
||||
const listener = async () => {
|
||||
const acceptedInvitation =
|
||||
await consumeInviteLinkFromWindowLocation(localNode);
|
||||
|
||||
if (acceptedInvitation) {
|
||||
setListId(acceptedInvitation.valueID as CoID<TodoList>);
|
||||
window.location.hash = acceptedInvitation.valueID;
|
||||
return;
|
||||
}
|
||||
|
||||
setListId(window.location.hash.slice(1) as CoID<TodoList>);
|
||||
};
|
||||
window.addEventListener("hashchange", listener);
|
||||
listener();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", listener);
|
||||
};
|
||||
}, [localNode]);
|
||||
|
||||
const createList = useCallback(
|
||||
(title: string) => {
|
||||
const listGroup = localNode.createGroup();
|
||||
const list = listGroup.createMap<TodoListContent>();
|
||||
|
||||
list.edit((list) => {
|
||||
list.set("title", title);
|
||||
});
|
||||
|
||||
window.location.hash = list.id;
|
||||
},
|
||||
[localNode]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
||||
{listId ? (
|
||||
<TodoListComponent listId={listId} />
|
||||
) : (
|
||||
<SubmittableInput
|
||||
onSubmit={createList}
|
||||
label="Create New List"
|
||||
placeholder="New list title"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.location.hash = "";
|
||||
logOut();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`<App>` is the main app component, handling client-side routing based on the CoValue ID (`CoID`) of our `TodoList`, stored in the URL hash - which can also contain invite links, which we intercept and use with `consumeInviteLinkFromWindowLocation`.
|
||||
## Structure
|
||||
|
||||
`createList` is the first time we see CoJSON in action: using our `localNode` (which we got from `useJazz`), we first create a group for a new todo list (which allows us to set permissions later). Then, within that group, we create a new `CoMap<TodoListContent>` with `listGroup.createMap()`.
|
||||
- [`src/basicComponents`](./src/basicComponents): simple components to build the UI, unrelated to Jazz (uses [shadcn/ui](https://ui.shadcn.com))
|
||||
- [`src/components`](./src/components/): helper components that do contain Jazz-specific logic, but aren't very relevant to understand the basics of Jazz and CoJSON
|
||||
- [`src/1_types.ts`](./src/1_types.ts),
|
||||
[`src/2_main.tsx`](./src/2_main.tsx),
|
||||
[`src/3_NewProjectForm.tsx`](./src/3_NewProjectForm.tsx),
|
||||
[`src/4_ProjectTodoTable.tsx`](./src/4_ProjectTodoTable.tsx): the main files for this example, see the walkthrough below
|
||||
|
||||
We immediately start editing the created `list`. Within the edit callback, we can use the `set` function, to collaboratively set the key `title` to the initial title provided to `createList`.
|
||||
## Walkthrough
|
||||
|
||||
If we have a current `listId` set, we render `<TodoListComponent>` with it, which we'll see next.
|
||||
### Main parts
|
||||
|
||||
If we have no `listId` set, the user can use the displayed creation input to create (and open) their first list.
|
||||
1. Defining the data model with CoJSON: [`src/1_types.ts`](./src/1_types.ts)
|
||||
|
||||
---
|
||||
2. The top-level provider `<WithJazz/>` and routing: [`src/2_main.tsx`](./src/2_main.tsx)
|
||||
|
||||
```typescript
|
||||
export function TodoListComponent({ listId }: { listId: CoID<TodoList> }) {
|
||||
const list = useTelepathicState(listId);
|
||||
3. Creating a new todo project: [`src/3_NewProjectForm.tsx`](./src/3_NewProjectForm.tsx)
|
||||
|
||||
const createTask = (text: string) => {
|
||||
if (!list) return;
|
||||
const task = list.coValue.getGroup().createMap<TaskContent>();
|
||||
4. Reactively rendering a todo project as a table, adding and editing tasks: [`src/4_ProjectTodoTable.tsx`](./src/4_ProjectTodoTable.tsx)
|
||||
|
||||
task.edit((task) => {
|
||||
task.set("text", text);
|
||||
task.set("done", false);
|
||||
});
|
||||
### Helpers
|
||||
|
||||
list.edit((list) => {
|
||||
list.set(task.id, true);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-full w-4xl">
|
||||
<div className="flex justify-between items-center gap-4 mb-4">
|
||||
<h1>
|
||||
{list?.get("title") ? (
|
||||
<>
|
||||
{list.get("title")}{" "}
|
||||
<span className="text-sm">({list.id})</span>
|
||||
</>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)}
|
||||
</h1>
|
||||
{list && <InviteButton list={list} />}
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">Done</TableHead>
|
||||
<TableHead>Task</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{list &&
|
||||
list
|
||||
.keys()
|
||||
.filter((key): key is CoID<Task> =>
|
||||
key.startsWith("co_")
|
||||
)
|
||||
.map((taskId) => (
|
||||
<TaskRow key={taskId} taskId={taskId} />
|
||||
))}
|
||||
<TableRow key="new">
|
||||
<TableCell>
|
||||
<Checkbox className="mt-1" disabled />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SubmittableInput
|
||||
onSubmit={(taskText) => createTask(taskText)}
|
||||
label="Add"
|
||||
placeholder="New task"
|
||||
disabled={!list}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Here in `<TodoListComponent>`, we use `useTelepathicData()` for the first time, in this case to load the CoValue for our `TodoList` and to reactively subscribe to updates to its content - whether we create edits locally, load persisted data, or receive sync updates from other devices or participants!
|
||||
|
||||
`createTask` is similar to `createList` we saw earlier, creating a new CoMap for a new task, and then adding it as a key to our `TodoList`.
|
||||
|
||||
As you can see, we iterate over the keys of `TodoList` and for those that look like `CoID`s (they always start with `co_`), we render a `<TaskRow>`.
|
||||
|
||||
Below all tasks, we render a simple input for adding a task.
|
||||
|
||||
---
|
||||
|
||||
```typescript
|
||||
function TaskRow({ taskId }: { taskId: CoID<Task> }) {
|
||||
const task = useTelepathicState(taskId);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
className="mt-1"
|
||||
checked={task?.get("done")}
|
||||
onCheckedChange={(checked) => {
|
||||
task?.edit((task) => {
|
||||
task.set("done", !!checked);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row justify-between items-center gap-2">
|
||||
<span className={task?.get("done") ? "line-through" : ""}>
|
||||
{task?.get("text") || <Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />}
|
||||
</span>
|
||||
<NameBadge accountID={task?.getLastEditor("text")} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`<TaskRow>` uses `useTelepathicState()` as well, to granularly load and subscribe to changes for that particular task (the only thing we let the user change is the "done" status).
|
||||
|
||||
We also use a `<NameBadge>` helper component to render the name of the author of the task, which we get by using the collaboration feature `getLastEditor(key)` on our `Task` CoMap, which returns the accountID of the last account that changed a given key in the CoMap.
|
||||
|
||||
---
|
||||
|
||||
```typescript
|
||||
function NameBadge({ accountID }: { accountID?: AccountID }) {
|
||||
const profile = useProfile(accountID);
|
||||
|
||||
const theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
const brightColor = uniqolor(accountID || "", { lightness: 80 }).color;
|
||||
const darkColor = uniqolor(accountID || "", { lightness: 20 }).color;
|
||||
|
||||
return (
|
||||
profile?.get("name") && <span
|
||||
className="rounded-full py-0.5 px-2 text-xs"
|
||||
style={{
|
||||
color: theme == "light" ? darkColor : brightColor,
|
||||
background: theme == "light" ? brightColor : darkColor,
|
||||
}}
|
||||
>
|
||||
{profile.get("name")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`<NameBadge>` uses `useProfile(accountID)`, which is a shorthand for loading an account's profile (which is always a `CoMap<{name: string}>`, but might have app-specific additional properties).
|
||||
|
||||
In our case, we just display the profile name (which, by the way, is set by the `LocalAuth` provider when we first create an account).
|
||||
|
||||
---
|
||||
|
||||
```typescript
|
||||
function InviteButton({ list }: { list: TodoList }) {
|
||||
const [existingInviteLink, setExistingInviteLink] = useState<string>();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
list.coValue.getGroup().myRole() === "admin" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="py-0"
|
||||
disabled={!list}
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
let inviteLink = existingInviteLink;
|
||||
if (list && !inviteLink) {
|
||||
inviteLink = createInviteLink(list, "writer");
|
||||
setExistingInviteLink(inviteLink);
|
||||
}
|
||||
if (inviteLink) {
|
||||
navigator.clipboard.writeText(inviteLink).then(() =>
|
||||
toast({
|
||||
description: "Copied invite link to clipboard!",
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Last, we have a look at the `<InviteButton>` component, which we use inside `<TodoListComponent>`. It only becomes visible when the current user is an admin in the `TodoList`'s group. You can see how we can create an invite link using `createInviteLink(coValue, role)` that allows anyone who has it to join the group as a specified role (here, as a writer).
|
||||
|
||||
---
|
||||
- (not yet explained) Creating invite links/QR codes with `<InviteButton/>`: [`src/components/InviteButton.tsx`](./src/components/InviteButton.tsx)
|
||||
|
||||
This is the whole Todo List app!
|
||||
|
||||
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
|
||||
## Questions / problems / feedback
|
||||
|
||||
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
|
||||
|
||||
|
||||
## Configuration: sync server
|
||||
|
||||
By default, the example app uses [Jazz Global Mesh](https://jazz.tools/mesh) (`wss://sync.jazz.tools`) - so cross-device use, invites and collaboration should just work.
|
||||
|
||||
You can also run a local sync server by running `npx cojson-simple-sync` and adding the query param `?sync=ws://localhost:4200` to the URL of the example app (for example: `http://localhost:5173/?sync=ws://localhost:4200`), or by setting the `sync` parameter of the `<WithJazz>` provider component in [./src/2_main.tsx](./src/2_main.tsx).
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
"components": "@/basicComponents",
|
||||
"utils": "@/basicComponents/lib/utils"
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="/src/2_main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
job "example-todo$BRANCH_SUFFIX" {
|
||||
region = "$REGION"
|
||||
datacenters = ["$REGION"]
|
||||
region = "global"
|
||||
datacenters = ["*"]
|
||||
|
||||
group "static" {
|
||||
// count = 3
|
||||
count = 4
|
||||
|
||||
network {
|
||||
port "http" {
|
||||
@@ -14,13 +14,17 @@ job "example-todo$BRANCH_SUFFIX" {
|
||||
constraint {
|
||||
attribute = "${node.class}"
|
||||
operator = "="
|
||||
value = "edge"
|
||||
value = "mesh"
|
||||
}
|
||||
|
||||
// spread {
|
||||
// attribute = "${node.datacenter}"
|
||||
// weight = 100
|
||||
// }
|
||||
spread {
|
||||
attribute = "${node.datacenter}"
|
||||
weight = 100
|
||||
}
|
||||
|
||||
constraint {
|
||||
distinct_hosts = true
|
||||
}
|
||||
|
||||
task "server" {
|
||||
driver = "docker"
|
||||
@@ -37,9 +41,7 @@ job "example-todo$BRANCH_SUFFIX" {
|
||||
|
||||
service {
|
||||
tags = ["public"]
|
||||
meta {
|
||||
public_name = "${BRANCH_SUBDOMAIN}example-todo"
|
||||
}
|
||||
name = "example-todo$BRANCH_SUFFIX"
|
||||
port = "http"
|
||||
provider = "consul"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-todo",
|
||||
"private": true,
|
||||
"version": "0.0.16",
|
||||
"version": "0.0.63",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -13,15 +13,19 @@
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"@types/qrcode": "^1.5.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-react": "^0.1.3",
|
||||
"jazz-react-auth-local": "^0.1.3",
|
||||
"lucide-react": "^0.265.0",
|
||||
"jazz-react": "^0.5.0",
|
||||
"jazz-react-auth-local": "^0.4.16",
|
||||
"lucide-react": "^0.274.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router": "^6.16.0",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.6",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uniqolor": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
47
examples/todo/src/1_types.ts
Normal file
47
examples/todo/src/1_types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { CoMap, CoList, AccountMigration, Profile } from "cojson";
|
||||
|
||||
/** Walkthrough: Defining the data model with CoJSON
|
||||
*
|
||||
* Here, we define our main data model of tasks, lists of tasks and projects
|
||||
* using CoJSON's collaborative map and list types, CoMap & CoList.
|
||||
*
|
||||
* CoMap values and CoLists items can contain:
|
||||
* - arbitrary immutable JSON
|
||||
* - references to other CoValues by their CoID
|
||||
**/
|
||||
|
||||
/** An individual task which collaborators can tick or rename */
|
||||
export type Task = CoMap<{ done: boolean; text: string; }>;
|
||||
|
||||
export type ListOfTasks = CoList<Task["id"]>;
|
||||
|
||||
/** Our top level object: a project with a title, referencing a list of tasks */
|
||||
export type TodoProject = CoMap<{
|
||||
title: string;
|
||||
/** A collaborative, ordered list of tasks */
|
||||
tasks: ListOfTasks["id"];
|
||||
}>;
|
||||
|
||||
export type ListOfProjects = CoList<TodoProject["id"]>;
|
||||
|
||||
/** The account root is an app-specific per-user private `CoMap`
|
||||
* where you can store top-level objects for that user */
|
||||
export type TodoAccountRoot = CoMap<{
|
||||
projects: ListOfProjects["id"];
|
||||
}>;
|
||||
|
||||
/** The account migration is run on account creation and on every log-in.
|
||||
* You can use it to set up the account root and any other initial CoValues you need.
|
||||
*/
|
||||
export const migration: AccountMigration<Profile, TodoAccountRoot> = (account) => {
|
||||
if (!account.get("root")) {
|
||||
account.set(
|
||||
"root",
|
||||
account.createMap<TodoAccountRoot>({
|
||||
projects: account.createList<ListOfProjects>().id,
|
||||
}).id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Walkthrough: Continue with ./2_main.tsx */
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user