Compare commits

..

10 Commits

Author SHA1 Message Date
Anselm
ad40b883eb Pre-release 2024-09-02 17:29:47 +01:00
Anselm
1c64ae1bba First sketch of creating and finding unique CoMaps 2024-09-02 17:28:03 +01:00
Anselm
6ce20517bc Pre-release 2024-08-29 14:38:27 +01:00
Anselm
9509ceb975 Make me context optional 2024-08-29 14:37:52 +01:00
Anselm
bcec3be423 Pre-release 2024-08-29 14:23:07 +01:00
Anselm
e3958d1609 Rest of clerk & auth implementation + some fixes and patches 2024-08-29 14:21:22 +01:00
Anselm
0fc7291178 Make it mostly work 2024-08-28 17:39:48 +01:00
Anselm
51d1cc677b Refactor all browser auth methods to new API 2024-08-23 15:56:09 +01:00
Anselm
0cf7a7aa85 Merge branch 'main' into anselm-jazz-182 2024-08-23 11:20:49 +01:00
Anselm
0503479fcf Auth refactor and clerk WIP 2024-08-19 10:48:01 +01:00
205 changed files with 4880 additions and 5668 deletions

View File

@@ -1,5 +0,0 @@
---
"jazz-tools": patch
---
Fix on CoMapInit to not allow null values on required refs

View File

@@ -0,0 +1,6 @@
---
"jazz-tools": patch
"cojson": patch
---
First sketch of API for creating and finding unique CoValues

View File

@@ -1,5 +0,0 @@
---
"cojson": patch
---
Clean up binary stream ending logic

View File

@@ -1,5 +0,0 @@
---
"jazz-tools": patch
---
fix: handle null values for co.refs

View File

@@ -1,5 +0,0 @@
---
"jazz-tools": patch
---
Fix loadAsBlob resolving too early

View File

@@ -1,5 +0,0 @@
---
"jazz-run": patch
---
Added sync command to start a local sync server

30
.changeset/pre.json Normal file
View File

@@ -0,0 +1,30 @@
{
"mode": "pre",
"tag": "unique",
"initialVersions": {
"jazz-example-chat": "0.0.82-new-auth.1",
"jazz-example-chat-clerk": "0.0.80-new-auth.1",
"jazz-inspector": "0.0.59",
"jazz-example-pets": "0.0.100-new-auth.1",
"jazz-example-todo": "0.0.99-new-auth.1",
"cojson": "0.7.34",
"cojson-storage-indexeddb": "0.7.34",
"cojson-storage-sqlite": "0.7.34",
"cojson-transport-ws": "0.7.34",
"hash-slash": "0.2.0",
"jazz-browser": "0.7.35-new-auth.0",
"jazz-browser-auth-clerk": "0.7.33-new-auth.0",
"jazz-browser-media-images": "0.7.35-new-auth.0",
"jazz-nodejs": "0.7.35-new-auth.0",
"jazz-react": "0.7.35-new-auth.1",
"jazz-react-auth-clerk": "0.7.33-new-auth.1",
"jazz-run": "0.7.35-new-auth.0",
"jazz-tools": "0.7.35-new-auth.0"
},
"changesets": [
"dirty-plants-sniff",
"small-students-buy",
"smart-mice-camp",
"twelve-lobsters-pull"
]
}

View File

@@ -0,0 +1,6 @@
---
"jazz-react-auth-clerk": patch
"jazz-example-chat-clerk": patch
---
Implement Clerk auth method + example

View File

@@ -0,0 +1,8 @@
---
"jazz-browser": patch
"jazz-nodejs": patch
"jazz-react": patch
"jazz-tools": patch
---
Implement new top-level context creation and auth method API

View File

@@ -1,5 +0,0 @@
---
"jazz-inspector": patch
---
fix(inspector): subscribe to latent covalues instead of loading them immediately

View File

@@ -0,0 +1,5 @@
---
"jazz-react": patch
---
Make me in useAccount potentially undefined to work with logged out state

View File

@@ -1,50 +0,0 @@
name: Playwright Tests
on:
push:
branches: ["main"]
pull_request:
types: [opened, synchronize, reopened]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
strategy:
matrix:
example: ["pets"]
steps:
- uses: actions/checkout@v3
with:
submodules: true
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v3
with:
node-version: 16
cache: "pnpm"
- name: Pnpm Build
run: |
pnpm install
pnpm turbo build;
working-directory: ./examples/${{ matrix.example }}
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
working-directory: ./examples/${{ matrix.example }}
- name: Run Playwright tests
run: pnpm exec playwright test
working-directory: ./examples/${{ matrix.example }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: ${{ matrix.example }}-playwright-report
path: ./examples/${{ matrix.example }}/playwright-report/
retention-days: 30

1
examples/chat-clerk/.env Normal file
View File

@@ -0,0 +1 @@
VITE_CLERK_PUBLISHABLE_KEY=pk_test_ZXZpZGVudC1kYW5lLTg5LmNsZXJrLmFjY291bnRzLmRldiQ

View File

@@ -9,10 +9,5 @@ module.exports = {
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
rules: {},
}

View File

@@ -0,0 +1,735 @@
# jazz-example-chat
## 0.0.80-unique.2
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.35-unique.2
- cojson@0.7.35-unique.2
- jazz-react@0.7.35-unique.2
- jazz-react-auth-clerk@0.7.33-unique.2
## 0.0.80-new-auth.1
### Patch Changes
- Updated dependencies
- jazz-react@0.7.35-new-auth.1
- jazz-react-auth-clerk@0.7.33-new-auth.1
## 0.0.80-new-auth.0
### Patch Changes
- Implement Clerk auth method + example
- Updated dependencies
- Updated dependencies
- jazz-react-auth-clerk@0.7.33-new-auth.0
- jazz-react@0.7.35-new-auth.0
- jazz-tools@0.7.35-new-auth.0
## 0.0.79
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.32
- jazz-react@0.7.32
## 0.0.78
### Patch Changes
- Updated dependencies
- cojson@0.7.31
- jazz-react@0.7.31
- jazz-tools@0.7.31
## 0.0.77
### Patch Changes
- jazz-react@0.7.30
## 0.0.76
### Patch Changes
- Updated dependencies
- cojson@0.7.29
- jazz-react@0.7.29
- jazz-tools@0.7.29
## 0.0.75
### Patch Changes
- Updated dependencies
- cojson@0.7.28
- jazz-react@0.7.28
- jazz-tools@0.7.28
## 0.0.74
### Patch Changes
- jazz-react@0.7.27
## 0.0.73
### Patch Changes
- Updated dependencies
- cojson@0.7.26
- jazz-react@0.7.26
- jazz-tools@0.7.26
## 0.0.72
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.25
- jazz-react@0.7.25
## 0.0.71
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.24
- jazz-react@0.7.24
## 0.0.70
### Patch Changes
- Updated dependencies
- cojson@0.7.23
- jazz-react@0.7.23
- jazz-tools@0.7.23
## 0.0.69
### Patch Changes
- jazz-react@0.7.22
## 0.0.68
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.21
- jazz-react@0.7.21
## 0.0.67
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.20
- jazz-react@0.7.20
## 0.0.66
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.19
- jazz-react@0.7.19
## 0.0.65
### Patch Changes
- Updated dependencies
- cojson@0.7.18
- jazz-react@0.7.18
- jazz-tools@0.7.18
## 0.0.64
### Patch Changes
- Updated dependencies
- cojson@0.7.17
- jazz-react@0.7.17
- jazz-tools@0.7.17
## 0.0.63
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.16
- jazz-react@0.7.16
## 0.0.62
### Patch Changes
- Updated dependencies
- jazz-react@0.7.15
## 0.0.61
### Patch Changes
- Updated dependencies
- cojson@0.7.14
- jazz-tools@0.7.14
- jazz-react@0.7.14
## 0.0.60
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.13
- jazz-react@0.7.13
## 0.0.59
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.12
- jazz-react@0.7.12
## 0.0.58
### Patch Changes
- Updated dependencies
- cojson@0.7.11
- jazz-react@0.7.11
- jazz-tools@0.7.11
## 0.0.57
### Patch Changes
- Updated dependencies
- cojson@0.7.10
- jazz-react@0.7.10
- jazz-tools@0.7.10
## 0.0.56
### Patch Changes
- Updated dependencies
- cojson@0.7.9
- jazz-react@0.7.9
- jazz-tools@0.7.9
## 0.0.55
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.8
- jazz-react@0.7.8
## 0.0.54
### Patch Changes
- Updated dependencies [9fdc91c]
- jazz-react@0.7.7
## 0.0.53
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.6
- jazz-react@0.7.6
## 0.0.52
### Patch Changes
- Updated dependencies
- jazz-react@0.7.5
## 0.0.51
### Patch Changes
- Updated dependencies
- jazz-react@0.7.4
## 0.0.50
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.3
- jazz-react@0.7.3
## 0.0.49
### Patch Changes
- Updated dependencies
- jazz-react@0.7.2
## 0.0.48
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.1
- jazz-react@0.7.1
## 0.0.47
### Patch Changes
- Updated dependencies [8636319]
- Updated dependencies [1a35307]
- Updated dependencies [8636319]
- Updated dependencies [1a35307]
- Updated dependencies [96c494f]
- Updated dependencies [59c18c3]
- Updated dependencies [19f52b7]
- Updated dependencies [8636319]
- Updated dependencies [1a35307]
- Updated dependencies [d8fe2b1]
- Updated dependencies [19004b4]
- Updated dependencies [a78f168]
- Updated dependencies [1200aae]
- Updated dependencies [60d5ca2]
- Updated dependencies [52675c9]
- Updated dependencies [129e2c1]
- Updated dependencies [6d49e9b]
- Updated dependencies [1cfa279]
- Updated dependencies [704af7d]
- Updated dependencies [e97f730]
- Updated dependencies [1a35307]
- Updated dependencies [460478f]
- Updated dependencies [6b0418f]
- Updated dependencies [e299c3e]
- Updated dependencies [ed5643a]
- Updated dependencies [bde684f]
- Updated dependencies [bf0f8ec]
- Updated dependencies [c4151fc]
- Updated dependencies [63374cc]
- Updated dependencies [8636319]
- Updated dependencies [01ac646]
- Updated dependencies [a5e68a4]
- Updated dependencies [8636319]
- Updated dependencies [952982e]
- Updated dependencies [1a35307]
- Updated dependencies [5fa277c]
- Updated dependencies [60d5ca2]
- Updated dependencies [21771c4]
- Updated dependencies [77c2b56]
- Updated dependencies [63374cc]
- Updated dependencies [d2e03ff]
- Updated dependencies [354bdcd]
- Updated dependencies [ece35b3]
- Updated dependencies [60d5ca2]
- Updated dependencies [69ac514]
- Updated dependencies [f8a5c46]
- Updated dependencies [f0f6f1b]
- Updated dependencies [e5eed5b]
- Updated dependencies [1a44f87]
- Updated dependencies [627d895]
- Updated dependencies [1200aae]
- Updated dependencies [63374cc]
- Updated dependencies [ece35b3]
- Updated dependencies [38d4410]
- Updated dependencies [85d2b62]
- Updated dependencies [fd86c11]
- Updated dependencies [52675c9]
- jazz-tools@0.7.0
- cojson@0.7.0
- jazz-react@0.7.0
- hash-slash@0.2.0
## 0.0.47-alpha.42
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.0-alpha.42
- cojson@0.7.0-alpha.42
- jazz-react@0.7.0-alpha.42
## 0.0.47-alpha.41
### Patch Changes
- jazz-tools@0.7.0-alpha.41
- jazz-react@0.7.0-alpha.41
## 0.0.47-alpha.40
### Patch Changes
- Updated dependencies
- jazz-react@0.7.0-alpha.40
## 0.0.47-alpha.39
### Patch Changes
- Updated dependencies
- cojson@0.7.0-alpha.39
- jazz-react@0.7.0-alpha.39
- jazz-tools@0.7.0-alpha.39
## 0.0.47-alpha.38
### Patch Changes
- Updated dependencies
- Updated dependencies
- Updated dependencies
- Updated dependencies
- Updated dependencies
- jazz-tools@0.7.0-alpha.38
- jazz-react@0.7.0-alpha.38
- cojson@0.7.0-alpha.38
## 0.0.47-alpha.37
### Patch Changes
- Updated dependencies
- jazz-react@0.7.0-alpha.37
- cojson@0.7.0-alpha.37
- jazz-tools@0.7.0-alpha.37
## 0.0.47-alpha.36
### Patch Changes
- Updated dependencies [1a35307]
- Updated dependencies [1a35307]
- Updated dependencies [1a35307]
- Updated dependencies [1a35307]
- Updated dependencies [6b0418f]
- Updated dependencies [1a35307]
- cojson@0.7.0-alpha.36
- jazz-tools@0.7.0-alpha.36
- jazz-react@0.7.0-alpha.36
## 0.0.47-alpha.35
### Patch Changes
- Updated dependencies
- Updated dependencies
- cojson@0.7.0-alpha.35
- jazz-tools@0.7.0-alpha.35
- jazz-react@0.7.0-alpha.35
## 0.0.47-alpha.34
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.0-alpha.34
- jazz-react@0.7.0-alpha.34
## 0.0.47-alpha.33
### Patch Changes
- Updated dependencies
- jazz-react@0.7.0-alpha.33
## 0.0.47-alpha.32
### Patch Changes
- Updated dependencies
- Updated dependencies
- Updated dependencies
- hash-slash@0.2.0-alpha.3
- jazz-tools@0.7.0-alpha.32
- jazz-react@0.7.0-alpha.32
## 0.0.47-alpha.31
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.0-alpha.31
- jazz-react@0.7.0-alpha.31
## 0.0.47-alpha.30
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.0-alpha.30
- jazz-react@0.7.0-alpha.30
## 0.0.47-alpha.29
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.0-alpha.29
- cojson@0.7.0-alpha.29
- jazz-react@0.7.0-alpha.29
## 0.0.47-alpha.28
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.0-alpha.28
- cojson@0.7.0-alpha.28
- jazz-react@0.7.0-alpha.28
## 0.0.47-alpha.27
### Patch Changes
- Updated dependencies
- Updated dependencies
- jazz-tools@0.7.0-alpha.27
- cojson@0.7.0-alpha.27
- jazz-react@0.7.0-alpha.27
## 0.0.47-alpha.26
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.0-alpha.26
- jazz-react@0.7.0-alpha.26
## 0.0.47-alpha.25
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.0-alpha.25
- jazz-react@0.7.0-alpha.25
## 0.0.47-alpha.24
### Patch Changes
- Updated dependencies
- Updated dependencies
- Updated dependencies
- jazz-tools@0.7.0-alpha.24
- cojson@0.7.0-alpha.24
- jazz-react@0.7.0-alpha.24
## 0.0.47-alpha.23
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.0-alpha.23
- jazz-react@0.7.0-alpha.23
## 0.0.47-alpha.22
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.0-alpha.22
- jazz-react@0.7.0-alpha.22
## 0.0.47-alpha.21
### Patch Changes
- Updated dependencies
- jazz-react@0.7.0-alpha.21
- jazz-tools@0.7.0-alpha.21
## 0.0.47-alpha.20
### Patch Changes
- Updated dependencies
- Updated dependencies
- jazz-react@0.7.0-alpha.20
- jazz-tools@0.7.0-alpha.20
## 0.0.47-alpha.19
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.0-alpha.19
- jazz-react@0.7.0-alpha.19
## 0.0.47-alpha.18
### Patch Changes
- jazz-react@0.7.0-alpha.18
## 0.0.47-alpha.17
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.0-alpha.17
- jazz-react@0.7.0-alpha.17
## 0.0.47-alpha.16
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.0-alpha.16
- jazz-react@0.7.0-alpha.16
## 0.0.47-alpha.15
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.0-alpha.15
- jazz-react@0.7.0-alpha.15
## 0.0.47-alpha.14
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.0-alpha.14
- jazz-react@0.7.0-alpha.14
## 0.0.47-alpha.13
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.0-alpha.13
- jazz-react@0.7.0-alpha.13
## 0.0.47-alpha.12
### Patch Changes
- Updated dependencies
- jazz-react@0.7.0-alpha.12
- jazz-tools@0.7.0-alpha.12
## 0.0.47-alpha.11
### Patch Changes
- Updated dependencies
- jazz-react@0.7.0-alpha.11
- jazz-tools@0.7.0-alpha.11
- cojson@0.7.0-alpha.11
## 0.0.47-alpha.10
### Patch Changes
- Updated dependencies
- jazz-react@0.7.0-alpha.10
- jazz-tools@0.7.0-alpha.10
- cojson@0.7.0-alpha.10
## 0.0.47-alpha.9
### Patch Changes
- Updated dependencies
- jazz-react@0.7.0-alpha.9
- jazz-tools@0.7.0-alpha.9
## 0.0.47-alpha.8
### Patch Changes
- Updated dependencies
- jazz-react@0.7.0-alpha.8
- jazz-tools@0.7.0-alpha.8
## 0.0.47-alpha.7
### Patch Changes
- Updated dependencies
- jazz-react@0.7.0-alpha.7
- jazz-tools@0.7.0-alpha.7
- cojson@0.7.0-alpha.7
## 0.0.47-alpha.6
### Patch Changes
- Updated dependencies
- jazz-react@0.7.0-alpha.6
- jazz-tools@0.7.0-alpha.6
## 0.0.47-alpha.5
### Patch Changes
- Updated dependencies
- jazz-react@0.7.0-alpha.5
- jazz-tools@0.7.0-alpha.5
- cojson@0.7.0-alpha.5
## 0.0.47-alpha.4
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.0-alpha.4
- jazz-react@0.7.0-alpha.4
## 0.0.47-alpha.3
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.0-alpha.3
- jazz-react@0.7.0-alpha.3
## 0.0.47-alpha.2
### Patch Changes
- Updated dependencies
- hash-slash@0.2.0-alpha.2
- jazz-react@0.7.0-alpha.2
- jazz-tools@0.7.0-alpha.2
## 0.0.47-alpha.1
### Patch Changes
- Updated dependencies
- hash-slash@0.2.0-alpha.1
- jazz-react@0.7.0-alpha.1
- jazz-tools@0.7.0-alpha.1
- cojson@0.7.0-alpha.1
## 0.0.47-alpha.0
### Patch Changes
- Updated dependencies
- hash-slash@0.2.0-alpha.0
- jazz-react@0.7.0-alpha.0
- jazz-tools@0.7.0-alpha.0
- cojson@0.7.0-alpha.0
## 0.0.46
### Patch Changes
- Updated dependencies
- jazz-react@0.5.0
- jazz-react-auth-local@0.4.16

View File

@@ -1,20 +1,19 @@
# Jazz Music Player Example
# Jazz Chat Example
Live version: https://example-musicplayer.jazz.tools
Live version: https://example-chat.jazz.tools
## Installing & running the example locally
(this requires `pnpm` to be installed, see [https://pnpm.io/installation](https://pnpm.io/installation))
Start by checking out `jazz`
```bash
git clone https://github.com/gardencmp/jazz.git
cd jazz/examples/musicPlayer
cd jazz/examples/chat
pnpm pack --pack-destination /tmp
mkdir -p ~/jazz-examples/musicPlayer # or any other directory
tar -xf /tmp/jazz-example-musicPlayer-* --strip-components 1 -C ~/jazz-examples/musicPlayer
cd ~/jazz-examples/musicPlayer
mkdir -p ~/jazz-examples/chat # or any other directory
tar -xf /tmp/jazz-example-chat-* --strip-components 1 -C ~/jazz-examples/chat
cd ~/jazz-examples/chat
```
This ensures that you have the example app without git history and independent of the Jazz multi-package monorepo.
@@ -35,6 +34,7 @@ pnpm dev
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.

View File

@@ -2,11 +2,13 @@
<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>Vite + TS + React + Tailwind</title>
<title>Jazz Chat Example</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/2_main.tsx"></script>
<script type="module" src="/src/app.tsx"></script>
</body>
</html>

View File

@@ -1,4 +1,4 @@
job "example-musicPlayer$BRANCH_SUFFIX" {
job "chat$BRANCH_SUFFIX" {
region = "global"
datacenters = ["*"]
@@ -41,7 +41,7 @@ job "example-musicPlayer$BRANCH_SUFFIX" {
service {
tags = ["public"]
name = "example-pets$BRANCH_SUFFIX"
name = "chat$BRANCH_SUFFIX"
port = "http"
provider = "consul"
}

View File

@@ -0,0 +1,56 @@
{
"name": "jazz-example-chat-clerk",
"private": true,
"version": "0.0.80-unique.2",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "echo 'chat example is codegolfed'",
"preview": "vite preview"
},
"lint-staged": {
"*.{ts,tsx}": "eslint --fix",
"*.{js,jsx,mdx,json}": "prettier --write"
},
"dependencies": {
"@clerk/clerk-react": "^5.4.1",
"@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",
"cojson": "workspace:*",
"hash-slash": "workspace:*",
"jazz-react": "workspace:*",
"jazz-tools": "workspace:*",
"jazz-react-auth-clerk": "workspace:*",
"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": "^5.0.10"
}
}

View File

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -0,0 +1,62 @@
import { CoMap, CoList, co, Group, ID } from "jazz-tools";
import { createJazzReactApp } from "jazz-react";
import { JazzClerkAuth } from "jazz-react-auth-clerk";
import { createRoot } from "react-dom/client";
import { useIframeHashRouter } from "hash-slash";
import { ChatScreen } from "./chatScreen.tsx";
import { StrictMode } from "react";
import { ClerkProvider, SignedIn, useAuth } from "@clerk/clerk-react";
export class Message extends CoMap {
text = co.string;
}
export class Chat extends CoList.Of(co.ref(Message)) {}
const Jazz = createJazzReactApp();
export const { useAccount, useCoState } = Jazz;
function App() {
const { signOut } = useAuth();
const { me } = useAccount();
const createChat = () => {
if (!me) return;
const group = Group.create({ owner: me });
group.addMember("everyone", "writer");
const chat = Chat.create([], { owner: group });
location.hash = "/chat/" + chat.id;
};
return (
<div className="flex flex-col items-center justify-between w-screen h-screen p-2 dark:bg-black dark:text-white">
<div className="rounded mb-5 px-2 py-1 text-sm self-end">
{me?.profile?.name} ·{" "}
<button onClick={() => signOut()}>Log Out</button>
</div>
{useIframeHashRouter().route({
"/": () => createChat() as never,
"/chat/:id": (id) => <ChatScreen chatID={id as ID<Chat>} />,
})}
</div>
);
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ClerkProvider
publishableKey={import.meta.env.VITE_CLERK_PUBLISHABLE_KEY}
afterSignOutUrl="/"
>
<JazzClerkAuth>
<SignedIn>
<Jazz.Provider peer="wss://mesh.jazz.tools/?key=chat-example-jazz-clerk@gcmp.io">
<App />
</Jazz.Provider>
</SignedIn>
<JazzClerkAuth.BasicUI appName="Chat" />
</JazzClerkAuth>
</ClerkProvider>
</StrictMode>
);

View File

@@ -0,0 +1,42 @@
import { ID } from 'jazz-tools';
import { Chat, Message, useCoState } from './app.tsx';
export function ChatScreen(props: { chatID: ID<Chat> }) {
const chat = useCoState(Chat, props.chatID, [{}]);
return chat ? <div className='w-full max-w-xl h-full flex flex-col items-stretch'>
{chat.length > 0
? chat.map((msg) => <ChatBubble msg={msg} key={msg.id} />)
: <div className='m-auto text-sm'>(Empty chat)</div>}
<ChatInput onSubmit={(text) => {
chat.push(
Message.create({ text }, { owner: chat._owner })
);
}} />
</div> : <div>Loading...</div>;
}
function ChatBubble(props: { msg: Message }) {
const lastEdit = props.msg._edits.text;
const align = lastEdit.by?.isMe ? 'items-end' : 'items-start';
return <div className={`${align} 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.msg.text }
</div>
<div className='text-xs text-neutral-500 ml-2'>
{ lastEdit.by?.profile?.name }{' '}
{ lastEdit.madeAt?.toLocaleTimeString() }
</div>
</div>;
}
function ChatInput(props: { onSubmit: (text: string) => void }) {
return <input className='rounded p-2 border mt-auto dark:bg-black dark:text-white border-stone-300 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 = '';
}} />;
}

View File

@@ -1,7 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
@@ -9,68 +9,70 @@
--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;
}
}

View File

@@ -1,6 +1,5 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',

View File

@@ -22,5 +22,3 @@ dist-ssr
*.njsproj
*.sln
*.sw?
sync-db/

View File

@@ -1,5 +1,29 @@
# jazz-example-chat
## 0.0.82-unique.2
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.35-unique.2
- cojson@0.7.35-unique.2
- jazz-react@0.7.35-unique.2
## 0.0.82-new-auth.1
### Patch Changes
- Updated dependencies
- jazz-react@0.7.35-new-auth.1
## 0.0.82-new-auth.0
### Patch Changes
- Updated dependencies
- jazz-react@0.7.35-new-auth.0
- jazz-tools@0.7.35-new-auth.0
## 0.0.81
### Patch Changes

View File

@@ -39,4 +39,4 @@ If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or
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 jazz-run 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 `<Jazz.Provider>` provider component in [./src/2_main.tsx](./src/2_main.tsx).
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 `<Jazz.Provider>` provider component in [./src/2_main.tsx](./src/2_main.tsx).

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-chat",
"private": true,
"version": "0.0.81",
"version": "0.0.82-unique.2",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,43 +1,50 @@
import { CoMap, CoList, co, Group, ID } from "jazz-tools";
import { createJazzReactContext, DemoAuth } from "jazz-react";
import { createJazzReactApp, DemoAuth } from "jazz-react";
import { createRoot } from "react-dom/client";
import { useIframeHashRouter } from "hash-slash";
import { ChatScreen } from "./chatScreen.tsx";
import { StrictMode } from "react";
export class Message extends CoMap {
text = co.string;
text = co.string;
}
export class Chat extends CoList.Of(co.ref(Message)) {}
const Jazz = createJazzReactContext({
auth: DemoAuth({ appName: "Jazz Chat" }),
peer: `wss://mesh.jazz.tools/?key=you@example.com`
});
const Jazz = createJazzReactApp();
export const { useAccount, useCoState } = Jazz;
function App() {
const { me, logOut } = useAccount();
const { me } = useAccount();
const createChat = () => {
const group = Group.create({ owner: me });
group.addMember("everyone", "writer");
const chat = Chat.create([], { owner: group });
location.hash = "/chat/" + chat.id;
};
const createChat = () => {
if (!me) return;
const group = Group.create({ owner: me });
group.addMember("everyone", "writer");
const chat = Chat.create([], { owner: group });
location.hash = "/chat/" + chat.id;
};
return <div className="flex flex-col items-center justify-between w-screen h-screen p-2 dark:bg-black dark:text-white">
<div className="rounded mb-5 px-2 py-1 text-sm self-end">
{me.profile?.name} · <button onClick={logOut}>Log Out</button>
</div>
{useIframeHashRouter().route({
'/': () => createChat() as never,
'/chat/:id': (id) => <ChatScreen chatID={id as ID<Chat>} />
})}
</div>;
return (
<div className="flex flex-col items-center justify-between w-screen h-screen p-2 dark:bg-black dark:text-white">
<div className="rounded mb-5 px-2 py-1 text-sm self-end">
{me?.profile?.name} · {/*<button onClick={logOut}>Log Out</button>*/}
</div>
{useIframeHashRouter().route({
"/": () => createChat() as never,
"/chat/:id": (id) => <ChatScreen chatID={id as ID<Chat>} />,
})}
</div>
);
}
createRoot(document.getElementById("root")!)
.render(<StrictMode><Jazz.Provider><App/></Jazz.Provider></StrictMode>);
createRoot(document.getElementById("root")!).render(
<StrictMode>
<DemoAuth>
<DemoAuth.BasicUI appName="Jazz Chat" />
<Jazz.Provider peer="wss://mesh.jazz.tools/?key=chat-example-jazz@gcmp.io">
<App />
</Jazz.Provider>
</DemoAuth>
</StrictMode>
);

View File

@@ -22,5 +22,3 @@ dist-ssr
*.njsproj
*.sln
*.sw?
sync-db/

View File

@@ -1,5 +1,13 @@
# jazz-example-chat
## 0.0.60-unique.0
### Patch Changes
- Updated dependencies
- cojson@0.7.35-unique.2
- cojson-transport-ws@0.7.35-unique.2
## 0.0.59
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-inspector",
"private": true,
"version": "0.0.59",
"version": "0.0.60-unique.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -5,7 +5,7 @@ import {
RawCoValue,
RawAccount,
AgentSecret,
AccountID,
RawAccountID,
cojsonInternals,
WasmCrypto,
} from "cojson";
@@ -82,7 +82,7 @@ export default function CoJsonViewerApp() {
});
}, [currentAccount, goToIndex]);
const addAccount = (id: AccountID, secret: AgentSecret) => {
const addAccount = (id: RawAccountID, secret: AgentSecret) => {
const newAccount = { id, secret };
setAccounts([...accounts, newAccount]);
setCurrentAccount(newAccount);
@@ -238,14 +238,14 @@ function AccountSwitcher({
function AddAccountForm({
addAccount,
}: {
addAccount: (id: AccountID, secret: AgentSecret) => void;
addAccount: (id: RawAccountID, secret: AgentSecret) => void;
}) {
const [id, setId] = useState("");
const [secret, setSecret] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
addAccount(id as AccountID, secret as AgentSecret);
addAccount(id as RawAccountID, secret as AgentSecret);
setId("");
setSecret("");
};

View File

@@ -104,54 +104,6 @@ export async function resolveCoValue(
};
}
function subscribeToCoValue(
coValueId: CoID<RawCoValue>,
node: LocalNode,
callback: (result: Awaited<ReturnType<typeof resolveCoValue>>) => void,
) {
return node.subscribe(coValueId, (value) => {
if (value === "unavailable") {
callback({
value: undefined,
snapshot: "unavailable",
type: null,
extendedType: undefined,
});
} else {
const snapshot = value.toJSON() as JSONObject;
const type = value.type as CoJsonType;
let extendedType: ExtendedCoJsonType | undefined;
if (type === "comap") {
if (isBrowserImage(snapshot)) {
extendedType = "image";
} else if (isAccount(snapshot)) {
extendedType = "account";
} else if (isGroup(snapshot)) {
extendedType = "group";
} else {
const children = Object.values(snapshot).slice(0, 10);
if (
children.every(
(c) => typeof c === "string" && c.startsWith("co_"),
) &&
children.length > 3
) {
extendedType = "record";
}
}
}
callback({
value,
snapshot,
type,
extendedType,
});
}
});
}
export function useResolvedCoValue(
coValueId: CoID<RawCoValue>,
node: LocalNode,
@@ -160,17 +112,7 @@ export function useResolvedCoValue(
useState<Awaited<ReturnType<typeof resolveCoValue>>>();
useEffect(() => {
let isMounted = true;
const unsubscribe = subscribeToCoValue(coValueId, node, (newResult) => {
if (isMounted) {
setResult(newResult);
}
});
return () => {
isMounted = false;
unsubscribe();
};
resolveCoValue(coValueId, node).then(setResult);
}, [coValueId, node]);
return (
@@ -192,30 +134,18 @@ export function useResolvedCoValues(
>([]);
useEffect(() => {
let isMounted = true;
const unsubscribes: (() => void)[] = [];
coValueIds.forEach((coValueId, index) => {
const unsubscribe = subscribeToCoValue(
coValueId,
node,
(newResult) => {
if (isMounted) {
setResults((prevResults) => {
const newResults = [...prevResults];
newResults[index] = newResult;
return newResults;
});
}
},
console.log("RETECHING", coValueIds);
const fetchResults = async () => {
if (coValueIds.length === 0) return;
const resolvedValues = await Promise.all(
coValueIds.map((coValueId) => resolveCoValue(coValueId, node)),
);
unsubscribes.push(unsubscribe);
});
return () => {
isMounted = false;
unsubscribes.forEach((unsubscribe) => unsubscribe());
console.log({ resolvedValues });
setResults(resolvedValues);
};
fetchResults();
}, [coValueIds, node]);
return results;

View File

@@ -1,19 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'prettier'
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

View File

@@ -1 +0,0 @@
# jazz-example-musicplayer

View File

@@ -1,16 +0,0 @@
{
"$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"
}
}

View File

@@ -1,13 +0,0 @@
<!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 - Music Player example</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/2_main.tsx"></script>
</body>
</html>

View File

@@ -1,44 +0,0 @@
{
"name": "jazz-example-music-player",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write './src/**/*.{ts,tsx}'",
"preview": "vite preview"
},
"lint-staged": {
"*.{ts,tsx}": "eslint --fix",
"*.{js,jsx,mdx,json}": "prettier --write"
},
"dependencies": {
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"jazz-react": "workspace:*",
"jazz-tools": "workspace:*",
"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"
},
"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"
}
}

View File

@@ -1,143 +0,0 @@
import {
CoMap,
CoList,
BinaryCoStream,
co,
Profile,
Account,
} from "jazz-tools";
import { getAudioFileData } from "./lib/audio/getAudioFileData";
/** 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
* - other CoValues
**/
export class MusicTrack extends CoMap {
/**
* Attributes are defined as class properties
* and you can get the types from the `co` module
* here we are defining the title and duration for our music track
*
* Tip: try to follow the co.string defintion to discover the other available primitives!
*/
title = co.string;
duration = co.number;
/**
* With `co.ref` you can define relations between your coValues.
*
* Attributes are required by default unless you mark them as optional.
*/
sourceTrack = co.optional.ref(MusicTrack);
/**
* In Jazz you can files using BinaryCoStream.
*
* As for any other coValue the music files we put inside BinaryCoStream
* is available offline and end-to-end encrypted 😉
*/
file = co.ref(BinaryCoStream);
waveform = co.ref(MusicTrackWaveform);
}
export class MusicTrackWaveform extends CoMap {
data = co.json<number[]>();
}
/**
* CoList is the collaborative version of Array
*
* They are strongly typed and accept only the type you define here
* as "CoList.Of" argument
*/
export class ListOfTracks extends CoList.Of(co.ref(MusicTrack)) {}
export class Playlist extends CoMap {
title = co.string;
tracks = co.ref(ListOfTracks);
}
export class ListOfPlaylists extends CoList.Of(co.ref(Playlist)) {}
/** The account root is an app-specific per-user private `CoMap`
* where you can store top-level objects for that user */
export class MusicaAccountRoot extends CoMap {
// The root playlist works as container for the tracks that
// the user has uploaded
rootPlaylist = co.ref(Playlist);
// Here we store the list of playlists that the user has created
// or that has been invited to
playlists = co.ref(ListOfPlaylists);
// We store the active track and playlist as coValue here
// so when the user reloads the page can see the last played
// track and playlist
// You can also add the position in time if you want make it possible
// to resume the song
activeTrack = co.optional.ref(MusicTrack);
activePlaylist = co.ref(Playlist);
}
export class MusicaAccount extends Account {
profile = co.ref(Profile);
root = co.ref(MusicaAccountRoot);
/** 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.
*
* It's a nice way to shape the inital structure of the account data
* and add "onboarding" info.
*/
async migrate(creationProps?: { name: string }) {
super.migrate(creationProps);
if (!this._refs.root) {
const ownership = { owner: this };
const trackFile = await (await fetch("/example.mp3")).blob();
const data = await getAudioFileData(trackFile);
const initialMusicTrack = MusicTrack.create(
{
file: await BinaryCoStream.createFromBlob(
trackFile,
ownership,
),
duration: data.duration,
waveform: MusicTrackWaveform.create(
{ data: data.waveform },
ownership,
),
title: "Example audio",
},
ownership,
);
const tracks = ListOfTracks.create([initialMusicTrack], ownership);
const rootPlaylist = Playlist.create(
{
tracks,
title: "",
},
ownership,
);
this.root = MusicaAccountRoot.create(
{
rootPlaylist,
playlists: ListOfPlaylists.create([], ownership),
activeTrack: initialMusicTrack,
activePlaylist: rootPlaylist,
},
ownership,
);
}
}
}
/** Walkthrough: Continue with ./2_main.tsx */

View File

@@ -1,105 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { createHashRouter, RouterProvider } from "react-router-dom";
import { useMediaPlayer } from "./4_useMediaPlayer";
import { HomePage } from "./5_HomePage";
import { createNewPlaylist, uploadMusicTracks } from "./3_actions";
import { PlaylistPage } from "./6_PlaylistPage";
import { InvitePage } from "./7_InvitePage";
import { Button } from "./basicComponents/Button";
import { FileUploadButton } from "./basicComponents/FileUploadButton";
import { PlayerControls } from "./components/PlayerControls";
import "./index.css";
import { MusicaAccount } from "@/1_schema";
import { createJazzReactContext, DemoAuth } from "jazz-react";
const syncServer = new URLSearchParams(location.search).get("sync") as `ws://${string}` | `wss://${string}` | undefined;
/**
* Walkthrough: The top-level provider `<Jazz.Provider/>`
*
* This shows how to use the top-level provider `<Jazz.Provider/>`,
* which provides the rest of the app with a controlled account (used through `useAccount` later).
* Here we use `DemoAuth` which is great for prototyping you app without wasting time on figuring out
* the best way to do auth.
*
* `<Jazz.Provider/>` also runs our account migration
*/
const Jazz = createJazzReactContext({
auth: DemoAuth({ appName: "Musica Jazz", accountSchema: MusicaAccount }),
peer: syncServer ?? "wss://mesh.jazz.tools/?key=you@example.com",
});
export const { useAccount, useCoState, useAcceptInvite } = Jazz;
function Main() {
const mediaPlayer = useMediaPlayer();
/**
* `me` represents the current user account, which will determine
* access rights to CoValues. We get it from the top-level provider `<WithJazz/>`.
*/
const { me } = useAccount();
async function handleFileLoad(files: FileList) {
if (!me) return;
/**
* Follow this function definition to see how we update
* values in Jazz and manage files!
*/
/** Walkthrough: Continue with ./3_actions.ts */
await uploadMusicTracks(me, files);
}
async function handleCreatePlaylist() {
if (!me) return;
const playlist = await createNewPlaylist(me);
router.navigate(`/playlist/${playlist.id}`);
}
const router = createHashRouter([
{
path: "/",
element: <HomePage mediaPlayer={mediaPlayer} />,
},
{
path: "/playlist/:playlistId",
element: <PlaylistPage mediaPlayer={mediaPlayer} />,
},
{
path: "/invite/*",
element: <InvitePage />,
},
]);
return (
<>
<div className="flex items-center bg-gray-300">
<img src="jazz-logo.png" className="px-3 h-[20px]" />
<div className="text-nowrap">Jazz music player</div>
<div className="flex w-full gap-1 justify-end">
<FileUploadButton onFileLoad={handleFileLoad}>
Add file
</FileUploadButton>
<Button onClick={handleCreatePlaylist}>
Create new playlist
</Button>
</div>
</div>
<RouterProvider router={router} />
<PlayerControls mediaPlayer={mediaPlayer} />
</>
);
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Jazz.Provider>
<Main />
</Jazz.Provider>
</React.StrictMode>,
);

View File

@@ -1,128 +0,0 @@
import { getAudioFileData } from "@/lib/audio/getAudioFileData";
import { BinaryCoStream, Group } from "jazz-tools";
import {
ListOfTracks,
MusicaAccount,
MusicTrack,
MusicTrackWaveform,
Playlist,
} from "./1_schema";
/**
* Walkthrough: Actions
*
* With Jazz is very simple to update the state, you
* just mutate the values and we take care of triggering
* the updates and sync and persist the values you change.
*
* We have grouped the complex updates here in an actions file
* just to keep them separated from the components.
*
* Jazz is very unopinionated in this sense and you can adopt the
* pattern that best fits your app.
*/
export async function uploadMusicTracks(
account: MusicaAccount,
files: FileList,
) {
// The ownership object defines the user that owns the created coValues
// by setting the ownership with "account" we configure the coValues to be private
const ownership = {
owner: account,
};
for (const file of files) {
const data = await getAudioFileData(file);
// We transform the file blob into a BinaryCoStream
// making it a collaborative value that is encrypted, easy
// to share across devices and users and available offline!
const binaryCoStream = await BinaryCoStream.createFromBlob(
file,
ownership,
);
const musicTrack = MusicTrack.create(
{
file: binaryCoStream,
duration: data.duration,
waveform: MusicTrackWaveform.create(
{ data: data.waveform },
ownership,
),
title: file.name,
},
ownership,
);
// The newly created musicTrack can be associated to the
// user track list using a simple push call
account.root?.rootPlaylist?.tracks?.push(musicTrack);
}
}
export async function createNewPlaylist(account: MusicaAccount) {
// Since playlists are meant to be shared we associate them
// to a group which will contain the keys required to get
// access to the "owned" values
const playlistGroup = Group.create({ owner: account });
const ownership = { owner: playlistGroup };
const playlist = Playlist.create(
{
title: "New Playlist",
tracks: ListOfTracks.create([], ownership),
},
ownership,
);
// Again, we associate the new playlist to the
// user by pushing it into the playlists CoList
account.root?.playlists?.push(playlist);
return playlist;
}
export async function addTrackToPlaylist(
playlist: Playlist,
track: MusicTrack,
account: MusicaAccount | undefined,
) {
if (!account) return;
/**
* Since musicTracks are created as private values (see uploadMusicTracks)
* to make them shareable as part of the playlist we are cloning them
* and setting the playlist group as owner of the clone
*
* In the future it will be possible to "inherit" the parent group so you
* won't need to clone values to have this kind of sharing granularity
*/
const ownership = { owner: playlist._owner };
const blob = await BinaryCoStream.loadAsBlob(track._refs.file.id, account);
const waveform = await MusicTrackWaveform.load(
track._refs.waveform.id,
account,
{},
);
if (!blob || !waveform) return;
const trackClone = MusicTrack.create(
{
file: await BinaryCoStream.createFromBlob(blob, ownership),
duration: track.duration,
waveform: MusicTrackWaveform.create(
{ data: waveform.data },
ownership,
),
title: track.title,
sourceTrack: track,
},
ownership,
);
playlist.tracks?.push(trackClone);
}

View File

@@ -1,99 +0,0 @@
import { usePlayMedia } from "@/lib/audio/usePlayMedia";
import { usePlayState } from "@/lib/audio/usePlayState";
import { useAccount, useCoState } from "./2_main";
import { MusicTrack, Playlist } from "@/1_schema";
import { useRef, useState } from "react";
import { getNextTrack, getPrevTrack } from "./lib/getters";
import { BinaryCoStream, ID } from "jazz-tools";
export function useMediaPlayer() {
const { me } = useAccount();
const playState = usePlayState();
const playMedia = usePlayMedia();
const [loading, setLoading] = useState<ID<MusicTrack> | null>(null);
const activeTrack = useCoState(MusicTrack, me?.root?._refs.activeTrack?.id);
// Reference used to avoid out-of-order track loads
const lastLoadedTrackId = useRef<ID<MusicTrack> | null>(null);
async function loadTrack(track: MusicTrack) {
if (!me.root) return;
lastLoadedTrackId.current = track.id;
setLoading(track.id);
const file = await BinaryCoStream.loadAsBlob(track._refs.file.id, me);
if (!file) {
setLoading(null);
return;
}
// Check if another track has been loaded during
// the file download
if (lastLoadedTrackId.current !== track.id) {
return;
}
me.root.activeTrack = track;
await playMedia(file);
setLoading(null);
}
async function playNextTrack() {
if (!me?.root) return;
const track = await getNextTrack(me);
if (track) {
me.root.activeTrack = track;
await loadTrack(track);
}
}
async function playPrevTrack() {
if (!me?.root) return;
const track = await getPrevTrack(me);
if (track) {
await loadTrack(track);
}
}
async function setActiveTrack(track: MusicTrack, playlist?: Playlist) {
if (!me?.root) return;
if (
activeTrack?.id === track.id &&
lastLoadedTrackId.current !== null
) {
playState.toggle();
return;
}
me.root.activePlaylist = playlist ?? me.root.rootPlaylist;
await loadTrack(track);
if (playState.value === "pause") {
playState.toggle();
}
}
return {
activeTrack,
setActiveTrack,
playNextTrack,
playPrevTrack,
loading,
};
}
export type MediaPlayer = ReturnType<typeof useMediaPlayer>;

View File

@@ -1,60 +0,0 @@
import { useAccount } from "./2_main";
import { MediaPlayer } from "./4_useMediaPlayer";
import { Link } from "./basicComponents/Link";
import { MusicTrackRow } from "./components/MusicTrackRow";
import { usePlayState } from "./lib/audio/usePlayState";
export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
const { me } = useAccount({
root: {
rootPlaylist: {
tracks: [{}],
},
playlists: [{}],
},
});
const tracks = me?.root.rootPlaylist.tracks;
const playState = usePlayState();
const isPlaying = playState.value === "play";
const playlists = me?.root.playlists;
return (
<>
{playlists && playlists.length > 0 && (
<div className="p-3">
<b>Playlists</b>
<div className="flex py-6 gap-6">
{playlists.map((playlist) => (
<Link
key={playlist.id}
to={`/playlist/${playlist.id}`}
>
{playlist.title}
</Link>
))}
</div>
</div>
)}
<ul className="flex flex-col">
{tracks?.map(
(track) =>
track && (
<MusicTrackRow
track={track}
key={track.id}
isLoading={mediaPlayer.loading === track.id}
isPlaying={
mediaPlayer.activeTrack?.id === track.id &&
isPlaying
}
onClick={mediaPlayer.setActiveTrack}
/>
),
)}
</ul>
</>
);
}

View File

@@ -1,84 +0,0 @@
import { createInviteLink } from "jazz-react";
import { ID } from "jazz-tools";
import { ChangeEvent } from "react";
import { useParams } from "react-router";
import { useAccount, useCoState } from "./2_main";
import { Playlist } from "./1_schema";
import { MediaPlayer } from "./4_useMediaPlayer";
import { addTrackToPlaylist } from "./3_actions";
import { Button } from "./basicComponents/Button";
import { Link } from "./basicComponents/Link";
import { MusicTrackRow } from "./components/MusicTrackRow";
import { usePlayState } from "./lib/audio/usePlayState";
import { AddTracksToPlaylistSection } from "./components/AddTracksToPlaylistSection";
export function PlaylistPage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
const { playlistId } = useParams<{ playlistId: ID<Playlist> }>();
const playlist = useCoState(Playlist, playlistId, {
tracks: [{}],
});
const { me } = useAccount();
const playState = usePlayState();
const isPlaying = playState.value === "play";
if (!playlist) return null;
const handlePlaylistTitleChange = (evt: ChangeEvent<HTMLInputElement>) => {
playlist.title = evt.target.value;
};
const handlePlaylistShareClick = async () => {
if (playlist._owner.myRole() !== "admin") return;
const inviteLink = createInviteLink(playlist, "reader");
await navigator.clipboard.writeText(inviteLink);
alert(`Invite link copied into the clipboard`);
};
return (
<>
<div className="flex bg-gray-200">
<Link to="/">Back</Link>
<input
className="w-full bg-transparent p-1 m-1"
value={playlist.title}
onChange={handlePlaylistTitleChange}
/>
<Button onClick={handlePlaylistShareClick}>Share</Button>
</div>
<ul className="flex flex-col py-6">
{playlist.tracks?.map(
(track) =>
track && (
<MusicTrackRow
track={track}
key={track.id}
isLoading={mediaPlayer.loading === track.id}
isPlaying={
mediaPlayer.activeTrack?.id === track.id &&
isPlaying
}
onClick={() => {
mediaPlayer.setActiveTrack(track, playlist);
}}
/>
),
)}
</ul>
<AddTracksToPlaylistSection
playlist={playlist}
onTrackClick={(track) =>
addTrackToPlaylist(playlist, track, me)
}
/>
</>
);
}

View File

@@ -1,38 +0,0 @@
import { ID } from "jazz-tools";
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useAcceptInvite, useAccount } from "./2_main";
import { Playlist } from "./1_schema";
export function InvitePage() {
const navigate = useNavigate();
const { me } = useAccount({
root: {
playlists: [],
},
});
useAcceptInvite({
invitedObjectSchema: Playlist,
onAccept: useCallback(
async (playlistId: ID<Playlist>) => {
if (!me) return;
const playlist = await Playlist.load(playlistId, me, {});
if (
playlist &&
!me.root.playlists.some((item) => playlist.id !== item?.id)
) {
me.root.playlists.push(playlist);
}
navigate("/playlist/" + playlistId);
},
[navigate, me],
),
});
return <p>Accepting invite....</p>;
}

View File

@@ -1,20 +0,0 @@
import { cn } from "@/lib/utils";
import { ReactNode } from "react";
export function Button(props: {
className?: string;
onClick?: () => void;
children: ReactNode;
}) {
return (
<button
onClick={props.onClick}
className={cn(
"p-2 bg-blue-300 hover:cursor-pointer flex items-center",
props.className,
)}
>
{props.children}
</button>
);
}

View File

@@ -1,23 +0,0 @@
import { ReactNode } from "react";
export function FileUploadButton(props: {
onFileLoad: (files: FileList) => Promise<void>;
children: ReactNode;
}) {
async function handleFileLoad(evt: React.ChangeEvent<HTMLInputElement>) {
if (!evt.target.files) return;
await props.onFileLoad(evt.target.files);
evt.target.value = "";
}
return (
<button className="bg-blue-300 hover:cursor-pointer flex items-center">
<label className="flex items-center cursor-pointer p-2">
<input type="file" onChange={handleFileLoad} multiple hidden />
{props.children}
</label>
</button>
);
}

View File

@@ -1,13 +0,0 @@
import { ReactNode } from "react";
import { Link as RouterLink } from "react-router-dom";
export function Link(props: { to: string; children: ReactNode }) {
return (
<RouterLink
to={props.to}
className="p-2 w-fit bg-blue-300 hover:cursor-pointer flex items-center"
>
{props.children}
</RouterLink>
);
}

View File

@@ -1,79 +0,0 @@
import { useAccount, useCoState } from "@/2_main";
import { Playlist, MusicTrack, ListOfTracks } from "@/1_schema";
import { Button } from "@/basicComponents/Button";
import { useState } from "react";
export function AddTracksToPlaylistSection({
playlist,
onTrackClick,
}: {
playlist: Playlist;
onTrackClick: (track: MusicTrack) => Promise<void>;
}) {
const { me } = useAccount({
root: {
rootPlaylist: {
tracks: [{}],
},
},
});
const listOfTracks = useCoState(ListOfTracks, playlist._refs.tracks.id, []);
const currentTracksIds = new Set(
listOfTracks?.map((track) => track?._refs.sourceTrack?.id),
);
const tracksToAdd = me?.root.rootPlaylist.tracks.filter(
(track) => !currentTracksIds.has(track.id),
);
if (!tracksToAdd?.length) return null;
return (
<div>
Add tracks to the playlist
<ul className="flex flex-col px-1 py-6 gap-6">
{tracksToAdd.map((track) => (
<li
key={track.id}
className={"flex items-center gap-6 bg-slate-200"}
>
<AddTrackButton
track={track}
onTrackClick={onTrackClick}
/>
{track.title}
</li>
))}
</ul>
</div>
);
}
function AddTrackButton({
track,
onTrackClick,
}: {
track: MusicTrack;
onTrackClick: (track: MusicTrack) => Promise<void>;
}) {
const [isLoading, setLoading] = useState(false);
async function handleClick() {
if (isLoading) return;
setLoading(true);
try {
await onTrackClick(track);
} finally {
setLoading(false);
}
}
return (
<Button className="py-2 px-4" onClick={handleClick}>
{isLoading ? <div className="animate-spin">߷</div> : "+"}
</Button>
);
}

View File

@@ -1,55 +0,0 @@
import { MusicTrack } from "@/1_schema";
import { cn } from "@/lib/utils";
import { ChangeEvent } from "react";
export function MusicTrackRow({
track,
isLoading,
isPlaying,
onClick,
}: {
track: MusicTrack;
isLoading: boolean;
isPlaying: boolean;
onClick: (track: MusicTrack) => void;
}) {
function handleTrackTitleChange(evt: ChangeEvent<HTMLInputElement>) {
track.title = evt.target.value;
}
return (
<li
className={
"flex gap-1 hover:bg-slate-200 group py-2 px-2 cursor-pointer"
}
onClick={() => onClick(track)}
>
<button
className={cn(
"flex items-center justify-center bg-transparent w-8 h-8 ",
!isPlaying && "group-hover:bg-slate-300 rounded-full",
)}
onClick={() => onClick(track)}
>
{isLoading ? (
<div className="animate-spin">߷</div>
) : isPlaying ? (
"⏸️"
) : (
"▶️"
)}
</button>
<div className="relative" onClick={(evt) => evt.stopPropagation()}>
<input
className="absolute w-full h-full left-0 bg-transparent px-1"
value={track.title}
onChange={handleTrackTitleChange}
spellCheck="false"
/>
<span className="opacity-0 px-1 w-fit pointer-events-none whitespace-pre">
{track.title}
</span>
</div>
</li>
);
}

View File

@@ -1,53 +0,0 @@
import { MediaPlayer } from "@/4_useMediaPlayer";
import { usePlayState } from "@/lib/audio/usePlayState";
import { Waveform } from "./Waveform";
import { useAccount } from "@/2_main";
import { useMediaEndListener } from "@/lib/audio/useMediaEndListener";
import { useKeyboardListener } from "@/lib/useKeyboardListener";
export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
const playState = usePlayState();
const isPlaying = playState.value === "play";
const activePlaylist = useAccount({
root: {
activePlaylist: {},
},
}).me?.root.activePlaylist;
useMediaEndListener(mediaPlayer.playNextTrack);
useKeyboardListener("Space", () => {
if (document.activeElement !== document.body) return;
playState.toggle();
});
if (!mediaPlayer.activeTrack) return null;
const activeTrackTitle = mediaPlayer.activeTrack.title;
const head = activePlaylist?.title
? `${activePlaylist.title} / ${activeTrackTitle}`
: activeTrackTitle;
return (
<div className=" flex flex-col fixed bottom-0 left-0 border-t-2 w-full p-4 gap-3">
<div>Playling: {head}</div>
<div className="flex items-center w-full">
<div className="flex flex-shrink gap-3 text-xl">
{" "}
<button onClick={mediaPlayer.playPrevTrack}></button>
{mediaPlayer.loading ? (
<div className="animate-spin">߷</div>
) : !isPlaying ? (
<button onClick={playState.toggle}></button>
) : (
<button onClick={playState.toggle}></button>
)}
<button onClick={mediaPlayer.playNextTrack}></button>
</div>
<Waveform track={mediaPlayer.activeTrack} height={30} />
</div>
</div>
);
}

View File

@@ -1,60 +0,0 @@
import { useCoState } from "@/2_main";
import { MusicTrack, MusicTrackWaveform } from "@/1_schema";
import { usePlayerCurrentTime } from "@/lib/audio/usePlayerCurrentTime";
import { cn } from "@/lib/utils";
export function Waveform(props: { track: MusicTrack; height: number }) {
const { track, height } = props;
const waveformData = useCoState(
MusicTrackWaveform,
track._refs.waveform.id,
{},
)?.data;
const duration = track.duration;
const currentTime = usePlayerCurrentTime();
if (!waveformData) {
return (
<div
style={{
height,
}}
/>
);
}
const barCount = waveformData.length;
const activeBar = Math.ceil(barCount * (currentTime.value / duration));
function seek(i: number) {
currentTime.setValue((i / barCount) * duration);
}
return (
<div
className="flex justify-center items-end w-full"
style={{
height,
gap: 1,
}}
>
{waveformData.map((value, i) => (
<button
type="button"
key={i}
onClick={() => seek(i)}
className={cn(
"w-1 transition-colors rounded-none rounded-t-lg min-h-1",
activeBar >= i ? "bg-gray-500" : "bg-gray-300",
"hover:bg-black hover:border-1 hover:border-solid hover:border-black",
"focus-visible:outline-black focus:outline-none",
)}
style={{
height: height * value,
}}
/>
))}
</div>
);
}

View File

@@ -1,56 +0,0 @@
import { createContext, useContext } from "react";
export class AudioManager {
mediaElement: HTMLAudioElement;
audioObjectURL: string | null = null;
constructor() {
const mediaElement = new Audio();
this.mediaElement = mediaElement;
}
async unloadCurrentAudio() {
if (this.audioObjectURL) {
URL.revokeObjectURL(this.audioObjectURL);
this.audioObjectURL = null;
}
}
async loadAudio(file: Blob) {
await this.unloadCurrentAudio();
const { mediaElement } = this;
const audioObjectURL = URL.createObjectURL(file);
this.audioObjectURL = audioObjectURL;
mediaElement.src = audioObjectURL;
}
play() {
if (this.mediaElement.ended) {
this.mediaElement.fastSeek(0);
}
this.mediaElement.play();
}
pause() {
this.mediaElement.pause();
}
destroy() {
this.unloadCurrentAudio();
this.mediaElement.pause();
}
}
const context = createContext<AudioManager>(new AudioManager());
export function useAudioManager() {
return useContext(context);
}
export const AudionManagerProvider = context.Provider;

View File

@@ -1,45 +0,0 @@
export async function getAudioFileData(file: Blob, samples = 200) {
const ctx = new AudioContext();
const buffer = await file.arrayBuffer();
const decodedAudio = await ctx.decodeAudioData(buffer);
return {
waveform: transformDecodedAudioToWaveformData(decodedAudio, samples),
duration: decodedAudio.duration,
};
}
const transformDecodedAudioToWaveformData = (
audioBuffer: AudioBuffer,
samples: number,
) => {
const rawData = audioBuffer.getChannelData(0); // We only need to work with one channel of data
const blockSize = Math.floor(rawData.length / samples); // the number of samples in each subdivision
const sampledData: number[] = new Array(samples);
let max = 0;
for (let i = 0; i < samples; i++) {
const blockStart = blockSize * i; // the location of the first sample in the block
let sum = 0;
for (let j = 0; j < blockSize; j++) {
sum = sum + Math.abs(rawData[blockStart + j]); // find the sum of all the samples in the block
}
const sampledValue = sum / blockSize; // divide the sum by the block size to get the average
if (max < sampledValue) {
max = sampledValue;
}
sampledData[i] = sampledValue;
}
const multiplier = max ** -1;
for (let i = 0; i < samples; i++) {
sampledData[i] = sampledData[i] * multiplier;
}
return sampledData;
};

View File

@@ -1,14 +0,0 @@
import { useEffect } from "react";
import { useAudioManager } from "./AudioManager";
export function useMediaEndListener(callback: () => void) {
const audioManager = useAudioManager();
useEffect(() => {
audioManager.mediaElement.addEventListener("ended", callback);
return () => {
audioManager.mediaElement.removeEventListener("ended", callback);
};
}, [audioManager, callback]);
}

View File

@@ -1,24 +0,0 @@
import { useRef } from "react";
import { useAudioManager } from "./AudioManager";
export function usePlayMedia() {
const audioManager = useAudioManager();
const previousMediaLoad = useRef<Promise<unknown>>();
async function playMedia(file: Blob) {
// Wait for the previous load to finish
// to avoid to incur into concurrency issues
await previousMediaLoad.current;
const promise = audioManager.loadAudio(file);
previousMediaLoad.current = promise;
await promise;
audioManager.play();
}
return playMedia;
}

View File

@@ -1,39 +0,0 @@
import { useLayoutEffect, useState } from "react";
import { useAudioManager } from "./AudioManager";
export type PlayState = "pause" | "play";
export function usePlayState() {
const audioManager = useAudioManager();
const [value, setValue] = useState<PlayState>("pause");
useLayoutEffect(() => {
setValue(audioManager.mediaElement.paused ? "pause" : "play");
const onPlay = () => {
setValue("play");
};
const onPause = () => {
setValue("pause");
};
audioManager.mediaElement.addEventListener("play", onPlay);
audioManager.mediaElement.addEventListener("pause", onPause);
return () => {
audioManager.mediaElement.removeEventListener("play", onPlay);
audioManager.mediaElement.removeEventListener("pause", onPause);
};
}, [audioManager]);
function togglePlayState() {
if (value === "pause") {
audioManager.play();
} else {
audioManager.pause();
}
}
return { value, toggle: togglePlayState };
}

View File

@@ -1,32 +0,0 @@
import { useLayoutEffect, useState } from "react";
import { useAudioManager } from "./AudioManager";
export function usePlayerCurrentTime() {
const audioManager = useAudioManager();
const [value, setValue] = useState<number>(0);
useLayoutEffect(() => {
setValue(audioManager.mediaElement.currentTime);
const onTimeUpdate = () => {
setValue(audioManager.mediaElement.currentTime);
};
audioManager.mediaElement.addEventListener("timeupdate", onTimeUpdate);
return () => {
audioManager.mediaElement.removeEventListener("timeupdate", onTimeUpdate);
};
}, [audioManager]);
function setCurrentTime(time: number) {
if (audioManager.mediaElement.paused) audioManager.play();
audioManager.mediaElement.currentTime = time;
}
return {
value,
setValue: setCurrentTime,
};
}

View File

@@ -1,30 +0,0 @@
import { MusicaAccount } from "../1_schema";
export async function getNextTrack(account: MusicaAccount) {
if (!account.root?.activePlaylist?.tracks) return;
const tracks = account.root.activePlaylist.tracks;
const activeTrack = account.root._refs.activeTrack;
const currentIndex = tracks.findIndex(
(item) => item?.id === activeTrack.id,
);
const nextIndex = (currentIndex + 1) % tracks.length;
return tracks[nextIndex];
}
export async function getPrevTrack(account: MusicaAccount) {
if (!account.root?.activePlaylist?.tracks) return;
const tracks = account.root.activePlaylist.tracks;
const activeTrack = account.root._refs.activeTrack;
const currentIndex = tracks.findIndex(
(item) => item?.id === activeTrack.id,
);
const previousIndex = (currentIndex - 1 + tracks.length) % tracks.length;
return tracks[previousIndex];
}

View File

@@ -1,16 +0,0 @@
import { useEffect } from "react";
export function useKeyboardListener(code: string, callback: () => void) {
useEffect(() => {
const handler = (evt: KeyboardEvent) => {
if (evt.code === code) {
callback();
}
};
window.addEventListener("keyup", handler);
return () => {
window.removeEventListener("keyup", handler);
};
}, [callback, code]);
}

View File

@@ -1,6 +0,0 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -1,24 +0,0 @@
# 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?

View File

@@ -1,67 +0,0 @@
# Password Manager Example
Live version: https://example-pass-manager.jazz.tools
![Password Manager Screenshot](demo.png "Screenshot")
## Installing & running the example locally
(this requires `pnpm` to be installed, see [https://pnpm.io/installation](https://pnpm.io/installation))
Start by checking out `jazz`
```bash
git clone https://github.com/gardencmp/jazz.git
cd jazz/examples/password-manager
pnpm pack --pack-destination /tmp
mkdir -p ~/jazz-examples/password-manager # or any other directory
tar -xf /tmp/jazz-example-pass-manager-* --strip-components 1 -C ~/jazz-examples/password-manager
cd ~/jazz-examples/password-manager
```
This ensures that you have the example app without git history and independent of the Jazz multi-package monorepo.
Install dependencies:
```bash
pnpm install
```
Start the dev server:
```bash
pnpm dev
```
## Structure
- [`src/components`](./src/components/): UI components
- [`src/1_schema.ts`](./src/1_schema.ts): Jazz data model
- [`src/2_main.tsx`](./src/2_main.tsx): Main App component wrapped in `<Jazz.Provider>`
- [`src/3_vault.tsx`](./src/3_vault.tsx): Password Manager Vault page
- [`src/4_actions.tsx`](./src/4_actions.tsx): Jazz specific actions
- [`src/5_App.tsx`](./src/5_App.tsx): App router - also handles invite links
- [`src/types.ts`](./src/types.ts): shared types
## Walkthrough
### Main parts
1. Define the data model with CoJSON: [`src/1_schema.ts`](./src/1_schema.ts)
2. Wrap the App with the top-level provider `<Jazz.Provider>`: [`src/2_main.tsx`](./src/2_main.tsx)
3. Reactively render password items from folders inside a table, creating/sharing/deleting folders, creating/editing/deleting password items: [`src/3_vault.tsx`](./src/3_vault.tsx)
4. Implement Jazz specific actions: [`src/4_actions.tsx`](./src/4_actions.tsx)
5. Implement useAcceptInvite(): [`src/5_App.tsx`](./src/5_App.tsx)
## 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 `<Jazz.Provider>` provider component in [./src/2_main.tsx](./src/2_main.tsx).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

View File

@@ -1,37 +0,0 @@
{
"name": "jazz-password-manager",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives",
"preview": "vite preview",
"clean-install": "rm -rf node_modules pnpm-lock.yaml && pnpm install"
},
"dependencies": {
"jazz-react": "workspace:*",
"jazz-tools": "workspace:*",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.41.5",
"react-router-dom": "^6.16.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"@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": "^5.0.10"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,81 +0,0 @@
import { Account, co, CoList, CoMap, Group, Profile } from "jazz-tools";
export class PasswordItem extends CoMap {
name = co.string;
username = co.optional.string;
username_input_selector = co.optional.string;
password = co.string;
password_input_selector = co.optional.string;
uri = co.optional.string;
folder = co.ref(Folder);
deleted = co.boolean;
}
export class PasswordList extends CoList.Of(co.ref(PasswordItem)) {}
export class Folder extends CoMap {
name = co.string;
items = co.ref(PasswordList);
}
export class FolderList extends CoList.Of(co.ref(Folder)) {}
export class PasswordManagerAccountRoot extends CoMap {
folders = co.ref(FolderList);
}
export class PasswordManagerAccount extends Account {
profile = co.ref(Profile);
root = co.ref(PasswordManagerAccountRoot);
migrate(this: PasswordManagerAccount, creationProps?: { name: string }) {
super.migrate(creationProps);
if (!this._refs.root) {
const group = Group.create({ owner: this });
const firstFolder = Folder.create(
{
name: "Default",
items: PasswordList.create([], { owner: group }),
},
{ owner: group }
);
firstFolder.items?.push(
PasswordItem.create(
{
name: "Gmail",
username: "user@gmail.com",
password: "password123",
uri: "https://gmail.com",
folder: firstFolder,
deleted: false,
},
{ owner: group }
)
);
firstFolder.items?.push(
PasswordItem.create(
{
name: "Facebook",
username: "user@facebook.com",
password: "facebookpass",
uri: "https://facebook.com",
folder: firstFolder,
deleted: false,
},
{ owner: group }
)
);
this.root = PasswordManagerAccountRoot.create(
{
folders: FolderList.create([firstFolder], {
owner: this,
}),
},
{ owner: this }
);
}
}
}

View File

@@ -1,23 +0,0 @@
import ReactDOM from "react-dom/client";
import App from "./5_App.tsx";
import "./index.css";
import { createJazzReactContext, PasskeyAuth } from "jazz-react";
import { PasswordManagerAccount } from "./1_schema.ts";
const auth = PasskeyAuth<PasswordManagerAccount>({
appName: "Jazz Password Manager",
accountSchema: PasswordManagerAccount,
});
const Jazz = createJazzReactContext<PasswordManagerAccount>({
auth,
peer: "wss://mesh.jazz.tools/?key=you@example.com",
});
export const { useAccount, useCoState, useAcceptInvite } = Jazz;
ReactDOM.createRoot(document.getElementById("root")!).render(
<Jazz.Provider>
<App />
</Jazz.Provider>
);

View File

@@ -1,261 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect, useState } from "react";
import Button from "./components/button";
import Table from "./components/table";
import NewItemModal from "./components/new-item-modal";
import InviteModal from "./components/invite-modal";
import { saveItem, deleteItem, createFolder, updateItem } from "./4_actions";
import { Alert, AlertDescription } from "./components/alert";
import { Folder, FolderList, PasswordItem } from "./1_schema";
import { useAccount, useCoState } from "./2_main";
import { CoMapInit, Group, ID } from "jazz-tools";
import { useNavigate, useParams } from "react-router-dom";
import { PasswordItemFormValues } from "./types";
const VaultPage: React.FC = () => {
const { me, logOut } = useAccount();
const sharedFolderId = useParams<{ sharedFolderId: ID<Folder> }>()
.sharedFolderId;
const sharedFolder = useCoState(Folder, sharedFolderId);
const navigate = useNavigate();
useEffect(() => {
if (!sharedFolderId || !sharedFolder || !me.root?.folders) return;
const existsIndex = me.root?.folders.findIndex(
(f) => f?.id === sharedFolder.id
);
if (existsIndex > -1) {
me.root?.folders?.splice(existsIndex, 1);
}
me.root?.folders?.push(sharedFolder);
navigate("/vault");
}, [sharedFolder, me.root?.folders, sharedFolderId, navigate]);
const items = me.root?.folders?.flatMap(
(folder) =>
folder?.items?.filter(
(item): item is Exclude<typeof item, null> => !!item
) || []
);
const folders = useCoState(FolderList, me.root?._refs.folders?.id, [
{ items: [{}] },
]);
const [selectedFolder, setSelectedFolder] = useState<Folder | undefined>();
const [isNewItemModalOpen, setIsNewItemModalOpen] = useState(false);
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [isNewFolderInputVisible, setIsNewFolderInputVisible] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [editingItem, setEditingItem] = useState<PasswordItem | null>(null);
const [error, setError] = useState<string | null>(null);
const filteredItems = selectedFolder
? items?.filter(
(item) => item?.folder?.name === selectedFolder.name && !item.deleted
)
: items?.filter((item) => !item?.deleted);
const handleSaveNewItem = async (newItem: PasswordItemFormValues) => {
try {
saveItem(newItem as CoMapInit<PasswordItem>);
} catch (err: any) {
setError("Failed to save new item. Please try again.");
throw new Error(err);
}
};
const handleUpdateItem = async (updatedItem: PasswordItemFormValues) => {
if (!editingItem) return;
try {
updateItem(editingItem, updatedItem);
setEditingItem(null);
} catch (err: any) {
setError("Failed to update item. Please try again.");
throw new Error(err);
}
};
const handleDeleteItem = async (item: PasswordItem) => {
try {
deleteItem(item);
} catch (err) {
setError("Failed to delete item. Please try again.");
}
};
const handleCreateFolder = async () => {
if (newFolderName) {
try {
const newFolder = createFolder(newFolderName, me);
setNewFolderName("");
setIsNewFolderInputVisible(false);
setSelectedFolder(newFolder);
} catch (err) {
setError("Failed to create folder. Please try again.");
}
}
};
const handleDeleteFolder = async () => {
try {
const selectedFolderIndex = me.root?.folders?.findIndex(
(folder) => folder?.id === selectedFolder?.id
);
if (selectedFolderIndex !== undefined && selectedFolderIndex > -1)
me.root?.folders?.splice(selectedFolderIndex, 1);
} catch (err) {
setError("Failed to create folder. Please try again.");
}
};
const handleLogout = async () => {
try {
logOut();
} catch (err) {
setError("Failed to logout. Please try again.");
}
};
const columns = [
{ header: "Name", accessor: "name" as const },
{ header: "Username", accessor: "username" as const },
{ header: "URI", accessor: "uri" as const },
{
header: "Actions",
accessor: "id" as const,
render: (item: PasswordItem) => (
<div className="flex flex-wrap gap-2">
<Button onClick={() => navigator.clipboard.writeText(item.password)}>
Copy Password
</Button>
<Button
onClick={() => setEditingItem(item)}
disabled={
item._owner.castAs(Group).myRole() !== "admin" &&
item._owner.castAs(Group).myRole() !== "writer"
}
>
Edit
</Button>
<Button
onClick={() => handleDeleteItem(item)}
variant="danger"
disabled={
item._owner.castAs(Group).myRole() !== "admin" &&
item._owner.castAs(Group).myRole() !== "writer"
}
>
Delete
</Button>
</div>
),
},
];
return (
<div className="container mx-auto px-4 py-8">
<div className="container flex justify-between items-center">
<h1 className="text-3xl font-bold mb-8">Password Vault</h1>
<Button onClick={handleLogout} variant="secondary">
Logout
</Button>
</div>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="mb-4 flex flex-wrap justify-between items-center gap-4">
<div className="flex flex-wrap gap-2">
<Button
key={"folder-all"}
onClick={() => setSelectedFolder(undefined)}
variant={!selectedFolder ? "primary" : "secondary"}
>
All
</Button>
{folders?.map((folder) => (
<Button
key={folder.id}
onClick={() => setSelectedFolder(folder)}
variant={
selectedFolder?.name === folder?.name ? "primary" : "secondary"
}
>
{folder?.name}
</Button>
))}
{isNewFolderInputVisible ? (
<div className="flex gap-2">
<input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
className="border rounded px-2 py-1"
/>
<Button onClick={handleCreateFolder}>Save</Button>
</div>
) : (
<Button onClick={() => setIsNewFolderInputVisible(true)}>
New Folder
</Button>
)}
</div>
<div className="flex gap-2">
<Button
onClick={() => setIsNewItemModalOpen(true)}
disabled={
!selectedFolder ||
(selectedFolder._owner.castAs(Group).myRole() !== "admin" &&
selectedFolder._owner.castAs(Group).myRole() !== "writer")
}
>
New Item
</Button>
<Button
onClick={() => setIsInviteModalOpen(true)}
disabled={
!selectedFolder ||
(selectedFolder._owner.castAs(Group).myRole() !== "admin" &&
selectedFolder._owner.castAs(Group).myRole() !== "writer")
}
>
Share Folder
</Button>
<Button onClick={handleDeleteFolder} disabled={!selectedFolder}>
Delete Folder
</Button>
</div>
</div>
<div className="overflow-x-auto">
<Table data={filteredItems} columns={columns} />
</div>
{folders ? (
<NewItemModal
isOpen={isNewItemModalOpen || !!editingItem}
onClose={() => {
setIsNewItemModalOpen(false);
setEditingItem(null);
}}
onSave={editingItem ? handleUpdateItem : handleSaveNewItem}
folders={folders}
selectedFolder={selectedFolder}
initialValues={
editingItem && editingItem.folder ? { ...editingItem } : undefined
}
/>
) : null}
{folders ? (
<InviteModal
isOpen={isInviteModalOpen}
onClose={() => setIsInviteModalOpen(false)}
selectedFolder={selectedFolder}
/>
) : null}
</div>
);
};
export default VaultPage;

View File

@@ -1,56 +0,0 @@
import { Group } from "jazz-tools";
import {
Folder,
PasswordItem,
PasswordList,
PasswordManagerAccount,
} from "./1_schema";
import { CoMapInit } from "jazz-tools";
import { createInviteLink } from "jazz-react";
import { PasswordItemFormValues } from "./types";
export const saveItem = (item: CoMapInit<PasswordItem>): PasswordItem => {
const passwordItem = PasswordItem.create(item, {
owner: item.folder!._owner,
});
passwordItem.folder?.items?.push(passwordItem);
return passwordItem;
};
export const updateItem = (
item: PasswordItem,
values: PasswordItemFormValues
): PasswordItem => {
item.applyDiff(values as Partial<CoMapInit<PasswordItem>>);
return item;
};
export const deleteItem = (item: PasswordItem): void => {
const found = item.folder?.items?.findIndex(
(passwordItem) => passwordItem?.id === item.id
);
if (found !== undefined && found > -1) item.folder?.items?.splice(found, 1);
};
export const createFolder = (
folderName: string,
me: PasswordManagerAccount
): Folder => {
const group = Group.create({ owner: me });
const folder = Folder.create(
{ name: folderName, items: PasswordList.create([], { owner: group }) },
{ owner: group }
);
me.root?.folders?.push(folder);
return folder;
};
export const shareFolder = (
folder: Folder,
permission: "reader" | "writer" | "admin"
): string | undefined => {
if (folder._owner && folder.id) {
return createInviteLink(folder, permission);
}
return undefined;
};

View File

@@ -1,37 +0,0 @@
import React from "react";
import { createHashRouter, Navigate, RouterProvider } from "react-router-dom";
import VaultPage from "./3_vault";
import { useAcceptInvite } from "./2_main";
import { Folder } from "./1_schema";
const App: React.FC = () => {
const router = createHashRouter([
{
path: "/",
element: <Navigate to={"/vault"} />,
},
{
path: "/vault",
element: <VaultPage />,
},
{
path: "/vault/:sharedFolderId",
element: <VaultPage />,
},
{
path: "/invite/*",
element: <p>Accepting invite...</p>,
},
]);
useAcceptInvite({
invitedObjectSchema: Folder,
onAccept: async (sharedFolderId) => {
router.navigate(`/vault/${sharedFolderId}`);
},
});
return <RouterProvider router={router} />;
};
export default App;

View File

@@ -1,41 +0,0 @@
import React from "react";
interface AlertProps {
children: React.ReactNode;
variant?: "default" | "destructive";
className?: string;
}
export const Alert: React.FC<AlertProps> = ({
children,
variant = "default",
className = "",
}) => {
const baseClasses = "p-4 rounded-md mb-4";
const variantClasses = {
default: "bg-blue-100 text-blue-700",
destructive: "bg-red-100 text-red-700",
};
const classes = `${baseClasses} ${variantClasses[variant]} ${className}`;
return (
<div className={classes} role="alert">
{children}
</div>
);
};
interface AlertDescriptionProps {
children: React.ReactNode;
className?: string;
}
export const AlertDescription: React.FC<AlertDescriptionProps> = ({
children,
className = "",
}) => {
const classes = `text-sm ${className}`;
return <p className={classes}>{children}</p>;
};

View File

@@ -1,40 +0,0 @@
import { useState } from "react";
import { PasskeyAuth } from "jazz-react";
export const PrettyAuthUI: PasskeyAuth.Component = ({
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"
/>
<input type="submit" value="Sign Up as new account" />
</form>
<button onClick={logIn}>Log In with existing account</button>
</div>
)}
</div>
);
};

View File

@@ -1,59 +0,0 @@
import React from "react";
import Button from "./button";
interface BaseModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
const BaseModal: React.FC<BaseModalProps> = ({
isOpen,
onClose,
title,
children,
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 transition-opacity" aria-hidden="true">
<div className="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<span
className="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true"
>
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 className="text-lg leading-6 font-medium text-gray-900">
{title}
</h3>
<div className="mt-2">{children}</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<Button
variant="secondary"
onClick={onClose}
className="w-full sm:w-auto sm:ml-3"
>
Close
</Button>
</div>
</div>
</div>
</div>
);
};
export default BaseModal;

View File

@@ -1,38 +0,0 @@
import React from "react";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger";
size?: "small" | "medium" | "large";
}
const Button: React.FC<ButtonProps> = ({
children,
variant = "primary",
size = "medium",
className = "",
...props
}) => {
const baseClasses =
"font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed";
const variantClasses = {
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
secondary:
"bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500",
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
};
const sizeClasses = {
small: "px-2 py-1 text-sm",
medium: "px-4 py-2",
large: "px-6 py-3 text-lg",
};
const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
return (
<button className={classes} {...props}>
{children}
</button>
);
};
export default Button;

View File

@@ -1,144 +0,0 @@
import React, { useState } from "react";
import BaseModal from "./base-modal";
import Button from "./button";
import { shareFolder } from "../4_actions";
import { Folder } from "../1_schema";
import { Group } from "jazz-tools";
interface InviteModalProps {
isOpen: boolean;
onClose: () => void;
selectedFolder: Folder | undefined;
}
const InviteModal: React.FC<InviteModalProps> = ({
isOpen,
onClose,
selectedFolder,
}) => {
const [selectedPermission, setSelectedPermission] = useState<
"reader" | "writer" | "admin"
>("reader");
const [inviteLink, setInviteLink] = useState("");
const members = selectedFolder?._owner.castAs(Group).members;
const invitedMembers = members
? members
.filter((m) => !m.account?.isMe && m.role !== "revoked")
.map((m) => m.account)
: [];
const handleCreateInviteLink = () => {
if (!selectedFolder || !selectedPermission) return;
const inviteLink = shareFolder(selectedFolder, selectedPermission);
if (!inviteLink) return;
setInviteLink(inviteLink);
};
return (
<BaseModal isOpen={isOpen} onClose={onClose} title="Invite Users">
<div className="space-y-4">
<div>
<label
htmlFor="folder"
className="block text-sm font-medium text-gray-700"
>
Select Folder to Share
</label>
<select
id="folder"
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
>
<option key={selectedFolder?.id} value={selectedFolder?.id}>
{selectedFolder?.name}
</option>
</select>
</div>
<div>
<label
htmlFor="permission"
className="block text-sm font-medium text-gray-700"
>
Select Permission
</label>
<select
id="permission"
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
value={selectedPermission}
onChange={(e) =>
setSelectedPermission(
e.target.value as "reader" | "writer" | "admin"
)
}
>
<option value="reader">Reader</option>
<option value="writer">Writer</option>
</select>
</div>
<div>
<h3 className="text-lg font-medium mb-2">Existing Shared Users</h3>
<div className="max-h-40 overflow-y-auto bg-gray-100 rounded-md p-2">
{invitedMembers.length > 0 ? (
<ul className="list-disc list-inside">
{invitedMembers.map((user) => (
<li
key={user?.id}
className="text-sm flex justify-between items-center"
>
<span>{user?.profile?.name}</span>
<button
onClick={() => {
if (!user?._raw) return;
selectedFolder?._owner
.castAs(Group)
._raw.removeMember(user?._raw);
}}
className="ml-4 bg-red-500 text-white px-2 py-1 rounded text-xs hover:bg-red-600"
>
Remove
</button>
</li>
))}
</ul>
) : (
<p className="text-sm text-gray-500">
No users currently have access to this folder.
</p>
)}
</div>
</div>
<Button onClick={handleCreateInviteLink} className="w-full">
Create Invite Link
</Button>
{inviteLink && (
<div className="mt-4">
<label
htmlFor="inviteLink"
className="block text-sm font-medium text-gray-700"
>
Invite Link
</label>
<div className="mt-1 flex rounded-md shadow-sm">
<input
type="text"
id="inviteLink"
className="flex-1 min-w-0 block w-full px-3 py-2 rounded-none rounded-l-md text-sm border-gray-300 focus:ring-indigo-500 focus:border-indigo-500"
value={inviteLink}
readOnly
/>
<Button
type="button"
className="inline-flex items-center px-3 rounded-r-md border border-l-0 border-gray-300 text-gray-500 text-sm"
onClick={() => navigator.clipboard.writeText(inviteLink)}
>
Copy
</Button>
</div>
</div>
)}
</div>
</BaseModal>
);
};
export default InviteModal;

View File

@@ -1,200 +0,0 @@
import React, { useEffect } from "react";
import { useForm, SubmitHandler } from "react-hook-form";
import BaseModal from "./base-modal";
import Button from "./button";
import { Alert, AlertDescription } from "./alert";
import { Folder } from "../1_schema";
import { CoMap } from "jazz-tools";
import { PasswordItemFormValues } from "../types";
interface NewItemModalProps {
isOpen: boolean;
onClose: () => void;
initialValues?: PasswordItemFormValues;
onSave: (item: PasswordItemFormValues) => void;
folders: Folder[];
selectedFolder: Folder | undefined;
}
const NewItemModal: React.FC<NewItemModalProps> = ({
isOpen,
onClose,
initialValues,
onSave,
folders,
selectedFolder,
}) => {
const {
register,
handleSubmit,
setValue,
formState: { errors },
reset,
// @ts-expect-error error
} = useForm<PasswordItemFormValues>({
defaultValues: initialValues || {
name: "",
username: "",
password: "",
uri: "",
deleted: false,
folder: selectedFolder,
},
});
useEffect(() => {
if (initialValues) {
Object.entries(initialValues).forEach(([key, value]) => {
const valueToSet = value instanceof CoMap ? value.id : value;
setValue(key as keyof PasswordItemFormValues & string, valueToSet);
});
} else {
reset();
}
}, [initialValues, setValue, reset]);
const onSubmit: SubmitHandler<PasswordItemFormValues> = (data) => {
const folderId = data?.folder as unknown as string;
const selectedFolder = folders.find((folder) => folder.id === folderId);
if (selectedFolder) {
data.folder = selectedFolder;
}
onSave(data);
onClose();
};
return (
<BaseModal
isOpen={isOpen}
onClose={onClose}
title={initialValues ? "Edit Password" : "Add New Password"}
>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-gray-700"
>
Name
</label>
<input
type="text"
{...register("name", { required: "Name is required" })}
id="name"
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
{errors.name && (
<Alert variant="destructive">
<AlertDescription>{errors.name.message}</AlertDescription>
</Alert>
)}
</div>
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700"
>
Username
</label>
<input
type="text"
{...register("username")}
id="username"
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
Password
</label>
<input
type="password"
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message: "Password must be at least 8 characters long",
},
})}
id="password"
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
{errors.password && (
<Alert variant="destructive">
<AlertDescription>{errors.password.message}</AlertDescription>
</Alert>
)}
</div>
<div>
<label
htmlFor="uri"
className="block text-sm font-medium text-gray-700"
>
URI
</label>
<input
type="url"
{...register("uri", {
validate: (value) =>
!value ||
value.startsWith("http://") ||
value.startsWith("https://") ||
"URI must start with http:// or https://",
})}
id="uri"
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
{errors.uri && (
<Alert variant="destructive">
<AlertDescription>{errors.uri.message}</AlertDescription>
</Alert>
)}
</div>
<div>
<label
htmlFor="folder"
className="block text-sm font-medium text-gray-700"
>
Folder
</label>
<select
{...register("folder", { required: "Must select a folder" })}
id="folder"
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">Select a folder</option>
{folders.map((folder) => (
<option
key={folder.id}
value={folder.id}
selected={
initialValues
? initialValues?.folder?.id === folder.id
: selectedFolder?.id === folder.id
}
>
{folder.name}
</option>
))}
</select>
{errors.folder && (
<Alert variant="destructive">
<AlertDescription>{errors.folder.message}</AlertDescription>
</Alert>
)}
</div>
<div className="flex justify-end space-x-2">
<Button type="button" variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button type="submit">{initialValues ? "Update" : "Save"}</Button>
</div>
</form>
</BaseModal>
);
};
export default NewItemModal;

View File

@@ -1,54 +0,0 @@
import React from "react";
interface Column<T> {
header: string;
accessor: keyof T;
render?: (item: T) => React.ReactNode;
}
interface TableProps<T> {
data: T[] | undefined;
columns: Column<T>[];
onRowClick?: (item: T) => void;
}
function Table<T>({ data, columns, onRowClick }: TableProps<T>) {
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{columns.map((column, index) => (
<th
key={index}
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{data?.map((item, rowIndex) => (
<tr
key={rowIndex}
onClick={() => onRowClick && onRowClick(item)}
className={onRowClick ? "cursor-pointer hover:bg-gray-50" : ""}
>
{columns.map((column, colIndex) => (
<td key={colIndex} className="px-6 py-4 whitespace-nowrap">
{column.render
? column.render(item)
: (item[column.accessor] as React.ReactNode)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export default Table;

View File

@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1,11 +0,0 @@
import { FieldValues } from "react-hook-form";
import { Folder } from "./1_schema";
export interface PasswordItemFormValues extends FieldValues {
name: string;
username?: string;
password: string;
uri?: string;
deleted: boolean;
folder: Folder | null;
}

View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,12 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -1,39 +0,0 @@
{
"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"
}
]
}

View File

@@ -1,12 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.ts"
]
}

View File

@@ -1,7 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})

View File

@@ -22,9 +22,3 @@ dist-ssr
*.njsproj
*.sln
*.sw?
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
sync-db/

View File

@@ -1,5 +1,30 @@
# jazz-example-pets
## 0.0.100-unique.2
### Patch Changes
- Updated dependencies
- jazz-tools@0.7.35-unique.2
- jazz-browser-media-images@0.7.35-unique.2
- jazz-react@0.7.35-unique.2
## 0.0.100-new-auth.1
### Patch Changes
- Updated dependencies
- jazz-react@0.7.35-new-auth.1
## 0.0.100-new-auth.0
### Patch Changes
- Updated dependencies
- jazz-react@0.7.35-new-auth.0
- jazz-tools@0.7.35-new-auth.0
- jazz-browser-media-images@0.7.35-new-auth.0
## 0.0.99
### Patch Changes

View File

@@ -7,7 +7,6 @@ Live version: https://example-pets.jazz.tools
(this requires `pnpm` to be installed, see [https://pnpm.io/installation](https://pnpm.io/installation))
Start by checking out `jazz`
```bash
git clone https://github.com/gardencmp/jazz.git
cd jazz/examples/pets
@@ -35,8 +34,9 @@ pnpm dev
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 jazz-run 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 `<Jazz.Provider>` provider component in [./src/2_main.tsx](./src/2_main.tsx).
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 `<Jazz.Provider>` provider component in [./src/2_main.tsx](./src/2_main.tsx).

View File

@@ -1,16 +1,14 @@
{
"name": "jazz-example-pets",
"private": true,
"version": "0.0.99",
"version": "0.0.100-unique.2",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write './src/**/*.{ts,tsx}'",
"preview": "vite preview",
"test": "playwright test",
"test:ui": "playwright test --ui"
"preview": "vite preview"
},
"lint-staged": {
"*.{ts,tsx}": "eslint --fix",
@@ -37,8 +35,6 @@
"uniqolor": "^1.1.0"
},
"devDependencies": {
"@playwright/test": "^1.46.1",
"@types/node": "^22.5.1",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
@@ -48,11 +44,9 @@
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"is-ci": "^3.0.1",
"postcss": "^8.4.27",
"tailwindcss": "^3.3.3",
"typescript": "^5.0.2",
"vite": "^4.4.5",
"vite-plugin-top-level-await": "^1.4.4"
"vite": "^4.4.5"
}
}

View File

@@ -1,51 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
import isCI from "is-ci";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./tests",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: isCI,
/* Retry on CI only */
retries: isCI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: isCI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: isCI ? "http://localhost:4173/" : "http://localhost:5173",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
permissions: ["clipboard-read", "clipboard-write"],
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
/* Run your local dev server before starting the tests */
webServer: {
command: "pnpm preview",
url: "http://localhost:4173/",
reuseExistingServer: !isCI,
},
});

View File

@@ -3,18 +3,16 @@ import ReactDOM from "react-dom/client";
import { Link, RouterProvider, createHashRouter } from "react-router-dom";
import "./index.css";
import { createJazzReactContext, DemoAuth, PasskeyAuth } from "jazz-react";
import { createJazzReactApp, PasskeyAuth, usePasskeyAuth } from "jazz-react";
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 { PetAccount, PetPost } from "./1_schema.ts";
import { supportsWebAuthn } from "./lib/support.ts";
/** Walkthrough: The top-level provider `<WithJazz/>`
*
@@ -25,21 +23,7 @@ import { supportsWebAuthn } from "./lib/support.ts";
const appName = "Jazz Rate My Pet Example";
const passkeyAuth = PasskeyAuth<PetAccount>({
appName,
Component: PrettyAuthUI,
accountSchema: PetAccount,
});
const authFallback = DemoAuth<PetAccount>({
appName,
accountSchema: PetAccount,
});
const Jazz = createJazzReactContext({
auth: supportsWebAuthn ? passkeyAuth : authFallback,
peer: "wss://mesh.jazz.tools/?key=you@example.com",
});
const Jazz = createJazzReactApp({ AccountSchema: PetAccount });
// eslint-disable-next-line react-refresh/only-export-components
export const { useAccount, useCoState, useAcceptInvite } = Jazz;
@@ -48,9 +32,12 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<ThemeProvider>
<TitleAndLogo name={appName} />
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
<Jazz.Provider loading={<div>Loading</div>}>
<App />
</Jazz.Provider>
<PasskeyAuth appName={appName}>
<Jazz.Provider peer="wss://mesh.jazz.tools/?key=pets-example-jazz@gcmp.io">
<App />
</Jazz.Provider>
<PasskeyAuth.BasicUI />
</PasskeyAuth>
</div>
</ThemeProvider>
</React.StrictMode>,
@@ -64,7 +51,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
*/
export default function App() {
const { logOut } = useAccount();
const {state: passKeyState} = usePasskeyAuth();
const router = createHashRouter([
{
@@ -94,12 +81,16 @@ export default function App() {
<>
<RouterProvider router={router} />
<Button
onClick={() => router.navigate("/").then(logOut)}
variant="outline"
>
Log Out
</Button>
{passKeyState.state === "signedIn" && (
<Button
onClick={() =>
router.navigate("/").then(passKeyState.logOut)
}
variant="outline"
>
Log Out
</Button>
)}
</>
);
}
@@ -107,7 +98,7 @@ export default function App() {
export function PostOverview() {
const { me } = useAccount();
const myPosts = me.root?.posts;
const myPosts = me?.root?.posts;
return (
<>

Some files were not shown because too many files have changed in this diff Show More