Compare commits

...

328 Commits

Author SHA1 Message Date
Anselm
87ef6d5064 Pre-release 2024-09-22 09:06:01 -07:00
Anselm
e2ec51d3db Merge branch 'trishalim-jazz-311' 2024-09-22 08:50:17 -07:00
Anselm Eickhoff
566be392da Merge pull request #412 from gardencmp/trishalim-jazz-312
Fix footer overlaps side nav in docs
2024-09-22 08:47:08 -07:00
Anselm
7497ed2a7c Clean up nav related components and fix footer after botched merge 2024-09-22 07:16:36 -07:00
Anselm
13b0dfc38f Merge branch 'main' into trishalim-jazz-312 2024-09-22 07:09:52 -07:00
Anselm Eickhoff
1debe44cf1 Merge pull request #424 from gardencmp/feature/binarycostream-reconnection-test
test(BinaryCoStream): add a reconnection e2e test
2024-09-22 06:53:07 -07:00
Anselm Eickhoff
b737e14069 Merge pull request #411 from gardencmp/trishalim-jazz-311
Create separate pages for each section in the docs
2024-09-20 11:45:55 -07:00
Anselm
82fd148370 Add static params for packages 2024-09-20 10:58:12 -07:00
Guido D'Orsi
d0fc6dbc13 test(BinaryCoStrea): add a reconnection e2e test 2024-09-20 18:35:01 +02:00
Trisha Lim
f588c3f265 Move packages documentation to separate routes 2024-09-20 17:26:43 +01:00
Anselm Eickhoff
659e80bbf4 Merge pull request #356 from gardencmp/anselm-jazz-284
Design system setup
2024-09-20 16:15:50 +01:00
Anselm
3585e2acde Fix lint and dep errors 2024-09-20 07:44:13 -07:00
Anselm
98554523c8 Kind of start using components on the docs/guide page 2024-09-20 14:45:04 +01:00
Anselm
dbeaa35110 Use/extract components for mesh page 2024-09-20 14:30:11 +01:00
Anselm
61c719c728 Merge branch 'main' into anselm-jazz-284 2024-09-20 14:05:07 +01:00
Anselm Eickhoff
48fddecd30 Merge pull request #420 from gardencmp/trishalim-jazz-313 2024-09-20 10:57:49 +01:00
Trisha Lim
f5779077df Fix horizontal overflow on tablet in docs 2024-09-19 18:43:26 +01:00
Trisha Lim
5149fb6fbf Fix horizontal overflow on tablet in docs 2024-09-19 17:51:16 +01:00
Trisha Lim
dcc6134180 Clip text that is too long 2024-09-19 16:28:17 +01:00
Trisha Lim
ecb336829d Fix footer overlaps side nav in docs 2024-09-19 16:28:17 +01:00
Trisha Lim
2778f919a6 Use dynamic route for packages 2024-09-19 16:05:25 +01:00
Trisha Lim
ab6fcc7a7a Add metadata to each package page 2024-09-19 12:29:37 +01:00
Trisha Lim
aa284802ab Use Link instead of anchor tag 2024-09-19 11:40:15 +01:00
Trisha Lim
a6af56bc2e Add section for "Simple Public Sharing" 2024-09-19 11:36:29 +01:00
Anselm Eickhoff
751f0b4d10 Merge pull request #410 from gardencmp/feature/fix-playlist-invitation-add 2024-09-19 08:30:03 +01:00
Trisha Lim
a233eb6396 Remove sections that don't exist 2024-09-18 21:01:52 +01:00
Trisha Lim
c91d9b99be Move packages documentation to separate routes 2024-09-18 20:52:58 +01:00
Guido D'Orsi
a5b798a257 fix(musicPlayer): fix the playlist check on invitation 2024-09-18 21:48:44 +02:00
Anselm Eickhoff
18b71442ca Merge pull request #394 from gardencmp/gudorsi-jazz-300
fix(BinaryCoStream): set the stream content message as low priority in the WS outgoing channel
2024-09-18 17:36:52 +01:00
Guido D'Orsi
f350e902a8 changeset 2024-09-18 18:35:39 +02:00
Guido D'Orsi
38a6447887 fix(priority): group detection and only set binary costream as low 2024-09-18 18:29:27 +02:00
Anselm Eickhoff
39da70558b Merge pull request #408 from gardencmp/trishalim-jazz-310
Separate chat code example to 2 tabs
2024-09-18 16:50:34 +01:00
Anselm Eickhoff
73f62966ed Merge pull request #405 from gardencmp/trishalim-jazz-309
Change mono font to Commit Mono
2024-09-18 16:47:35 +01:00
Trisha Lim
c41e035b67 Change mono font to Commit Mono 2024-09-18 16:43:05 +01:00
Trisha Lim
f4c466ea30 Change mono font to IBM Plex Mono 2024-09-18 16:43:05 +01:00
Trisha Lim
91e9a267f0 Match accent color with Jazz logo 2024-09-18 16:33:53 +01:00
Trisha Lim
d1ec24a05b Dark mode styling 2024-09-18 16:28:38 +01:00
Guido D'Orsi
657159e034 chore(PeerState): sort attributes declaration 2024-09-18 17:20:41 +02:00
Guido D'Orsi
0327913e53 chore: comments 2024-09-18 17:19:06 +02:00
Trisha Lim
5ad9d652be Fix build, rewrite ChatCodeExample to CodeExampleTabs 2024-09-18 16:16:02 +01:00
Guido D'Orsi
3cde94a94b chore: fix sync.test.ts type errors 2024-09-18 16:59:20 +02:00
Guido D'Orsi
a7f036c71f Merge remote-tracking branch 'origin/main' into gudorsi-jazz-300 2024-09-18 16:58:21 +02:00
Guido D'Orsi
20b47ad8af feat: simpler priority system and extract content priority from headers 2024-09-18 16:55:05 +02:00
Trisha Lim
6f2eac383d Styling code area 2024-09-18 15:43:27 +01:00
Trisha Lim
5d40e83297 Design tabs for chat code example 2024-09-18 14:19:57 +01:00
Anselm Eickhoff
7721ccffaf Merge pull request #403 from gardencmp/JAZZ-308/check-example-app-urls
fix(examples): updated URLs for example apps
2024-09-17 14:24:56 +01:00
pax-k
aa7edc8e3f fix(examples): updated URLs for example apps 2024-09-17 16:23:33 +03:00
Anselm Eickhoff
bba1728988 Merge pull request #399 from gardencmp/JAZZ-305/integrate-test-coverage
feat: added test coverage
2024-09-17 09:16:24 +01:00
Guido D'Orsi
f1dff3e3fd test: fix sync.test.ts tests 2024-09-16 23:47:48 +02:00
Guido D'Orsi
d5604b9eb5 chore: generalize PriorityBasedMessageQueue 2024-09-16 23:40:16 +02:00
Guido D'Orsi
3acf61e2c3 feat: move the priority management from the websocket peer to the peer state 2024-09-16 23:37:57 +02:00
Guido D'Orsi
3a0d25d048 feat: extract PeerState and refactor it into a class 2024-09-16 23:37:57 +02:00
Guido D'Orsi
28720043a4 chore(cojson-transport-ws): add tests and split code 2024-09-16 23:37:57 +02:00
Guido D'Orsi
79491353da fix: fixes errors in sync due the extra push param 2024-09-16 23:37:57 +02:00
Guido D'Orsi
35eb8829ea feat(cojson-transport-ws): interleaved weighed round robin 2024-09-16 23:37:57 +02:00
Guido D'Orsi
5cd38d5974 fix(BinaryCoStream): set the stream content message as low priority in the WS outgoing stream 2024-09-16 23:37:57 +02:00
pax-k
30f1fa1557 feat: added test coverage 2024-09-16 20:48:18 +03:00
Anselm Eickhoff
fe035c3b3f Merge pull request #392 from gardencmp/feature/BinaryCoStream-e2e-ci+
chore: run e2e/BinaryCoStream tests on CI
2024-09-16 16:31:02 +01:00
Guido D'Orsi
1efe8ee6c7 chore: run e2e/BinaryCoStream tests on CI 2024-09-12 18:36:15 +02:00
Guido D'Orsi
f410a8540b fix: in the BinaryCoStream e2e test retry failed subscribes 2024-09-12 18:35:37 +02:00
Anselm Eickhoff
acb6249292 Merge pull request #385 from gardencmp/feature/sync-duration-test 2024-09-11 09:46:01 +01:00
Guido D'Orsi
77c38a1c4b chore(ci): remove BinaryCoStream from the CI tests 2024-09-10 18:14:11 +02:00
Guido D'Orsi
6e1d56a9b0 feat: add more size choices and reduce the default to 1KB 2024-09-10 18:14:11 +02:00
Guido D'Orsi
d10a7b9c76 test: e2e tests for the BinaryCoStream sync 2024-09-10 18:14:11 +02:00
Anselm Eickhoff
209f295399 Merge pull request #388 from gardencmp/feature/auth-laoding 2024-09-09 18:39:10 +01:00
Guido D'Orsi
cac2ec9aa1 changeset 2024-09-09 19:06:43 +02:00
Guido D'Orsi
30a9e7a94f fix(jazz-react): mark the auth as loading when authState is not ready 2024-09-09 19:05:52 +02:00
Anselm Eickhoff
af5adc37ca Merge pull request #386 from gardencmp/fix/music-player-example 2024-09-09 11:51:04 +01:00
Guido D'Orsi
af12226998 fix(musicPlayer): move the upload of example data outside of the initial migration 2024-09-09 12:24:27 +02:00
Anselm Eickhoff
b8bf440f84 Merge pull request #384 from gardencmp/fix-lockfile 2024-09-06 17:31:58 +01:00
Guido D'Orsi
5198a249d1 fix: align deps 2024-09-06 16:51:46 +02:00
Guido D'Orsi
918322bc4a fix lockfile 2024-09-06 16:50:30 +02:00
Anselm Eickhoff
b66791d81a Merge pull request #383 from gardencmp/feature/monorepo-improvements
test(deepLoading): fix skipped test
2024-09-06 15:47:04 +01:00
Anselm Eickhoff
a2fbd9ed22 Merge pull request #334 from gardencmp/feature/music-player-example
feat(example): add a music player example
2024-09-06 15:34:17 +01:00
Guido D'Orsi
89111c5bf3 test(deepLoading): fix skipped test 2024-09-06 16:05:41 +02:00
Anselm Eickhoff
d4a358d0f9 Merge pull request #382 from gardencmp/feature/monorepo-improvements 2024-09-05 16:53:43 +01:00
Guido D'Orsi
daf24d65f6 chore: fix or skip failing tests 2024-09-05 17:44:50 +02:00
Guido D'Orsi
9517cef183 chore: fix monorepo linting action 2024-09-05 17:38:17 +02:00
Guido D'Orsi
f70950cc82 feat(ci): run unit tests on PR 2024-09-05 17:17:53 +02:00
Guido D'Orsi
ec3dffa3e4 feat: add sherif to check monorepo dependencies 2024-09-05 17:17:35 +02:00
Guido D'Orsi
207bea3c1c tiny typo 2024-09-04 23:55:33 +02:00
Guido D'Orsi
fcae20d77c feat(musicPlayer): walkthrough until 3_actions 2024-09-04 23:54:22 +02:00
Guido D'Orsi
0a542941ad feat: add docs on the schema file 2024-09-04 23:54:06 +02:00
Guido D'Orsi
5dceef8c03 fix: avoid adding duplicate playlists when accepting 2024-09-04 23:54:06 +02:00
Guido D'Orsi
fb01eb42f1 fix: remove the example audio auto-delete 2024-09-04 23:54:06 +02:00
Guido D'Orsi
e1e3a8352e docs: update the README.md and cleanup the changelog 2024-09-04 23:54:06 +02:00
Guido D'Orsi
09678c4277 chore: merge jazz and main 2024-09-04 23:54:06 +02:00
Guido D'Orsi
5b83669368 feat: other UI improvements 2024-09-04 23:54:06 +02:00
Guido D'Orsi
30c8c922a3 feat: improve the click areas and add the Space keybinding 2024-09-04 23:54:06 +02:00
Guido D'Orsi
e05b327eb5 feat: player controls, example audio and ui fixes 2024-09-04 23:54:06 +02:00
Guido D'Orsi
06665cbbf7 feat: improve the add to playlist section nand invitation flow 2024-09-04 23:54:06 +02:00
Guido D'Orsi
c0425df2ee fix: make the playlist invite work 2024-09-04 23:54:06 +02:00
Guido D'Orsi
42cbef8063 feat(music-player): implement playlist page and sharing 2024-09-04 23:54:06 +02:00
Guido D'Orsi
d1abf06621 feat: improve the playing stability and clean up the code 2024-09-04 23:54:06 +02:00
Guido D'Orsi
1c00020859 feat: implement the basic interactions with the player (upload and play) 2024-09-04 23:54:06 +02:00
Guido D'Orsi
00dd9cea99 feat: setup the boilerplate for the musicPlayer example 2024-09-04 23:54:04 +02:00
Anselm Eickhoff
607cd0ab97 Merge pull request #367 from gardencmp/gudorsi-jazz-141 2024-09-04 21:06:05 +01:00
Anselm Eickhoff
9f65e56b21 Update forty-plants-kiss.md 2024-09-04 21:05:13 +01:00
Anselm Eickhoff
82c5d1bf5c Merge pull request #376 from gardencmp/JAZZ-295/implement-password-manager-demo 2024-09-04 16:49:49 +01:00
pax-k
deda3e571f fix(password-manager): tsconfig 2024-09-04 18:03:43 +03:00
Anselm Eickhoff
2d4039c5d2 Merge pull request #378 from gardencmp/JAZZ-296/data-in-inspector-flashes-into-view-then-disappears
fix(inspector): subscribe to latent covalues instead of loading them immediately
2024-09-04 14:24:52 +01:00
pax-k
63dd9190a2 chore: format 2024-09-04 16:16:33 +03:00
pax-k
3dfa630923 chore: cleanup 2024-09-04 16:08:08 +03:00
pax-k
63daf6af09 chore: changeset 2024-09-04 16:00:42 +03:00
pax-k
c85ccd96aa fix(inspector): subscribe to latent covalues instead of loading them immediately 2024-09-04 15:58:48 +03:00
Guido D'Orsi
403b430ee4 chore: changeset 2024-09-03 18:34:04 +02:00
pax-k
f58845ef74 fix(password-manager): correct main script reference 2024-09-03 17:19:23 +03:00
pax-k
38fad35945 chore: password manager demo cleanup 2024-09-03 15:58:40 +03:00
pax-k
2533740a7c feat: password manager demo (wip) 2024-09-03 13:21:20 +03:00
Guido D'Orsi
109a7d6128 chore: use Effect.never to keep the server up, remove LocalNode comment 2024-09-03 11:45:10 +02:00
Anselm Eickhoff
ec1906e262 Merge pull request #368 from gardencmp/JAZZ-278/investigate-bug-when-assigning-null-values-to-corefs-in-applydiff 2024-09-03 09:24:46 +01:00
pax-k
6f8028253d chore: changeset 2024-09-02 17:50:54 +03:00
pax-k
0704a76006 fix: handle null values for co.refs 2024-09-02 16:49:53 +03:00
Anselm Eickhoff
ab93a0b679 Fix #353 2024-09-02 12:11:24 +01:00
Guido D'Orsi
ed85560547 feat: rename command and add persistence 2024-09-02 11:36:52 +02:00
Guido D'Orsi
a242adf3b3 docs: update cojson-simple-sync command 2024-09-01 17:12:53 +02:00
Guido D'Orsi
1a9c7ecefd feat(jazz-run): add a command to spin a local sync server 2024-09-01 17:10:29 +02:00
Anselm Eickhoff
aded528a27 Merge pull request #357 from gardencmp/gudorsi-jazz-281 2024-08-31 17:50:00 +01:00
Guido D'Orsi
152ae9865e chore(ci): debug error with artifacts 2024-08-31 18:21:24 +02:00
Guido D'Orsi
8bd92b44f5 chore(ci): increase expect timeout to 15000 on CI 2024-08-31 17:06:11 +02:00
Guido D'Orsi
8d15e04fd0 chore(ci): fix Install Playwright Browsers 2024-08-30 19:41:07 +02:00
Guido D'Orsi
b7f3ece0f1 chore: fix action indentation 2024-08-30 19:38:32 +02:00
Guido D'Orsi
4c94ae3da2 test(pets): test incoming reaction 2024-08-30 19:34:14 +02:00
Guido D'Orsi
cd23ce0a51 feat: github action for playwright tests 2024-08-30 19:19:07 +02:00
Guido D'Orsi
8538036af7 test(pets): use pom 2024-08-30 19:13:35 +02:00
Anselm
6ee7133924 Homepage mostly extracted 2024-08-30 16:23:28 +01:00
Anselm
4fa19ece52 Pull nav into design system 2024-08-29 19:22:21 +01:00
Anselm
875ac4b84f An attempt to pull out copy and make the landign page tsx again 2024-08-29 19:09:53 +01:00
Anselm
346e797447 Pull out more stuff 2024-08-29 18:51:17 +01:00
Anselm
7f6d778301 First extraction 🏗️ 2024-08-29 11:20:17 +01:00
Guido D'Orsi
40287a5682 feat(pets): add an e2e test suite 2024-08-29 02:26:28 +02:00
Anselm
bcfe4b794e Basic connection to design system 2024-08-28 19:03:51 +01:00
Anselm
7a27193ceb Include design system in homepage pnpm workspace 2024-08-28 18:36:36 +01:00
Anselm
6db14cefa0 Merge remote-tracking branch 'sammii-design-system/main' into anselm-jazz-284 2024-08-28 18:33:32 +01:00
Anselm
b57e1a64ac Move into subfolders in preparation of move into monorepo 2024-08-28 18:29:38 +01:00
Sammii
a1b83cceea whipping up a design system 2024-08-28 17:59:39 +01:00
Sammii
cd29438304 create next app 2024-08-28 15:01:15 +01:00
Samantha Haylock
7695a817ca first commit 2024-08-27 10:41:32 +01:00
Anselm Eickhoff
2ddce1a2de Merge pull request #327 from gardencmp/gudorsi-jazz-254 2024-08-22 19:36:30 +01:00
Guido D'Orsi
49a8b54a8a Add changesets 2024-08-22 20:33:51 +02:00
Anselm Eickhoff
04b15c7d4a Merge pull request #345 from gardencmp/gudorsi-jazz-261 2024-08-22 18:37:37 +01:00
Guido D'Orsi
5909e7e894 fix(pets): set the ownership of reactions to the write group 2024-08-22 19:25:30 +02:00
Anselm Eickhoff
7a3d519970 Merge pull request #337 from gardencmp/feat/jazz-255-loadasblob
fix(BinaryCoStream): resolve the loadAsBlob call only when the stream id ended
2024-08-22 15:00:13 +01:00
Anselm
35bbcd94e6 Add changesets 2024-08-22 14:59:40 +01:00
Anselm Eickhoff
f6629b2b58 Merge pull request #336 from gardencmp/anselm-jazz-264
Adopt neverthrow throughout cojson
2024-08-22 14:34:45 +01:00
Anselm
b4891db9fa Release 2024-08-22 14:31:02 +01:00
Anselm
4e16575f97 Hotifx release 2024-08-22 14:29:31 +01:00
Anselm
12ab20ecd9 Use queueable fork 2024-08-22 14:29:03 +01:00
Anselm
2c3a40c94b Hotfix release 2024-08-22 14:06:35 +01:00
Anselm
0d8175ba1c Use fork of queueable 2024-08-22 14:06:08 +01:00
Anselm Eickhoff
311ed74709 Delete oldExamples directory 2024-08-22 13:45:57 +01:00
Anselm Eickhoff
c86301fcfd Delete oldPackages/jazz-browser-auth0 directory 2024-08-22 13:45:47 +01:00
Anselm
032f69f692 Fix jazz-run deps even more 2024-08-22 12:49:34 +01:00
Anselm
6dc52b2a6d Fix jazz-run dependencies 2024-08-22 12:47:35 +01:00
Anselm
1232c0240a Hotifx release 2024-08-22 12:03:33 +01:00
Anselm
55fa74f44a Hack to make jazz-run create account work 2024-08-22 12:02:50 +01:00
Guido D'Orsi
627e04151d fix(CoMapInit): type validation on falsy values for required refs 2024-08-22 02:35:58 +02:00
Anselm
5d91f9f8dc Hotifx release 2024-08-21 16:20:55 +01:00
Anselm
08f1f77834 Stop using tryAddTransactionsAsync 2024-08-21 16:20:35 +01:00
Anselm
ea882aba63 Hotfix release 2024-08-21 15:59:16 +01:00
Anselm
513a78ab9b Better logging for failed transactions 2024-08-21 15:58:38 +01:00
Anselm
406ab9b0da Hotfix release 2024-08-21 15:24:27 +01:00
Anselm
140f6616cb Remove WS buffer filling log message 2024-08-21 15:23:41 +01:00
Anselm
b09589b15e Hotfix release 2024-08-21 11:39:18 +01:00
Anselm
00638897f4 only one async transaction per covalue again 2024-08-21 11:36:47 +01:00
Anselm Eickhoff
4cd68c1930 minimal inspector readme 2024-08-21 11:20:39 +01:00
Guido D'Orsi
f1f3607e28 test(BinaryCoStream): add tests for loadAsBlob 2024-08-20 19:46:13 +02:00
Guido D'Orsi
7b9f503b9e test(coStream): add tests for isBinaryStreamEnded 2024-08-20 19:35:11 +02:00
Guido D'Orsi
a18f44399c fix(BinaryCoStream): resolve the loadAsBlob call only when the stream is ended 2024-08-20 19:20:36 +02:00
Anselm
5094e6d536 Hotfix release 2024-08-20 18:03:01 +01:00
Anselm
39242e7f68 Start introducing neverthrow, make tryAddNewTransactionsAsync and handleNewContent less throwy 2024-08-20 18:02:26 +01:00
Anselm Eickhoff
be7e208b1c Merge pull request #333 from gardencmp/anselm-jazz-265
Make sure errors in storage peer completely crash jazz context instead of failing silently
2024-08-20 14:09:19 +01:00
Anselm
c3bffbf4de Release 2024-08-20 14:02:53 +01:00
Anselm
d46467f318 Hotfix release 2024-08-20 12:58:09 +01:00
Anselm
6d21400803 Get rid of simulated errors 2024-08-20 12:57:50 +01:00
Anselm
db53161296 Hotfix release 2024-08-20 12:46:15 +01:00
Anselm
cb4a116cec Make simulated errors even more likely 2024-08-20 12:45:34 +01:00
Anselm
013199b9b2 Increase chance of simulated storage bug 2024-08-20 11:43:28 +01:00
Anselm
a8b74ff703 Hotfix release 2024-08-20 11:43:20 +01:00
Anselm
b1985a9161 Throw properly on peer that should crash on close 2024-08-20 11:42:53 +01:00
Anselm
3bf512719f Hotfix release 2024-08-20 11:22:44 +01:00
Anselm
d83ed69d41 Allow crashing whole local node on peer errors 2024-08-20 11:20:20 +01:00
Anselm
fdde8db664 Hotfix release 2024-08-19 17:19:40 +01:00
Anselm
dd5581ba2d Handle ws closing while buffering 2024-08-19 17:19:01 +01:00
Anselm
07fe2b9dcf Release 2024-08-19 16:45:35 +01:00
Anselm
b297c93b80 Release fixes 2024-08-19 15:08:08 +01:00
Anselm
d2e62e5b44 Reduce log level on loading message 2024-08-19 14:50:26 +01:00
Anselm
fe73ce7514 Make failed transaction log message leaner 2024-08-19 14:48:57 +01:00
Anselm Eickhoff
0fed16cea4 Merge pull request #323 from gardencmp/callumflack-jazz-240
fix: chat example URL
2024-08-19 11:15:39 +01:00
Callum Flack
08804ad435 fix: chat example URL 2024-08-19 11:03:33 +10:00
Anselm
79fa7724ad Release 2024-08-15 19:35:11 +01:00
Anselm
4604c2184a Adapt type of applyDiff to make CoMaps fully subclassable again 2024-08-15 19:34:47 +01:00
Anselm
11bac697fb Release 2024-08-15 19:23:19 +01:00
Anselm
96cec27f89 Close both ends of the peer on gracefulShutdown 2024-08-15 19:22:59 +01:00
Anselm
bcd412b8f9 Properly close connecting websockets 2024-08-15 19:22:19 +01:00
Anselm
6b456e2841 Properly close and delete peer on incoming disconnect/timeout 2024-08-15 19:10:22 +01:00
Anselm
1df72b3dc8 Reintroduce nomad example deploy, but only on main 2024-08-15 19:08:42 +01:00
Anselm
402d692739 Don't deploy examples to Nomad anymore 2024-08-15 17:02:40 +01:00
Anselm
fad46b2fb5 Release 2024-08-15 16:37:47 +01:00
Anselm
0153c80cf2 Immediately resolve an already-open websocket 2024-08-15 16:37:23 +01:00
Anselm
4f75dc8d97 Release 2024-08-15 16:17:31 +01:00
Anselm
e2c79cccb5 Remove noisy log 2024-08-15 16:17:03 +01:00
Anselm
c14a0e05be Release 2024-08-15 15:30:22 +01:00
Anselm
016dd3a5dd Fix ignoring server peers 2024-08-15 15:30:00 +01:00
Anselm
5c4ca9103c Release 2024-08-15 13:36:33 +01:00
Anselm
b4aad92907 Option to not expect pings 2024-08-15 13:36:08 +01:00
Anselm
56d1e095a1 Release 2024-08-15 13:02:39 +01:00
Anselm Eickhoff
6dee9aae49 Merge pull request #281 from gardencmp/anselm-jazz-190
Remove effect from jazz
2024-08-15 11:51:53 +01:00
Anselm Eickhoff
a10bff981e Merge pull request #284 from gardencmp/anselm-jazz-227
Remove effect from peer communication
2024-08-15 11:51:10 +01:00
Anselm Eickhoff
e333f7884a Merge pull request #313 from gardencmp/anselm-jazz-246
Remove effect from jazz-tools and dependent packages
2024-08-15 11:50:44 +01:00
Anselm
8ea7bf237b Remove effect from storage implementation 2024-08-15 11:13:10 +01:00
Anselm
5e8409fa08 Remove rest of effect use 2024-08-14 17:51:10 +01:00
Anselm
23354c1767 Progress on removing effect 2024-08-14 15:24:20 +01:00
Anselm Eickhoff
0efb69d0db Merge pull request #312 from pax-k/JAZZ-252/make-sure-castas-preserves-subscriptionscope
fix: preserve subscriptionScope for castAs in CoList and CoMap
2024-08-14 14:51:54 +01:00
pax-k
0462c4e41b fix: preserve subscriptionScope for castAs in CoList and CoMap 2024-08-14 16:50:12 +03:00
Anselm
70a5673197 More progress 2024-08-13 12:25:15 +01:00
Anselm Eickhoff
9ec3203485 Merge pull request #285 from gdorsi/main
docs: fix the get started links position
2024-08-12 17:46:41 +01:00
Guido D'Orsi
1a46f9b2e1 docs: fix the get started links position 2024-08-10 16:17:18 +02:00
Anselm
77bb26a8d7 Only use queable in cojson, rework tests 2024-08-09 16:44:50 +01:00
Anselm
2a36dcf592 WIP switch to queueable 2024-08-09 13:59:26 +01:00
Anselm
fc2bcadbe2 Remove effect schema from jazz schema 2024-08-08 18:18:17 +01:00
Anselm
46b0cc1adb Release 2024-08-08 14:44:00 +01:00
Anselm Eickhoff
d75d1c6a3f Merge pull request #279 from pax-k/JAZZ-219/implement-applydiff-on-comap-to-only-update-changed-fields
feat: Implement applyDiff on CoMap to only update changed fields
2024-08-08 13:50:17 +01:00
pax-k
13b236aeed feat: Implement applyDiff on CoMap to only update changed fields 2024-08-08 11:03:08 +03:00
Anselm Eickhoff
1c0a61b0b2 Merge pull request #271 from pax-k/document-max-recommended-tx-size
chore: document MAX_RECOMMENDED_TX_SIZE
2024-08-07 16:32:07 +01:00
Anselm
ceb92438f4 Release 2024-08-07 14:23:03 +01:00
Anselm Eickhoff
9bdd62ed4c Merge pull request #244 from gardencmp/anselm-jazz-187
Remove effectful API for loading/subscribing
2024-08-07 14:20:42 +01:00
pax-k
3f5ef7e799 chore: formatting 2024-08-06 19:35:14 +03:00
pax-k
e7a573fa94 chore: document MAX_RECOMMENDED_TX_SIZE 2024-08-06 19:22:41 +03:00
Anselm Eickhoff
364060eaa7 Merge pull request #249 from Schniz/schniz/allow-optional-encoder-to-be-empty-upon-creation 2024-08-03 19:59:09 +01:00
Gal Schlezinger
a3ddc3d5e0 allow co.optional.encoded to be empty on creation 2024-08-03 21:53:13 +03:00
Anselm Eickhoff
185f747adb Merge pull request #248 from timolins/new-explorer-ux 2024-08-03 13:51:47 +01:00
Timo Lins
895d281088 Remove legacy inspector to resolve TS errors 2024-08-03 13:09:27 +02:00
Timo Lins
b44e4354f7 Merge branch 'new-explorer-ux' of https://github.com/timolins/jazz into new-explorer-ux 2024-08-03 12:59:18 +02:00
Timo Lins
3fcb0665ec Fix TS errors 2024-08-03 12:59:14 +02:00
Tobias Lins
be49d33ce5 Update co-stream-view.tsx 2024-08-03 12:24:30 +02:00
Timo Lins
c7dae1608b Add new entry point 2024-08-03 10:49:29 +02:00
Timo Lins
b020c5868b Move app to legacy app 2024-08-03 10:48:06 +02:00
Timo Lins
eae42d3afe Revert app.tsx changes 2024-08-03 10:47:51 +02:00
Anselm
a816e2436e Remove effectful API for loading/subscribing 2024-07-31 16:21:26 +01:00
Anselm
b09e35e372 release 2024-07-29 10:40:10 +01:00
Anselm
d2c8121c9c Fix storage option in jazz-react 2024-07-29 10:34:39 +01:00
Anselm
380bb88ffa Mostly complete OPFS implementation (single-tab only) 2024-07-29 10:33:18 +01:00
Timo Lins
e0e3726b3c Update title 2024-07-28 18:02:06 +02:00
Timo Lins
c2253a7979 Improve styling of blob page 2024-07-28 17:40:23 +02:00
Timo Lins
9d244226ec Redesign root page, add group support & costream pages
Co-authored-by: Tobias Lins <me@tobi.sh>
2024-07-28 17:26:34 +02:00
Timo Lins
71df5e3a59 Fix native browser controls 2024-07-28 14:53:26 +02:00
Timo Lins
3a738dad88 Persists path in url 2024-07-28 13:53:39 +02:00
Timo Lins
56d301cfde Refactor the new viewer 2024-07-27 23:16:37 +02:00
Timo Lins
5efec6d5ea Move to a table view 2024-07-27 20:58:55 +02:00
Timo Lins
32769b24f1 Add new inspector 2024-07-27 20:10:24 +02:00
Anselm
6ab53c263d Release 2024-07-26 17:23:02 +01:00
Anselm
e7f3e4e242 Increase disconnect timeout for now 2024-07-26 17:21:06 +01:00
Anselm Eickhoff
8bb5201647 Merge pull request #236 from timolins/patch-1
[Homepage] Make current year dynamic in footer
2024-07-22 15:55:16 +01:00
Timo Lins
a9fc94f53d Make current year dynamic in footer 2024-07-22 10:06:48 +02:00
Anselm Eickhoff
ca7c0510d1 Merge pull request #234 from Schniz/schniz/co-optional 2024-07-21 10:14:41 +01:00
Gal Schlezinger
1bf16f0859 add co.optional syntax 2024-07-21 09:10:44 +03:00
Anselm Eickhoff
21b503c188 Merge pull request #224 from datner/datner/give-it-a-try
cojson-transport-ws: reuse runtime and use fibers instead of setTimeout
2024-07-15 14:35:18 +01:00
Anselm
0053e9796c Release 2024-07-15 11:01:31 +01:00
Anselm
e84941b1b1 Fix another bug in CoMap 'has' proxy trap 2024-07-15 10:59:14 +01:00
Anselm
57f6f8d67e Release 2024-07-14 17:55:47 +01:00
Anselm
5b8e69d973 Fix bug in CoMap 'has' trap 2024-07-14 17:55:05 +01:00
Anselm
7213b1bfa3 Release 2024-07-13 13:32:50 +01:00
Anselm Eickhoff
11f0770f08 Merge pull request #230 from tobiaslins/add-support-for-in
Add support for property existence
2024-07-13 13:30:15 +01:00
Tobias Lins
44e6dc3ae8 Remove useless check 2024-07-13 13:57:37 +02:00
Tobias Lins
b5d20d2488 Better implementation 2024-07-13 13:26:28 +02:00
Tobias Lins
0185545838 Add support for property existence 2024-07-13 12:26:10 +02:00
Yuval Datner
8c8f85859c style: prettier 2024-07-12 15:42:52 +03:00
Yuval Datner
104384409e refactor: change to yieldable error 2024-07-12 15:42:27 +03:00
Yuval Datner
179827ae56 small refactor for readability 2024-07-12 15:13:25 +03:00
Yuval Datner
6645829876 do stream stuff 2024-07-12 15:13:23 +03:00
Gal Schlezinger
68cb302722 store jazzPings on global 2024-07-12 15:13:22 +03:00
Gal Schlezinger
8dc33f2790 fix bugs because I misindented things 2024-07-12 15:13:21 +03:00
Gal Schlezinger
5f64ba326c jazzPings 2024-07-12 15:13:20 +03:00
Gal Schlezinger
7ccb15107c cojson-transport-ws: reuse runtime and use fibers instead of setTimeout
when calling Effect.runFork we don't propagate layers
and using fibers can allow us to interrupt ongoing requests
when the pings fail
2024-07-12 15:13:16 +03:00
Anselm
b102964743 Get rid of husky for now 2024-07-12 11:56:54 +01:00
Anselm
216d50a09c Remove old homepage build 2024-07-12 11:55:44 +01:00
Anselm
07ea59fdcb Release 2024-07-12 11:14:15 +01:00
Anselm
932a84a47f Update to Effect 3.5.2 2024-07-12 11:13:41 +01:00
Anselm
34dda7bdbd Release 2024-07-05 11:18:53 +01:00
Anselm
49fa153581 Merge branch 'fix-fs' 2024-07-05 11:17:54 +01:00
Anselm
c80b827775 Release 2024-06-30 16:28:53 +01:00
Anselm Eickhoff
a2bf9f988a Merge pull request #218 from Schniz/fix-mutating-nullable-field 2024-06-30 08:31:57 +01:00
Gal Schlezinger
ac27b2d5c2 jazz-tools: allow to mutate nullable fields into null
when having a co.encoded(Schema.NullOr(Schema.String)), construction
with null works well, but mutating a value into null throws.
This commit fixes it and adds a test that verifies it actually works.
2024-06-30 09:49:15 +03:00
Anselm
c813518fdc Release 2024-06-28 16:27:15 +01:00
Anselm
d5034ed5c3 Provide current res in ProgressiveImg 2024-06-28 16:26:03 +01:00
Anselm
cf2c29a365 Use verce lanalytics & speed insights 2024-06-28 14:54:58 +01:00
Anselm
d948823db6 Add package manager to package.json 2024-06-28 12:08:23 +01:00
Anselm
060ad4630d Resurrect inspector 2024-06-27 16:54:23 +01:00
Anselm
0ddceac4c0 Fix FS bugs 2024-06-26 13:45:09 +01:00
Anselm
a862cb8819 Release 2024-06-25 15:13:55 +01:00
Anselm Eickhoff
4246aed7db Merge pull request #214 from gardencmp/effect-streams-for-peers
Effect streams for peers
2024-06-25 15:12:56 +01:00
Anselm
41554e0e0b Release 2024-06-25 13:57:24 +01:00
Anselm
93c4d8155e Fix CoList.toJSON() 2024-06-25 13:56:20 +01:00
Anselm
24eefd49f1 Release 2024-06-25 13:46:54 +01:00
Anselm
e712f1e8ef Fix circular toJSON bug #215 2024-06-25 13:46:14 +01:00
Anselm
33db0fd654 Keep old hompage lockfile (unrelated) 2024-06-13 22:17:00 +01:00
Anselm
478ded93de Merge branch 'main' into effect-streams-for-peers 2024-06-13 22:12:33 +01:00
Anselm
89ad1fb79d Fix remaining tests by introducing even more special cases 2024-06-13 22:11:25 +01:00
Anselm Eickhoff
1ba40806ec Merge pull request #213 from tobiaslins/fix-demo-auth-nextjs
Fix DemoAuth for Next.js
2024-06-13 17:43:11 +01:00
Tobias Lins
73ae281e4a Fix demoAuth for next.js 2024-06-13 18:41:29 +02:00
Anselm
a35353c987 Use Effect streams 2024-06-13 15:58:21 +01:00
Anselm
1cb91003cc Release 2024-06-12 15:12:00 +01:00
Anselm
d850022491 Fix #210 2024-06-12 15:10:22 +01:00
Anselm
93792ab6f6 Remove lofi conf badge 2024-06-06 12:02:31 +01:00
Anselm
95dfe7af6a Guide fix 2024-06-04 17:56:34 +01:00
Anselm
734258eb17 Release 2024-06-04 12:14:59 +01:00
Anselm
f3bcf96fad Also cache agent ID in RawControlledAccount 2024-06-04 12:13:54 +01:00
Anselm
5cf0bc1911 Release 2024-06-04 12:09:35 +01:00
Anselm
d32a6b275f Formatting 2024-06-04 12:09:16 +01:00
Anselm
6caba9f8e7 Cache currentAgentID in RawAccount 2024-06-04 12:06:13 +01:00
Anselm
641f1dbfbe Release 2024-06-03 17:33:59 +01:00
Anselm
58d9a104d6 Fix CoMaps not initialising properly when passing too many init options 2024-06-03 17:33:24 +01:00
Anselm
7b9d24c8ef Release 2024-06-01 17:25:06 +02:00
Anselm Eickhoff
4225fdd537 Merge pull request #200 from gdorsi/main
fix: improve compatibility with React compiler and concurrent features
2024-06-01 17:24:17 +02:00
Anselm
9fdc91c6de Add changeset 2024-06-01 17:23:18 +02:00
Anselm
93d8c85e5c Formatting 2024-06-01 17:22:38 +02:00
Anselm
929cddc3c3 Release 2024-06-01 17:13:02 +02:00
Anselm
e0bc63f016 Provide way to create accounts as another account 2024-06-01 17:12:42 +02:00
Anselm
b29ac306ea Release 2024-06-01 14:00:50 +02:00
Anselm
e8e883f4d6 Ability to add seed accounts to DemoAuth 2024-06-01 14:00:26 +02:00
Guido D'Orsi
3325ff1cd6 fix: make Jazz react compiler ready 2024-05-31 10:19:33 +02:00
Anselm
4fe14f03b4 Formatting fixes 2024-05-31 09:13:25 +02:00
Anselm
90e2a661e4 Very last fix 2024-05-31 09:00:56 +02:00
Anselm
6ed53ecb79 Last fixes 2024-05-31 08:59:41 +02:00
Anselm
c18775766c More fixes 2024-05-31 08:56:11 +02:00
Anselm
4bb3a6209a Doc fixes 2024-05-31 08:44:45 +02:00
Anselm
0f44a547a4 Lots more guide progress 2024-05-28 17:10:59 +01:00
429 changed files with 26248 additions and 17280 deletions

View File

@@ -12,7 +12,7 @@
"jazz-react",
"jazz-nodejs",
"jazz-run",
"cojson-transport-nodejs-ws",
"cojson-transport-ws",
"cojson-storage-indexeddb",
"cojson-storage-sqlite"
]

View File

@@ -3,15 +3,13 @@ name: Build and Deploy
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build-examples:
runs-on: ubuntu-latest
strategy:
matrix:
example: ["chat", "pets", "todo"]
example: ["chat", "pets", "todo", "inspector"]
# example: ["twit", "chat", "counter-js-auth0", "pets", "twit", "file-drop", "inspector"]
steps:
@@ -19,15 +17,32 @@ jobs:
with:
submodules: true
- uses: pnpm/action-setup@v2
with:
version: 8
- name: Enable corepack
run: corepack enable
- uses: actions/setup-node@v3
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: 16
node-version-file: '.node-version'
cache: 'pnpm'
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v3
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
@@ -53,59 +68,12 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
build-homepage:
runs-on: ubuntu-latest
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: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: gardencmp
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pnpm Install (root)
run: |
pnpm install
working-directory: .
- name: Pnpm Install & Build (homepage)
run: |
pnpm install
pnpm build;
working-directory: ./homepage/homepage
- name: Docker Build & Push
uses: docker/build-push-action@v4
with:
context: ./homepage
push: true
tags: ghcr.io/gardencmp/${{github.event.repository.name}}-homepage-jazz:${{github.head_ref || github.ref_name}}-${{github.sha}}-${{github.run_number}}-${{github.run_attempt}}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-examples:
runs-on: ubuntu-latest
needs: build-examples
strategy:
matrix:
example: ["chat", "pets", "todo"]
example: ["chat", "pets", "todo", "inspector"]
# example: ["twit", "chat", "counter-js-auth0", "pets", "twit", "file-drop", "inspector"]
steps:
@@ -135,37 +103,4 @@ jobs:
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN}' < job-template.nomad > job-instance.nomad;
cat job-instance.nomad;
NOMAD_ADDR=${{ secrets.NOMAD_ADDR }} nomad job run job-instance.nomad;
working-directory: ./examples/${{ matrix.example }}
deploy-homepage:
runs-on: ubuntu-latest
needs: build-homepage
steps:
- uses: actions/checkout@v3
with:
submodules: true
- uses: gacts/install-nomad@v1
- name: Tailscale
uses: tailscale/github-action@v1
with:
authkey: ${{ secrets.TAILSCALE_AUTHKEY }}
- name: Deploy on Nomad
run: |
if [ "${{github.ref_name}}" == "main" ]; then
export BRANCH_SUFFIX="";
export BRANCH_SUBDOMAIN="";
else
export BRANCH_SUFFIX=-${{github.head_ref || github.ref_name}};
export BRANCH_SUBDOMAIN=${{github.head_ref || github.ref_name}}.;
fi
export DOCKER_USER=gardencmp;
export DOCKER_PASSWORD=${{ secrets.DOCKER_PULL_PAT }};
export DOCKER_TAG=ghcr.io/gardencmp/${{github.event.repository.name}}-homepage-jazz:${{github.head_ref || github.ref_name}}-${{github.sha}}-${{github.run_number}}-${{github.run_attempt}};
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN}' < job-template.nomad > job-instance.nomad;
cat job-instance.nomad;
NOMAD_ADDR=${{ secrets.NOMAD_ADDR }} nomad job run job-instance.nomad;
working-directory: ./homepage
working-directory: ./examples/${{ matrix.example }}

21
.github/workflows/monorepo-linting.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: Monorepo linting
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
monorepo-linting:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version-file: '.node-version'
- name: Run sherif
run: npx sherif@1.0.0

64
.github/workflows/playwright.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: Playwright Tests
on:
push:
branches: ["main"]
pull_request:
types: [opened, synchronize, reopened]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
strategy:
matrix:
project: ["e2e/BinaryCoStream", "examples/pets"]
steps:
- uses: actions/checkout@v3
with:
submodules: true
- name: Enable corepack
run: corepack enable
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version-file: '.node-version'
cache: 'pnpm'
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v3
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Pnpm Build
run: pnpm turbo build;
working-directory: ./${{ matrix.project }}
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
working-directory: ./${{ matrix.project }}
- name: Run Playwright tests
run: pnpm exec playwright test
working-directory: ./${{ matrix.project }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: ${{ hashFiles(format('{0}/package.json', matrix.project)) }}-playwright-report
path: ./${{ matrix.project }}/playwright-report/
retention-days: 30

47
.github/workflows/unit-test.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Unit Tests
on:
pull_request:
types: [opened, synchronize, reopened]
push:
branches:
- 'main'
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Enable corepack
run: corepack enable
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version-file: '.node-version'
cache: 'pnpm'
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v3
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Pnpm Build
run: pnpm turbo build;
- name: Unit Tests
run: pnpm test

3
.gitignore vendored
View File

@@ -3,4 +3,5 @@ yarn-error.log
lerna-debug.log
docsTmp
.DS_Store
.turbo
.turbo
coverage

View File

@@ -1,2 +0,0 @@
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"

1
.node-version Normal file
View File

@@ -0,0 +1 @@
20

View File

@@ -23,5 +23,8 @@ dist-ssr
*.sln
*.sw?
.env
TwitAllTwitsCreatorCredentials.json
sync-db/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@@ -0,0 +1,34 @@
{
"name": "@jazz-e2e/binarycostream",
"private": true,
"version": "0.0.81",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "playwright test",
"test:ui": "playwright test --ui"
},
"lint-staged": {
"*.{js,jsx,mdx,json}": "prettier --write"
},
"dependencies": {
"cojson": "workspace:*",
"hash-slash": "workspace:*",
"is-ci": "^3.0.1",
"jazz-react": "workspace:*",
"jazz-tools": "workspace:*",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@playwright/test": "^1.46.1",
"@types/node": "^22.5.1",
"@types/react": "^18.2.19",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react-swc": "^3.3.2",
"typescript": "^5.3.3",
"vite": "^5.0.10"
}
}

View File

@@ -0,0 +1,49 @@
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",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
/* Run your local dev server before starting the tests */
webServer: isCI ? {
command: "pnpm preview",
url: "http://localhost:4173/",
} : undefined,
});

View File

@@ -0,0 +1,42 @@
import { Account, BinaryCoStream, ID } from "jazz-tools";
import { useEffect } from "react";
import { useAccount, useCoState } from "./jazz";
import { UploadedFile } from "./schema";
import { waitForCoValue } from "./lib/waitForCoValue";
async function getUploadedFile(
me: Account,
uploadedFileId: ID<UploadedFile>) {
const uploadedFile = await waitForCoValue(UploadedFile, uploadedFileId, me, Boolean, {})
uploadedFile.coMapDownloaded = true;
await BinaryCoStream.loadAsBlob(uploadedFile._refs.file.id, me);
return uploadedFile;
}
export function DownloaderPeer(props: { testCoMapId: ID<UploadedFile> }) {
const account = useAccount();
const testCoMap = useCoState(UploadedFile, props.testCoMapId, {});
useEffect(() => {
getUploadedFile(account.me, props.testCoMapId).then(value => {
value.syncCompleted = true;
});
}, []);
return (
<>
<h1>Downloader Peer</h1>
<div>Fetching: {props.testCoMapId}</div>
<div data-testid="result">
Covalue: {Boolean(testCoMap?.id) ? "Downloaded" : "Not Downloaded"}
</div>
<div data-testid="result">
File:{" "}
{Boolean(testCoMap?.syncCompleted) ? "Downloaded" : "Not Downloaded"}
</div>
</>
);
}

View File

@@ -0,0 +1,86 @@
import { ID } from "jazz-tools";
import { useEffect, useState } from "react";
import { useAccount, useCoState } from "./jazz";
import { createCredentiallessIframe } from "./lib/createCredentiallessIframe";
import { generateTestFile } from "./lib/generateTestFile";
import { getDownloaderPeerUrl } from "./lib/getDownloaderPeerUrl";
import { UploadedFile } from "./schema";
import { waitForCoValue } from "./lib/waitForCoValue";
import { getDefaultFileSize, getIsAutoUpload } from "./lib/searchParams";
import { BytesRadioGroup } from "./lib/BytesRadioGroup";
export function UploaderPeer() {
const account = useAccount();
const [uploadedFileId, setUploadedFileId] = useState<
ID<UploadedFile> | undefined
>(undefined);
const [syncDuration, setSyncDuration] = useState<number | null>(null);
const [bytes, setBytes] = useState(getDefaultFileSize);
const testFile = useCoState(UploadedFile, uploadedFileId, {});
async function uploadTestFile() {
if (!account) return;
// Mark the sync start
performance.mark("sync-start");
const file = await generateTestFile(account.me, bytes);
// Create a credential-less iframe to spawn the downloader peer
const iframe = createCredentiallessIframe(getDownloaderPeerUrl(file));
document.body.appendChild(iframe);
setSyncDuration(null);
setUploadedFileId(file.id);
// The downloader peer will set the syncCompleted to true when the download is complete.
// We use this to measure the sync duration.
await waitForCoValue(
UploadedFile,
file.id,
account.me,
(value) => value.syncCompleted,
{}
);
iframe.remove();
// Calculate the sync duration
performance.mark("sync-end");
const measure = performance.measure("sync", "sync-start", "sync-end");
setSyncDuration(measure.duration);
}
useEffect(() => {
if (getIsAutoUpload()) {
uploadTestFile();
}
}, []);
return (
<>
<BytesRadioGroup selectedValue={bytes} onChange={setBytes} />
<button onClick={uploadTestFile}>Upload Test File</button>
{uploadedFileId && <div>{uploadedFileId}</div>}
{syncDuration && (
<div data-testid="sync-duration">
Sync Duration: {syncDuration.toFixed(2)}ms
</div>
)}
{uploadedFileId && (
<div data-testid="result">
Sync Completed: {String(Boolean(syncDuration))}
</div>
)}
{testFile?.coMapDownloaded && (
<div data-testid="co-map-downloaded">
CoMap synced!
</div>
)}
</>
);
}

View File

@@ -0,0 +1,24 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { DownloaderPeer } from "./DownloaderPeer";
import { Jazz } from "./jazz";
import { UploaderPeer } from "./UploaderPeer";
import { getValueId } from "./lib/searchParams";
function Main() {
const valueId = getValueId();
if (valueId) {
return <DownloaderPeer testCoMapId={valueId} />;
}
return <UploaderPeer />;
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Jazz.Provider>
<Main />
</Jazz.Provider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,38 @@
import { createJazzReactContext, DemoAuth } from "jazz-react";
import { useEffect } from "react";
import { getValueId } from "./lib/searchParams";
function AutoLoginComponent(props: {
appName: string;
loading: boolean;
existingUsers: string[];
logInAs: (existingUser: string) => void;
signUp: (username: string) => void;
}) {
useEffect(() => {
if (props.loading) return;
props.signUp("Test User");
}, [props.loading]);
return <div>Signing up...</div>;
}
const key = getValueId()
? `downloader-e2e@jazz.tools`
: `uploader-e2e@jazz.tools`;
const localSync = new URLSearchParams(location.search).has("localSync");
const Jazz = createJazzReactContext({
auth: DemoAuth({
appName: "BinaryCoStream Sync",
Component: AutoLoginComponent,
}),
peer: localSync
? `ws://localhost:4200?key=${key}`
: `wss://mesh.jazz.tools/?key=${key}`,
});
export const { useAccount, useCoState } = Jazz;
export { Jazz };

View File

@@ -0,0 +1,67 @@
export function BytesRadioGroup(props: {
selectedValue: number;
onChange: (value: number) => void;
}) {
return (
<p>
<BytesRadioInput
label="1KB"
value={1e3}
selectedValue={props.selectedValue}
onChange={props.onChange} />
<BytesRadioInput
label="10KB"
value={1e4}
selectedValue={props.selectedValue}
onChange={props.onChange} />
<BytesRadioInput
label="100KB"
value={1e5}
selectedValue={props.selectedValue}
onChange={props.onChange} />
<BytesRadioInput
label="150KB"
value={1e5 + 5e4}
selectedValue={props.selectedValue}
onChange={props.onChange} />
<BytesRadioInput
label="200KB"
value={2e6}
selectedValue={props.selectedValue}
onChange={props.onChange} />
<BytesRadioInput
label="500KB"
value={5e6}
selectedValue={props.selectedValue}
onChange={props.onChange} />
<BytesRadioInput
label="1MB"
value={1e6}
selectedValue={props.selectedValue}
onChange={props.onChange} />
<BytesRadioInput
label="10MB"
value={1e7}
selectedValue={props.selectedValue}
onChange={props.onChange} />
</p>
);
}
function BytesRadioInput(props: {
label: string;
value: number;
selectedValue: number;
onChange: (value: number) => void;
}) {
return (
<label>
<input
type="radio"
name="bytes"
value={props.value}
checked={props.value === props.selectedValue}
onChange={() => props.onChange(props.value)} />
{props.label}
</label>
);
}

View File

@@ -0,0 +1,21 @@
/**
* Creates a credentialess iframe that can be used to test the sync
* in an isolated environment. (no storage sharing)
*
* see: https://developer.mozilla.org/en-US/docs/Web/Security/IFrame_credentialless
*/
export function createCredentiallessIframe(url: string) {
const iframe = document.createElement("iframe");
// @ts-ignore
iframe.credentialless = true;
iframe.src = url;
iframe.style.width = "300px";
iframe.style.height = "300px";
iframe.style.border = "1px solid black";
iframe.style.position = "absolute";
iframe.style.top = "0";
iframe.style.right = "0";
iframe.setAttribute("data-testid", "downloader-peer");
return iframe;
}

View File

@@ -0,0 +1,27 @@
import { Account, Group, BinaryCoStream } from "jazz-tools";
import { UploadedFile } from "../schema";
export async function generateTestFile(me: Account, bytes: number) {
const group = Group.create({ owner: me });
group.addMember("everyone", "writer");
const ownership = { owner: group };
const testFile = UploadedFile.create(
{
file: await BinaryCoStream.createFromBlob(
new Blob(['1'.repeat(bytes)], { type: 'image/png' }),
ownership
),
syncCompleted: false,
coMapDownloaded: false,
},
ownership
);
const url = new URL(window.location.href);
url.searchParams.set("valueId", testFile.id);
return testFile;
}

View File

@@ -0,0 +1,8 @@
import { UploadedFile } from "src/schema";
export function getDownloaderPeerUrl(value: UploadedFile) {
const url = new URL(window.location.href);
url.searchParams.set("valueId", value.id);
return url.toString();
}

View File

@@ -0,0 +1,14 @@
import { ID } from "jazz-tools";
import { UploadedFile } from "../schema";
export function getValueId() {
return new URLSearchParams(location.search).get("valueId") as ID<UploadedFile> | undefined ?? undefined;
}
export function getIsAutoUpload() {
return new URLSearchParams(location.search).has("auto");
}
export function getDefaultFileSize() {
return parseInt(new URLSearchParams(location.search).get("fileSize") ?? 1e3.toString());
}

View File

@@ -0,0 +1,39 @@
import {
Account,
CoValue,
CoValueClass,
DepthsIn,
ID,
subscribeToCoValue,
} from "jazz-tools";
export function waitForCoValue<T extends CoValue>(
coMap: CoValueClass<T>,
valueId: ID<T>,
account: Account,
predicate: (value: T) => boolean,
depth: DepthsIn<T>
) {
return new Promise<T>((resolve) => {
function subscribe() {
const unsubscribe = subscribeToCoValue(
coMap,
valueId,
account,
depth,
(value) => {
if (predicate(value)) {
resolve(value);
unsubscribe();
}
},
() => {
unsubscribe();
setTimeout(subscribe, 100);
}
);
}
subscribe();
});
}

View File

@@ -0,0 +1,7 @@
import { BinaryCoStream, co, CoMap } from "jazz-tools";
export class UploadedFile extends CoMap {
file = co.ref(BinaryCoStream);
syncCompleted = co.boolean;
coMapDownloaded = co.boolean;
}

View File

@@ -0,0 +1,36 @@
import { test, expect } from "@playwright/test";
import { setTimeout } from "node:timers/promises";
test.describe("BinaryCoStream - Sync", () => {
test("should sync a file between the two peers", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Upload Test File" }).click();
await page.getByTestId("sync-duration").waitFor();
await expect(page.getByTestId("result")).toHaveText("Sync Completed: true");
});
test("should handle reconnections", async ({ page, browser }) => {
const context = browser.contexts()[0];
await page.goto("/?fileSize=" + 1e6); // 1MB file
await page.getByRole("button", { name: "Upload Test File" }).click();
// Wait for the coMapDonwloaded signal to ensure that the iframe is loaded
await page.getByTestId("co-map-downloaded").waitFor();
await context.setOffline(true);
// Wait for the ping system to detect the offline state
await setTimeout(10000);
await context.setOffline(false);
// Wait for the sync to complete
await page.getByTestId("sync-duration").waitFor();
await expect(page.getByTestId("result")).toHaveText("Sync Completed: true");
});
});

View File

@@ -19,11 +19,7 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
"baseUrl": "."
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -1,15 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import path from "path";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
minify: false
}

View File

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

View File

@@ -1,5 +1,379 @@
# jazz-example-chat
## 0.0.82
### Patch Changes
- Updated dependencies [49a8b54]
- Updated dependencies [35bbcd9]
- Updated dependencies [6f80282]
- Updated dependencies [35bbcd9]
- Updated dependencies [cac2ec9]
- Updated dependencies [f350e90]
- jazz-tools@0.7.35
- cojson@0.7.35
- jazz-react@0.7.35
## 0.0.81
### Patch Changes
- Updated dependencies [5d91f9f]
- Updated dependencies [5094e6d]
- Updated dependencies [b09589b]
- Updated dependencies [2c3a40c]
- Updated dependencies [4e16575]
- Updated dependencies [ea882ab]
- cojson@0.7.34
- jazz-react@0.7.34
- jazz-tools@0.7.34
## 0.0.81-neverthrow.6
### Patch Changes
- Updated dependencies
- cojson@0.7.34-neverthrow.8
- jazz-react@0.7.34-neverthrow.8
- jazz-tools@0.7.34-neverthrow.8
## 0.0.81-neverthrow.5
### Patch Changes
- Updated dependencies
- cojson@0.7.34-neverthrow.7
- jazz-react@0.7.34-neverthrow.7
- jazz-tools@0.7.34-neverthrow.7
## 0.0.81-neverthrow.4
### Patch Changes
- Updated dependencies
- cojson@0.7.34-neverthrow.4
- jazz-react@0.7.34-neverthrow.4
- jazz-tools@0.7.34-neverthrow.4
## 0.0.81-neverthrow.3
### Patch Changes
- Updated dependencies
- cojson@0.7.34-neverthrow.3
- jazz-react@0.7.34-neverthrow.3
- jazz-tools@0.7.34-neverthrow.3
## 0.0.81-neverthrow.2
### Patch Changes
- jazz-react@0.7.34-neverthrow.2
## 0.0.81-neverthrow.1
### Patch Changes
- Updated dependencies
- cojson@0.7.34-neverthrow.1
- jazz-react@0.7.34-neverthrow.1
- jazz-tools@0.7.34-neverthrow.1
## 0.0.81-neverthrow.0
### Patch Changes
- Updated dependencies
- cojson@0.7.34-neverthrow.0
- jazz-react@0.7.34-neverthrow.0
- jazz-tools@0.7.34-neverthrow.0
## 0.0.80
### Patch Changes
- Updated dependencies [b297c93]
- Updated dependencies [3bf5127]
- Updated dependencies [a8b74ff]
- Updated dependencies [db53161]
- cojson@0.7.33
- jazz-react@0.7.33
- jazz-tools@0.7.33
## 0.0.80-hotfixes.5
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.5
- jazz-react@0.7.33-hotfixes.5
- jazz-tools@0.7.33-hotfixes.5
## 0.0.80-hotfixes.4
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.4
- jazz-react@0.7.33-hotfixes.4
- jazz-tools@0.7.33-hotfixes.4
## 0.0.80-hotfixes.3
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.3
- jazz-react@0.7.33-hotfixes.3
- jazz-tools@0.7.33-hotfixes.3
## 0.0.80-hotfixes.2
### Patch Changes
- jazz-react@0.7.33-hotfixes.2
## 0.0.80-hotfixes.1
### Patch Changes
- jazz-react@0.7.33-hotfixes.1
## 0.0.80-hotfixes.0
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.0
- jazz-react@0.7.33-hotfixes.0
- jazz-tools@0.7.33-hotfixes.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

View File

@@ -1,12 +1,13 @@
# Jazz Chat Example
Live version: https://example-chat.jazz.tools
Live version: [https://chat.jazz.tools](https://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/chat
@@ -34,9 +35,8 @@ 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 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).
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/?peer=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.51",
"version": "0.0.82",
"type": "module",
"scripts": {
"dev": "vite",
@@ -18,13 +18,12 @@
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.4",
"@types/qrcode": "^1.5.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"hash-slash": "workspace:*",
"jazz-tools": "workspace:*",
"jazz-react": "workspace:*",
"cojson": "workspace:*",
"hash-slash": "workspace:*",
"jazz-react": "workspace:*",
"jazz-tools": "workspace:*",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",
"react": "^18.2.0",
@@ -37,18 +36,19 @@
"uniqolor": "^1.1.0"
},
"devDependencies": {
"@types/react": "^18.2.15",
"@types/qrcode": "^1.5.1",
"@types/react": "^18.2.19",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.2.1",
"@typescript-eslint/parser": "^6.2.1",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.14",
"eslint": "^8.45.0",
"eslint": "^8.46.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",
"typescript": "^5.3.3",
"vite": "^5.0.10"
}
}

View File

@@ -3,6 +3,7 @@ import { createJazzReactContext, 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;
@@ -39,4 +40,4 @@ function App() {
}
createRoot(document.getElementById("root")!)
.render(<Jazz.Provider><App/></Jazz.Provider>);
.render(<StrictMode><Jazz.Provider><App/></Jazz.Provider></StrictMode>);

View File

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

View File

@@ -0,0 +1,234 @@
# jazz-example-chat
## 0.0.60
### Patch Changes
- 63daf6a: fix(inspector): subscribe to latent covalues instead of loading them immediately
- Updated dependencies [35bbcd9]
- Updated dependencies [f350e90]
- cojson@0.7.35
- cojson-transport-ws@0.7.35
## 0.0.59
### Patch Changes
- Updated dependencies [5d91f9f]
- Updated dependencies [5094e6d]
- Updated dependencies [b09589b]
- Updated dependencies [2c3a40c]
- Updated dependencies [406ab9b]
- Updated dependencies [4e16575]
- Updated dependencies [ea882ab]
- cojson@0.7.34
- cojson-transport-ws@0.7.34
## 0.0.59-neverthrow.6
### Patch Changes
- Updated dependencies
- cojson@0.7.34-neverthrow.8
- cojson-transport-ws@0.7.34-neverthrow.8
## 0.0.59-neverthrow.5
### Patch Changes
- Updated dependencies
- cojson@0.7.34-neverthrow.7
- cojson-transport-ws@0.7.34-neverthrow.7
## 0.0.59-neverthrow.4
### Patch Changes
- Updated dependencies
- cojson@0.7.34-neverthrow.4
- cojson-transport-ws@0.7.34-neverthrow.4
## 0.0.59-neverthrow.3
### Patch Changes
- Updated dependencies
- cojson@0.7.34-neverthrow.3
- cojson-transport-ws@0.7.34-neverthrow.3
## 0.0.59-neverthrow.2
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.34-neverthrow.2
## 0.0.59-neverthrow.1
### Patch Changes
- Updated dependencies
- cojson@0.7.34-neverthrow.1
- cojson-transport-ws@0.7.34-neverthrow.1
## 0.0.59-neverthrow.0
### Patch Changes
- Updated dependencies
- cojson@0.7.34-neverthrow.0
- cojson-transport-ws@0.7.34-neverthrow.0
## 0.0.58
### Patch Changes
- Updated dependencies [fdde8db]
- Updated dependencies [b297c93]
- Updated dependencies [07fe2b9]
- Updated dependencies [3bf5127]
- Updated dependencies [a8b74ff]
- Updated dependencies [db53161]
- cojson-transport-ws@0.7.33
- cojson@0.7.33
## 0.0.58-hotfixes.5
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.5
- cojson-transport-ws@0.7.33-hotfixes.5
## 0.0.58-hotfixes.4
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.4
- cojson-transport-ws@0.7.33-hotfixes.4
## 0.0.58-hotfixes.3
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.33-hotfixes.3
- cojson@0.7.33-hotfixes.3
## 0.0.58-hotfixes.2
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.33-hotfixes.2
## 0.0.58-hotfixes.1
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.33-hotfixes.1
## 0.0.58-hotfixes.0
### Patch Changes
- Updated dependencies
- cojson@0.7.33-hotfixes.0
- cojson-transport-ws@0.7.33-hotfixes.0
## 0.0.57
### Patch Changes
- Updated dependencies
- Updated dependencies
- cojson-transport-ws@0.7.31
- cojson@0.7.31
## 0.0.56
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.30
## 0.0.55
### Patch Changes
- Updated dependencies
- cojson@0.7.29
- cojson-transport-ws@0.7.29
## 0.0.54
### Patch Changes
- Updated dependencies
- cojson@0.7.28
- cojson-transport-ws@0.7.28
## 0.0.53
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.27
## 0.0.52
### Patch Changes
- Updated dependencies
- cojson@0.7.26
- cojson-transport-ws@0.7.26
## 0.0.51
### Patch Changes
- Updated dependencies
- cojson@0.7.23
- cojson-transport-ws@0.7.23
## 0.0.50
### Patch Changes
- Updated dependencies
- cojson-transport-ws@0.7.22
## 0.0.49
### Patch Changes
- Updated dependencies
- cojson@0.7.18
- cojson-transport-ws@0.7.18
## 0.0.48
### Patch Changes
- Updated dependencies
- cojson@0.7.17
- cojson-transport-ws@0.7.17
## 0.0.47
### Patch Changes
- Updated dependencies
- cojson@0.6.7
- jazz-react@0.5.5
- jazz-react-auth-local@0.4.18
## 0.0.46
### Patch Changes
- Updated dependencies
- jazz-react@0.5.0
- jazz-react-auth-local@0.4.16

View File

@@ -0,0 +1,7 @@
# Jazz Inspector
Live address: https://inspector.jazz.tools
Use this to visually inspect a Jazz account or other CoValue.
For now, you can get your account credentials from the `jazz-logged-in-secret` local-storage key from within your Jazz app.

View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/jazz-logo.png" />
<link rel="stylesheet" href="/src/index.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Jazz Inspector</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/app.tsx"></script>
</body>
</html>

View File

@@ -1,7 +1,7 @@
{
"name": "inspector",
"name": "jazz-inspector",
"private": true,
"version": "0.0.47",
"version": "0.0.60",
"type": "module",
"scripts": {
"dev": "vite",
@@ -13,13 +13,11 @@
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.4",
"@types/qrcode": "^1.5.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"hash-slash": "workspace:*",
"jazz-react": "workspace:*",
"jazz-react-auth-local": "workspace:*",
"cojson": "workspace:*",
"cojson-transport-ws": "workspace:*",
"hash-slash": "workspace:*",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",
"react": "^18.2.0",
@@ -32,18 +30,19 @@
"uniqolor": "^1.1.0"
},
"devDependencies": {
"@types/react": "^18.2.15",
"@types/qrcode": "^1.5.1",
"@types/react": "^18.2.19",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.2.1",
"@typescript-eslint/parser": "^6.2.1",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.14",
"eslint": "^8.45.0",
"eslint": "^8.46.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",
"typescript": "^5.3.3",
"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,4 @@
import ReactDOM from "react-dom/client";
import App from "./viewer/new-app";
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -0,0 +1,92 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--primary: 24 9.8% 10%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--ring: 20 14.3% 4.1%;
--radius: 0.5rem;
}
.dark {
--background: 20 14.3% 4.1%;
--foreground: 60 9.1% 97.8%;
--card: 20 14.3% 4.1%;
--card-foreground: 60 9.1% 97.8%;
--popover: 20 14.3% 4.1%;
--popover-foreground: 60 9.1% 97.8%;
--primary: 60 9.1% 97.8%;
--primary-foreground: 24 9.8% 10%;
--secondary: 12 6.5% 15.1%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 15.1%;
--muted-foreground: 24 5.4% 63.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 15.1%;
--input: 12 6.5% 15.1%;
--ring: 24 5.7% 82.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
margin: 0;
padding: 0;
}
}
.animate-in {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateZ(400px) translateY(30px) scale(1.05);
opacity: 0.4;
}
to {
transform: translateZ(0) scale(1);
opacity: 1;
}
}

View File

@@ -0,0 +1,18 @@
export function LinkIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-3 h-3"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244"
/>
</svg>
);
}

View File

@@ -0,0 +1,42 @@
import React from "react";
import { PageInfo } from "./types";
interface BreadcrumbsProps {
path: PageInfo[];
onBreadcrumbClick: (index: number) => void;
}
export const Breadcrumbs: React.FC<BreadcrumbsProps> = ({
path,
onBreadcrumbClick,
}) => {
return (
<div className="z-20 relative bg-indigo-400/10 backdrop-blur-sm rounded-lg inline-flex px-2 py-1 whitespace-pre transition-all items-center space-x-1 min-h-10">
<button
onClick={() => onBreadcrumbClick(-1)}
className="flex items-center justify-center p-1 rounded-sm hover:bg-indigo-500/10 transition-colors"
aria-label="Go to home"
>
<img src="jazz-logo.png" alt="Jazz Logo" className="size-5" />
</button>
{path.map((page, index) => {
return (
<span
key={index}
className="inline-block first:pl-1 last:pr-1"
>
{index === 0 ? null : (
<span className="text-indigo-500/30">{" / "}</span>
)}
<button
onClick={() => onBreadcrumbClick(index)}
className="text-indigo-700 hover:underline"
>
{index === 0 ? page.name || "Root" : page.name}
</button>
</span>
);
})}
</div>
);
};

View File

@@ -0,0 +1,353 @@
import {
CoID,
LocalNode,
RawBinaryCoStream,
RawCoStream,
RawCoValue,
} from "cojson";
import { JsonObject, JsonValue } from "cojson/src/jsonValue";
import { PageInfo } from "./types";
import { base64URLtoBytes } from "cojson/src/base64url";
import { useEffect, useState } from "react";
import { ArrowDownToLine } from "lucide-react";
import {
BinaryStreamItem,
BinaryStreamStart,
CoStreamItem,
} from "cojson/src/coValues/coStream";
import { AccountOrGroupPreview } from "./value-renderer";
// typeguard for BinaryStreamStart
function isBinaryStreamStart(item: unknown): item is BinaryStreamStart {
return (
typeof item === "object" &&
item !== null &&
"type" in item &&
item.type === "start"
);
}
function detectCoStreamType(value: RawCoStream | RawBinaryCoStream) {
const firstKey = Object.keys(value.items)[0];
if (!firstKey)
return {
type: "unknown",
};
const items = value.items[firstKey as never]?.map((v) => v.value);
if (!items)
return {
type: "unknown",
};
const firstItem = items[0];
if (!firstItem)
return {
type: "unknown",
};
// This is a binary stream
if (isBinaryStreamStart(firstItem)) {
return {
type: "binary",
items: items as BinaryStreamItem[],
};
} else {
return {
type: "coStream",
};
}
}
async function getBlobFromCoStream({
items,
onlyFirstChunk = false,
}: {
items: BinaryStreamItem[];
onlyFirstChunk?: boolean;
}) {
if (onlyFirstChunk && items.length > 1) {
items = items.slice(0, 2);
}
const chunks: Uint8Array[] = [];
const binary_U_prefixLength = 8;
let lastProgressUpdate = Date.now();
for (const item of items.slice(1)) {
if (item.type === "end") {
break;
}
if (item.type !== "chunk") {
console.error("Invalid binary stream chunk", item);
return undefined;
}
const chunk = base64URLtoBytes(item.chunk.slice(binary_U_prefixLength));
// totalLength += chunk.length;
chunks.push(chunk);
if (Date.now() - lastProgressUpdate > 100) {
lastProgressUpdate = Date.now();
}
}
const defaultMime = "mimeType" in items[0] ? items[0].mimeType : null;
const blob = new Blob(chunks, defaultMime ? { type: defaultMime } : {});
const mimeType =
defaultMime === "" ? await detectPDFMimeType(blob) : defaultMime;
return {
blob,
mimeType: mimeType as string,
unfinishedChunks: items.length > 1,
totalSize:
"totalSizeBytes" in items[0]
? (items[0].totalSizeBytes as number)
: undefined,
};
}
const detectPDFMimeType = async (blob: Blob): Promise<string> => {
const arrayBuffer = await blob.slice(0, 4).arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const header = uint8Array.reduce(
(acc, byte) => acc + String.fromCharCode(byte),
"",
);
if (header === "%PDF") {
return "application/pdf";
}
return "application/octet-stream";
};
const BinaryDownloadButton = ({
pdfBlob,
fileName = "document",
label,
mimeType,
}: {
pdfBlob: Blob;
mimeType?: string;
fileName?: string;
label: string;
}) => {
const downloadFile = () => {
const url = URL.createObjectURL(
new Blob([pdfBlob], mimeType ? { type: mimeType } : {}),
);
const link = document.createElement("a");
link.href = url;
link.download =
mimeType === "application/pdf" ? `${fileName}.pdf` : fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
return (
<button
className="flex items-center gap-2 px-2 py-1 text-gray-900 border border-gray-900/10 bg-clip-border shadow-sm transition-colors rounded bg-gray-50 text-sm"
onClick={downloadFile}
>
<ArrowDownToLine size={16} />
{label}
{/* Download {mimeType === "application/pdf" ? "PDF" : "File"} */}
</button>
);
};
const LabelContentPair = ({
label,
content,
}: {
label: string;
content: React.ReactNode;
}) => {
return (
<div className="flex flex-col gap-1.5 ">
<span className="uppercase text-xs font-medium text-gray-600 tracking-wide">
{label}
</span>
<span>{content}</span>
</div>
);
};
function RenderCoBinaryStream({
value,
items,
}: {
items: BinaryStreamItem[];
value: RawBinaryCoStream;
}) {
const [file, setFile] = useState<
| {
blob: Blob;
mimeType: string;
unfinishedChunks: boolean;
totalSize: number | undefined;
}
| undefined
| null
>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// load only the first chunk to get the mime type and size
getBlobFromCoStream({
items,
onlyFirstChunk: true,
})
.then((v) => {
if (v) {
setFile(v);
if (v.mimeType.includes("image")) {
// If it's an image, load the full blob
getBlobFromCoStream({
items,
}).then((s) => {
if (s) setFile(s);
});
}
}
})
.finally(() => setIsLoading(false));
}, [items]);
if (!isLoading && !file) return <div>No blob</div>;
if (isLoading) return <div>Loading...</div>;
if (!file) return <div>No blob</div>;
const { blob, mimeType } = file;
const sizeInKB = (file.totalSize || 0) / 1024;
return (
<div className="space-y-8 mt-4">
<div className="grid grid-cols-3 gap-2 max-w-3xl">
<LabelContentPair
label="Mime Type"
content={
<span className="font-mono bg-gray-100 rounded px-2 py-1 text-sm">
{mimeType || "No mime type"}
</span>
}
/>
<LabelContentPair
label="Size"
content={<span>{sizeInKB.toFixed(2)} KB</span>}
/>
<LabelContentPair
label="Download"
content={
<BinaryDownloadButton
fileName={value.id.toString()}
pdfBlob={blob}
mimeType={mimeType}
label={
mimeType === "application/pdf"
? "Download PDF"
: "Download File"
}
/>
}
/>
</div>
{mimeType === "image/png" || mimeType === "image/jpeg" ? (
<LabelContentPair
label="Preview"
content={
<div className="bg-gray-50 p-3 rounded-sm">
<RenderBlobImage blob={blob} />
</div>
}
/>
) : null}
</div>
);
}
function RenderCoStream({
value,
node,
}: {
value: RawCoStream;
node: LocalNode;
}) {
const streamPerUser = Object.keys(value.items);
const userCoIds = streamPerUser.map(
(stream) => stream.split("_session")[0],
);
return (
<div className="grid grid-cols-3 gap-2">
{userCoIds.map((id, idx) => (
<div
className="bg-gray-100 p-3 rounded-lg transition-colors overflow-hidden bg-white border hover:bg-gray-100/5 cursor-pointer shadow-sm"
key={id}
>
<AccountOrGroupPreview
coId={id as CoID<RawCoValue>}
node={node}
/>
{/* @ts-expect-error - TODO: fix types */}
{value.items[streamPerUser[idx]]?.map(
(item: CoStreamItem<JsonValue>) => (
<div>
{new Date(item.madeAt).toLocaleString()}{" "}
{JSON.stringify(item.value)}
</div>
),
)}
</div>
))}
</div>
);
}
export function CoStreamView({
value,
node,
}: {
data: JsonObject;
onNavigate: (pages: PageInfo[]) => void;
node: LocalNode;
value: RawCoStream;
}) {
// if (!value) return <div>No value</div>;
const streamType = detectCoStreamType(value);
if (streamType.type === "binary") {
if (streamType.items === undefined) {
return <div>No binary stream</div>;
}
return (
<RenderCoBinaryStream
value={value as RawBinaryCoStream}
items={streamType.items}
/>
);
}
if (streamType.type === "coStream") {
return <RenderCoStream value={value} node={node} />;
}
if (streamType.type === "unknown") return <div>Unknown stream type</div>;
return <div>Unknown stream type</div>;
}
function RenderBlobImage({ blob }: { blob: Blob }) {
const urlCreator = window.URL || window.webkitURL;
return <img src={urlCreator.createObjectURL(blob)} />;
}

View File

@@ -0,0 +1,73 @@
import { CoID, LocalNode, RawCoValue } from "cojson";
import { JsonObject } from "cojson/src/jsonValue";
import { CoMapPreview, ValueRenderer } from "./value-renderer";
import clsx from "clsx";
import { PageInfo, isCoId } from "./types";
import { ResolveIcon } from "./type-icon";
export function GridView({
data,
onNavigate,
node,
}: {
data: JsonObject;
onNavigate: (pages: PageInfo[]) => void;
node: LocalNode;
}) {
const entries = Object.entries(data);
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-2">
{entries.map(([key, child], childIndex) => (
<div
key={childIndex}
className={clsx(
"bg-gray-100 p-3 rounded-lg transition-colors overflow-hidden",
isCoId(child)
? "bg-white border hover:bg-gray-100/5 cursor-pointer shadow-sm"
: "bg-gray-50",
)}
onClick={() =>
isCoId(child) &&
onNavigate([
{ coId: child as CoID<RawCoValue>, name: key },
])
}
>
<h3 className="truncate">
{isCoId(child) ? (
<span className="font-medium flex justify-between">
{key}
<div className="px-2 py-1 text-xs bg-gray-100 rounded">
<ResolveIcon
coId={child as CoID<RawCoValue>}
node={node}
/>
</div>
</span>
) : (
<span>{key}</span>
)}
</h3>
<div className="mt-2 text-sm">
{isCoId(child) ? (
<CoMapPreview
coId={child as CoID<RawCoValue>}
node={node}
/>
) : (
<ValueRenderer
json={child}
onCoIDClick={(coId) => {
onNavigate([{ coId, name: key }]);
}}
compact
/>
)}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { LocalNode } from "cojson";
import { Breadcrumbs } from "./breadcrumbs";
import { usePagePath } from "./use-page-path";
import { PageInfo } from "./types";
import { PageStack } from "./page-stack";
export default function CoJsonViewer({
defaultPath,
node,
}: {
defaultPath?: PageInfo[];
node: LocalNode;
}) {
const { path, addPages, goToIndex, goBack } = usePagePath(defaultPath);
return (
<div className="w-full h-screen bg-gray-100 p-4 overflow-hidden">
<Breadcrumbs path={path} onBreadcrumbClick={goToIndex} />
<PageStack
path={path}
node={node}
goBack={goBack}
addPages={addPages}
/>
</div>
);
}

View File

@@ -0,0 +1,310 @@
import React, { useState, useEffect } from "react";
import {
LocalNode,
CoID,
RawCoValue,
RawAccount,
AgentSecret,
AccountID,
cojsonInternals,
WasmCrypto,
} from "cojson";
import { createWebSocketPeer } from "cojson-transport-ws";
import { Trash2 } from "lucide-react";
import { Breadcrumbs } from "./breadcrumbs";
import { usePagePath } from "./use-page-path";
import { PageStack } from "./page-stack";
import { resolveCoValue, useResolvedCoValue } from "./use-resolve-covalue";
import clsx from "clsx";
interface Account {
id: CoID<RawAccount>;
secret: AgentSecret;
}
export default function CoJsonViewerApp() {
const [accounts, setAccounts] = useState<Account[]>(() => {
const storedAccounts = localStorage.getItem("inspectorAccounts");
return storedAccounts ? JSON.parse(storedAccounts) : [];
});
const [currentAccount, setCurrentAccount] = useState<Account | null>(() => {
const lastSelectedId = localStorage.getItem("lastSelectedAccountId");
if (lastSelectedId) {
const lastAccount = accounts.find(
(account) => account.id === lastSelectedId,
);
return lastAccount || null;
}
return null;
});
const [localNode, setLocalNode] = useState<LocalNode | null>(null);
const [coValueId, setCoValueId] = useState<CoID<RawCoValue> | "">("");
const { path, addPages, goToIndex, goBack, setPage } = usePagePath();
useEffect(() => {
localStorage.setItem("inspectorAccounts", JSON.stringify(accounts));
}, [accounts]);
useEffect(() => {
if (currentAccount) {
localStorage.setItem("lastSelectedAccountId", currentAccount.id);
} else {
localStorage.removeItem("lastSelectedAccountId");
}
}, [currentAccount]);
useEffect(() => {
if (!currentAccount) {
setLocalNode(null);
goToIndex(-1);
return;
}
WasmCrypto.create().then(async (crypto) => {
const wsPeer = createWebSocketPeer({
id: "mesh",
websocket: new WebSocket("wss://mesh.jazz.tools"),
role: "server",
});
const node = await LocalNode.withLoadedAccount({
accountID: currentAccount.id,
accountSecret: currentAccount.secret,
sessionID: cojsonInternals.newRandomSessionID(
currentAccount.id,
),
peersToLoadFrom: [wsPeer],
crypto,
migration: async () => {
console.log("Not running any migration in inspector");
},
});
setLocalNode(node);
});
}, [currentAccount, goToIndex]);
const addAccount = (id: AccountID, secret: AgentSecret) => {
const newAccount = { id, secret };
setAccounts([...accounts, newAccount]);
setCurrentAccount(newAccount);
};
const deleteCurrentAccount = () => {
if (currentAccount) {
const updatedAccounts = accounts.filter(
(account) => account.id !== currentAccount.id,
);
setAccounts(updatedAccounts);
setCurrentAccount(
updatedAccounts.length > 0 ? updatedAccounts[0] : null,
);
}
};
const handleCoValueIdSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (coValueId) {
setPage(coValueId);
}
};
return (
<div className="w-full h-screen bg-gray-100 p-4 overflow-hidden flex flex-col">
<div className="flex justify-between items-center mb-4">
<Breadcrumbs path={path} onBreadcrumbClick={goToIndex} />
<AccountSwitcher
accounts={accounts}
currentAccount={currentAccount}
setCurrentAccount={setCurrentAccount}
deleteCurrentAccount={deleteCurrentAccount}
localNode={localNode}
/>
</div>
<PageStack
path={path}
node={localNode}
goBack={goBack}
addPages={addPages}
>
{!currentAccount ? (
<AddAccountForm addAccount={addAccount} />
) : (
<form
onSubmit={handleCoValueIdSubmit}
aria-hidden={path.length !== 0}
className={clsx(
"flex flex-col justify-center items-center gap-2 h-full w-full mb-20 ",
"transition-all duration-150",
path.length > 0
? "opacity-0 -translate-y-2 scale-95"
: "opacity-100",
)}
>
<fieldset className="flex flex-col gap-2 text-sm">
<h2 className="text-3xl font-medium text-gray-950 text-center mb-4">
Jazz CoValue Inspector
</h2>
<input
className="border p-4 rounded-lg min-w-[21rem] font-mono"
placeholder="co_z1234567890abcdef123456789"
value={coValueId}
onChange={(e) =>
setCoValueId(
e.target.value as CoID<RawCoValue>,
)
}
/>
<button
type="submit"
className="bg-indigo-500 hover:bg-indigo-500/80 text-white px-4 py-2 rounded-md"
>
Inspect
</button>
<hr />
<button
type="button"
className="border inline-block px-2 py-1.5 text-black rounded"
onClick={() => {
setCoValueId(currentAccount.id);
setPage(currentAccount.id);
}}
>
Inspect My Account
</button>
</fieldset>
</form>
)}
</PageStack>
</div>
);
}
function AccountSwitcher({
accounts,
currentAccount,
setCurrentAccount,
deleteCurrentAccount,
localNode,
}: {
accounts: Account[];
currentAccount: Account | null;
setCurrentAccount: (account: Account | null) => void;
deleteCurrentAccount: () => void;
localNode: LocalNode | null;
}) {
return (
<div className="relative flex items-center gap-1">
<select
value={currentAccount?.id || "add-account"}
onChange={(e) => {
if (e.target.value === "add-account") {
setCurrentAccount(null);
} else {
const account = accounts.find(
(a) => a.id === e.target.value,
);
setCurrentAccount(account || null);
}
}}
className="p-2 px-4 bg-gray-100/50 border border-indigo-500/10 backdrop-blur-sm rounded-md text-indigo-700 appearance-none"
>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{localNode ? (
<AccountNameDisplay
accountId={account.id}
node={localNode}
/>
) : (
account.id
)}
</option>
))}
<option value="add-account">Add account</option>
</select>
{currentAccount && (
<button
onClick={deleteCurrentAccount}
className="p-3 rounded hover:bg-gray-200 transition-colors"
title="Delete Account"
>
<Trash2 size={16} className="text-gray-500" />
</button>
)}
</div>
);
}
function AddAccountForm({
addAccount,
}: {
addAccount: (id: AccountID, 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);
setId("");
setSecret("");
};
return (
<form
onSubmit={handleSubmit}
className="flex flex-col gap-2 max-w-md mx-auto h-full justify-center"
>
<h2 className="text-2xl font-medium text-gray-900 mb-3">
Add an Account to Inspect
</h2>
<input
className="border py-2 px-3 rounded-md"
placeholder="Account ID"
value={id}
onChange={(e) => setId(e.target.value)}
/>
<input
type="password"
className="border py-2 px-3 rounded-md"
placeholder="Account Secret"
value={secret}
onChange={(e) => setSecret(e.target.value)}
/>
<button
type="submit"
className="bg-indigo-500 text-white px-4 py-2 rounded-md"
>
Add Account
</button>
</form>
);
}
function AccountNameDisplay({
accountId,
node,
}: {
accountId: CoID<RawAccount>;
node: LocalNode;
}) {
const { snapshot } = useResolvedCoValue(accountId, node);
const [name, setName] = useState<string | null>(null);
useEffect(() => {
if (snapshot && typeof snapshot === "object" && "profile" in snapshot) {
const profileId = snapshot.profile as CoID<RawCoValue>;
resolveCoValue(profileId, node).then((profileResult) => {
if (
profileResult.snapshot &&
typeof profileResult.snapshot === "object" &&
"name" in profileResult.snapshot
) {
setName(profileResult.snapshot.name as string);
}
});
}
}, [snapshot, node]);
return name ? `${name} <${accountId}>` : accountId;
}

View File

@@ -0,0 +1,55 @@
import { Page } from "./page"; // Assuming you have a Page component
import { CoID, LocalNode, RawCoValue } from "cojson";
// Define the structure of a page in the path
interface PageInfo {
coId: CoID<RawCoValue>;
name?: string;
}
// Props for the PageStack component
interface PageStackProps {
path: PageInfo[];
node?: LocalNode | null;
goBack: () => void;
addPages: (pages: PageInfo[]) => void;
children?: React.ReactNode;
}
export function PageStack({
path,
node,
goBack,
addPages,
children,
}: PageStackProps) {
return (
<div className="relative mt-4 h-[calc(100vh-6rem)]">
{children && (
<div className="absolute inset-0 pb-20">{children}</div>
)}
{node &&
path.map((page, index) => (
<Page
key={`${page.coId}-${index}`}
coId={page.coId}
node={node}
name={page.name || page.coId}
onHeaderClick={goBack}
onNavigate={addPages}
isTopLevel={index === path.length - 1}
style={{
transform: `translateZ(${(index - path.length + 1) * 200}px) scale(${
1 - (path.length - index - 1) * 0.05
}) translateY(${-(index - path.length + 1) * -4}%)`,
opacity: 1 - (path.length - index - 1) * 0.05,
zIndex: index,
transitionProperty: "transform, opacity",
transitionDuration: "0.3s",
transitionTimingFunction: "ease-out",
}}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,154 @@
import clsx from "clsx";
import { CoID, LocalNode, RawCoStream, RawCoValue } from "cojson";
import { useEffect, useState } from "react";
import { useResolvedCoValue } from "./use-resolve-covalue";
import { GridView } from "./grid-view";
import { PageInfo } from "./types";
import { TableView } from "./table-viewer";
import { TypeIcon } from "./type-icon";
import { CoStreamView } from "./co-stream-view";
import { AccountOrGroupPreview } from "./value-renderer";
type PageProps = {
coId: CoID<RawCoValue>;
node: LocalNode;
name: string;
onNavigate: (newPages: PageInfo[]) => void;
onHeaderClick?: () => void;
isTopLevel?: boolean;
style: React.CSSProperties;
};
export function Page({
coId,
node,
name,
onNavigate,
onHeaderClick,
style,
isTopLevel,
}: PageProps) {
const { value, snapshot, type, extendedType } = useResolvedCoValue(
coId,
node,
);
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
const supportsTableView = type === "colist" || extendedType === "record";
// Automatically switch to table view if the page is a CoMap record
useEffect(() => {
if (supportsTableView) {
setViewMode("table");
}
}, [supportsTableView]);
if (snapshot === "unavailable") {
return <div style={style}>Data unavailable</div>;
}
if (!snapshot) {
return <div style={style}></div>;
}
return (
<div
style={style}
className={clsx(
"absolute inset-0 border border-gray-900/5 bg-clip-padding bg-white rounded-xl shadow-lg p-6 animate-in",
)}
>
{!isTopLevel && (
<div
className="absolute inset-x-0 top-0 h-10"
aria-label="Back"
onClick={() => {
onHeaderClick?.();
}}
aria-hidden="true"
></div>
)}
<div className="flex justify-between items-center mb-4">
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-bold flex items-start flex-col gap-1">
<span>
{name}
{typeof snapshot === "object" &&
"name" in snapshot ? (
<span className="text-gray-600 font-medium">
{" "}
{
(
snapshot as {
name: string;
}
).name
}
</span>
) : null}
</span>
</h2>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-700 font-medium py-0.5 px-1 -ml-0.5 rounded bg-gray-700/5 inline-block font-mono">
{type && (
<TypeIcon
type={type}
extendedType={extendedType}
/>
)}
</span>
<span className="text-xs text-gray-700 font-medium py-0.5 px-1 -ml-0.5 rounded bg-gray-700/5 inline-block font-mono">
{coId}
</span>
</div>
</div>
{/* {supportsTableView && (
<button
onClick={toggleViewMode}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
{viewMode === "grid" ? "Table View" : "Grid View"}
</button>
)} */}
</div>
<div className="overflow-auto max-h-[calc(100%-4rem)]">
{type === "costream" ? (
<CoStreamView
data={snapshot}
onNavigate={onNavigate}
node={node}
value={value as RawCoStream}
/>
) : viewMode === "grid" ? (
<GridView
data={snapshot}
onNavigate={onNavigate}
node={node}
/>
) : (
<TableView
data={snapshot}
node={node}
onNavigate={onNavigate}
/>
)}
{/* --- */}
{extendedType !== "account" && extendedType !== "group" && (
<div className="text-xs text-gray-500 mt-4">
Owned by{" "}
<AccountOrGroupPreview
coId={value.group.id}
node={node}
showId
onClick={() => {
onNavigate([
{ coId: value.group.id, name: "owner" },
]);
}}
/>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
import { CoID, LocalNode, RawCoValue } from "cojson";
import { JsonObject } from "cojson/src/jsonValue";
import { PageInfo } from "./types";
import { useMemo, useState } from "react";
import { ValueRenderer } from "./value-renderer";
import { LinkIcon } from "../link-icon";
import { useResolvedCoValues } from "./use-resolve-covalue";
export function TableView({
data,
node,
onNavigate,
}: {
data: JsonObject;
node: LocalNode;
onNavigate: (pages: PageInfo[]) => void;
}) {
const [visibleRowsCount, setVisibleRowsCount] = useState(10);
const [coIdArray, visibleRows] = useMemo(() => {
const coIdArray = Array.isArray(data)
? data
: Object.values(data).every(
(k) => typeof k === "string" && k.startsWith("co_"),
)
? Object.values(data).map((k) => k as CoID<RawCoValue>)
: [];
const visibleRows = coIdArray.slice(0, visibleRowsCount);
return [coIdArray, visibleRows];
}, [data, visibleRowsCount]);
const resolvedRows = useResolvedCoValues(visibleRows, node);
const hasMore = visibleRowsCount < coIdArray.length;
if (!coIdArray.length) {
return <div>No data to display</div>;
}
if (resolvedRows.length === 0) {
return <div>Loading...</div>;
}
const keys = Array.from(
new Set(
resolvedRows.flatMap((item) => Object.keys(item.snapshot || {})),
),
);
const loadMore = () => {
setVisibleRowsCount((prevVisibleRows) => prevVisibleRows + 10);
};
return (
<div>
<table className="min-w-full divide-y divide-gray-200">
<thead className="sticky top-0 border-b">
<tr>
{["", ...keys].map((key) => (
<th
key={key}
className="px-4 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 rounded"
>
{key}
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{resolvedRows
.slice(0, visibleRowsCount)
.map((item, index) => (
<tr key={index}>
<td className="px-1 py-0">
<button
onClick={() =>
onNavigate([
{
coId: item.value!.id,
name: index.toString(),
},
])
}
className="px-4 py-4 whitespace-nowrap text-sm text-gray-500 hover:text-blue-500 hover:bg-gray-100 rounded"
>
<LinkIcon />
</button>
</td>
{keys.map((key) => (
<td
key={key}
className="px-4 py-4 whitespace-nowrap text-sm text-gray-500"
>
<ValueRenderer
json={
(item.snapshot as JsonObject)[
key
]
}
onCoIDClick={(coId) => {
async function handleClick() {
onNavigate([
{
coId: item.value!
.id,
name: index.toString(),
},
{
coId: coId,
name: key,
},
]);
}
handleClick();
}}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
<div className="py-4 text-gray-500 flex items-center justify-between gap-2">
<span>
Showing {Math.min(visibleRowsCount, coIdArray.length)} of{" "}
{coIdArray.length}
</span>
{hasMore && (
<div className="text-center">
<button
onClick={loadMore}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Load More
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { CoID, LocalNode, RawCoValue } from "cojson";
import {
CoJsonType,
ExtendedCoJsonType,
useResolvedCoValue,
} from "./use-resolve-covalue";
export const TypeIcon = ({
type,
extendedType,
}: {
type: CoJsonType;
extendedType?: ExtendedCoJsonType;
}) => {
const iconMap: Record<ExtendedCoJsonType | CoJsonType, string> = {
record: "{} Record",
image: "🖼️ Image",
comap: "{} CoMap",
costream: "≋ CoStream",
colist: "☰ CoList",
account: "👤 Account",
group: "👥 Group",
};
const iconKey = extendedType || type;
const icon = iconMap[iconKey as keyof typeof iconMap];
return icon ? <span className="font-mono">{icon}</span> : null;
};
export const ResolveIcon = ({
coId,
node,
}: {
coId: CoID<RawCoValue>;
node: LocalNode;
}) => {
const { type, extendedType, snapshot } = useResolvedCoValue(coId, node);
if (snapshot === "unavailable" && !type) {
return <div className="text-gray-600 font-medium">Unavailable</div>;
}
if (!type) return <div className="whitespace-pre w-14 font-mono"> </div>;
return <TypeIcon type={type} extendedType={extendedType} />;
};

View File

@@ -0,0 +1,9 @@
import { CoID, RawCoValue } from "cojson";
export type PageInfo = {
coId: CoID<RawCoValue>;
name?: string;
};
export const isCoId = (coId: unknown): coId is CoID<RawCoValue> =>
typeof coId === "string" && coId.startsWith("co_");

View File

@@ -0,0 +1,107 @@
import { useState, useEffect, useCallback } from "react";
import { PageInfo } from "./types";
import { CoID, RawCoValue } from "cojson";
export function usePagePath(defaultPath?: PageInfo[]) {
const [path, setPath] = useState<PageInfo[]>(() => {
const hash = window.location.hash.slice(2); // Remove '#/'
if (hash) {
try {
return decodePathFromHash(hash);
} catch (e) {
console.error("Failed to parse hash:", e);
}
}
return defaultPath || [];
});
const updatePath = useCallback((newPath: PageInfo[]) => {
setPath(newPath);
const hash = encodePathToHash(newPath);
window.location.hash = `#/${hash}`;
}, []);
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.slice(2);
if (hash) {
try {
const newPath = decodePathFromHash(hash);
setPath(newPath);
} catch (e) {
console.error("Failed to parse hash:", e);
}
} else if (defaultPath) {
setPath(defaultPath);
}
};
window.addEventListener("hashchange", handleHashChange);
return () => window.removeEventListener("hashchange", handleHashChange);
}, [defaultPath]);
useEffect(() => {
if (
defaultPath &&
JSON.stringify(path) !== JSON.stringify(defaultPath)
) {
updatePath(defaultPath);
}
}, [defaultPath, path, updatePath]);
const addPages = useCallback(
(newPages: PageInfo[]) => {
updatePath([...path, ...newPages]);
},
[path, updatePath],
);
const goToIndex = useCallback(
(index: number) => {
updatePath(path.slice(0, index + 1));
},
[path, updatePath],
);
const setPage = useCallback(
(coId: CoID<RawCoValue>) => {
updatePath([{ coId, name: "Root" }]);
},
[updatePath],
);
const goBack = useCallback(() => {
if (path.length > 1) {
updatePath(path.slice(0, path.length - 1));
}
}, [path, updatePath]);
return {
path,
setPage,
addPages,
goToIndex,
goBack,
};
}
function encodePathToHash(path: PageInfo[]): string {
return path
.map((page) => {
if (page.name && page.name !== "Root") {
return `${page.coId}:${encodeURIComponent(page.name)}`;
}
return page.coId;
})
.join("/");
}
function decodePathFromHash(hash: string): PageInfo[] {
return hash.split("/").map((segment) => {
const [coId, encodedName] = segment.split(":");
return {
coId,
name: encodedName ? decodeURIComponent(encodedName) : undefined,
} as PageInfo;
});
}

View File

@@ -0,0 +1,222 @@
import { CoID, LocalNode, RawBinaryCoStream, RawCoValue } from "cojson";
import { useEffect, useState } from "react";
export type CoJsonType = "comap" | "costream" | "colist";
export type ExtendedCoJsonType = "image" | "record" | "account" | "group";
type JSON = string | number | boolean | null | JSON[] | { [key: string]: JSON };
type JSONObject = { [key: string]: JSON };
type ResolvedImageDefinition = {
originalSize: [number, number];
placeholderDataURL?: string;
[res: `${number}x${number}`]: RawBinaryCoStream["id"];
};
// Type guard for browser image
export const isBrowserImage = (
coValue: JSONObject,
): coValue is ResolvedImageDefinition => {
return "originalSize" in coValue && "placeholderDataURL" in coValue;
};
export type ResolvedGroup = {
readKey: string;
[key: string]: JSON;
};
export const isGroup = (coValue: JSONObject): coValue is ResolvedGroup => {
return "readKey" in coValue;
};
export type ResolvedAccount = {
profile: {
name: string;
};
[key: string]: JSON;
};
export const isAccount = (coValue: JSONObject): coValue is ResolvedAccount => {
return isGroup(coValue) && "profile" in coValue;
};
export async function resolveCoValue(
coValueId: CoID<RawCoValue>,
node: LocalNode,
): Promise<
| {
value: RawCoValue;
snapshot: JSONObject;
type: CoJsonType | null;
extendedType: ExtendedCoJsonType | undefined;
}
| {
value: undefined;
snapshot: "unavailable";
type: null;
extendedType: undefined;
}
> {
const value = await node.load(coValueId);
if (value === "unavailable") {
return {
value: undefined,
snapshot: "unavailable",
type: null,
extendedType: undefined,
};
}
const snapshot = value.toJSON() as JSONObject;
const type = value.type as CoJsonType;
// Determine extended type
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 {
// This check is a bit of a hack
// There might be a better way to do this
const children = Object.values(snapshot).slice(0, 10);
if (
children.every(
(c) => typeof c === "string" && c.startsWith("co_"),
) &&
children.length > 3
) {
extendedType = "record";
}
}
}
return {
value,
snapshot,
type,
extendedType,
};
}
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,
) {
const [result, setResult] =
useState<Awaited<ReturnType<typeof resolveCoValue>>>();
useEffect(() => {
let isMounted = true;
const unsubscribe = subscribeToCoValue(coValueId, node, (newResult) => {
if (isMounted) {
setResult(newResult);
}
});
return () => {
isMounted = false;
unsubscribe();
};
}, [coValueId, node]);
return (
result || {
value: undefined,
snapshot: undefined,
type: undefined,
extendedType: undefined,
}
);
}
export function useResolvedCoValues(
coValueIds: CoID<RawCoValue>[],
node: LocalNode,
) {
const [results, setResults] = useState<
Awaited<ReturnType<typeof resolveCoValue>>[]
>([]);
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;
});
}
},
);
unsubscribes.push(unsubscribe);
});
return () => {
isMounted = false;
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
}, [coValueIds, node]);
return results;
}

View File

@@ -0,0 +1,248 @@
import clsx from "clsx";
import { CoID, JsonValue, LocalNode, RawCoValue } from "cojson";
import { LinkIcon } from "../link-icon";
import {
isBrowserImage,
resolveCoValue,
useResolvedCoValue,
} from "./use-resolve-covalue";
import React, { useEffect, useState } from "react";
// Is there a chance we can pass the actual CoValue here?
export function ValueRenderer({
json,
compact,
onCoIDClick,
}: {
json: JsonValue | undefined;
compact?: boolean;
onCoIDClick?: (childNode: CoID<RawCoValue>) => void;
}) {
if (typeof json === "undefined" || json === undefined) {
return <span className="text-gray-400">undefined</span>;
}
if (json === null) {
return <span className="text-gray-400">null</span>;
}
if (typeof json === "string" && json.startsWith("co_")) {
return (
<span
className={clsx(
"inline-flex gap-1 items-center",
onCoIDClick &&
"text-blue-500 cursor-pointer hover:underline",
)}
onClick={() => {
onCoIDClick?.(json as CoID<RawCoValue>);
}}
>
{json}
{onCoIDClick && <LinkIcon />}
</span>
);
}
if (typeof json === "string") {
return (
<span className="text-green-900 font-mono">
{/* <span className="select-none opacity-70">{'"'}</span> */}
{json}
{/* <span className="select-none opacity-70">{'"'}</span> */}
</span>
);
}
if (typeof json === "number") {
return <span className="text-purple-500">{json}</span>;
}
if (typeof json === "boolean") {
return (
<span
className={clsx(
json
? "text-green-700 bg-green-700/5"
: "text-amber-700 bg-amber-500/5",
"font-mono",
"inline-block px-1 py-0.5 rounded",
)}
>
{json.toString()}
</span>
);
}
if (Array.isArray(json)) {
return (
<span title={JSON.stringify(json)}>
Array <span className="text-gray-500">({json.length})</span>
</span>
);
}
if (typeof json === "object") {
return (
<span
title={JSON.stringify(json, null, 2)}
className="inline-block max-w-64 truncate"
>
{compact ? (
<span>
Object{" "}
<span className="text-gray-500">
({Object.keys(json).length})
</span>
</span>
) : (
JSON.stringify(json, null, 2)
)}
</span>
);
}
return <span>{String(json)}</span>;
}
export const CoMapPreview = ({
coId,
node,
limit = 6,
}: {
coId: CoID<RawCoValue>;
node: LocalNode;
limit?: number;
}) => {
const { value, snapshot, type, extendedType } = useResolvedCoValue(
coId,
node,
);
if (!snapshot) {
return (
<div className="rounded bg-gray-100 animate-pulse whitespace-pre w-24">
{" "}
</div>
);
}
if (snapshot === "unavailable" && !value) {
return <div className="text-gray-500">Unavailable</div>;
}
if (extendedType === "image" && isBrowserImage(snapshot)) {
return (
<div>
<img
src={snapshot.placeholderDataURL}
className="size-8 border-2 border-white drop-shadow-md my-2"
/>
<span className="text-gray-500 text-sm">
{snapshot.originalSize[0]} x {snapshot.originalSize[1]}
</span>
{/* <CoMapPreview coId={value[]} node={node} /> */}
{/* <ProgressiveImg image={value}>
{({ src }) => <img src={src} className={clsx("w-full")} />}
</ProgressiveImg> */}
</div>
);
}
if (extendedType === "record") {
return (
<div>
Record{" "}
<span className="text-gray-500">
({Object.keys(snapshot).length})
</span>
</div>
);
}
if (type === "colist") {
return (
<div>
List{" "}
<span className="text-gray-500">
({(snapshot as unknown as []).length})
</span>
</div>
);
}
return (
<div className="text-sm flex flex-col gap-2 items-start">
<div className="grid grid-cols-[auto_1fr] gap-2">
{Object.entries(snapshot)
.slice(0, limit)
.map(([key, value]) => (
<React.Fragment key={key}>
<span className="font-medium">{key}: </span>
<span>
<ValueRenderer json={value} />
</span>
</React.Fragment>
))}
</div>
{Object.entries(snapshot).length > limit && (
<div className="text-left text-xs text-gray-500 mt-2">
{Object.entries(snapshot).length - limit} more
</div>
)}
</div>
);
};
export function AccountOrGroupPreview({
coId,
node,
showId = false,
onClick,
}: {
coId: CoID<RawCoValue>;
node: LocalNode;
showId?: boolean;
onClick?: (name?: string) => void;
}) {
const { snapshot, extendedType } = useResolvedCoValue(coId, node);
const [name, setName] = useState<string | null>(null);
useEffect(() => {
if (extendedType === "account") {
resolveCoValue(
(snapshot as unknown as { profile: CoID<RawCoValue> }).profile,
node,
).then(({ snapshot }) => {
if (
typeof snapshot === "object" &&
"name" in snapshot &&
typeof snapshot.name === "string"
) {
setName(snapshot.name);
}
});
}
}, [snapshot, node, extendedType]);
if (!snapshot) return <span>Loading...</span>;
if (extendedType !== "account" && extendedType !== "group") {
return <span>CoID is not an account or group</span>;
}
const displayName =
extendedType === "account" ? name || "Account" : "Group";
const displayText = showId ? `${displayName} (${coId})` : displayName;
const props = onClick
? {
onClick: () => onClick(displayName),
className: "text-blue-500 cursor-pointer hover:underline",
}
: {
className: "text-gray-500",
};
return <span {...props}>{displayText}</span>;
}

View File

@@ -5,6 +5,7 @@ module.exports = {
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'prettier'
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',

View File

@@ -0,0 +1,13 @@
# jazz-example-musicplayer
## 0.0.2
### Patch Changes
- Updated dependencies [49a8b54]
- Updated dependencies [6f80282]
- Updated dependencies [35bbcd9]
- Updated dependencies [cac2ec9]
- Updated dependencies [f350e90]
- jazz-tools@0.7.35
- jazz-react@0.7.35

View File

@@ -1,19 +1,20 @@
# Jazz Twit Example
# Jazz Music Player Example
Live version: https://twit.jazz.tools
Live version: https://music-demo.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/twit
cd jazz/examples/musicPlayer
pnpm pack --pack-destination /tmp
mkdir -p ~/jazz-examples/twit # or any other directory
tar -xf /tmp/jazz-example-twit-* --strip-components 1 -C ~/jazz-examples/twit
cd ~/jazz-examples/twit
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
```
This ensures that you have the example app without git history and independent of the Jazz multi-package monorepo.
@@ -34,9 +35,8 @@ 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 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).
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/?peer=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

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/jazz-logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Jazz - Music Player example</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/2_main.tsx"></script>
</body>
</html>

View File

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

View File

@@ -0,0 +1,44 @@
{
"name": "jazz-example-music-player",
"private": true,
"version": "0.0.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"
},
"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.19",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.2.1",
"@typescript-eslint/parser": "^6.2.1",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.14",
"eslint": "^8.46.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.3.3",
"vite": "^5.0.10"
}
}

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -0,0 +1,124 @@
import {
CoMap,
CoList,
BinaryCoStream,
co,
Profile,
Account,
} from "jazz-tools";
/** 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);
exampleDataLoaded = co.optional.boolean;
}
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.
*/
async migrate(creationProps?: { name: string }) {
super.migrate(creationProps);
if (!this._refs.root) {
const ownership = { owner: this };
const tracks = ListOfTracks.create([], ownership);
const rootPlaylist = Playlist.create(
{
tracks,
title: "",
},
ownership,
);
this.root = MusicaAccountRoot.create(
{
rootPlaylist,
playlists: ListOfPlaylists.create([], ownership),
activeTrack: null,
activePlaylist: rootPlaylist,
exampleDataLoaded: false,
},
ownership,
);
}
}
}
/** Walkthrough: Continue with ./2_main.tsx */

View File

@@ -0,0 +1,106 @@
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";
import { useUploadExampleData } from "./lib/useUploadExampleData";
/**
* 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: "wss://mesh.jazz.tools/?key=you@example.com",
});
export const { useAccount, useCoState, useAcceptInvite } = Jazz;
function Main() {
const mediaPlayer = useMediaPlayer();
useUploadExampleData();
/**
* `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

@@ -0,0 +1,128 @@
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: Iterable<File>,
) {
// 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

@@ -0,0 +1,99 @@
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

@@ -0,0 +1,60 @@
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

@@ -0,0 +1,84 @@
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

@@ -0,0 +1,38 @@
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

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,23 @@
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

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,79 @@
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

@@ -0,0 +1,55 @@
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

@@ -0,0 +1,53 @@
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

@@ -0,0 +1,60 @@
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

@@ -0,0 +1,56 @@
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

@@ -0,0 +1,45 @@
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

@@ -0,0 +1,14 @@
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

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

@@ -0,0 +1,39 @@
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

@@ -0,0 +1,32 @@
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

@@ -0,0 +1,30 @@
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

@@ -0,0 +1,16 @@
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

@@ -0,0 +1,28 @@
import { useEffect } from "react";
import { useAccount } from "../2_main";
import { uploadMusicTracks } from "@/3_actions";
import { MusicaAccount } from "@/1_schema";
export function useUploadExampleData() {
const { me } = useAccount({
root: {}
});
const shouldUploadOnboardingData = me?.root?.exampleDataLoaded === false;
useEffect(() => {
if (me?.root && shouldUploadOnboardingData) {
me.root.exampleDataLoaded = true;
uploadOnboardingData(me).then(() => {
me.root.exampleDataLoaded = true;
});
}
}, [shouldUploadOnboardingData])
}
async function uploadOnboardingData(me: MusicaAccount) {
const trackFile = await (await fetch("/example.mp3")).blob();
return uploadMusicTracks(me, [new File([trackFile], "Example song")]);
}

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