Compare commits
227 Commits
jazz-react
...
jazz-react
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87ef6d5064 | ||
|
|
e2ec51d3db | ||
|
|
566be392da | ||
|
|
7497ed2a7c | ||
|
|
13b0dfc38f | ||
|
|
1debe44cf1 | ||
|
|
b737e14069 | ||
|
|
82fd148370 | ||
|
|
d0fc6dbc13 | ||
|
|
f588c3f265 | ||
|
|
659e80bbf4 | ||
|
|
3585e2acde | ||
|
|
98554523c8 | ||
|
|
dbeaa35110 | ||
|
|
61c719c728 | ||
|
|
48fddecd30 | ||
|
|
f5779077df | ||
|
|
5149fb6fbf | ||
|
|
dcc6134180 | ||
|
|
ecb336829d | ||
|
|
2778f919a6 | ||
|
|
ab6fcc7a7a | ||
|
|
aa284802ab | ||
|
|
a6af56bc2e | ||
|
|
751f0b4d10 | ||
|
|
a233eb6396 | ||
|
|
c91d9b99be | ||
|
|
a5b798a257 | ||
|
|
18b71442ca | ||
|
|
f350e902a8 | ||
|
|
38a6447887 | ||
|
|
39da70558b | ||
|
|
73f62966ed | ||
|
|
c41e035b67 | ||
|
|
f4c466ea30 | ||
|
|
91e9a267f0 | ||
|
|
d1ec24a05b | ||
|
|
657159e034 | ||
|
|
0327913e53 | ||
|
|
5ad9d652be | ||
|
|
3cde94a94b | ||
|
|
a7f036c71f | ||
|
|
20b47ad8af | ||
|
|
6f2eac383d | ||
|
|
5d40e83297 | ||
|
|
7721ccffaf | ||
|
|
aa7edc8e3f | ||
|
|
bba1728988 | ||
|
|
f1dff3e3fd | ||
|
|
d5604b9eb5 | ||
|
|
3acf61e2c3 | ||
|
|
3a0d25d048 | ||
|
|
28720043a4 | ||
|
|
79491353da | ||
|
|
35eb8829ea | ||
|
|
5cd38d5974 | ||
|
|
30f1fa1557 | ||
|
|
fe035c3b3f | ||
|
|
1efe8ee6c7 | ||
|
|
f410a8540b | ||
|
|
acb6249292 | ||
|
|
77c38a1c4b | ||
|
|
6e1d56a9b0 | ||
|
|
d10a7b9c76 | ||
|
|
209f295399 | ||
|
|
cac2ec9aa1 | ||
|
|
30a9e7a94f | ||
|
|
af5adc37ca | ||
|
|
af12226998 | ||
|
|
b8bf440f84 | ||
|
|
5198a249d1 | ||
|
|
918322bc4a | ||
|
|
b66791d81a | ||
|
|
a2fbd9ed22 | ||
|
|
89111c5bf3 | ||
|
|
d4a358d0f9 | ||
|
|
daf24d65f6 | ||
|
|
9517cef183 | ||
|
|
f70950cc82 | ||
|
|
ec3dffa3e4 | ||
|
|
207bea3c1c | ||
|
|
fcae20d77c | ||
|
|
0a542941ad | ||
|
|
5dceef8c03 | ||
|
|
fb01eb42f1 | ||
|
|
e1e3a8352e | ||
|
|
09678c4277 | ||
|
|
5b83669368 | ||
|
|
30c8c922a3 | ||
|
|
e05b327eb5 | ||
|
|
06665cbbf7 | ||
|
|
c0425df2ee | ||
|
|
42cbef8063 | ||
|
|
d1abf06621 | ||
|
|
1c00020859 | ||
|
|
00dd9cea99 | ||
|
|
607cd0ab97 | ||
|
|
9f65e56b21 | ||
|
|
82c5d1bf5c | ||
|
|
deda3e571f | ||
|
|
2d4039c5d2 | ||
|
|
63dd9190a2 | ||
|
|
3dfa630923 | ||
|
|
63daf6af09 | ||
|
|
c85ccd96aa | ||
|
|
403b430ee4 | ||
|
|
f58845ef74 | ||
|
|
38fad35945 | ||
|
|
2533740a7c | ||
|
|
109a7d6128 | ||
|
|
ec1906e262 | ||
|
|
6f8028253d | ||
|
|
0704a76006 | ||
|
|
ab93a0b679 | ||
|
|
ed85560547 | ||
|
|
a242adf3b3 | ||
|
|
1a9c7ecefd | ||
|
|
aded528a27 | ||
|
|
152ae9865e | ||
|
|
8bd92b44f5 | ||
|
|
8d15e04fd0 | ||
|
|
b7f3ece0f1 | ||
|
|
4c94ae3da2 | ||
|
|
cd23ce0a51 | ||
|
|
8538036af7 | ||
|
|
6ee7133924 | ||
|
|
4fa19ece52 | ||
|
|
875ac4b84f | ||
|
|
346e797447 | ||
|
|
7f6d778301 | ||
|
|
40287a5682 | ||
|
|
bcfe4b794e | ||
|
|
7a27193ceb | ||
|
|
6db14cefa0 | ||
|
|
b57e1a64ac | ||
|
|
a1b83cceea | ||
|
|
cd29438304 | ||
|
|
7695a817ca | ||
|
|
2ddce1a2de | ||
|
|
49a8b54a8a | ||
|
|
04b15c7d4a | ||
|
|
5909e7e894 | ||
|
|
7a3d519970 | ||
|
|
35bbcd94e6 | ||
|
|
f6629b2b58 | ||
|
|
b4891db9fa | ||
|
|
4e16575f97 | ||
|
|
12ab20ecd9 | ||
|
|
2c3a40c94b | ||
|
|
0d8175ba1c | ||
|
|
311ed74709 | ||
|
|
c86301fcfd | ||
|
|
032f69f692 | ||
|
|
6dc52b2a6d | ||
|
|
1232c0240a | ||
|
|
55fa74f44a | ||
|
|
627e04151d | ||
|
|
5d91f9f8dc | ||
|
|
08f1f77834 | ||
|
|
ea882aba63 | ||
|
|
513a78ab9b | ||
|
|
406ab9b0da | ||
|
|
140f6616cb | ||
|
|
b09589b15e | ||
|
|
00638897f4 | ||
|
|
4cd68c1930 | ||
|
|
f1f3607e28 | ||
|
|
7b9f503b9e | ||
|
|
a18f44399c | ||
|
|
5094e6d536 | ||
|
|
39242e7f68 | ||
|
|
be7e208b1c | ||
|
|
c3bffbf4de | ||
|
|
d46467f318 | ||
|
|
6d21400803 | ||
|
|
db53161296 | ||
|
|
cb4a116cec | ||
|
|
013199b9b2 | ||
|
|
a8b74ff703 | ||
|
|
b1985a9161 | ||
|
|
3bf512719f | ||
|
|
d83ed69d41 | ||
|
|
fdde8db664 | ||
|
|
dd5581ba2d | ||
|
|
07fe2b9dcf | ||
|
|
b297c93b80 | ||
|
|
d2e62e5b44 | ||
|
|
fe73ce7514 | ||
|
|
0fed16cea4 | ||
|
|
08804ad435 | ||
|
|
79fa7724ad | ||
|
|
4604c2184a | ||
|
|
11bac697fb | ||
|
|
96cec27f89 | ||
|
|
bcd412b8f9 | ||
|
|
6b456e2841 | ||
|
|
1df72b3dc8 | ||
|
|
402d692739 | ||
|
|
fad46b2fb5 | ||
|
|
0153c80cf2 | ||
|
|
4f75dc8d97 | ||
|
|
e2c79cccb5 | ||
|
|
c14a0e05be | ||
|
|
016dd3a5dd | ||
|
|
5c4ca9103c | ||
|
|
b4aad92907 | ||
|
|
56d1e095a1 | ||
|
|
6dee9aae49 | ||
|
|
a10bff981e | ||
|
|
e333f7884a | ||
|
|
8ea7bf237b | ||
|
|
5e8409fa08 | ||
|
|
23354c1767 | ||
|
|
0efb69d0db | ||
|
|
0462c4e41b | ||
|
|
70a5673197 | ||
|
|
9ec3203485 | ||
|
|
1a46f9b2e1 | ||
|
|
77bb26a8d7 | ||
|
|
2a36dcf592 | ||
|
|
fc2bcadbe2 | ||
|
|
46b0cc1adb | ||
|
|
d75d1c6a3f | ||
|
|
13b236aeed | ||
|
|
1c0a61b0b2 | ||
|
|
3f5ef7e799 | ||
|
|
e7a573fa94 |
29
.github/workflows/build-and-deploy.yaml
vendored
29
.github/workflows/build-and-deploy.yaml
vendored
@@ -3,8 +3,6 @@ name: Build and Deploy
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
build-examples:
|
||||
@@ -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
|
||||
|
||||
|
||||
21
.github/workflows/monorepo-linting.yml
vendored
Normal file
21
.github/workflows/monorepo-linting.yml
vendored
Normal 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
64
.github/workflows/playwright.yml
vendored
Normal 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
47
.github/workflows/unit-test.yml
vendored
Normal 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
3
.gitignore
vendored
@@ -3,4 +3,5 @@ yarn-error.log
|
||||
lerna-debug.log
|
||||
docsTmp
|
||||
.DS_Store
|
||||
.turbo
|
||||
.turbo
|
||||
coverage
|
||||
1
.node-version
Normal file
1
.node-version
Normal file
@@ -0,0 +1 @@
|
||||
20
|
||||
@@ -23,5 +23,8 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
TwitAllTwitsCreatorCredentials.json
|
||||
sync-db/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
34
e2e/BinaryCoStream/package.json
Normal file
34
e2e/BinaryCoStream/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
49
e2e/BinaryCoStream/playwright.config.ts
Normal file
49
e2e/BinaryCoStream/playwright.config.ts
Normal 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,
|
||||
});
|
||||
42
e2e/BinaryCoStream/src/DownloaderPeer.tsx
Normal file
42
e2e/BinaryCoStream/src/DownloaderPeer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
e2e/BinaryCoStream/src/UploaderPeer.tsx
Normal file
86
e2e/BinaryCoStream/src/UploaderPeer.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
24
e2e/BinaryCoStream/src/app.tsx
Normal file
24
e2e/BinaryCoStream/src/app.tsx
Normal 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>,
|
||||
);
|
||||
38
e2e/BinaryCoStream/src/jazz.tsx
Normal file
38
e2e/BinaryCoStream/src/jazz.tsx
Normal 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 };
|
||||
67
e2e/BinaryCoStream/src/lib/BytesRadioGroup.tsx
Normal file
67
e2e/BinaryCoStream/src/lib/BytesRadioGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
e2e/BinaryCoStream/src/lib/createCredentiallessIframe.ts
Normal file
21
e2e/BinaryCoStream/src/lib/createCredentiallessIframe.ts
Normal 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;
|
||||
}
|
||||
27
e2e/BinaryCoStream/src/lib/generateTestFile.ts
Normal file
27
e2e/BinaryCoStream/src/lib/generateTestFile.ts
Normal 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;
|
||||
}
|
||||
|
||||
8
e2e/BinaryCoStream/src/lib/getDownloaderPeerUrl.ts
Normal file
8
e2e/BinaryCoStream/src/lib/getDownloaderPeerUrl.ts
Normal 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();
|
||||
}
|
||||
14
e2e/BinaryCoStream/src/lib/searchParams.ts
Normal file
14
e2e/BinaryCoStream/src/lib/searchParams.ts
Normal 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());
|
||||
}
|
||||
39
e2e/BinaryCoStream/src/lib/waitForCoValue.ts
Normal file
39
e2e/BinaryCoStream/src/lib/waitForCoValue.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
7
e2e/BinaryCoStream/src/schema.tsx
Normal file
7
e2e/BinaryCoStream/src/schema.tsx
Normal 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;
|
||||
}
|
||||
36
e2e/BinaryCoStream/tests/sync.spec.ts
Normal file
36
e2e/BinaryCoStream/tests/sync.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -19,11 +19,7 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
2
examples/chat/.gitignore
vendored
2
examples/chat/.gitignore
vendored
@@ -22,3 +22,5 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
sync-db/
|
||||
@@ -1,5 +1,217 @@
|
||||
# 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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-chat",
|
||||
"private": true,
|
||||
"version": "0.0.71",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>);
|
||||
2
examples/inspector/.gitignore
vendored
2
examples/inspector/.gitignore
vendored
@@ -22,3 +22,5 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
sync-db/
|
||||
@@ -1,5 +1,190 @@
|
||||
# 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
|
||||
|
||||
@@ -1,42 +1,7 @@
|
||||
# Jazz Chat Example
|
||||
# Jazz Inspector
|
||||
|
||||
Live version: https://example-chat.jazz.tools
|
||||
Live address: https://inspector.jazz.tools
|
||||
|
||||
## Installing & running the example locally
|
||||
Use this to visually inspect a Jazz account or other CoValue.
|
||||
|
||||
(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
|
||||
pnpm pack --pack-destination /tmp
|
||||
mkdir -p ~/jazz-examples/chat # or any other directory
|
||||
tar -xf /tmp/jazz-example-chat-* --strip-components 1 -C ~/jazz-examples/chat
|
||||
cd ~/jazz-examples/chat
|
||||
```
|
||||
|
||||
This ensures that you have the example app without git history and independent of the Jazz multi-package monorepo.
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Start the dev server:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Questions / problems / feedback
|
||||
|
||||
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
|
||||
|
||||
|
||||
## Configuration: sync server
|
||||
|
||||
By default, the example app uses [Jazz Global Mesh](https://jazz.tools/mesh) (`wss://sync.jazz.tools`) - so cross-device use, invites and collaboration should just work.
|
||||
|
||||
You can also run a local sync server by running `npx cojson-simple-sync` and adding the query param `?sync=ws://localhost:4200` to the URL of the example app (for example: `http://localhost:5173/?sync=ws://localhost:4200`), or by setting the `sync` parameter of the `<WithJazz>` provider component in [./src/2_main.tsx](./src/2_main.tsx).
|
||||
For now, you can get your account credentials from the `jazz-logged-in-secret` local-storage key from within your Jazz app.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-inspector",
|
||||
"private": true,
|
||||
"version": "0.0.51",
|
||||
"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:*",
|
||||
"cojson": "workspace:*",
|
||||
"cojson-transport-ws": "workspace:*",
|
||||
"effect": "^3.5.2",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
WasmCrypto,
|
||||
} from "cojson";
|
||||
import { createWebSocketPeer } from "cojson-transport-ws";
|
||||
import { Effect } from "effect";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Breadcrumbs } from "./breadcrumbs";
|
||||
import { usePagePath } from "./use-page-path";
|
||||
@@ -62,13 +61,11 @@ export default function CoJsonViewerApp() {
|
||||
}
|
||||
|
||||
WasmCrypto.create().then(async (crypto) => {
|
||||
const wsPeer = await Effect.runPromise(
|
||||
createWebSocketPeer({
|
||||
id: "mesh",
|
||||
websocket: new WebSocket("wss://mesh.jazz.tools"),
|
||||
role: "server",
|
||||
}),
|
||||
);
|
||||
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,
|
||||
|
||||
@@ -104,6 +104,54 @@ export async function resolveCoValue(
|
||||
};
|
||||
}
|
||||
|
||||
function subscribeToCoValue(
|
||||
coValueId: CoID<RawCoValue>,
|
||||
node: LocalNode,
|
||||
callback: (result: Awaited<ReturnType<typeof resolveCoValue>>) => void,
|
||||
) {
|
||||
return node.subscribe(coValueId, (value) => {
|
||||
if (value === "unavailable") {
|
||||
callback({
|
||||
value: undefined,
|
||||
snapshot: "unavailable",
|
||||
type: null,
|
||||
extendedType: undefined,
|
||||
});
|
||||
} else {
|
||||
const snapshot = value.toJSON() as JSONObject;
|
||||
const type = value.type as CoJsonType;
|
||||
let extendedType: ExtendedCoJsonType | undefined;
|
||||
|
||||
if (type === "comap") {
|
||||
if (isBrowserImage(snapshot)) {
|
||||
extendedType = "image";
|
||||
} else if (isAccount(snapshot)) {
|
||||
extendedType = "account";
|
||||
} else if (isGroup(snapshot)) {
|
||||
extendedType = "group";
|
||||
} else {
|
||||
const children = Object.values(snapshot).slice(0, 10);
|
||||
if (
|
||||
children.every(
|
||||
(c) => typeof c === "string" && c.startsWith("co_"),
|
||||
) &&
|
||||
children.length > 3
|
||||
) {
|
||||
extendedType = "record";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callback({
|
||||
value,
|
||||
snapshot,
|
||||
type,
|
||||
extendedType,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useResolvedCoValue(
|
||||
coValueId: CoID<RawCoValue>,
|
||||
node: LocalNode,
|
||||
@@ -112,7 +160,17 @@ export function useResolvedCoValue(
|
||||
useState<Awaited<ReturnType<typeof resolveCoValue>>>();
|
||||
|
||||
useEffect(() => {
|
||||
resolveCoValue(coValueId, node).then(setResult);
|
||||
let isMounted = true;
|
||||
const unsubscribe = subscribeToCoValue(coValueId, node, (newResult) => {
|
||||
if (isMounted) {
|
||||
setResult(newResult);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [coValueId, node]);
|
||||
|
||||
return (
|
||||
@@ -134,18 +192,30 @@ export function useResolvedCoValues(
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("RETECHING", coValueIds);
|
||||
const fetchResults = async () => {
|
||||
if (coValueIds.length === 0) return;
|
||||
const resolvedValues = await Promise.all(
|
||||
coValueIds.map((coValueId) => resolveCoValue(coValueId, node)),
|
||||
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);
|
||||
});
|
||||
|
||||
console.log({ resolvedValues });
|
||||
setResults(resolvedValues);
|
||||
return () => {
|
||||
isMounted = false;
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
};
|
||||
|
||||
fetchResults();
|
||||
}, [coValueIds, node]);
|
||||
|
||||
return results;
|
||||
|
||||
@@ -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',
|
||||
9
examples/musicPlayer/.prettierrc.js
Normal file
9
examples/musicPlayer/.prettierrc.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/** @type {import("prettier").Config} */
|
||||
const config = {
|
||||
trailingComma: "all",
|
||||
tabWidth: 4,
|
||||
semi: true,
|
||||
singleQuote: false,
|
||||
};
|
||||
|
||||
export default config;
|
||||
13
examples/musicPlayer/CHANGELOG.md
Normal file
13
examples/musicPlayer/CHANGELOG.md
Normal 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
|
||||
@@ -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).
|
||||
13
examples/musicPlayer/index.html
Normal file
13
examples/musicPlayer/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/jazz-logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Jazz - Music Player example</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/2_main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,4 @@
|
||||
job "chat$BRANCH_SUFFIX" {
|
||||
job "example-musicPlayer$BRANCH_SUFFIX" {
|
||||
region = "global"
|
||||
datacenters = ["*"]
|
||||
|
||||
@@ -41,7 +41,7 @@ job "chat$BRANCH_SUFFIX" {
|
||||
|
||||
service {
|
||||
tags = ["public"]
|
||||
name = "chat$BRANCH_SUFFIX"
|
||||
name = "example-pets$BRANCH_SUFFIX"
|
||||
port = "http"
|
||||
provider = "consul"
|
||||
}
|
||||
44
examples/musicPlayer/package.json
Normal file
44
examples/musicPlayer/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
examples/musicPlayer/public/example.mp3
Normal file
BIN
examples/musicPlayer/public/example.mp3
Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
124
examples/musicPlayer/src/1_schema.ts
Normal file
124
examples/musicPlayer/src/1_schema.ts
Normal 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 */
|
||||
106
examples/musicPlayer/src/2_main.tsx
Normal file
106
examples/musicPlayer/src/2_main.tsx
Normal 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>,
|
||||
);
|
||||
128
examples/musicPlayer/src/3_actions.ts
Normal file
128
examples/musicPlayer/src/3_actions.ts
Normal 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);
|
||||
}
|
||||
99
examples/musicPlayer/src/4_useMediaPlayer.ts
Normal file
99
examples/musicPlayer/src/4_useMediaPlayer.ts
Normal 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>;
|
||||
60
examples/musicPlayer/src/5_HomePage.tsx
Normal file
60
examples/musicPlayer/src/5_HomePage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
examples/musicPlayer/src/6_PlaylistPage.tsx
Normal file
84
examples/musicPlayer/src/6_PlaylistPage.tsx
Normal 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)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
examples/musicPlayer/src/7_InvitePage.tsx
Normal file
38
examples/musicPlayer/src/7_InvitePage.tsx
Normal 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>;
|
||||
}
|
||||
20
examples/musicPlayer/src/basicComponents/Button.tsx
Normal file
20
examples/musicPlayer/src/basicComponents/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
13
examples/musicPlayer/src/basicComponents/Link.tsx
Normal file
13
examples/musicPlayer/src/basicComponents/Link.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
55
examples/musicPlayer/src/components/MusicTrackRow.tsx
Normal file
55
examples/musicPlayer/src/components/MusicTrackRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
examples/musicPlayer/src/components/PlayerControls.tsx
Normal file
53
examples/musicPlayer/src/components/PlayerControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
examples/musicPlayer/src/components/Waveform.tsx
Normal file
60
examples/musicPlayer/src/components/Waveform.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
examples/musicPlayer/src/lib/audio/AudioManager.ts
Normal file
56
examples/musicPlayer/src/lib/audio/AudioManager.ts
Normal 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;
|
||||
45
examples/musicPlayer/src/lib/audio/getAudioFileData.ts
Normal file
45
examples/musicPlayer/src/lib/audio/getAudioFileData.ts
Normal 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;
|
||||
};
|
||||
14
examples/musicPlayer/src/lib/audio/useMediaEndListener.ts
Normal file
14
examples/musicPlayer/src/lib/audio/useMediaEndListener.ts
Normal 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]);
|
||||
}
|
||||
24
examples/musicPlayer/src/lib/audio/usePlayMedia.ts
Normal file
24
examples/musicPlayer/src/lib/audio/usePlayMedia.ts
Normal 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;
|
||||
}
|
||||
39
examples/musicPlayer/src/lib/audio/usePlayState.ts
Normal file
39
examples/musicPlayer/src/lib/audio/usePlayState.ts
Normal 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 };
|
||||
}
|
||||
32
examples/musicPlayer/src/lib/audio/usePlayerCurrentTime.ts
Normal file
32
examples/musicPlayer/src/lib/audio/usePlayerCurrentTime.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
30
examples/musicPlayer/src/lib/getters.ts
Normal file
30
examples/musicPlayer/src/lib/getters.ts
Normal 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];
|
||||
}
|
||||
16
examples/musicPlayer/src/lib/useKeyboardListener.ts
Normal file
16
examples/musicPlayer/src/lib/useKeyboardListener.ts
Normal 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]);
|
||||
}
|
||||
28
examples/musicPlayer/src/lib/useUploadExampleData.ts
Normal file
28
examples/musicPlayer/src/lib/useUploadExampleData.ts
Normal 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")]);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
13
examples/password-manager/CHANGELOG.md
Normal file
13
examples/password-manager/CHANGELOG.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# jazz-password-manager
|
||||
|
||||
## 0.0.1
|
||||
|
||||
### 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
|
||||
67
examples/password-manager/README.md
Normal file
67
examples/password-manager/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Password Manager Example
|
||||
|
||||
Live version: https://passwords-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/password-manager
|
||||
pnpm pack --pack-destination /tmp
|
||||
mkdir -p ~/jazz-examples/password-manager # or any other directory
|
||||
tar -xf /tmp/jazz-example-pass-manager-* --strip-components 1 -C ~/jazz-examples/password-manager
|
||||
cd ~/jazz-examples/password-manager
|
||||
```
|
||||
|
||||
This ensures that you have the example app without git history and independent of the Jazz multi-package monorepo.
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Start the dev server:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- [`src/components`](./src/components/): UI components
|
||||
- [`src/1_schema.ts`](./src/1_schema.ts): Jazz data model
|
||||
- [`src/2_main.tsx`](./src/2_main.tsx): Main App component wrapped in `<Jazz.Provider>`
|
||||
- [`src/3_vault.tsx`](./src/3_vault.tsx): Password Manager Vault page
|
||||
- [`src/4_actions.tsx`](./src/4_actions.tsx): Jazz specific actions
|
||||
- [`src/5_App.tsx`](./src/5_App.tsx): App router - also handles invite links
|
||||
- [`src/types.ts`](./src/types.ts): shared types
|
||||
|
||||
## Walkthrough
|
||||
|
||||
### Main parts
|
||||
|
||||
1. Define the data model with CoJSON: [`src/1_schema.ts`](./src/1_schema.ts)
|
||||
|
||||
2. Wrap the App with the top-level provider `<Jazz.Provider>`: [`src/2_main.tsx`](./src/2_main.tsx)
|
||||
|
||||
3. Reactively render password items from folders inside a table, creating/sharing/deleting folders, creating/editing/deleting password items: [`src/3_vault.tsx`](./src/3_vault.tsx)
|
||||
|
||||
4. Implement Jazz specific actions: [`src/4_actions.tsx`](./src/4_actions.tsx)
|
||||
|
||||
5. Implement useAcceptInvite(): [`src/5_App.tsx`](./src/5_App.tsx)
|
||||
|
||||
## Questions / problems / feedback
|
||||
|
||||
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
|
||||
|
||||
## Configuration: sync server
|
||||
|
||||
By default, the example app uses [Jazz Global Mesh](https://jazz.tools/mesh) (`wss://sync.jazz.tools`) - so cross-device use, invites and collaboration should just work.
|
||||
|
||||
You can also run a local sync server by running `npx cojson-simple-sync` and adding the query param `?sync=ws://localhost:4200` to the URL of the example app (for example: `http://localhost:5173/?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).
|
||||
BIN
examples/password-manager/demo.png
Normal file
BIN
examples/password-manager/demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
@@ -2,9 +2,8 @@
|
||||
<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>Twit</title>
|
||||
<title>Vite + TS + React + Tailwind</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
37
examples/password-manager/package.json
Normal file
37
examples/password-manager/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "jazz-password-manager",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives",
|
||||
"preview": "vite preview",
|
||||
"clean-install": "rm -rf node_modules pnpm-lock.yaml && pnpm install"
|
||||
},
|
||||
"dependencies": {
|
||||
"jazz-react": "workspace:*",
|
||||
"jazz-tools": "workspace:*",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.41.5",
|
||||
"react-router-dom": "^6.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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": "^4.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"
|
||||
}
|
||||
}
|
||||
81
examples/password-manager/src/1_schema.ts
Normal file
81
examples/password-manager/src/1_schema.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Account, co, CoList, CoMap, Group, Profile } from "jazz-tools";
|
||||
|
||||
export class PasswordItem extends CoMap {
|
||||
name = co.string;
|
||||
username = co.optional.string;
|
||||
username_input_selector = co.optional.string;
|
||||
password = co.string;
|
||||
password_input_selector = co.optional.string;
|
||||
uri = co.optional.string;
|
||||
folder = co.ref(Folder);
|
||||
deleted = co.boolean;
|
||||
}
|
||||
|
||||
export class PasswordList extends CoList.Of(co.ref(PasswordItem)) {}
|
||||
|
||||
export class Folder extends CoMap {
|
||||
name = co.string;
|
||||
items = co.ref(PasswordList);
|
||||
}
|
||||
|
||||
export class FolderList extends CoList.Of(co.ref(Folder)) {}
|
||||
|
||||
export class PasswordManagerAccountRoot extends CoMap {
|
||||
folders = co.ref(FolderList);
|
||||
}
|
||||
|
||||
export class PasswordManagerAccount extends Account {
|
||||
profile = co.ref(Profile);
|
||||
root = co.ref(PasswordManagerAccountRoot);
|
||||
|
||||
migrate(this: PasswordManagerAccount, creationProps?: { name: string }) {
|
||||
super.migrate(creationProps);
|
||||
if (!this._refs.root) {
|
||||
const group = Group.create({ owner: this });
|
||||
const firstFolder = Folder.create(
|
||||
{
|
||||
name: "Default",
|
||||
items: PasswordList.create([], { owner: group }),
|
||||
},
|
||||
{ owner: group }
|
||||
);
|
||||
|
||||
firstFolder.items?.push(
|
||||
PasswordItem.create(
|
||||
{
|
||||
name: "Gmail",
|
||||
username: "user@gmail.com",
|
||||
password: "password123",
|
||||
uri: "https://gmail.com",
|
||||
folder: firstFolder,
|
||||
deleted: false,
|
||||
},
|
||||
{ owner: group }
|
||||
)
|
||||
);
|
||||
|
||||
firstFolder.items?.push(
|
||||
PasswordItem.create(
|
||||
{
|
||||
name: "Facebook",
|
||||
username: "user@facebook.com",
|
||||
password: "facebookpass",
|
||||
uri: "https://facebook.com",
|
||||
folder: firstFolder,
|
||||
deleted: false,
|
||||
},
|
||||
{ owner: group }
|
||||
)
|
||||
);
|
||||
|
||||
this.root = PasswordManagerAccountRoot.create(
|
||||
{
|
||||
folders: FolderList.create([firstFolder], {
|
||||
owner: this,
|
||||
}),
|
||||
},
|
||||
{ owner: this }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
examples/password-manager/src/2_main.tsx
Normal file
23
examples/password-manager/src/2_main.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./5_App.tsx";
|
||||
import "./index.css";
|
||||
import { createJazzReactContext, PasskeyAuth } from "jazz-react";
|
||||
import { PasswordManagerAccount } from "./1_schema.ts";
|
||||
|
||||
const auth = PasskeyAuth<PasswordManagerAccount>({
|
||||
appName: "Jazz Password Manager",
|
||||
accountSchema: PasswordManagerAccount,
|
||||
});
|
||||
|
||||
const Jazz = createJazzReactContext<PasswordManagerAccount>({
|
||||
auth,
|
||||
peer: "wss://mesh.jazz.tools/?key=you@example.com",
|
||||
});
|
||||
|
||||
export const { useAccount, useCoState, useAcceptInvite } = Jazz;
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<Jazz.Provider>
|
||||
<App />
|
||||
</Jazz.Provider>
|
||||
);
|
||||
261
examples/password-manager/src/3_vault.tsx
Normal file
261
examples/password-manager/src/3_vault.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Button from "./components/button";
|
||||
import Table from "./components/table";
|
||||
import NewItemModal from "./components/new-item-modal";
|
||||
import InviteModal from "./components/invite-modal";
|
||||
|
||||
import { saveItem, deleteItem, createFolder, updateItem } from "./4_actions";
|
||||
import { Alert, AlertDescription } from "./components/alert";
|
||||
import { Folder, FolderList, PasswordItem } from "./1_schema";
|
||||
import { useAccount, useCoState } from "./2_main";
|
||||
import { CoMapInit, Group, ID } from "jazz-tools";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { PasswordItemFormValues } from "./types";
|
||||
|
||||
const VaultPage: React.FC = () => {
|
||||
const { me, logOut } = useAccount();
|
||||
const sharedFolderId = useParams<{ sharedFolderId: ID<Folder> }>()
|
||||
.sharedFolderId;
|
||||
const sharedFolder = useCoState(Folder, sharedFolderId);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!sharedFolderId || !sharedFolder || !me.root?.folders) return;
|
||||
const existsIndex = me.root?.folders.findIndex(
|
||||
(f) => f?.id === sharedFolder.id
|
||||
);
|
||||
if (existsIndex > -1) {
|
||||
me.root?.folders?.splice(existsIndex, 1);
|
||||
}
|
||||
me.root?.folders?.push(sharedFolder);
|
||||
navigate("/vault");
|
||||
}, [sharedFolder, me.root?.folders, sharedFolderId, navigate]);
|
||||
|
||||
const items = me.root?.folders?.flatMap(
|
||||
(folder) =>
|
||||
folder?.items?.filter(
|
||||
(item): item is Exclude<typeof item, null> => !!item
|
||||
) || []
|
||||
);
|
||||
const folders = useCoState(FolderList, me.root?._refs.folders?.id, [
|
||||
{ items: [{}] },
|
||||
]);
|
||||
|
||||
const [selectedFolder, setSelectedFolder] = useState<Folder | undefined>();
|
||||
const [isNewItemModalOpen, setIsNewItemModalOpen] = useState(false);
|
||||
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
||||
const [isNewFolderInputVisible, setIsNewFolderInputVisible] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [editingItem, setEditingItem] = useState<PasswordItem | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const filteredItems = selectedFolder
|
||||
? items?.filter(
|
||||
(item) => item?.folder?.name === selectedFolder.name && !item.deleted
|
||||
)
|
||||
: items?.filter((item) => !item?.deleted);
|
||||
|
||||
const handleSaveNewItem = async (newItem: PasswordItemFormValues) => {
|
||||
try {
|
||||
saveItem(newItem as CoMapInit<PasswordItem>);
|
||||
} catch (err: any) {
|
||||
setError("Failed to save new item. Please try again.");
|
||||
throw new Error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateItem = async (updatedItem: PasswordItemFormValues) => {
|
||||
if (!editingItem) return;
|
||||
try {
|
||||
updateItem(editingItem, updatedItem);
|
||||
setEditingItem(null);
|
||||
} catch (err: any) {
|
||||
setError("Failed to update item. Please try again.");
|
||||
throw new Error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteItem = async (item: PasswordItem) => {
|
||||
try {
|
||||
deleteItem(item);
|
||||
} catch (err) {
|
||||
setError("Failed to delete item. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (newFolderName) {
|
||||
try {
|
||||
const newFolder = createFolder(newFolderName, me);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderInputVisible(false);
|
||||
setSelectedFolder(newFolder);
|
||||
} catch (err) {
|
||||
setError("Failed to create folder. Please try again.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFolder = async () => {
|
||||
try {
|
||||
const selectedFolderIndex = me.root?.folders?.findIndex(
|
||||
(folder) => folder?.id === selectedFolder?.id
|
||||
);
|
||||
if (selectedFolderIndex !== undefined && selectedFolderIndex > -1)
|
||||
me.root?.folders?.splice(selectedFolderIndex, 1);
|
||||
} catch (err) {
|
||||
setError("Failed to create folder. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
logOut();
|
||||
} catch (err) {
|
||||
setError("Failed to logout. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ header: "Name", accessor: "name" as const },
|
||||
{ header: "Username", accessor: "username" as const },
|
||||
{ header: "URI", accessor: "uri" as const },
|
||||
{
|
||||
header: "Actions",
|
||||
accessor: "id" as const,
|
||||
render: (item: PasswordItem) => (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => navigator.clipboard.writeText(item.password)}>
|
||||
Copy Password
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setEditingItem(item)}
|
||||
disabled={
|
||||
item._owner.castAs(Group).myRole() !== "admin" &&
|
||||
item._owner.castAs(Group).myRole() !== "writer"
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDeleteItem(item)}
|
||||
variant="danger"
|
||||
disabled={
|
||||
item._owner.castAs(Group).myRole() !== "admin" &&
|
||||
item._owner.castAs(Group).myRole() !== "writer"
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="container flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold mb-8">Password Vault</h1>
|
||||
<Button onClick={handleLogout} variant="secondary">
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="mb-4 flex flex-wrap justify-between items-center gap-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
key={"folder-all"}
|
||||
onClick={() => setSelectedFolder(undefined)}
|
||||
variant={!selectedFolder ? "primary" : "secondary"}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
{folders?.map((folder) => (
|
||||
<Button
|
||||
key={folder.id}
|
||||
onClick={() => setSelectedFolder(folder)}
|
||||
variant={
|
||||
selectedFolder?.name === folder?.name ? "primary" : "secondary"
|
||||
}
|
||||
>
|
||||
{folder?.name}
|
||||
</Button>
|
||||
))}
|
||||
{isNewFolderInputVisible ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
className="border rounded px-2 py-1"
|
||||
/>
|
||||
<Button onClick={handleCreateFolder}>Save</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => setIsNewFolderInputVisible(true)}>
|
||||
New Folder
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => setIsNewItemModalOpen(true)}
|
||||
disabled={
|
||||
!selectedFolder ||
|
||||
(selectedFolder._owner.castAs(Group).myRole() !== "admin" &&
|
||||
selectedFolder._owner.castAs(Group).myRole() !== "writer")
|
||||
}
|
||||
>
|
||||
New Item
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsInviteModalOpen(true)}
|
||||
disabled={
|
||||
!selectedFolder ||
|
||||
(selectedFolder._owner.castAs(Group).myRole() !== "admin" &&
|
||||
selectedFolder._owner.castAs(Group).myRole() !== "writer")
|
||||
}
|
||||
>
|
||||
Share Folder
|
||||
</Button>
|
||||
<Button onClick={handleDeleteFolder} disabled={!selectedFolder}>
|
||||
Delete Folder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table data={filteredItems} columns={columns} />
|
||||
</div>
|
||||
{folders ? (
|
||||
<NewItemModal
|
||||
isOpen={isNewItemModalOpen || !!editingItem}
|
||||
onClose={() => {
|
||||
setIsNewItemModalOpen(false);
|
||||
setEditingItem(null);
|
||||
}}
|
||||
onSave={editingItem ? handleUpdateItem : handleSaveNewItem}
|
||||
folders={folders}
|
||||
selectedFolder={selectedFolder}
|
||||
initialValues={
|
||||
editingItem && editingItem.folder ? { ...editingItem } : undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{folders ? (
|
||||
<InviteModal
|
||||
isOpen={isInviteModalOpen}
|
||||
onClose={() => setIsInviteModalOpen(false)}
|
||||
selectedFolder={selectedFolder}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VaultPage;
|
||||
56
examples/password-manager/src/4_actions.ts
Normal file
56
examples/password-manager/src/4_actions.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Group } from "jazz-tools";
|
||||
import {
|
||||
Folder,
|
||||
PasswordItem,
|
||||
PasswordList,
|
||||
PasswordManagerAccount,
|
||||
} from "./1_schema";
|
||||
import { CoMapInit } from "jazz-tools";
|
||||
import { createInviteLink } from "jazz-react";
|
||||
import { PasswordItemFormValues } from "./types";
|
||||
|
||||
export const saveItem = (item: CoMapInit<PasswordItem>): PasswordItem => {
|
||||
const passwordItem = PasswordItem.create(item, {
|
||||
owner: item.folder!._owner,
|
||||
});
|
||||
passwordItem.folder?.items?.push(passwordItem);
|
||||
return passwordItem;
|
||||
};
|
||||
|
||||
export const updateItem = (
|
||||
item: PasswordItem,
|
||||
values: PasswordItemFormValues
|
||||
): PasswordItem => {
|
||||
item.applyDiff(values as Partial<CoMapInit<PasswordItem>>);
|
||||
return item;
|
||||
};
|
||||
|
||||
export const deleteItem = (item: PasswordItem): void => {
|
||||
const found = item.folder?.items?.findIndex(
|
||||
(passwordItem) => passwordItem?.id === item.id
|
||||
);
|
||||
if (found !== undefined && found > -1) item.folder?.items?.splice(found, 1);
|
||||
};
|
||||
|
||||
export const createFolder = (
|
||||
folderName: string,
|
||||
me: PasswordManagerAccount
|
||||
): Folder => {
|
||||
const group = Group.create({ owner: me });
|
||||
const folder = Folder.create(
|
||||
{ name: folderName, items: PasswordList.create([], { owner: group }) },
|
||||
{ owner: group }
|
||||
);
|
||||
me.root?.folders?.push(folder);
|
||||
return folder;
|
||||
};
|
||||
|
||||
export const shareFolder = (
|
||||
folder: Folder,
|
||||
permission: "reader" | "writer" | "admin"
|
||||
): string | undefined => {
|
||||
if (folder._owner && folder.id) {
|
||||
return createInviteLink(folder, permission);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
37
examples/password-manager/src/5_App.tsx
Normal file
37
examples/password-manager/src/5_App.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import { createHashRouter, Navigate, RouterProvider } from "react-router-dom";
|
||||
import VaultPage from "./3_vault";
|
||||
import { useAcceptInvite } from "./2_main";
|
||||
import { Folder } from "./1_schema";
|
||||
|
||||
const App: React.FC = () => {
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <Navigate to={"/vault"} />,
|
||||
},
|
||||
{
|
||||
path: "/vault",
|
||||
element: <VaultPage />,
|
||||
},
|
||||
{
|
||||
path: "/vault/:sharedFolderId",
|
||||
element: <VaultPage />,
|
||||
},
|
||||
{
|
||||
path: "/invite/*",
|
||||
element: <p>Accepting invite...</p>,
|
||||
},
|
||||
]);
|
||||
|
||||
useAcceptInvite({
|
||||
invitedObjectSchema: Folder,
|
||||
onAccept: async (sharedFolderId) => {
|
||||
router.navigate(`/vault/${sharedFolderId}`);
|
||||
},
|
||||
});
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
|
||||
export default App;
|
||||
41
examples/password-manager/src/components/alert.tsx
Normal file
41
examples/password-manager/src/components/alert.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
|
||||
interface AlertProps {
|
||||
children: React.ReactNode;
|
||||
variant?: "default" | "destructive";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Alert: React.FC<AlertProps> = ({
|
||||
children,
|
||||
variant = "default",
|
||||
className = "",
|
||||
}) => {
|
||||
const baseClasses = "p-4 rounded-md mb-4";
|
||||
const variantClasses = {
|
||||
default: "bg-blue-100 text-blue-700",
|
||||
destructive: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
const classes = `${baseClasses} ${variantClasses[variant]} ${className}`;
|
||||
|
||||
return (
|
||||
<div className={classes} role="alert">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AlertDescriptionProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const AlertDescription: React.FC<AlertDescriptionProps> = ({
|
||||
children,
|
||||
className = "",
|
||||
}) => {
|
||||
const classes = `text-sm ${className}`;
|
||||
|
||||
return <p className={classes}>{children}</p>;
|
||||
};
|
||||
40
examples/password-manager/src/components/auth.tsx
Normal file
40
examples/password-manager/src/components/auth.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { PasskeyAuth } from "jazz-react";
|
||||
|
||||
export const PrettyAuthUI: PasskeyAuth.Component = ({
|
||||
loading,
|
||||
logIn,
|
||||
signUp,
|
||||
}) => {
|
||||
const [username, setUsername] = useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center p-5">
|
||||
{loading ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
<div className="w-72 flex flex-col gap-4">
|
||||
<form
|
||||
className="w-72 flex flex-col gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
signUp(username);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
placeholder="Display name"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="webauthn"
|
||||
className="text-base"
|
||||
/>
|
||||
|
||||
<input type="submit" value="Sign Up as new account" />
|
||||
</form>
|
||||
<button onClick={logIn}>Log In with existing account</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
59
examples/password-manager/src/components/base-modal.tsx
Normal file
59
examples/password-manager/src/components/base-modal.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
import Button from "./button";
|
||||
|
||||
interface BaseModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const BaseModal: React.FC<BaseModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 transition-opacity" aria-hidden="true">
|
||||
<div className="absolute inset-0 bg-gray-500 opacity-75"></div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className="hidden sm:inline-block sm:align-middle sm:h-screen"
|
||||
aria-hidden="true"
|
||||
>
|
||||
​
|
||||
</span>
|
||||
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-2">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
className="w-full sm:w-auto sm:ml-3"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseModal;
|
||||
38
examples/password-manager/src/components/button.tsx
Normal file
38
examples/password-manager/src/components/button.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
size?: "small" | "medium" | "large";
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
children,
|
||||
variant = "primary",
|
||||
size = "medium",
|
||||
className = "",
|
||||
...props
|
||||
}) => {
|
||||
const baseClasses =
|
||||
"font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
const variantClasses = {
|
||||
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
|
||||
secondary:
|
||||
"bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500",
|
||||
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
|
||||
};
|
||||
const sizeClasses = {
|
||||
small: "px-2 py-1 text-sm",
|
||||
medium: "px-4 py-2",
|
||||
large: "px-6 py-3 text-lg",
|
||||
};
|
||||
|
||||
const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
|
||||
|
||||
return (
|
||||
<button className={classes} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
144
examples/password-manager/src/components/invite-modal.tsx
Normal file
144
examples/password-manager/src/components/invite-modal.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React, { useState } from "react";
|
||||
import BaseModal from "./base-modal";
|
||||
import Button from "./button";
|
||||
import { shareFolder } from "../4_actions";
|
||||
import { Folder } from "../1_schema";
|
||||
import { Group } from "jazz-tools";
|
||||
|
||||
interface InviteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedFolder: Folder | undefined;
|
||||
}
|
||||
|
||||
const InviteModal: React.FC<InviteModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedFolder,
|
||||
}) => {
|
||||
const [selectedPermission, setSelectedPermission] = useState<
|
||||
"reader" | "writer" | "admin"
|
||||
>("reader");
|
||||
const [inviteLink, setInviteLink] = useState("");
|
||||
|
||||
const members = selectedFolder?._owner.castAs(Group).members;
|
||||
const invitedMembers = members
|
||||
? members
|
||||
.filter((m) => !m.account?.isMe && m.role !== "revoked")
|
||||
.map((m) => m.account)
|
||||
: [];
|
||||
|
||||
const handleCreateInviteLink = () => {
|
||||
if (!selectedFolder || !selectedPermission) return;
|
||||
const inviteLink = shareFolder(selectedFolder, selectedPermission);
|
||||
if (!inviteLink) return;
|
||||
setInviteLink(inviteLink);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseModal isOpen={isOpen} onClose={onClose} title="Invite Users">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="folder"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Select Folder to Share
|
||||
</label>
|
||||
<select
|
||||
id="folder"
|
||||
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
|
||||
>
|
||||
<option key={selectedFolder?.id} value={selectedFolder?.id}>
|
||||
{selectedFolder?.name}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="permission"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Select Permission
|
||||
</label>
|
||||
<select
|
||||
id="permission"
|
||||
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
|
||||
value={selectedPermission}
|
||||
onChange={(e) =>
|
||||
setSelectedPermission(
|
||||
e.target.value as "reader" | "writer" | "admin"
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="reader">Reader</option>
|
||||
<option value="writer">Writer</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-2">Existing Shared Users</h3>
|
||||
<div className="max-h-40 overflow-y-auto bg-gray-100 rounded-md p-2">
|
||||
{invitedMembers.length > 0 ? (
|
||||
<ul className="list-disc list-inside">
|
||||
{invitedMembers.map((user) => (
|
||||
<li
|
||||
key={user?.id}
|
||||
className="text-sm flex justify-between items-center"
|
||||
>
|
||||
<span>{user?.profile?.name}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!user?._raw) return;
|
||||
selectedFolder?._owner
|
||||
.castAs(Group)
|
||||
._raw.removeMember(user?._raw);
|
||||
}}
|
||||
className="ml-4 bg-red-500 text-white px-2 py-1 rounded text-xs hover:bg-red-600"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">
|
||||
No users currently have access to this folder.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleCreateInviteLink} className="w-full">
|
||||
Create Invite Link
|
||||
</Button>
|
||||
{inviteLink && (
|
||||
<div className="mt-4">
|
||||
<label
|
||||
htmlFor="inviteLink"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Invite Link
|
||||
</label>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<input
|
||||
type="text"
|
||||
id="inviteLink"
|
||||
className="flex-1 min-w-0 block w-full px-3 py-2 rounded-none rounded-l-md text-sm border-gray-300 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
value={inviteLink}
|
||||
readOnly
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
className="inline-flex items-center px-3 rounded-r-md border border-l-0 border-gray-300 text-gray-500 text-sm"
|
||||
onClick={() => navigator.clipboard.writeText(inviteLink)}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteModal;
|
||||
200
examples/password-manager/src/components/new-item-modal.tsx
Normal file
200
examples/password-manager/src/components/new-item-modal.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useForm, SubmitHandler } from "react-hook-form";
|
||||
import BaseModal from "./base-modal";
|
||||
import Button from "./button";
|
||||
import { Alert, AlertDescription } from "./alert";
|
||||
import { Folder } from "../1_schema";
|
||||
import { CoMap } from "jazz-tools";
|
||||
import { PasswordItemFormValues } from "../types";
|
||||
|
||||
interface NewItemModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialValues?: PasswordItemFormValues;
|
||||
onSave: (item: PasswordItemFormValues) => void;
|
||||
folders: Folder[];
|
||||
selectedFolder: Folder | undefined;
|
||||
}
|
||||
|
||||
const NewItemModal: React.FC<NewItemModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialValues,
|
||||
onSave,
|
||||
folders,
|
||||
selectedFolder,
|
||||
}) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
reset,
|
||||
// @ts-expect-error error
|
||||
} = useForm<PasswordItemFormValues>({
|
||||
defaultValues: initialValues || {
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
uri: "",
|
||||
deleted: false,
|
||||
folder: selectedFolder,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValues) {
|
||||
Object.entries(initialValues).forEach(([key, value]) => {
|
||||
const valueToSet = value instanceof CoMap ? value.id : value;
|
||||
setValue(key as keyof PasswordItemFormValues & string, valueToSet);
|
||||
});
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
}, [initialValues, setValue, reset]);
|
||||
|
||||
const onSubmit: SubmitHandler<PasswordItemFormValues> = (data) => {
|
||||
const folderId = data?.folder as unknown as string;
|
||||
const selectedFolder = folders.find((folder) => folder.id === folderId);
|
||||
if (selectedFolder) {
|
||||
data.folder = selectedFolder;
|
||||
}
|
||||
onSave(data);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={initialValues ? "Edit Password" : "Add New Password"}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register("name", { required: "Name is required" })}
|
||||
id="name"
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
{errors.name && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{errors.name.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register("username")}
|
||||
id="username"
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
{...register("password", {
|
||||
required: "Password is required",
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: "Password must be at least 8 characters long",
|
||||
},
|
||||
})}
|
||||
id="password"
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
{errors.password && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{errors.password.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="uri"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
URI
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
{...register("uri", {
|
||||
validate: (value) =>
|
||||
!value ||
|
||||
value.startsWith("http://") ||
|
||||
value.startsWith("https://") ||
|
||||
"URI must start with http:// or https://",
|
||||
})}
|
||||
id="uri"
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
{errors.uri && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{errors.uri.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="folder"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Folder
|
||||
</label>
|
||||
<select
|
||||
{...register("folder", { required: "Must select a folder" })}
|
||||
id="folder"
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Select a folder</option>
|
||||
{folders.map((folder) => (
|
||||
<option
|
||||
key={folder.id}
|
||||
value={folder.id}
|
||||
selected={
|
||||
initialValues
|
||||
? initialValues?.folder?.id === folder.id
|
||||
: selectedFolder?.id === folder.id
|
||||
}
|
||||
>
|
||||
{folder.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.folder && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{errors.folder.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">{initialValues ? "Update" : "Save"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewItemModal;
|
||||
54
examples/password-manager/src/components/table.tsx
Normal file
54
examples/password-manager/src/components/table.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from "react";
|
||||
|
||||
interface Column<T> {
|
||||
header: string;
|
||||
accessor: keyof T;
|
||||
render?: (item: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
interface TableProps<T> {
|
||||
data: T[] | undefined;
|
||||
columns: Column<T>[];
|
||||
onRowClick?: (item: T) => void;
|
||||
}
|
||||
|
||||
function Table<T>({ data, columns, onRowClick }: TableProps<T>) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{columns.map((column, index) => (
|
||||
<th
|
||||
key={index}
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{column.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{data?.map((item, rowIndex) => (
|
||||
<tr
|
||||
key={rowIndex}
|
||||
onClick={() => onRowClick && onRowClick(item)}
|
||||
className={onRowClick ? "cursor-pointer hover:bg-gray-50" : ""}
|
||||
>
|
||||
{columns.map((column, colIndex) => (
|
||||
<td key={colIndex} className="px-6 py-4 whitespace-nowrap">
|
||||
{column.render
|
||||
? column.render(item)
|
||||
: (item[column.accessor] as React.ReactNode)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Table;
|
||||
3
examples/password-manager/src/index.css
Normal file
3
examples/password-manager/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user